From 4617e8ba83f6e1c1eee7460dc50f7abf5d4ea97a Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 8 Jul 2026 14:58:59 -0300 Subject: [PATCH 01/33] feat(work-orders): add Phase 4 board search and advanced search API Harden contextual search on the weekly board and expose paginated cross-week GET /board/search with date presets, FE filter params, SQL indexes, and unit tests. --- .../Controllers/WorkOrderController.cs | 13 +- .../20260624200000_Phase4_SearchIndexes.cs | 48 ++++ .../Helpers/WorkOrderBoardQueryFilters.cs | 19 -- .../Helpers/WorkOrderBoardSearchFilter.cs | 47 ++++ .../WorkOrderAdvancedSearchDataService.cs | 64 +++++ .../WorkOrderBoardDataService.cs | 7 +- .../IWorkOrderAdvancedSearchDataService.cs | 9 + .../WorkOrderAdvancedSearchModels.cs | 27 ++ .../DTOs/WorkOrderBoardRequestDTOs.cs | 22 ++ .../WorkOrderSearchDateRangeResolver.cs | 95 +++++++ .../WorkOrderAdvancedSearchService.cs | 63 +++++ .../IWorkOrderAdvancedSearchService.cs | 11 + .../WorkOrderBoardSearchTests.cs | 240 ++++++++++++++++++ scripts/load-test/README.md | 40 +++ scripts/load-test/work-order-search.k6.js | 60 +++++ 15 files changed, 742 insertions(+), 23 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs create mode 100644 SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs create mode 100644 SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs create mode 100644 scripts/load-test/README.md create mode 100644 scripts/load-test/work-order-search.k6.js diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index df2d601..f227ffd 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -47,6 +47,7 @@ namespace Api.SeaHavenIndustries.Controllers private readonly IWorkOrderBoardUpdateService _boardUpdateService; private readonly IWorkOrderBoardCreateService _boardCreateService; private readonly IWorkOrderBoardCancelService _boardCancelService; + private readonly IWorkOrderAdvancedSearchService _advancedSearchService; public WorkOrderController( UserManager userManager, @@ -67,7 +68,8 @@ namespace Api.SeaHavenIndustries.Controllers IWorkOrderBoardService workOrderBoardService, IWorkOrderBoardUpdateService boardUpdateService, IWorkOrderBoardCreateService boardCreateService, - IWorkOrderBoardCancelService boardCancelService) + IWorkOrderBoardCancelService boardCancelService, + IWorkOrderAdvancedSearchService advancedSearchService) { _userManager = userManager; _workOrderService = workOrderService; @@ -88,6 +90,7 @@ namespace Api.SeaHavenIndustries.Controllers _boardUpdateService = boardUpdateService; _boardCreateService = boardCreateService; _boardCancelService = boardCancelService; + _advancedSearchService = advancedSearchService; } [HttpGet("board")] @@ -114,6 +117,14 @@ namespace Api.SeaHavenIndustries.Controllers return Ok(result); } + [HttpGet("board/search")] + public async Task SearchBoard([FromQuery] WorkOrderAdvancedSearchQueryDto query) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var result = await _advancedSearchService.SearchAsync(query, userId); + return Ok(result); + } + [HttpGet("lookups/dispatchers")] public async Task GetDispatcherLookups() { diff --git a/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs new file mode 100644 index 0000000..427b6cf --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class Phase4_SearchIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_workOrders_SiteCode", + table: "workOrders", + column: "SiteCode", + filter: "[istemplate] = 0"); + + migrationBuilder.CreateIndex( + name: "IX_workOrders_InternalWONumber", + table: "workOrders", + column: "InternalWONumber", + filter: "[istemplate] = 0"); + + migrationBuilder.CreateIndex( + name: "IX_workOrders_LifecycleStatus_ScheduledDate", + table: "workOrders", + columns: new[] { "LifecycleStatus", "ScheduledDate" }, + filter: "[istemplate] = 0"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_workOrders_SiteCode", + table: "workOrders"); + + migrationBuilder.DropIndex( + name: "IX_workOrders_InternalWONumber", + table: "workOrders"); + + migrationBuilder.DropIndex( + name: "IX_workOrders_LifecycleStatus_ScheduledDate", + table: "workOrders"); + } + } +} diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index 35465ca..bb9919b 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -134,24 +134,5 @@ namespace SeaHaven.DataServices.Helpers && w.TargetWeek >= dateFrom && w.TargetWeek <= dateTo)); } - - public static IQueryable ApplySearchFilter(IQueryable query, string? search) - { - if (string.IsNullOrWhiteSpace(search)) - return query; - - var s = search.Trim().ToLower(); - return query.Where(w => - (w.SiteCode ?? "").ToLower().Contains(s) - || (w.InternalWONumber ?? "").ToLower().Contains(s) - || (w.WorkerOrderNumber ?? "").ToLower().Contains(s) - || (w.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s)) - || (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s)) - || (w.Service ?? "").ToLower().Contains(s) - || (w.Trade ?? "").ToLower().Contains(s) - || (w.Problem ?? "").ToLower().Contains(s) - || (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s)) - || (w.Status ?? "").ToLower().Contains(s)); - } } } diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs new file mode 100644 index 0000000..eac12ba --- /dev/null +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs @@ -0,0 +1,47 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.DataServices.Helpers +{ + public static class WorkOrderBoardSearchFilter + { + public static string? NormalizeSearch(string? search) + { + if (string.IsNullOrWhiteSpace(search)) + return null; + + var trimmed = search.Trim(); + return trimmed.Length < 2 ? null : trimmed; + } + + public static IQueryable Apply(IQueryable query, string? search) + { + var normalized = NormalizeSearch(search); + if (normalized == null) + return query; + + var s = normalized.ToLower(); + return query.Where(w => + (w.SiteCode ?? "").ToLower().Contains(s) + || (w.InternalWONumber ?? "").ToLower().Contains(s) + || (w.WorkerOrderNumber ?? "").ToLower().Contains(s) + || (w.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s)) + || (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s)) + || (w.Trade ?? "").ToLower().Contains(s) + || (w.Problem ?? "").ToLower().Contains(s) + || (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s)) + || (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.ContactName ?? "").ToLower().Contains(s)) + || (w.Status ?? "").ToLower().Contains(s) + || (w.LifecycleStatus == LifecycleStatus.Incomplete && "incomplete".Contains(s)) + || (w.LifecycleStatus == LifecycleStatus.Pending && ("pending".Contains(s) || "on hold".Contains(s) || "onhold".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.Scheduled && "scheduled".Contains(s)) + || (w.LifecycleStatus == LifecycleStatus.EnRoute && ("en route".Contains(s) || "enroute".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.OnSite && ("on site".Contains(s) || "onsite".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.InProgress && ("in progress".Contains(s) || "inprogress".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.Completed && ("completed".Contains(s) || "complete".Contains(s) || "done".Contains(s) || "closed".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.Rescheduled && "rescheduled".Contains(s)) + || (w.LifecycleStatus == LifecycleStatus.Canceled && ("canceled".Contains(s) || "cancelled".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.PendingQuote && ("pending quote".Contains(s) || "pendingquote".Contains(s) || "pending approval".Contains(s) || "pendingapproval".Contains(s)))); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs new file mode 100644 index 0000000..556fe4f --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs @@ -0,0 +1,64 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderAdvancedSearchDataService : IWorkOrderAdvancedSearchDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderAdvancedSearchDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task SearchAsync(WorkOrderAdvancedSearchQuery query) + { + var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); + baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo); + baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites); + baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types); + baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter( + baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId); + baseQuery = WorkOrderBoardQueryFilters.ApplyStatusFilter(baseQuery, query.Statuses); + baseQuery = WorkOrderBoardQueryFilters.ApplyPmTypeFilter(baseQuery, query.PmTypes); + baseQuery = WorkOrderBoardQueryFilters.ApplyVendorFilter(baseQuery, query.VendorIds); + baseQuery = WorkOrderBoardQueryFilters.ApplyDocStatusFilter(baseQuery, query.DocStatuses); + + var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search); + if (normalizedSearch != null) + baseQuery = WorkOrderBoardSearchFilter.Apply(baseQuery, normalizedSearch); + + var totalCount = await baseQuery.CountAsync(); + + var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir); + var paged = sorted + .Skip((query.Page - 1) * query.PageSize) + .Take(query.PageSize); + + var rows = await WorkOrderBoardProjection.ProjectRowsAsync(paged, isUnscheduled: false); + return new WorkOrderAdvancedSearchResult(rows, totalCount); + } + + private static IQueryable ApplySort(IQueryable query, string sortBy, string sortDir) + { + var descending = string.Equals(sortDir, "desc", StringComparison.OrdinalIgnoreCase); + var field = (sortBy ?? "scheduledDate").Trim().ToLowerInvariant(); + + return field switch + { + "wonumber" or "woNumber" => descending + ? query.OrderByDescending(w => w.InternalWONumber) + : query.OrderBy(w => w.InternalWONumber), + "duedate" or "dueDate" => descending + ? query.OrderByDescending(w => w.DueDate).ThenBy(w => w.InternalWONumber) + : query.OrderBy(w => w.DueDate).ThenBy(w => w.InternalWONumber), + _ => descending + ? query.OrderByDescending(w => w.ScheduledDate).ThenByDescending(w => w.InternalWONumber) + : query.OrderBy(w => w.ScheduledDate).ThenBy(w => w.InternalWONumber) + }; + } + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs index 05a4e82..51936eb 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs @@ -38,10 +38,11 @@ namespace SeaHaven.DataServices.Implementation var scheduledTotal = await scheduledBase.CountAsync(); - if (!string.IsNullOrWhiteSpace(query.Search)) + var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search); + if (normalizedSearch != null) { - scheduledBase = WorkOrderBoardQueryFilters.ApplySearchFilter(scheduledBase, query.Search); - unscheduledBase = WorkOrderBoardQueryFilters.ApplySearchFilter(unscheduledBase, query.Search); + scheduledBase = WorkOrderBoardSearchFilter.Apply(scheduledBase, normalizedSearch); + unscheduledBase = WorkOrderBoardSearchFilter.Apply(unscheduledBase, normalizedSearch); } var scheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync( diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs new file mode 100644 index 0000000..f758550 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs @@ -0,0 +1,9 @@ +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderAdvancedSearchDataService + { + Task SearchAsync(WorkOrderAdvancedSearchQuery query); + } +} diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs new file mode 100644 index 0000000..0cede89 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs @@ -0,0 +1,27 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Interfaces +{ + public record WorkOrderAdvancedSearchQuery( + string? Search, + DateOnly DateFrom, + DateOnly DateTo, + IReadOnlyList? Sites, + IReadOnlyList? Types, + IReadOnlyList? Dispatchers, + IReadOnlyList? Statuses, + IReadOnlyList? PmTypes, + IReadOnlyList? VendorIds, + IReadOnlyList? DocStatuses, + bool MyWorkOrders, + string? CurrentUserId, + int Page, + int PageSize, + string SortBy, + string SortDir); + + public record WorkOrderAdvancedSearchResult( + IReadOnlyList Rows, + int TotalCount); +} diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index d3ac591..31fae59 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.Helpers; namespace SeaHaven.Services.DTOs { @@ -23,4 +24,25 @@ namespace SeaHaven.Services.DTOs public int? PocContactId { get; set; } public string? PocNotes { get; set; } } + + /// Advanced search / filter query for the board. + public class WorkOrderAdvancedSearchQueryDto + { + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 50; + public string? Search { get; set; } + public WorkOrderAdvancedSearchDatePreset? DatePreset { get; set; } + public DateOnly? DateFrom { get; set; } + public DateOnly? DateTo { get; set; } + public List? Sites { get; set; } + public List? Types { get; set; } + public List? Dispatchers { get; set; } + public List? Statuses { get; set; } + public List? PmTypes { get; set; } + public List? VendorIds { get; set; } + public List? DocStatuses { get; set; } + public bool MyWorkOrders { get; set; } + public string? SortBy { get; set; } + public string? SortDir { get; set; } + } } diff --git a/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs b/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs new file mode 100644 index 0000000..d41df2f --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs @@ -0,0 +1,95 @@ +namespace SeaHaven.Services.Helpers +{ + public enum WorkOrderAdvancedSearchDatePreset + { + ThisWeek, + LastWeek, + ThisMonth, + Last3Months, + NextWeek, + NextMonth, + Custom + } + + public record WorkOrderSearchDateRange(DateOnly From, DateOnly To); + + public static class WorkOrderSearchDateRangeResolver + { + public static WorkOrderSearchDateRange Resolve( + WorkOrderAdvancedSearchDatePreset? preset, + DateOnly? dateFrom, + DateOnly? dateTo, + DateOnly? referenceDate = null) + { + var today = referenceDate ?? DateOnly.FromDateTime(DateTime.UtcNow); + var resolvedPreset = preset ?? WorkOrderAdvancedSearchDatePreset.ThisWeek; + + if (resolvedPreset == WorkOrderAdvancedSearchDatePreset.Custom) + { + if (!dateFrom.HasValue || !dateTo.HasValue) + throw new ArgumentException("dateFrom and dateTo are required when datePreset is custom."); + + if (dateTo.Value < dateFrom.Value) + throw new ArgumentException("dateTo must be on or after dateFrom."); + + return new WorkOrderSearchDateRange(dateFrom.Value, dateTo.Value); + } + + return resolvedPreset switch + { + WorkOrderAdvancedSearchDatePreset.ThisWeek => ThisIsoWeek(today), + WorkOrderAdvancedSearchDatePreset.LastWeek => LastIsoWeek(today), + WorkOrderAdvancedSearchDatePreset.ThisMonth => ThisMonth(today), + WorkOrderAdvancedSearchDatePreset.Last3Months => Last3Months(today), + WorkOrderAdvancedSearchDatePreset.NextWeek => NextIsoWeek(today), + WorkOrderAdvancedSearchDatePreset.NextMonth => NextMonth(today), + _ => ThisIsoWeek(today) + }; + } + + public static DateOnly GetIsoWeekStart(DateOnly date) + { + var diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7; + return date.AddDays(-diff); + } + + private static WorkOrderSearchDateRange ThisIsoWeek(DateOnly today) + { + var start = GetIsoWeekStart(today); + return new WorkOrderSearchDateRange(start, start.AddDays(6)); + } + + private static WorkOrderSearchDateRange LastIsoWeek(DateOnly today) + { + var start = GetIsoWeekStart(today).AddDays(-7); + return new WorkOrderSearchDateRange(start, start.AddDays(6)); + } + + private static WorkOrderSearchDateRange NextIsoWeek(DateOnly today) + { + var start = GetIsoWeekStart(today).AddDays(7); + return new WorkOrderSearchDateRange(start, start.AddDays(6)); + } + + private static WorkOrderSearchDateRange ThisMonth(DateOnly today) + { + var start = new DateOnly(today.Year, today.Month, 1); + var end = start.AddMonths(1).AddDays(-1); + return new WorkOrderSearchDateRange(start, end); + } + + private static WorkOrderSearchDateRange NextMonth(DateOnly today) + { + var start = new DateOnly(today.Year, today.Month, 1).AddMonths(1); + var end = start.AddMonths(1).AddDays(-1); + return new WorkOrderSearchDateRange(start, end); + } + + private static WorkOrderSearchDateRange Last3Months(DateOnly today) + { + var end = today; + var start = today.AddMonths(-3); + return new WorkOrderSearchDateRange(start, end); + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs new file mode 100644 index 0000000..285ec9e --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -0,0 +1,63 @@ +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderAdvancedSearchService : IWorkOrderAdvancedSearchService + { + private const int DefaultPageSize = 50; + private const int MaxPageSize = 100; + + private readonly IWorkOrderAdvancedSearchDataService _searchDataService; + + public WorkOrderAdvancedSearchService(IWorkOrderAdvancedSearchDataService searchDataService) + { + _searchDataService = searchDataService; + } + + public async Task> SearchAsync( + WorkOrderAdvancedSearchQueryDto query, + string? currentUserId) + { + var page = query.Page < 1 ? 1 : query.Page; + var pageSize = query.PageSize < 1 ? DefaultPageSize : Math.Min(query.PageSize, MaxPageSize); + + var dateRange = WorkOrderSearchDateRangeResolver.Resolve( + query.DatePreset, + query.DateFrom, + query.DateTo); + + var dataQuery = new WorkOrderAdvancedSearchQuery( + WorkOrderBoardSearchFilter.NormalizeSearch(query.Search), + dateRange.From, + dateRange.To, + query.Sites, + query.Types, + query.Dispatchers, + query.Statuses, + query.PmTypes, + query.VendorIds, + query.DocStatuses, + query.MyWorkOrders, + currentUserId, + page, + pageSize, + query.SortBy ?? "scheduledDate", + query.SortDir ?? "asc"); + + var result = await _searchDataService.SearchAsync(dataQuery); + var utcNow = DateTime.UtcNow; + + return new PagedResult + { + Items = result.Rows.Select(r => WorkOrderBoardService.MapRawRow(r, utcNow)).ToList(), + TotalCount = result.TotalCount, + Page = page, + PageSize = pageSize + }; + } + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs new file mode 100644 index 0000000..8da8835 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs @@ -0,0 +1,11 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderAdvancedSearchService + { + Task> SearchAsync( + WorkOrderAdvancedSearchQueryDto query, + string? currentUserId); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs new file mode 100644 index 0000000..290b63d --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -0,0 +1,240 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Implementation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardSearchFilterTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Theory] + [InlineData("BK5", 1)] + [InlineData("10000000001", 1)] + [InlineData("HVAC Tech", 1)] + [InlineData("Acme HVAC", 1)] + [InlineData("sched", 1)] + public async Task Apply_MatchesExpectedField(string search, int expectedId) + { + await using var context = CreateContext(); + await SeedSearchFixtures(context); + + var ids = await WorkOrderBoardSearchFilter + .Apply(context.workOrders.AsQueryable(), search) + .Select(w => w.Id) + .ToListAsync(); + + Assert.Contains(expectedId, ids); + } + + [Fact] + public void NormalizeSearch_IgnoresSingleCharacter() + { + Assert.Null(WorkOrderBoardSearchFilter.NormalizeSearch("a")); + Assert.Null(WorkOrderBoardSearchFilter.NormalizeSearch(" ")); + Assert.Equal("ab", WorkOrderBoardSearchFilter.NormalizeSearch(" ab ")); + } + + [Fact] + public async Task Apply_LifecycleLabel_MatchesScheduled() + { + await using var context = CreateContext(); + await SeedSearchFixtures(context); + + var ids = await WorkOrderBoardSearchFilter + .Apply(context.workOrders.AsQueryable(), "scheduled") + .Select(w => w.Id) + .ToListAsync(); + + Assert.Single(ids); + Assert.Equal(1, ids[0]); + } + + private static async Task SeedSearchFixtures(ApplicationDbContext context) + { + context.Vendors.Add(new Vendor + { + Id = 1, + CompanyName = "Acme HVAC", + ContactName = "HVAC Tech", + Phone = "555-0100" + }); + context.Dispatches.Add(new Dispatch { Id = 10, VendorId = 1 }); + context.workOrders.Add(new WorkOrder + { + Id = 1, + SiteCode = "BK5", + InternalWONumber = "10000000001", + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10 + }); + await context.SaveChangesAsync(); + } +} + +public class WorkOrderBoardSearchCountsTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Fact] + public async Task GetBoardRows_SearchReducesScheduledAndKeepsTotal() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + SiteCode = "BK5", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 2, + SiteCode = "BK6", + ScheduledDate = new DateTime(2026, 6, 24), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 3, + SiteCode = "BK5", + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete + }); + + await context.SaveChangesAsync(); + + var service = new WorkOrderBoardDataService(context); + var result = await service.GetBoardRowsAsync(new SeaHaven.DataServices.Interfaces.WorkOrderBoardQuery( + weekStart, + weekStart.AddDays(4), + null, + false, + null, + "BK5", + null)); + + Assert.Equal(2, result.ScheduledTotalBeforeSearch); + Assert.Single(result.ScheduledRows); + Assert.Equal("BK5", result.ScheduledRows[0].SiteCode); + Assert.Single(result.UnscheduledRows); + Assert.Equal("BK5", result.UnscheduledRows[0].SiteCode); + } +} + +public class WorkOrderSearchDateRangeResolverTests +{ + [Fact] + public void Resolve_ThisWeek_UsesIsoMonday() + { + var reference = new DateOnly(2026, 6, 24); + var range = SeaHaven.Services.Helpers.WorkOrderSearchDateRangeResolver.Resolve( + SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek, + null, + null, + reference); + + Assert.Equal(new DateOnly(2026, 6, 22), range.From); + Assert.Equal(new DateOnly(2026, 6, 28), range.To); + } + + [Fact] + public void Resolve_Custom_RequiresDates() + { + var ex = Assert.Throws(() => + SeaHaven.Services.Helpers.WorkOrderSearchDateRangeResolver.Resolve( + SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + null, + null)); + + Assert.Contains("dateFrom", ex.Message); + } +} + +public class WorkOrderAdvancedSearchServiceTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Fact] + public async Task SearchAsync_FiltersBySiteAndPaginates() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + SiteCode = "BK5", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000001" + }, + new WorkOrder + { + Id = 2, + SiteCode = "BK6", + ScheduledDate = new DateTime(2026, 6, 24), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000002" + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + + var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = weekStart, + DateTo = weekStart.AddDays(6), + Sites = new List { "BK5" }, + Page = 1, + PageSize = 50 + }, null); + + Assert.Equal(1, result.TotalCount); + Assert.Single(result.Items); + Assert.Equal("BK5", result.Items.First().SiteCode); + } + + [Fact] + public async Task SearchAsync_ClampsPageSizeToMax() + { + await using var context = CreateContext(); + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + + var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek, + PageSize = 500 + }, null); + + Assert.Equal(100, result.PageSize); + } +} diff --git a/scripts/load-test/README.md b/scripts/load-test/README.md new file mode 100644 index 0000000..c068043 --- /dev/null +++ b/scripts/load-test/README.md @@ -0,0 +1,40 @@ +# Load Test — Work Order Search (Tier S) + +Harness k6 para validar SLOs da Fase 4 em ambiente local ou staging. + +## Pré-requisitos + +- [k6](https://k6.io/docs/get-started/installation/) instalado +- API rodando com JWT válido +- (Opcional) Seed sintético: `scripts/seed-work-orders-search.ps1 -Count 15000` + +## Variáveis de ambiente + +| Variável | Descrição | Default | +|----------|-----------|---------| +| `BASE_URL` | URL base da API | `https://localhost:5001` | +| `JWT` | Bearer token | _(obrigatório)_ | +| `WEEK_START` | Segunda-feira ISO para board | `2026-06-22` | + +## Execução + +```powershell +$env:JWT = "" +$env:BASE_URL = "https://localhost:5001" +k6 run scripts/load-test/work-order-search.k6.js +``` + +## SLOs Tier S (p95) + +| Métrica | Limite | +|---------|--------| +| `board_no_search` | < 800ms | +| `board_with_search` | < 1000ms | +| `advanced_preset` | < 1200ms | +| `advanced_multi_filter` | < 1500ms | + +## Staging (pós Volume Discovery) + +1. Executar `scripts/volume-discovery.ps1` +2. Confirmar tier em `docs/work-orders/phase-4/search-tier-decision.md` +3. Repetir k6 com volume real; ajustar thresholds se tier = M diff --git a/scripts/load-test/work-order-search.k6.js b/scripts/load-test/work-order-search.k6.js new file mode 100644 index 0000000..ff69d67 --- /dev/null +++ b/scripts/load-test/work-order-search.k6.js @@ -0,0 +1,60 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend } from 'k6/metrics'; + +const boardNoSearch = new Trend('board_no_search', true); +const boardWithSearch = new Trend('board_with_search', true); +const advancedPreset = new Trend('advanced_preset', true); +const advancedMultiFilter = new Trend('advanced_multi_filter', true); + +const BASE_URL = __ENV.BASE_URL || 'https://localhost:5001'; +const JWT = __ENV.JWT || ''; +const WEEK_START = __ENV.WEEK_START || '2026-06-22'; + +export const options = { + vus: 10, + duration: '30s', + thresholds: { + board_no_search: ['p(95)<800'], + board_with_search: ['p(95)<1000'], + advanced_preset: ['p(95)<1200'], + advanced_multi_filter: ['p(95)<1500'], + }, +}; + +const headers = { + Authorization: `Bearer ${JWT}`, + Accept: 'application/json', +}; + +export default function () { + const boardRes = http.get( + `${BASE_URL}/api/workorders/board?weekStart=${WEEK_START}`, + { headers } + ); + boardNoSearch.add(boardRes.timings.duration); + check(boardRes, { 'board 200': (r) => r.status === 200 }); + + const searchRes = http.get( + `${BASE_URL}/api/workorders/board?weekStart=${WEEK_START}&search=BK5`, + { headers } + ); + boardWithSearch.add(searchRes.timings.duration); + check(searchRes, { 'board search 200': (r) => r.status === 200 }); + + const advRes = http.get( + `${BASE_URL}/api/workorders/board/search?datePreset=thisMonth&page=1&pageSize=50`, + { headers } + ); + advancedPreset.add(advRes.timings.duration); + check(advRes, { 'advanced preset 200': (r) => r.status === 200 }); + + const multiRes = http.get( + `${BASE_URL}/api/workorders/board/search?datePreset=thisMonth&sites=BK5&types=PM&search=HVAC&page=1`, + { headers } + ); + advancedMultiFilter.add(multiRes.timings.duration); + check(multiRes, { 'advanced multi 200': (r) => r.status === 200 }); + + sleep(0.5); +} From eb64c2f55cec2f437df2f039871ff9bb8c2e9412 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 9 Jul 2026 09:54:53 -0300 Subject: [PATCH 02/33] fix(work-orders): address PR #14 board PATCH review feedback Reuse field-lock DB checks during staged audits, defer dispatch creation to the final save, and return 404 for missing work orders. --- .../Controllers/WorkOrderController.cs | 8 +++ .../Implementation/WorkOrderAuditService.cs | 42 ++++++---------- .../WorkOrderBoardCancelService.cs | 2 +- .../WorkOrderBoardCreateService.cs | 12 ++--- .../WorkOrderBoardUpdateService.cs | 27 ++++++---- .../Interfaces/IWorkOrderAuditService.cs | 8 +-- .../WorkOrderBoardUpdateServiceTests.cs | 50 +++++++++++++++++++ 7 files changed, 101 insertions(+), 48 deletions(-) diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index f227ffd..f11196d 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -148,6 +148,14 @@ namespace Api.SeaHavenIndustries.Controllers CurrentState = ex.CurrentState }); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } catch (WorkOrderBoardValidationException ex) { return UnprocessableEntity(new WorkOrderBoardValidationErrorDto diff --git a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs index 0646cd0..b0e7c60 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs @@ -28,17 +28,17 @@ namespace SeaHaven.Services.Implementation => LogAsync(workOrderId, AuditActionType.SyncRejected, AuditEventType.Sync, AuditActorType.Sync, fieldName, oldValue, newValue); - public void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null) - => StageAudit(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId); + public Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null) + => StageAuditAsync(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId); - public void StageStatusChanged(int workOrderId, string? oldStatus, string? newStatus, string? actorId) - => StageAudit(workOrderId, AuditActionType.StatusChanged, "LifecycleStatus", oldStatus, newStatus, actorId); + public Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId) + => StageAuditAsync(workOrderId, AuditActionType.StatusChanged, "LifecycleStatus", oldStatus, newStatus, actorId); - public void StageAssignmentChanged(int workOrderId, string? oldValue, string? newValue, string? actorId) - => StageAudit(workOrderId, AuditActionType.AssignmentChanged, "AssignTo", oldValue, newValue, actorId); + public Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId) + => StageAuditAsync(workOrderId, AuditActionType.AssignmentChanged, "AssignTo", oldValue, newValue, actorId); - public void StageCreated(int workOrderId, string? actorId, string? woNumber) - => StageAudit(workOrderId, AuditActionType.Create, "WorkOrder", null, woNumber, actorId); + public Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber) + => StageAuditAsync(workOrderId, AuditActionType.Create, "WorkOrder", null, woNumber, actorId); public async Task LogAsync( int workOrderId, @@ -65,7 +65,7 @@ namespace SeaHaven.Services.Implementation await _context.SaveChangesAsync(); } - private void StageAudit( + private async Task StageAuditAsync( int workOrderId, AuditActionType action, string fieldName, @@ -76,7 +76,13 @@ namespace SeaHaven.Services.Implementation { AddAuditEntry(workOrderId, action, AuditEventType.Manual, AuditActorType.Dispatcher, fieldName, oldValue, newValue, actorId, dispatchId); - StageFieldLock(workOrderId, fieldName, actorId); + + if (action == AuditActionType.FieldChanged || + action == AuditActionType.StatusChanged || + action == AuditActionType.AssignmentChanged) + { + await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId); + } } private void AddAuditEntry( @@ -107,21 +113,5 @@ namespace SeaHaven.Services.Implementation }); } - private void StageFieldLock(int workOrderId, string fieldName, string? userId) - { - var alreadyTracked = _context.WorkOrderFieldLocks.Local - .Any(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName); - - if (alreadyTracked) - return; - - _context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock - { - WorkOrderId = workOrderId, - FieldName = fieldName, - LockedAt = DateTime.UtcNow, - LockedByUserId = userId - }); - } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs index 273e26f..78c86bd 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs @@ -47,7 +47,7 @@ namespace SeaHaven.Services.Implementation if (workOrder.LegacyStatus == null && workOrder.Status != null) workOrder.LegacyStatus = workOrder.Status; - _auditService.StageStatusChanged(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId); + await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId); await _context.SaveChangesAsync(); var row = await _boardService.GetBoardRowAsync(workOrderId); diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index 6d6c2a0..35ea71c 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -124,8 +124,8 @@ namespace SeaHaven.Services.Implementation _context.workOrders.Add(workOrder); - _auditService.StageCreated(workOrder.Id, actorId, woNumber); - StageChanges(workOrder.Id, changes, actorId, dispatch); + await _auditService.StageCreatedAsync(workOrder.Id, actorId, woNumber); + await StageChangesAsync(workOrder.Id, changes, actorId, dispatch); await _context.SaveChangesAsync(); @@ -183,7 +183,7 @@ namespace SeaHaven.Services.Implementation return normalized; } - private void StageChanges( + private async Task StageChangesAsync( int workOrderId, IEnumerable changes, string? actorId, @@ -195,13 +195,13 @@ namespace SeaHaven.Services.Implementation switch (change.Action) { case AuditActionType.StatusChanged: - _auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId); + await _auditService.StageStatusChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId); break; case AuditActionType.AssignmentChanged: - _auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId); + await _auditService.StageAssignmentChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId); break; default: - _auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue ?? "", change.NewValue, actorId, dispatchId); + await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue ?? "", change.NewValue, actorId, dispatchId); break; } } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 98239bb..1eb1ce7 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -106,16 +106,17 @@ namespace SeaHaven.Services.Implementation foreach (var change in changes) { + var dispatchId = change.DispatchId ?? (dispatch?.Id > 0 ? dispatch.Id : null); switch (change.Action) { case AuditActionType.StatusChanged: - _auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId); + await _auditService.StageStatusChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId); break; case AuditActionType.AssignmentChanged: - _auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId); + await _auditService.StageAssignmentChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId); break; default: - _auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, change.DispatchId); + await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, dispatchId); break; } } @@ -195,8 +196,7 @@ namespace SeaHaven.Services.Implementation CreatedDate = DateTime.UtcNow }; _context.Dispatches.Add(created); - await _context.SaveChangesAsync(); - workOrder.PrimaryDispatchId = created.Id; + workOrder.PrimaryDispatch = created; return created; } @@ -392,10 +392,10 @@ namespace SeaHaven.Services.Implementation var old = dispatch.VendorId.ToString(); if (dispatch.VendorId == vendorId.Value) - return FieldChange.Unchanged(auditField, dispatch.Id); + return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch)); dispatch.VendorId = vendorId.Value; - return FieldChange.ForField(auditField, old, vendorId.Value.ToString(), dispatch.Id); + return FieldChange.ForField(auditField, old, vendorId.Value.ToString(), ResolveDispatchIdForAudit(dispatch)); } private static FieldChange ApplyApptDate(Dispatch dispatch, string? value, string auditField) @@ -411,12 +411,14 @@ namespace SeaHaven.Services.Implementation var old = FormatDate(dispatch.ScheduledDate); var newVal = FormatDate(parsed); if (old == newVal) - return FieldChange.Unchanged(auditField, dispatch.Id); + return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch)); dispatch.ScheduledDate = parsed; - return FieldChange.ForField(auditField, old, newVal, dispatch.Id); + return FieldChange.ForField(auditField, old, newVal, ResolveDispatchIdForAudit(dispatch)); } + // apptTime is stored on WorkOrder (ScheduledStart/End) but requires a primary dispatch + // for concurrency (dispatchVersion) and audit context; apptDate lives on Dispatch.ScheduledDate. private static FieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value, string auditField) { if (!WorkOrderBoardApptTimeParser.TryParse(value, out var start, out var end, out var error)) @@ -435,9 +437,9 @@ namespace SeaHaven.Services.Implementation var newCombined = FormatApptRange(newStart, newEnd); if (oldCombined == newCombined) - return FieldChange.Unchanged(auditField, dispatch.Id); + return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch)); - return FieldChange.ForField(auditField, oldCombined, newCombined, dispatch.Id); + return FieldChange.ForField(auditField, oldCombined, newCombined, ResolveDispatchIdForAudit(dispatch)); } private static FieldChange ApplyDocStatus(WorkOrder workOrder, string? value, string auditField) @@ -500,6 +502,9 @@ namespace SeaHaven.Services.Implementation return vendorId; } + private static int? ResolveDispatchIdForAudit(Dispatch dispatch) + => dispatch.Id > 0 ? dispatch.Id : null; + private static string? FormatDate(DateTime? value) => value?.ToString("yyyy-MM-dd"); private static string? FormatDateTime(DateTime? value) => value?.ToString("O"); private static string? FormatApptRange(string? start, string? end) diff --git a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs index 7029709..3dc4b57 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs @@ -20,9 +20,9 @@ namespace SeaHaven.Services.Interfaces Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId); Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue); - void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null); - void StageStatusChanged(int workOrderId, string? oldStatus, string? newStatus, string? actorId); - void StageAssignmentChanged(int workOrderId, string? oldValue, string? newValue, string? actorId); - void StageCreated(int workOrderId, string? actorId, string? woNumber); + Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null); + Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId); + Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId); + Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber); } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 59b180c..cb2b73a 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -180,6 +180,56 @@ public class WorkOrderBoardUpdateServiceTests Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.FieldName == "SiteCode")); } + [Fact] + public async Task PatchField_SecondEditSameField_DoesNotDuplicateFieldLock() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + SiteCode = "OLD", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "BK5", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + var updated = await context.workOrders.FindAsync(1); + var secondResult = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "BK6", + WorkOrderVersion = ToVersion(updated!) + }, "actor-1"); + + Assert.Equal("BK6", secondResult.SiteCode); + Assert.Single(await context.WorkOrderFieldLocks.Where(l => l.FieldName == "SiteCode").ToListAsync()); + Assert.Equal(2, await context.WorkOrderAuditLogs.Where(a => a.FieldName == "SiteCode").CountAsync()); + } + + [Fact] + public async Task PatchField_NotFound_ThrowsNotFoundCode() + { + var (_, service) = CreateSut(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(999, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "BK5", + WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }) + }, "actor-1")); + + Assert.Equal("NotFound", ex.Code); + } + [Fact] public async Task PatchField_NormalizesWoNumber() { From 83272e495718507a8e13f1daac9a95161e3af785 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 9 Jul 2026 09:59:41 -0300 Subject: [PATCH 03/33] Revert "fix(work-orders): address PR #14 board PATCH review feedback" This reverts commit eb64c2f55cec2f437df2f039871ff9bb8c2e9412. --- .../Controllers/WorkOrderController.cs | 8 --- .../Implementation/WorkOrderAuditService.cs | 42 ++++++++++------ .../WorkOrderBoardCancelService.cs | 2 +- .../WorkOrderBoardCreateService.cs | 12 ++--- .../WorkOrderBoardUpdateService.cs | 27 ++++------ .../Interfaces/IWorkOrderAuditService.cs | 8 +-- .../WorkOrderBoardUpdateServiceTests.cs | 50 ------------------- 7 files changed, 48 insertions(+), 101 deletions(-) diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index f11196d..f227ffd 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -148,14 +148,6 @@ namespace Api.SeaHavenIndustries.Controllers CurrentState = ex.CurrentState }); } - catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") - { - return NotFound(new WorkOrderBoardValidationErrorDto - { - Code = ex.Code, - Message = ex.Message - }); - } catch (WorkOrderBoardValidationException ex) { return UnprocessableEntity(new WorkOrderBoardValidationErrorDto diff --git a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs index b0e7c60..0646cd0 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs @@ -28,17 +28,17 @@ namespace SeaHaven.Services.Implementation => LogAsync(workOrderId, AuditActionType.SyncRejected, AuditEventType.Sync, AuditActorType.Sync, fieldName, oldValue, newValue); - public Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null) - => StageAuditAsync(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId); + public void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null) + => StageAudit(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId); - public Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId) - => StageAuditAsync(workOrderId, AuditActionType.StatusChanged, "LifecycleStatus", oldStatus, newStatus, actorId); + public void StageStatusChanged(int workOrderId, string? oldStatus, string? newStatus, string? actorId) + => StageAudit(workOrderId, AuditActionType.StatusChanged, "LifecycleStatus", oldStatus, newStatus, actorId); - public Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId) - => StageAuditAsync(workOrderId, AuditActionType.AssignmentChanged, "AssignTo", oldValue, newValue, actorId); + public void StageAssignmentChanged(int workOrderId, string? oldValue, string? newValue, string? actorId) + => StageAudit(workOrderId, AuditActionType.AssignmentChanged, "AssignTo", oldValue, newValue, actorId); - public Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber) - => StageAuditAsync(workOrderId, AuditActionType.Create, "WorkOrder", null, woNumber, actorId); + public void StageCreated(int workOrderId, string? actorId, string? woNumber) + => StageAudit(workOrderId, AuditActionType.Create, "WorkOrder", null, woNumber, actorId); public async Task LogAsync( int workOrderId, @@ -65,7 +65,7 @@ namespace SeaHaven.Services.Implementation await _context.SaveChangesAsync(); } - private async Task StageAuditAsync( + private void StageAudit( int workOrderId, AuditActionType action, string fieldName, @@ -76,13 +76,7 @@ namespace SeaHaven.Services.Implementation { AddAuditEntry(workOrderId, action, AuditEventType.Manual, AuditActorType.Dispatcher, fieldName, oldValue, newValue, actorId, dispatchId); - - if (action == AuditActionType.FieldChanged || - action == AuditActionType.StatusChanged || - action == AuditActionType.AssignmentChanged) - { - await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId); - } + StageFieldLock(workOrderId, fieldName, actorId); } private void AddAuditEntry( @@ -113,5 +107,21 @@ namespace SeaHaven.Services.Implementation }); } + private void StageFieldLock(int workOrderId, string fieldName, string? userId) + { + var alreadyTracked = _context.WorkOrderFieldLocks.Local + .Any(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName); + + if (alreadyTracked) + return; + + _context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock + { + WorkOrderId = workOrderId, + FieldName = fieldName, + LockedAt = DateTime.UtcNow, + LockedByUserId = userId + }); + } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs index 78c86bd..273e26f 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs @@ -47,7 +47,7 @@ namespace SeaHaven.Services.Implementation if (workOrder.LegacyStatus == null && workOrder.Status != null) workOrder.LegacyStatus = workOrder.Status; - await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId); + _auditService.StageStatusChanged(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId); await _context.SaveChangesAsync(); var row = await _boardService.GetBoardRowAsync(workOrderId); diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index 35ea71c..6d6c2a0 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -124,8 +124,8 @@ namespace SeaHaven.Services.Implementation _context.workOrders.Add(workOrder); - await _auditService.StageCreatedAsync(workOrder.Id, actorId, woNumber); - await StageChangesAsync(workOrder.Id, changes, actorId, dispatch); + _auditService.StageCreated(workOrder.Id, actorId, woNumber); + StageChanges(workOrder.Id, changes, actorId, dispatch); await _context.SaveChangesAsync(); @@ -183,7 +183,7 @@ namespace SeaHaven.Services.Implementation return normalized; } - private async Task StageChangesAsync( + private void StageChanges( int workOrderId, IEnumerable changes, string? actorId, @@ -195,13 +195,13 @@ namespace SeaHaven.Services.Implementation switch (change.Action) { case AuditActionType.StatusChanged: - await _auditService.StageStatusChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId); + _auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId); break; case AuditActionType.AssignmentChanged: - await _auditService.StageAssignmentChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId); + _auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId); break; default: - await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue ?? "", change.NewValue, actorId, dispatchId); + _auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue ?? "", change.NewValue, actorId, dispatchId); break; } } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 1eb1ce7..98239bb 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -106,17 +106,16 @@ namespace SeaHaven.Services.Implementation foreach (var change in changes) { - var dispatchId = change.DispatchId ?? (dispatch?.Id > 0 ? dispatch.Id : null); switch (change.Action) { case AuditActionType.StatusChanged: - await _auditService.StageStatusChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId); + _auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId); break; case AuditActionType.AssignmentChanged: - await _auditService.StageAssignmentChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId); + _auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId); break; default: - await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, dispatchId); + _auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, change.DispatchId); break; } } @@ -196,7 +195,8 @@ namespace SeaHaven.Services.Implementation CreatedDate = DateTime.UtcNow }; _context.Dispatches.Add(created); - workOrder.PrimaryDispatch = created; + await _context.SaveChangesAsync(); + workOrder.PrimaryDispatchId = created.Id; return created; } @@ -392,10 +392,10 @@ namespace SeaHaven.Services.Implementation var old = dispatch.VendorId.ToString(); if (dispatch.VendorId == vendorId.Value) - return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch)); + return FieldChange.Unchanged(auditField, dispatch.Id); dispatch.VendorId = vendorId.Value; - return FieldChange.ForField(auditField, old, vendorId.Value.ToString(), ResolveDispatchIdForAudit(dispatch)); + return FieldChange.ForField(auditField, old, vendorId.Value.ToString(), dispatch.Id); } private static FieldChange ApplyApptDate(Dispatch dispatch, string? value, string auditField) @@ -411,14 +411,12 @@ namespace SeaHaven.Services.Implementation var old = FormatDate(dispatch.ScheduledDate); var newVal = FormatDate(parsed); if (old == newVal) - return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch)); + return FieldChange.Unchanged(auditField, dispatch.Id); dispatch.ScheduledDate = parsed; - return FieldChange.ForField(auditField, old, newVal, ResolveDispatchIdForAudit(dispatch)); + return FieldChange.ForField(auditField, old, newVal, dispatch.Id); } - // apptTime is stored on WorkOrder (ScheduledStart/End) but requires a primary dispatch - // for concurrency (dispatchVersion) and audit context; apptDate lives on Dispatch.ScheduledDate. private static FieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value, string auditField) { if (!WorkOrderBoardApptTimeParser.TryParse(value, out var start, out var end, out var error)) @@ -437,9 +435,9 @@ namespace SeaHaven.Services.Implementation var newCombined = FormatApptRange(newStart, newEnd); if (oldCombined == newCombined) - return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch)); + return FieldChange.Unchanged(auditField, dispatch.Id); - return FieldChange.ForField(auditField, oldCombined, newCombined, ResolveDispatchIdForAudit(dispatch)); + return FieldChange.ForField(auditField, oldCombined, newCombined, dispatch.Id); } private static FieldChange ApplyDocStatus(WorkOrder workOrder, string? value, string auditField) @@ -502,9 +500,6 @@ namespace SeaHaven.Services.Implementation return vendorId; } - private static int? ResolveDispatchIdForAudit(Dispatch dispatch) - => dispatch.Id > 0 ? dispatch.Id : null; - private static string? FormatDate(DateTime? value) => value?.ToString("yyyy-MM-dd"); private static string? FormatDateTime(DateTime? value) => value?.ToString("O"); private static string? FormatApptRange(string? start, string? end) diff --git a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs index 3dc4b57..7029709 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs @@ -20,9 +20,9 @@ namespace SeaHaven.Services.Interfaces Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId); Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue); - Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null); - Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId); - Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId); - Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber); + void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null); + void StageStatusChanged(int workOrderId, string? oldStatus, string? newStatus, string? actorId); + void StageAssignmentChanged(int workOrderId, string? oldValue, string? newValue, string? actorId); + void StageCreated(int workOrderId, string? actorId, string? woNumber); } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index cb2b73a..59b180c 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -180,56 +180,6 @@ public class WorkOrderBoardUpdateServiceTests Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.FieldName == "SiteCode")); } - [Fact] - public async Task PatchField_SecondEditSameField_DoesNotDuplicateFieldLock() - { - var (context, service) = CreateSut(); - var wo = new WorkOrder - { - Id = 1, - LifecycleStatus = LifecycleStatus.Scheduled, - SiteCode = "OLD", - RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } - }; - context.workOrders.Add(wo); - await context.SaveChangesAsync(); - - await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto - { - Field = WorkOrderBoardFieldNames.SiteCode, - Value = "BK5", - WorkOrderVersion = ToVersion(wo) - }, "actor-1"); - - var updated = await context.workOrders.FindAsync(1); - var secondResult = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto - { - Field = WorkOrderBoardFieldNames.SiteCode, - Value = "BK6", - WorkOrderVersion = ToVersion(updated!) - }, "actor-1"); - - Assert.Equal("BK6", secondResult.SiteCode); - Assert.Single(await context.WorkOrderFieldLocks.Where(l => l.FieldName == "SiteCode").ToListAsync()); - Assert.Equal(2, await context.WorkOrderAuditLogs.Where(a => a.FieldName == "SiteCode").CountAsync()); - } - - [Fact] - public async Task PatchField_NotFound_ThrowsNotFoundCode() - { - var (_, service) = CreateSut(); - - var ex = await Assert.ThrowsAsync(() => - service.PatchFieldAsync(999, new WorkOrderBoardPatchRequestDto - { - Field = WorkOrderBoardFieldNames.SiteCode, - Value = "BK5", - WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }) - }, "actor-1")); - - Assert.Equal("NotFound", ex.Code); - } - [Fact] public async Task PatchField_NormalizesWoNumber() { From 8c33eb7f9a194038974a252194f5121b374a82d1 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 9 Jul 2026 14:08:48 -0300 Subject: [PATCH 04/33] feat(work-orders): add Phase 5 scheduled domain events (WeekRolled) Introduce in-process WeekRolled job with idempotent ledger to increment carriedOver for the SHOC board, plus optional PastDue cache and admin reprocess endpoints while keeping isPastDue derived on-read. --- .../Controllers/WorkOrderJobsController.cs | 58 ++++ .../PastDueCacheHostedService.cs | 74 +++++ .../WorkOrderWeekRolledHostedService.cs | 82 +++++ .../Options/WorkOrderJobRunState.cs | 11 + .../Options/WorkOrderJobsOptions.cs | 23 ++ Api.SeaHavenIndustries/Program.cs | 8 + Api.SeaHavenIndustries/appsettings.json | 4 + .../Auth/ApplicationDbContext.cs | 5 + .../20260709170202_Phase5_DomainEvents.cs | 50 +++ .../ApplicationDbContextModelSnapshot.cs | 126 ++++--- .../Models/WorkOrderWeekRolledLedger.cs | 15 + .../WorkOrderDomainJobDataService.cs | 166 ++++++++++ .../IWorkOrderDomainJobDataService.cs | 38 +++ SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs | 21 ++ .../Implementation/PastDueCacheService.cs | 48 +++ .../Implementation/WorkOrderAuditService.cs | 11 + .../WorkOrderWeekRolledService.cs | 111 +++++++ .../Interfaces/IPastDueCacheService.cs | 9 + .../Interfaces/IWorkOrderAuditService.cs | 1 + .../Interfaces/IWorkOrderWeekRolledService.cs | 11 + .../WorkOrderWeekRolledTests.cs | 311 ++++++++++++++++++ 21 files changed, 1137 insertions(+), 46 deletions(-) create mode 100644 Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs create mode 100644 Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs create mode 100644 Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs create mode 100644 Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs create mode 100644 Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.cs create mode 100644 Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs create mode 100644 SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs create mode 100644 SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs create mode 100644 SeaHaven.Services/Implementation/PastDueCacheService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs create mode 100644 SeaHaven.Services/Interfaces/IPastDueCacheService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs new file mode 100644 index 0000000..4277475 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs @@ -0,0 +1,58 @@ +using Api.SeaHavenIndustries.Options; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers +{ + /// Ops-only endpoints for manual domain job reprocessing. + [Authorize(Roles = "Admin")] + [ApiController] + [Route("api/workorders/jobs")] + public class WorkOrderJobsController : ControllerBase + { + private readonly IWorkOrderWeekRolledService _weekRolledService; + private readonly IPastDueCacheService _pastDueCacheService; + + public WorkOrderJobsController( + IWorkOrderWeekRolledService weekRolledService, + IPastDueCacheService pastDueCacheService) + { + _weekRolledService = weekRolledService; + _pastDueCacheService = pastDueCacheService; + } + + /// + /// Reprocess WeekRolled for a specific operational week (Monday start). + /// Idempotent via WorkOrderWeekRolledLedger. + /// + [HttpPost("week-rolled")] + [ProducesResponseType(typeof(WeekRolledJobResult), StatusCodes.Status200OK)] + public async Task> RunWeekRolled( + [FromQuery] DateOnly? sourceWeekStart, + CancellationToken cancellationToken) + { + var weekStart = sourceWeekStart + ?? WorkOrderOperationalWeek.GetPreviousOperationalWeekStart(DateTime.UtcNow); + + if (weekStart.DayOfWeek != DayOfWeek.Monday) + return BadRequest("sourceWeekStart must be a Monday (operational week start)."); + + var result = await _weekRolledService.ProcessWeekRolledAsync(weekStart, cancellationToken); + return Ok(result); + } + + /// + /// Refresh optional OperationalFlags.PastDue cache (board isPastDue remains on-read). + /// + [HttpPost("past-due-cache")] + [ProducesResponseType(typeof(PastDueCacheJobResult), StatusCodes.Status200OK)] + public async Task> RunPastDueCache(CancellationToken cancellationToken) + { + var result = await _pastDueCacheService.RefreshAsync(cancellationToken); + return Ok(result); + } + } +} diff --git a/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs b/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs new file mode 100644 index 0000000..67b8cba --- /dev/null +++ b/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs @@ -0,0 +1,74 @@ +using Api.SeaHavenIndustries.Options; +using Microsoft.Extensions.Options; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.HostedServices +{ + public class PastDueCacheHostedService : BackgroundService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly PastDueCacheJobOptions _options; + private readonly WorkOrderJobRunState _runState; + private DateOnly? _lastRunDate; + + public PastDueCacheHostedService( + IServiceScopeFactory scopeFactory, + IOptions options, + WorkOrderJobRunState runState, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + _options = options.Value.PastDueCache; + _runState = runState; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + if (_options.Enabled && ShouldRunNow()) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + if (_lastRunDate != today) + { + _lastRunDate = today; + await RunJobAsync(stoppingToken); + } + } + + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + } + } + + private async Task RunJobAsync(CancellationToken stoppingToken) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + await service.RefreshAsync(stoppingToken); + + _runState.LastPastDueCacheRunUtc = DateTime.UtcNow; + _runState.LastPastDueCacheError = null; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _runState.LastPastDueCacheError = ex.Message; + _logger.LogError(ex, "PastDue cache hosted job failed."); + } + } + + private bool ShouldRunNow() + { + var now = DateTime.UtcNow; + + 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); + } + } +} diff --git a/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs b/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs new file mode 100644 index 0000000..7caf0a2 --- /dev/null +++ b/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs @@ -0,0 +1,82 @@ +using Api.SeaHavenIndustries.Options; +using Microsoft.Extensions.Options; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.HostedServices +{ + public class WorkOrderWeekRolledHostedService : BackgroundService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly WeekRolledJobOptions _options; + private readonly WorkOrderJobRunState _runState; + private DateOnly? _lastRunDate; + + public WorkOrderWeekRolledHostedService( + IServiceScopeFactory scopeFactory, + IOptions options, + WorkOrderJobRunState runState, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + _options = options.Value.WeekRolled; + _runState = runState; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + if (_options.Enabled && ShouldRunNow()) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + if (_lastRunDate != today) + { + _lastRunDate = today; + await RunJobAsync(stoppingToken); + } + } + + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + } + } + + private async Task RunJobAsync(CancellationToken stoppingToken) + { + try + { + var sourceWeekStart = WorkOrderOperationalWeek.GetPreviousOperationalWeekStart(DateTime.UtcNow); + + using var scope = _scopeFactory.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + await service.ProcessWeekRolledAsync(sourceWeekStart, stoppingToken); + + _runState.LastWeekRolledRunUtc = DateTime.UtcNow; + _runState.LastWeekRolledError = null; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _runState.LastWeekRolledError = ex.Message; + _logger.LogError(ex, "WeekRolled hosted job failed."); + } + } + + private bool ShouldRunNow() + { + var now = DateTime.UtcNow; + if (!Enum.TryParse(_options.DayOfWeek, ignoreCase: true, out var targetDay)) + targetDay = DayOfWeek.Monday; + + if (now.DayOfWeek != targetDay) + return false; + + if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt)) + runAt = new TimeOnly(0, 5); + + var current = TimeOnly.FromDateTime(now); + return current >= runAt && current < runAt.AddMinutes(1); + } + } +} diff --git a/Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs b/Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs new file mode 100644 index 0000000..dd95de5 --- /dev/null +++ b/Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs @@ -0,0 +1,11 @@ +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/Options/WorkOrderJobsOptions.cs b/Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs new file mode 100644 index 0000000..791638c --- /dev/null +++ b/Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs @@ -0,0 +1,23 @@ +namespace Api.SeaHavenIndustries.Options +{ + public class WorkOrderJobsOptions + { + public const string SectionName = "WorkOrderJobs"; + + public WeekRolledJobOptions WeekRolled { get; set; } = new(); + public PastDueCacheJobOptions PastDueCache { get; set; } = new(); + } + + public class WeekRolledJobOptions + { + public bool Enabled { get; set; } = true; + public string RunAtUtc { get; set; } = "00:05"; + public string DayOfWeek { get; set; } = "Monday"; + } + + public class PastDueCacheJobOptions + { + public bool Enabled { get; set; } + public string RunAtUtc { get; set; } = "00:10"; + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 6dc0e95..922582e 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -1,4 +1,6 @@ using Api.SeaHavenIndustries.Helper; +using Api.SeaHavenIndustries.HostedServices; +using Api.SeaHavenIndustries.Options; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; @@ -51,6 +53,12 @@ builder.Services.AddSingleton(sp builder.Services.AddDataServices(); builder.Services.AddBusinessServices(); +builder.Services.Configure( + builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName)); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); + builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; diff --git a/Api.SeaHavenIndustries/appsettings.json b/Api.SeaHavenIndustries/appsettings.json index 2d5dc80..f023879 100644 --- a/Api.SeaHavenIndustries/appsettings.json +++ b/Api.SeaHavenIndustries/appsettings.json @@ -23,5 +23,9 @@ "ValidIssuer": "http://localhost:7195", // Provide the real value via environment variable JWT__Secret or user-secrets. "Secret": "${JWT_SECRET}" + }, + "WorkOrderJobs": { + "WeekRolled": { "Enabled": true, "RunAtUtc": "00:05", "DayOfWeek": "Monday" }, + "PastDueCache": { "Enabled": false, "RunAtUtc": "00:10" } } } \ No newline at end of file diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index 7cce9c8..6f2460b 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -38,6 +38,10 @@ namespace Data.SeaHavenIndustries .HasIndex(l => new { l.WorkOrderId, l.FieldName }) .IsUnique(); + builder.Entity() + .HasIndex(l => new { l.WorkOrderId, l.SourceWeekStart }) + .IsUnique(); + builder.Entity() .Property(w => w.RowVersion) .IsRowVersion(); @@ -66,6 +70,7 @@ namespace Data.SeaHavenIndustries public DbSet workOrderAttachments { get; set; } public DbSet WorkOrderAuditLogs { get; set; } public DbSet WorkOrderFieldLocks { get; set; } + public DbSet WorkOrderWeekRolledLedgers { get; set; } public DbSet DropdownOptions { get; set; } public DbSet Vendors { get; set; } public DbSet VendorAccessTokens { get; set; } diff --git a/Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.cs b/Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.cs new file mode 100644 index 0000000..6a0307f --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class Phase5_DomainEvents : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "WorkOrderWeekRolledLedgers", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + WorkOrderId = table.Column(type: "int", nullable: false), + SourceWeekStart = table.Column(type: "date", nullable: false), + ProcessedAt = table.Column(type: "datetime2", nullable: false), + CorrelationId = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WorkOrderWeekRolledLedgers", x => x.Id); + table.ForeignKey( + name: "FK_WorkOrderWeekRolledLedgers_workOrders_WorkOrderId", + column: x => x.WorkOrderId, + principalTable: "workOrders", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_WorkOrderWeekRolledLedgers_WorkOrderId_SourceWeekStart", + table: "WorkOrderWeekRolledLedgers", + columns: new[] { "WorkOrderId", "SourceWeekStart" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "WorkOrderWeekRolledLedgers"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 061ccd8..2b54ef9 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,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Categories"); + b.ToTable("Categories", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => @@ -438,7 +438,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkerOrderId"); - b.ToTable("Comments"); + b.ToTable("Comments", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => @@ -504,7 +504,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("ContactId"); - b.ToTable("ContactDetails"); + b.ToTable("ContactDetails", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => @@ -596,7 +596,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("LocationId"); - b.ToTable("Contacts"); + b.ToTable("Contacts", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => @@ -614,7 +614,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Departments"); + b.ToTable("Departments", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => @@ -705,7 +705,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("Dispatches"); + b.ToTable("Dispatches", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => @@ -764,7 +764,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("DispatchChecklistItems"); + b.ToTable("DispatchChecklistItems", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => @@ -818,7 +818,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("DispatchId"); - b.ToTable("DispatchSignoffs"); + b.ToTable("DispatchSignoffs", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => @@ -888,7 +888,7 @@ namespace Data.SeaHavenIndustries.Migrations .IsUnique() .HasFilter("[Status] = 'Pending'"); - b.ToTable("DispatchUpliftRequests"); + b.ToTable("DispatchUpliftRequests", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => @@ -911,7 +911,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("DispatchWorkOrders"); + b.ToTable("DispatchWorkOrders", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => @@ -941,7 +941,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("DropdownOptions"); + b.ToTable("DropdownOptions", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => @@ -1046,7 +1046,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("RegionId"); - b.ToTable("Employees"); + b.ToTable("Employees", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => @@ -1086,7 +1086,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("EmployeeId") .IsUnique(); - b.ToTable("EmployeeAddresses"); + b.ToTable("EmployeeAddresses", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => @@ -1114,7 +1114,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("EmployeeId"); - b.ToTable("EmployeeEmails"); + b.ToTable("EmployeeEmails", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => @@ -1144,7 +1144,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("EmployeeId"); - b.ToTable("EmployeePhones"); + b.ToTable("EmployeePhones", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => @@ -1212,7 +1212,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("FollowUps"); + b.ToTable("FollowUps", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => @@ -1237,7 +1237,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("ForgetPasswordCodes"); + b.ToTable("ForgetPasswordCodes", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => @@ -1255,7 +1255,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("JobTitles"); + b.ToTable("JobTitles", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => @@ -1325,7 +1325,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Locations"); + b.ToTable("Locations", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => @@ -1383,7 +1383,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("LocationId"); - b.ToTable("PMSchedules"); + b.ToTable("PMSchedules", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => @@ -1457,7 +1457,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkorderId"); - b.ToTable("Quotes"); + b.ToTable("Quotes", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => @@ -1505,7 +1505,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("QuoteId"); - b.ToTable("QuotesLines"); + b.ToTable("QuotesLines", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => @@ -1523,7 +1523,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Regions"); + b.ToTable("Regions", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => @@ -1566,7 +1566,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("TaskListTemplates"); + b.ToTable("TaskListTemplates", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => @@ -1611,7 +1611,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("TaskListTemplateId"); - b.ToTable("TaskListTemplateItems"); + b.ToTable("TaskListTemplateItems", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => @@ -1713,7 +1713,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("LocationId"); - b.ToTable("Templates"); + b.ToTable("Templates", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => @@ -1777,7 +1777,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Vendors"); + b.ToTable("Vendors", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => @@ -1836,7 +1836,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("VendorId"); - b.ToTable("VendorAccessTokens"); + b.ToTable("VendorAccessTokens", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => @@ -1969,13 +1969,13 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("ScheduledStart") .HasColumnType("datetime2"); - b.Property("Severity") - .HasColumnType("nvarchar(max)"); - b.Property("Service") .HasMaxLength(128) .HasColumnType("nvarchar(128)"); + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + b.Property("SignOffAttachment") .HasColumnType("nvarchar(max)"); @@ -2031,17 +2031,11 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("AssignTo"); - b.HasIndex("AssignTo", "ScheduledDate"); - b.HasIndex("LocationId"); b.HasIndex("PrimaryDispatchId"); - b.HasIndex("ScheduledDate"); - - b.HasIndex("WorkOrderType"); - - b.ToTable("workOrders"); + b.ToTable("workOrders", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => @@ -2083,7 +2077,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkorderId"); - b.ToTable("workOrderAttachments"); + b.ToTable("workOrderAttachments", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => @@ -2133,7 +2127,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("WorkOrderAuditLogs"); + b.ToTable("WorkOrderAuditLogs", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => @@ -2177,7 +2171,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkorderId"); - b.ToTable("workOrderCategories"); + b.ToTable("workOrderCategories", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => @@ -2224,7 +2218,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkorderId"); - b.ToTable("WorkOrderContacts"); + b.ToTable("WorkOrderContacts", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => @@ -2253,7 +2247,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("Microsoft.AspNetCore.Identity.IdentityRole", b => @@ -2822,6 +2845,17 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("WorkOrder"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => { b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") diff --git a/Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs b/Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs new file mode 100644 index 0000000..efa661a --- /dev/null +++ b/Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Data.SeaHavenIndustries +{ + public class WorkOrderWeekRolledLedger + { + public int Id { get; set; } + public int WorkOrderId { get; set; } + [ForeignKey(nameof(WorkOrderId))] + public virtual WorkOrder? WorkOrder { get; set; } + public DateOnly SourceWeekStart { get; set; } + public DateTime ProcessedAt { get; set; } + public string CorrelationId { get; set; } = ""; + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs new file mode 100644 index 0000000..2d8433a --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs @@ -0,0 +1,166 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderDomainJobDataService : IWorkOrderDomainJobDataService + { + private static readonly LifecycleStatus?[] TerminalStatuses = + { + LifecycleStatus.Completed, + LifecycleStatus.Canceled + }; + + private readonly ApplicationDbContext _context; + + public WorkOrderDomainJobDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task> GetWeekRolledCandidatesAsync( + DateOnly sourceWeekStart, + DateOnly sourceWeekEnd, + int batchSize = 500, + int skip = 0, + CancellationToken cancellationToken = default) + { + var weekStartDate = sourceWeekStart.ToDateTime(TimeOnly.MinValue); + var weekEndDate = sourceWeekEnd.ToDateTime(TimeOnly.MinValue); + + return await _context.workOrders + .AsNoTracking() + .Where(w => w.IsDeleted != true) + .Where(w => w.istemplate != true) + .Where(w => w.ScheduledDate != null) + .Where(w => + w.ScheduledDate!.Value.Date >= weekStartDate.Date + && w.ScheduledDate.Value.Date <= weekEndDate.Date) + .Where(w => !TerminalStatuses.Contains(w.LifecycleStatus)) + .Where(w => !_context.WorkOrderWeekRolledLedgers.Any(l => + l.WorkOrderId == w.Id && l.SourceWeekStart == sourceWeekStart)) + .OrderBy(w => w.Id) + .Skip(skip) + .Take(batchSize) + .Select(w => w.Id) + .ToListAsync(cancellationToken); + } + + public async Task TryProcessWeekRolledAsync( + int workOrderId, + DateOnly sourceWeekStart, + string correlationId, + CancellationToken cancellationToken = default) + { + await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + + var alreadyProcessed = await _context.WorkOrderWeekRolledLedgers + .AnyAsync(l => l.WorkOrderId == workOrderId && l.SourceWeekStart == sourceWeekStart, cancellationToken); + + if (alreadyProcessed) + { + await transaction.RollbackAsync(cancellationToken); + return new WeekRolledProcessOutcome { Skipped = true }; + } + + var workOrder = await _context.workOrders + .FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken); + + if (workOrder == null) + { + await transaction.RollbackAsync(cancellationToken); + return new WeekRolledProcessOutcome { Skipped = true }; + } + + _context.WorkOrderWeekRolledLedgers.Add(new WorkOrderWeekRolledLedger + { + WorkOrderId = workOrderId, + SourceWeekStart = sourceWeekStart, + ProcessedAt = DateTime.UtcNow, + CorrelationId = correlationId + }); + + var oldCarriedOver = workOrder.CarriedOver; + workOrder.CarriedOver = oldCarriedOver + 1; + + try + { + await _context.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch (DbUpdateException) + { + await transaction.RollbackAsync(cancellationToken); + return new WeekRolledProcessOutcome { Skipped = true }; + } + + return new WeekRolledProcessOutcome + { + Processed = true, + OldCarriedOver = oldCarriedOver, + NewCarriedOver = workOrder.CarriedOver + }; + } + + public async Task RefreshPastDueFlagsAsync( + DateTime utcNow, + int batchSize = 500, + CancellationToken cancellationToken = default) + { + var today = utcNow.Date; + var setCount = 0; + var clearedCount = 0; + var examinedCount = 0; + var skip = 0; + + while (true) + { + var batch = await _context.workOrders + .Where(w => w.IsDeleted != true) + .Where(w => w.istemplate != true) + .Where(w => w.ScheduledDate != null) + .OrderBy(w => w.Id) + .Skip(skip) + .Take(batchSize) + .ToListAsync(cancellationToken); + + if (batch.Count == 0) + break; + + foreach (var workOrder in batch) + { + examinedCount++; + var shouldBePastDue = workOrder.ScheduledDate!.Value.Date < today + && !TerminalStatuses.Contains(workOrder.LifecycleStatus); + var hasFlag = workOrder.OperationalFlags.HasFlag(OperationalFlags.PastDue); + + if (shouldBePastDue && !hasFlag) + { + workOrder.OperationalFlags |= OperationalFlags.PastDue; + setCount++; + } + else if (!shouldBePastDue && hasFlag) + { + workOrder.OperationalFlags &= ~OperationalFlags.PastDue; + clearedCount++; + } + } + + await _context.SaveChangesAsync(cancellationToken); + skip += batchSize; + + if (batch.Count < batchSize) + break; + } + + return new PastDueRefreshResult + { + SetCount = setCount, + ClearedCount = clearedCount, + ExaminedCount = examinedCount + }; + } + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs new file mode 100644 index 0000000..4c6af01 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs @@ -0,0 +1,38 @@ +namespace SeaHaven.DataServices.Interfaces +{ + public sealed class WeekRolledProcessOutcome + { + public bool Processed { get; init; } + public bool Skipped { get; init; } + public int OldCarriedOver { get; init; } + public int NewCarriedOver { get; init; } + } + + public sealed class PastDueRefreshResult + { + public int SetCount { get; init; } + public int ClearedCount { get; init; } + public int ExaminedCount { get; init; } + } + + public interface IWorkOrderDomainJobDataService + { + Task> GetWeekRolledCandidatesAsync( + DateOnly sourceWeekStart, + DateOnly sourceWeekEnd, + int batchSize = 500, + int skip = 0, + CancellationToken cancellationToken = default); + + Task TryProcessWeekRolledAsync( + int workOrderId, + DateOnly sourceWeekStart, + string correlationId, + CancellationToken cancellationToken = default); + + Task RefreshPastDueFlagsAsync( + DateTime utcNow, + int batchSize = 500, + CancellationToken cancellationToken = default); + } +} diff --git a/SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs new file mode 100644 index 0000000..885c363 --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs @@ -0,0 +1,21 @@ +namespace SeaHaven.Services.DTOs +{ + public sealed class WeekRolledJobResult + { + public int Processed { get; set; } + public int Skipped { get; set; } + public int Failed { get; set; } + public string CorrelationId { get; set; } = ""; + public DateOnly SourceWeekStart { get; set; } + public DateOnly SourceWeekEnd { get; set; } + public long DurationMs { get; set; } + } + + public sealed class PastDueCacheJobResult + { + public int SetCount { get; set; } + public int ClearedCount { get; set; } + public int ExaminedCount { get; set; } + public long DurationMs { get; set; } + } +} diff --git a/SeaHaven.Services/Implementation/PastDueCacheService.cs b/SeaHaven.Services/Implementation/PastDueCacheService.cs new file mode 100644 index 0000000..33c1646 --- /dev/null +++ b/SeaHaven.Services/Implementation/PastDueCacheService.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class PastDueCacheService : IPastDueCacheService + { + private readonly IWorkOrderDomainJobDataService _dataService; + private readonly ILogger _logger; + + public PastDueCacheService( + IWorkOrderDomainJobDataService dataService, + ILogger logger) + { + _dataService = dataService; + _logger = logger; + } + + public async Task RefreshAsync(CancellationToken cancellationToken = default) + { + var stopwatch = Stopwatch.StartNew(); + var utcNow = DateTime.UtcNow; + + _logger.LogInformation("PastDue cache refresh started at {UtcNow}", utcNow); + + var refreshResult = await _dataService.RefreshPastDueFlagsAsync(utcNow, cancellationToken: cancellationToken); + + stopwatch.Stop(); + + var result = new PastDueCacheJobResult + { + SetCount = refreshResult.SetCount, + ClearedCount = refreshResult.ClearedCount, + ExaminedCount = refreshResult.ExaminedCount, + DurationMs = stopwatch.ElapsedMilliseconds + }; + + _logger.LogInformation( + "PastDue cache refresh completed. Set={SetCount}, Cleared={ClearedCount}, Examined={ExaminedCount}, DurationMs={DurationMs}", + result.SetCount, result.ClearedCount, result.ExaminedCount, result.DurationMs); + + return result; + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs index 0646cd0..73f871f 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs @@ -28,6 +28,17 @@ namespace SeaHaven.Services.Implementation => LogAsync(workOrderId, AuditActionType.SyncRejected, AuditEventType.Sync, AuditActorType.Sync, fieldName, oldValue, newValue); + public Task LogWeekRolledAsync(int workOrderId, int oldCarriedOver, int newCarriedOver, string correlationId) + => LogAsync( + workOrderId, + AuditActionType.WeekRolled, + AuditEventType.System, + AuditActorType.System, + "CarriedOver", + oldCarriedOver.ToString(), + newCarriedOver.ToString(), + correlationId: correlationId); + public void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null) => StageAudit(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId); diff --git a/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs b/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs new file mode 100644 index 0000000..bf82574 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs @@ -0,0 +1,111 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderWeekRolledService : IWorkOrderWeekRolledService + { + private const int BatchSize = 500; + + private readonly IWorkOrderDomainJobDataService _dataService; + private readonly IWorkOrderAuditService _auditService; + private readonly ILogger _logger; + + public WorkOrderWeekRolledService( + IWorkOrderDomainJobDataService dataService, + IWorkOrderAuditService auditService, + ILogger logger) + { + _dataService = dataService; + _auditService = auditService; + _logger = logger; + } + + public async Task ProcessWeekRolledAsync( + DateOnly sourceWeekStart, + CancellationToken cancellationToken = default) + { + var stopwatch = Stopwatch.StartNew(); + var sourceWeekEnd = WorkOrderOperationalWeek.GetOperationalWeekEnd(sourceWeekStart); + var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(sourceWeekStart); + + var processed = 0; + var skipped = 0; + var failed = 0; + var skip = 0; + + _logger.LogInformation( + "WeekRolled job started. CorrelationId={CorrelationId}, SourceWeekStart={SourceWeekStart}, SourceWeekEnd={SourceWeekEnd}", + correlationId, sourceWeekStart, sourceWeekEnd); + + while (!cancellationToken.IsCancellationRequested) + { + var candidates = await _dataService.GetWeekRolledCandidatesAsync( + sourceWeekStart, sourceWeekEnd, BatchSize, skip, cancellationToken); + + if (candidates.Count == 0) + break; + + foreach (var workOrderId in candidates) + { + if (cancellationToken.IsCancellationRequested) + break; + + try + { + var outcome = await _dataService.TryProcessWeekRolledAsync( + workOrderId, sourceWeekStart, correlationId, cancellationToken); + + if (outcome.Skipped) + { + skipped++; + continue; + } + + if (outcome.Processed) + { + await _auditService.LogWeekRolledAsync( + workOrderId, outcome.OldCarriedOver, outcome.NewCarriedOver, correlationId); + processed++; + } + } + catch (Exception ex) + { + failed++; + _logger.LogError(ex, + "WeekRolled failed for WorkOrderId={WorkOrderId}, CorrelationId={CorrelationId}", + workOrderId, correlationId); + } + } + + if (candidates.Count < BatchSize) + break; + + skip += BatchSize; + } + + stopwatch.Stop(); + + var result = new WeekRolledJobResult + { + Processed = processed, + Skipped = skipped, + Failed = failed, + CorrelationId = correlationId, + SourceWeekStart = sourceWeekStart, + SourceWeekEnd = sourceWeekEnd, + DurationMs = stopwatch.ElapsedMilliseconds + }; + + _logger.LogInformation( + "WeekRolled job completed. CorrelationId={CorrelationId}, Processed={Processed}, Skipped={Skipped}, Failed={Failed}, DurationMs={DurationMs}", + correlationId, processed, skipped, failed, result.DurationMs); + + return result; + } + } +} diff --git a/SeaHaven.Services/Interfaces/IPastDueCacheService.cs b/SeaHaven.Services/Interfaces/IPastDueCacheService.cs new file mode 100644 index 0000000..e87f498 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IPastDueCacheService.cs @@ -0,0 +1,9 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IPastDueCacheService + { + Task RefreshAsync(CancellationToken cancellationToken = default); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs index 7029709..eda29f8 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs @@ -19,6 +19,7 @@ namespace SeaHaven.Services.Interfaces Task LogStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId); Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId); Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue); + Task LogWeekRolledAsync(int workOrderId, int oldCarriedOver, int newCarriedOver, string correlationId); void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null); void StageStatusChanged(int workOrderId, string? oldStatus, string? newStatus, string? actorId); diff --git a/SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs b/SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs new file mode 100644 index 0000000..b8c1a6a --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs @@ -0,0 +1,11 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderWeekRolledService + { + Task ProcessWeekRolledAsync( + DateOnly sourceWeekStart, + CancellationToken cancellationToken = default); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs new file mode 100644 index 0000000..9627581 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs @@ -0,0 +1,311 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging.Abstractions; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderWeekRolledTests +{ + private static readonly DateOnly SourceWeekStart = new(2026, 6, 16); + private static readonly DateOnly SourceWeekEnd = new(2026, 6, 20); + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrderWeekRolledService CreateService(ApplicationDbContext context) + { + var dataService = new WorkOrderDomainJobDataService(context); + var audit = new WorkOrderAuditService(context, new WorkOrderFieldLockService(context)); + return new WorkOrderWeekRolledService( + dataService, + audit, + NullLogger.Instance); + } + + [Fact] + public async Task WeekRolled_IncrementsCarriedOver_ForEligibleWo() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Scheduled, + CarriedOver = 0 + }); + await context.SaveChangesAsync(); + + var service = CreateService(context); + var result = await service.ProcessWeekRolledAsync(SourceWeekStart); + + Assert.Equal(1, result.Processed); + Assert.Equal(0, result.Skipped); + Assert.Equal(0, result.Failed); + + var wo = await context.workOrders.FindAsync(1); + Assert.Equal(1, wo!.CarriedOver); + } + + [Fact] + public async Task WeekRolled_SkipsTerminalStatuses() + { + await using var context = CreateContext(); + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Completed + }, + new WorkOrder + { + Id = 2, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Canceled + }); + await context.SaveChangesAsync(); + + var service = CreateService(context); + var result = await service.ProcessWeekRolledAsync(SourceWeekStart); + + Assert.Equal(0, result.Processed); + Assert.Equal(0, result.Failed); + } + + [Fact] + public async Task WeekRolled_Idempotent_SecondRunSkips() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Scheduled, + CarriedOver = 0 + }); + await context.SaveChangesAsync(); + + var service = CreateService(context); + var first = await service.ProcessWeekRolledAsync(SourceWeekStart); + var second = await service.ProcessWeekRolledAsync(SourceWeekStart); + + Assert.Equal(1, first.Processed); + Assert.Equal(0, second.Processed); + Assert.Equal(0, second.Failed); + + var wo = await context.workOrders.FindAsync(1); + Assert.Equal(1, wo!.CarriedOver); + } + + [Fact] + public async Task WeekRolled_WritesAuditEvent() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.InProgress, + CarriedOver = 2 + }); + await context.SaveChangesAsync(); + + var service = CreateService(context); + await service.ProcessWeekRolledAsync(SourceWeekStart); + + var logs = await context.WorkOrderAuditLogs.ToListAsync(); + Assert.Single(logs); + Assert.Equal("WeekRolled", logs[0].Action); + Assert.Equal("System", logs[0].EventType); + Assert.Equal("System", logs[0].ActorType); + Assert.Equal("CarriedOver", logs[0].FieldName); + Assert.Equal("2", logs[0].OldValue); + Assert.Equal("3", logs[0].NewValue); + Assert.Equal("week:2026-06-16", logs[0].CorrelationId); + + var locks = await context.WorkOrderFieldLocks.ToListAsync(); + Assert.Empty(locks); + } + + [Fact] + public async Task WeekRolled_ConcurrentWithReschedule_DoesNotDuplicate() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Scheduled, + CarriedOver = 0 + }); + await context.SaveChangesAsync(); + + var dataService = new WorkOrderDomainJobDataService(context); + var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(SourceWeekStart); + + var first = await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId); + var second = await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId); + + Assert.True(first.Processed); + Assert.True(second.Skipped); + + var wo = await context.workOrders.FindAsync(1); + Assert.Equal(1, wo!.CarriedOver); + } + + [Fact] + public async Task WeekRolled_FailureReRun_IsSafeAfterPartialLedger() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Scheduled, + CarriedOver = 0 + }); + await context.SaveChangesAsync(); + + var dataService = new WorkOrderDomainJobDataService(context); + var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(SourceWeekStart); + + await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId); + + var service = CreateService(context); + var rerun = await service.ProcessWeekRolledAsync(SourceWeekStart); + + Assert.Equal(0, rerun.Processed); + Assert.Equal(0, rerun.Failed); + + var wo = await context.workOrders.FindAsync(1); + Assert.Equal(1, wo!.CarriedOver); + } + + [Fact] + public async Task PastDueCache_SyncsOperationalFlags() + { + await using var context = CreateContext(); + var pastDate = DateTime.UtcNow.Date.AddDays(-3); + var futureDate = DateTime.UtcNow.Date.AddDays(3); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + ScheduledDate = pastDate, + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 2, + ScheduledDate = pastDate, + LifecycleStatus = LifecycleStatus.Completed, + OperationalFlags = OperationalFlags.PastDue + }, + new WorkOrder + { + Id = 3, + ScheduledDate = futureDate, + LifecycleStatus = LifecycleStatus.Scheduled, + OperationalFlags = OperationalFlags.PastDue + }); + await context.SaveChangesAsync(); + + var dataService = new WorkOrderDomainJobDataService(context); + var result = await dataService.RefreshPastDueFlagsAsync(DateTime.UtcNow); + + Assert.Equal(1, result.SetCount); + Assert.Equal(2, result.ClearedCount); + + var wo1 = await context.workOrders.FindAsync(1); + var wo2 = await context.workOrders.FindAsync(2); + var wo3 = await context.workOrders.FindAsync(3); + + Assert.True(wo1!.OperationalFlags.HasFlag(OperationalFlags.PastDue)); + Assert.False(wo2!.OperationalFlags.HasFlag(OperationalFlags.PastDue)); + Assert.False(wo3!.OperationalFlags.HasFlag(OperationalFlags.PastDue)); + } + + [Fact] + public async Task Board_ReflectsCarriedOver_AfterWeekRolled() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Scheduled, + CarriedOver = 0 + }); + await context.SaveChangesAsync(); + + var service = CreateService(context); + await service.ProcessWeekRolledAsync(SourceWeekStart); + + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var row = await boardService.GetBoardRowAsync(1); + + Assert.NotNull(row); + Assert.Equal(1, row!.CarriedOver); + } + + [Fact] + public async Task Board_IsPastDue_UnaffectedByCache() + { + await using var context = CreateContext(); + var pastDate = DateTime.UtcNow.Date.AddDays(-2); + + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = pastDate, + LifecycleStatus = LifecycleStatus.Scheduled, + OperationalFlags = OperationalFlags.None + }); + await context.SaveChangesAsync(); + + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var row = await boardService.GetBoardRowAsync(1); + + Assert.NotNull(row); + Assert.True(row!.IsPastDue); + Assert.False(WorkOrderDerivedFields.IsPastDue(pastDate, LifecycleStatus.Scheduled) == false); + } + + [Fact] + public async Task Board_IsPastDue_IgnoresStalePastDueFlag() + { + await using var context = CreateContext(); + var futureDate = DateTime.UtcNow.Date.AddDays(5); + + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = futureDate, + LifecycleStatus = LifecycleStatus.Scheduled, + OperationalFlags = OperationalFlags.PastDue + }); + await context.SaveChangesAsync(); + + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var row = await boardService.GetBoardRowAsync(1); + + Assert.NotNull(row); + Assert.False(row!.IsPastDue); + } +} From dd2a805c24c083900f1f46a7c95a2d623d1fb4d3 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 10 Jul 2026 10:56:18 -0300 Subject: [PATCH 05/33] fix(work-orders): return 400 for invalid advanced-search custom date range Map ArgumentException from datePreset=custom without dates to BadRequest so the endpoint matches the Phase 4 contract. --- .../WorkOrderControllerSearchTests.cs | 101 ++++++++++++++++++ .../Controllers/WorkOrderController.cs | 13 ++- 2 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs new file mode 100644 index 0000000..61d37c9 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs @@ -0,0 +1,101 @@ +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Moq; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class WorkOrderControllerSearchTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static UserManager MockUserManager() + { + var store = new Mock>(); + return new Mock>( + store.Object, null!, null!, null!, null!, null!, null!, null!, null!).Object; + } + + private static WorkOrderController NewController( + ApplicationDbContext ctx, + IWorkOrderAdvancedSearchService advancedSearch) + { + var controller = new WorkOrderController( + MockUserManager(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + ctx, + Mock.Of(), + Mock.Of(), + null!, + null!, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + advancedSearch); + + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new System.Security.Claims.ClaimsPrincipal( + new System.Security.Claims.ClaimsIdentity( + new[] + { + new System.Security.Claims.Claim( + System.Security.Claims.ClaimTypes.NameIdentifier, + "test-user") + }, + "Test")) + } + }; + + return controller; + } + + [Fact] + public async Task SearchBoard_WhenCustomPresetMissingDates_ReturnsBadRequest() + { + using var ctx = NewContext(); + var advancedSearch = new WorkOrderAdvancedSearchService( + Mock.Of()); + var controller = NewController(ctx, advancedSearch); + + var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom + }); + + var badRequest = result.Should().BeOfType().Subject; + var body = badRequest.Value.Should().BeOfType().Subject; + body.Status.Should().Be("Error"); + body.Message.Should().Contain("dateFrom"); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index f227ffd..685c3c6 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -120,9 +120,16 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("board/search")] public async Task SearchBoard([FromQuery] WorkOrderAdvancedSearchQueryDto query) { - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var result = await _advancedSearchService.SearchAsync(query, userId); - return Ok(result); + try + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var result = await _advancedSearchService.SearchAsync(query, userId); + return Ok(result); + } + catch (ArgumentException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } } [HttpGet("lookups/dispatchers")] From f977483c17439e08ac9082adff2b3c3c79651ce2 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 10 Jul 2026 11:01:52 -0300 Subject: [PATCH 06/33] fix(work-orders): process all WeekRolled candidates across batches Offset paging skipped remaining WOs after ledger writes; always refetch the first page of unprocessed candidates and cover with a >BatchSize test. --- .../WorkOrderDomainJobDataService.cs | 4 +-- .../IWorkOrderDomainJobDataService.cs | 1 - .../WorkOrderWeekRolledService.cs | 7 ++-- .../WorkOrderWeekRolledTests.cs | 36 +++++++++++++++++++ 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs index 2d8433a..65d98bc 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs @@ -24,12 +24,13 @@ namespace SeaHaven.DataServices.Implementation DateOnly sourceWeekStart, DateOnly sourceWeekEnd, int batchSize = 500, - int skip = 0, CancellationToken cancellationToken = default) { var weekStartDate = sourceWeekStart.ToDateTime(TimeOnly.MinValue); var weekEndDate = sourceWeekEnd.ToDateTime(TimeOnly.MinValue); + // Always return the first page of remaining unprocessed candidates. + // Callers must not offset-page: ledger writes shrink this set after each batch. return await _context.workOrders .AsNoTracking() .Where(w => w.IsDeleted != true) @@ -42,7 +43,6 @@ namespace SeaHaven.DataServices.Implementation .Where(w => !_context.WorkOrderWeekRolledLedgers.Any(l => l.WorkOrderId == w.Id && l.SourceWeekStart == sourceWeekStart)) .OrderBy(w => w.Id) - .Skip(skip) .Take(batchSize) .Select(w => w.Id) .ToListAsync(cancellationToken); diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs index 4c6af01..5fac4ba 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs @@ -21,7 +21,6 @@ namespace SeaHaven.DataServices.Interfaces DateOnly sourceWeekStart, DateOnly sourceWeekEnd, int batchSize = 500, - int skip = 0, CancellationToken cancellationToken = default); Task TryProcessWeekRolledAsync( diff --git a/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs b/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs index bf82574..ec4eebf 100644 --- a/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs @@ -36,16 +36,17 @@ namespace SeaHaven.Services.Implementation var processed = 0; var skipped = 0; var failed = 0; - var skip = 0; _logger.LogInformation( "WeekRolled job started. CorrelationId={CorrelationId}, SourceWeekStart={SourceWeekStart}, SourceWeekEnd={SourceWeekEnd}", correlationId, sourceWeekStart, sourceWeekEnd); + // Always fetch the first page of remaining candidates. Processed rows write ledger + // entries that exclude them from the next query; offset paging would skip unprocessed rows. while (!cancellationToken.IsCancellationRequested) { var candidates = await _dataService.GetWeekRolledCandidatesAsync( - sourceWeekStart, sourceWeekEnd, BatchSize, skip, cancellationToken); + sourceWeekStart, sourceWeekEnd, BatchSize, cancellationToken); if (candidates.Count == 0) break; @@ -84,8 +85,6 @@ namespace SeaHaven.Services.Implementation if (candidates.Count < BatchSize) break; - - skip += BatchSize; } stopwatch.Stop(); diff --git a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs index 9627581..4aef509 100644 --- a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs @@ -193,6 +193,42 @@ public class WorkOrderWeekRolledTests Assert.Equal(1, wo!.CarriedOver); } + [Fact] + public async Task WeekRolled_ProcessesAllEligible_WhenMoreThanBatchSize() + { + // WorkOrderWeekRolledService.BatchSize is 500; more than one page must all be carried over. + const int eligibleCount = 501; + + await using var context = CreateContext(); + for (var i = 1; i <= eligibleCount; i++) + { + context.workOrders.Add(new WorkOrder + { + Id = i, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Scheduled, + CarriedOver = 0 + }); + } + await context.SaveChangesAsync(); + + var service = CreateService(context); + var result = await service.ProcessWeekRolledAsync(SourceWeekStart); + + Assert.Equal(eligibleCount, result.Processed); + Assert.Equal(0, result.Skipped); + Assert.Equal(0, result.Failed); + + var carriedOverCount = await context.workOrders.CountAsync(w => w.CarriedOver == 1); + Assert.Equal(eligibleCount, carriedOverCount); + + var ledgerCount = await context.WorkOrderWeekRolledLedgers.CountAsync(); + Assert.Equal(eligibleCount, ledgerCount); + + var auditCount = await context.WorkOrderAuditLogs.CountAsync(l => l.Action == "WeekRolled"); + Assert.Equal(eligibleCount, auditCount); + } + [Fact] public async Task PastDueCache_SyncsOperationalFlags() { From 8a5cb0cc907586ebf76d30797372cfbbc826f6a3 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 10 Jul 2026 11:45:24 -0300 Subject: [PATCH 07/33] fix(work-orders): make Phase4 search index migration discoverable by EF --- .../Auth/ApplicationDbContext.cs | 12 + ...624200000_Phase4_SearchIndexes.Designer.cs | 3006 +++++++++++++++++ .../ApplicationDbContextModelSnapshot.cs | 9 + 3 files changed, 3027 insertions(+) create mode 100644 Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index 7cce9c8..a055fcd 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -42,6 +42,18 @@ namespace Data.SeaHavenIndustries .Property(w => w.RowVersion) .IsRowVersion(); + builder.Entity() + .HasIndex(w => w.SiteCode) + .HasFilter("[istemplate] = 0"); + + builder.Entity() + .HasIndex(w => w.InternalWONumber) + .HasFilter("[istemplate] = 0"); + + builder.Entity() + .HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate }) + .HasFilter("[istemplate] = 0"); + builder.Entity() .Property(d => d.RowVersion) .IsRowVersion(); diff --git a/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs new file mode 100644 index 0000000..ee0a600 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs @@ -0,0 +1,3006 @@ +// +using System; +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260624200000_Phase4_SearchIndexes")] + partial class Phase4_SearchIndexes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrencyCode") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Importance") + .HasColumnType("int"); + + b.Property("IndustryId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerEmployeeId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceInstructions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("TerritoryId") + .HasColumnType("int"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("AddressType") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("nvarchar(max)"); + + b.Property("County") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ContactId"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Color") + .HasColumnType("nvarchar(max)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Contact") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeletedDate") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("Initials") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("Locations") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UniqueName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserImage") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AssetCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", 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("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenter") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenttext") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("Documents") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalCommentId") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RecordType") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkerOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkerOrderId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Territory") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("ContactDetails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactType") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchedAt") + .HasColumnType("datetime2"); + + b.Property("EmailSent") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NTEAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VerifiedAt") + .HasColumnType("datetime2"); + + b.Property("VerifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchChecklistItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", 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("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Signature") + .HasColumnType("nvarchar(max)"); + + b.Property("SignatureMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignoffType") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.ToTable("DispatchSignoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrentNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("DecidedAt") + .HasColumnType("datetime2"); + + b.Property("DecidedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DecisionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VendorReason") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId", "Status") + .IsUnique() + .HasFilter("[Status] = 'Pending'"); + + b.ToTable("DispatchUpliftRequests"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchWorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ParentValue") + .HasColumnType("nvarchar(max)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DropdownOptions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CanChangeProviderNumber") + .HasColumnType("bit"); + + b.Property("ConfirmUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTitleId") + .HasColumnType("int"); + + b.Property("LastLogin") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("PermissionGroupId") + .HasColumnType("int"); + + b.Property("RegionId") + .HasColumnType("int"); + + b.Property("SMSAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobTitleId"); + + b.HasIndex("RegionId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CountryCode") + .HasColumnType("nvarchar(max)"); + + b.Property("CountyId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("StateCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.Property("StreetLineTwo") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId") + .IsUnique(); + + b.ToTable("EmployeeAddresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailTypeId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeeEmails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.Property("Number") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeePhones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleDate") + .HasColumnType("datetime2"); + + b.Property("ScheduleStartTime") + .HasColumnType("time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LocationId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ForgetPasswordCodes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("JobTitles"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssetId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("LocationId"); + + b.ToTable("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("QuotId") + .HasColumnType("nvarchar(max)"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteId") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", 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("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Lineitem") + .HasColumnType("decimal(18,2)"); + + b.Property("LineitemNumber") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotesLines"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", 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("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TaskListTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", 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("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TaskListTemplateId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TaskListTemplateId"); + + b.ToTable("TaskListTemplateItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ContactId"); + + b.HasIndex("LocationId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TradeSpecialties") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", 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("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAccessTokens"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignDate") + .HasColumnType("date"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("AvettaTask") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("Building") + .HasColumnType("nvarchar(max)"); + + b.Property("CarriedOver") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Customer") + .HasColumnType("nvarchar(max)"); + + b.Property("DateReported") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DocStatus") + .HasColumnType("int"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalWorkOrderId") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LegacyStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("OperationalFlags") + .HasColumnType("int"); + + b.Property("OriginalDate") + .HasColumnType("date"); + + b.Property("OriginalWeek") + .HasColumnType("date"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("PrimaryDispatchId") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("Problem") + .HasColumnType("nvarchar(max)"); + + b.Property("RescheduleCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduleWeekOnly") + .HasColumnType("bit"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("ScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Source") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceEmailS3Key") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("SubTrade") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("TargetWeek") + .HasColumnType("date"); + + b.Property("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.Property("istemplate") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("InternalWONumber") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LifecycleStatus", "ScheduledDate") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("SiteCode") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("WorkOrderType"); + + b.ToTable("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderAttachments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("nvarchar(max)"); + + b.Property("ActorType") + .HasColumnType("nvarchar(max)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("LockedAt") + .HasColumnType("datetime2"); + + b.Property("LockedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "FieldName") + .IsUnique(); + + b.ToTable("WorkOrderFieldLocks"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Addresses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Comments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Comments") + .HasForeignKey("WorkerOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApplicationUser"); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Contacts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("Dispatches") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Dispatches") + .HasForeignKey("WorkOrderId"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("ChecklistItems") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Signoffs") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany() + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("DispatchWorkOrders") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.HasOne("Data.SeaHavenIndustries.Department", "Department") + .WithMany("Employees") + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.JobTitle", "JobTitle") + .WithMany("Employees") + .HasForeignKey("JobTitleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Region", "Region") + .WithMany("Employees") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Department"); + + b.Navigation("JobTitle"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithOne("Address") + .HasForeignKey("Data.SeaHavenIndustries.EmployeeAddress", "EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Emails") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Phones") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Employee"); + + b.Navigation("Location"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.HasOne("Data.SeaHavenIndustries.Assets", "Asset") + .WithMany("PMSchedules") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Asset"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contacts") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Quotes") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Contacts"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.HasOne("Data.SeaHavenIndustries.Quotes", "Quotes") + .WithMany("LineItems") + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") + .WithMany("Items") + .HasForeignKey("TaskListTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("Templates") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("Templates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("Templates") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Category"); + + b.Navigation("Locations"); + + b.Navigation("POC"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("WorkOrders") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("workOrders") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Dispatch", "PrimaryDispatch") + .WithMany() + .HasForeignKey("PrimaryDispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("workOrderAttachments") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("WorkOrderCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderCategories") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Category"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany("WorkOrderContacts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderContacts") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("POC"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("FieldLocks") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Navigation("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Navigation("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Navigation("ChecklistItems"); + + b.Navigation("Comments"); + + b.Navigation("DispatchWorkOrders"); + + b.Navigation("Signoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Navigation("Address"); + + b.Navigation("Emails"); + + b.Navigation("Phones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Navigation("Templates"); + + b.Navigation("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Navigation("LineItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Navigation("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Navigation("Comments"); + + b.Navigation("Dispatches"); + + b.Navigation("FieldLocks"); + + b.Navigation("Quotes"); + + b.Navigation("WorkOrderCategories"); + + b.Navigation("WorkOrderContacts"); + + b.Navigation("workOrderAttachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 061ccd8..0a169ec 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2033,12 +2033,21 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("AssignTo", "ScheduledDate"); + b.HasIndex("InternalWONumber") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LifecycleStatus", "ScheduledDate") + .HasFilter("[istemplate] = 0"); + b.HasIndex("LocationId"); b.HasIndex("PrimaryDispatchId"); b.HasIndex("ScheduledDate"); + b.HasIndex("SiteCode") + .HasFilter("[istemplate] = 0"); + b.HasIndex("WorkOrderType"); b.ToTable("workOrders"); From 0ce008e5eb3f92e56e2a197d0ac96d3d671f53e6 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 13 Jul 2026 13:12:07 -0300 Subject: [PATCH 08/33] feat(work-orders): add Phase 6 completion templates schema and media category --- .../Auth/ApplicationDbContext.cs | 4 + ...260625120000_Phase6_CompletionSlideOver.cs | 77 +++++++++++++++++++ .../ApplicationDbContextModelSnapshot.cs | 58 ++++++++++++++ .../Models/CompletionDocTemplate.cs | 13 ++++ .../Models/WorkOrderAttachments.cs | 7 +- 5 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs create mode 100644 Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index e2122a8..7c45127 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -42,6 +42,9 @@ 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(); @@ -80,6 +83,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..08ff98d --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs @@ -0,0 +1,77 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class Phase6_CompletionSlideOver : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Category", + table: "workOrderAttachments", + type: "int", + nullable: true); + + migrationBuilder.CreateTable( + name: "CompletionDocTemplates", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(max)", nullable: false), + ServiceKey = table.Column(type: "nvarchar(450)", nullable: false), + WorkOrderType = table.Column(type: "int", nullable: true), + TemplateUrl = table.Column(type: "nvarchar(max)", nullable: false), + IsActive = table.Column(type: "bit", nullable: false), + IsDeleted = table.Column(type: "bit", nullable: true), + createdby = table.Column(type: "nvarchar(max)", nullable: true), + DeleterUserId = table.Column(type: "nvarchar(max)", nullable: true), + DeletionTime = table.Column(type: "datetime2", nullable: true), + CreatedDate = table.Column(type: "datetime2", nullable: true), + LastModificationTime = table.Column(type: "datetime2", nullable: true), + LastModifierUserId = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_CompletionDocTemplates", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_CompletionDocTemplates_ServiceKey_IsActive", + table: "CompletionDocTemplates", + columns: new[] { "ServiceKey", "IsActive" }); + + 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.DropTable( + name: "CompletionDocTemplates"); + + migrationBuilder.DropColumn( + name: "Category", + table: "workOrderAttachments"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index c63dda8..2245ad2 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -374,6 +374,61 @@ namespace Data.SeaHavenIndustries.Migrations 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 => { b.Property("Id") @@ -2064,6 +2119,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("Attachments") .HasColumnType("nvarchar(max)"); + b.Property("Category") + .HasColumnType("int"); + b.Property("CreatedDate") .HasColumnType("datetime2"); 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/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; } - } } From af84001bf1afeb1ec9f40fb839f4d1a691225978 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 13 Jul 2026 13:13:00 -0300 Subject: [PATCH 09/33] feat(work-orders): add Phase 6 slide-over detail comments audit and media API --- .../Controllers/WorkOrderController.cs | 224 +++++++++++++++++- .../CompletionDocTemplateDataService.cs | 103 ++++++++ .../WorkOrderDetailDataService.cs | 90 +++++++ .../Interfaces/IWorkOrderDetailDataService.cs | 27 +++ .../Interfaces/WorkOrderDetailModels.cs | 30 +++ SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs | 104 ++++++++ .../Helpers/WorkOrderAuditProjection.cs | 41 ++++ .../Helpers/WorkOrderCommentProjection.cs | 23 ++ .../Helpers/WorkOrderMediaProjection.cs | 91 +++++++ .../Implementation/WorkOrderAuditService.cs | 12 +- .../Implementation/WorkOrderCommentService.cs | 59 +++++ .../WorkOrderCompletionService.cs | 178 ++++++++++++++ .../Implementation/WorkOrderDetailService.cs | 149 ++++++++++++ .../Implementation/WorkOrderMediaService.cs | 134 +++++++++++ .../Interfaces/IWorkOrderCommentService.cs | 11 + .../Interfaces/IWorkOrderCompletionService.cs | 19 ++ .../Interfaces/IWorkOrderDetailService.cs | 10 + .../Interfaces/IWorkOrderMediaService.cs | 12 + 18 files changed, 1313 insertions(+), 4 deletions(-) create mode 100644 SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs create mode 100644 SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderCommentService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderCompletionService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderDetailService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderMediaService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 685c3c6..9bfdfdf 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -48,6 +48,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, @@ -69,7 +73,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; @@ -91,6 +99,10 @@ namespace Api.SeaHavenIndustries.Controllers _boardCreateService = boardCreateService; _boardCancelService = boardCancelService; _advancedSearchService = advancedSearchService; + _workOrderDetailService = workOrderDetailService; + _workOrderCommentService = workOrderCommentService; + _workOrderCompletionService = workOrderCompletionService; + _workOrderMediaService = workOrderMediaService; } [HttpGet("board")] @@ -220,6 +232,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/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/WorkOrderDetailDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs new file mode 100644 index 0000000..8ea9358 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs @@ -0,0 +1,90 @@ +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)) + .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/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/WorkOrderDetailModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs new file mode 100644 index 0000000..cab956b --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs @@ -0,0 +1,30 @@ +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); + + 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/WorkOrderDetailDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs new file mode 100644 index 0000000..7ac1437 --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs @@ -0,0 +1,104 @@ +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 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 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/Helpers/WorkOrderAuditProjection.cs b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs new file mode 100644 index 0000000..8b56b47 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs @@ -0,0 +1,41 @@ +using Data.SeaHavenIndustries; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderAuditProjection + { + public static WorkOrderAuditEntryDto ToDto(WorkOrderAuditLog log) + { + return new WorkOrderAuditEntryDto + { + Type = MapEventType(log.EventType), + DispatcherId = log.UserId, + Action = log.Action ?? "", + FieldName = log.FieldName, + OldValue = log.OldValue ?? "", + NewValue = log.NewValue ?? "", + Time = log.CreatedAt.ToString("o"), + DispatchId = log.DispatchId + }; + } + + public static List ToDtos(IEnumerable logs) + => logs.Select(ToDto).ToList(); + + 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/WorkOrderCommentProjection.cs b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs new file mode 100644 index 0000000..096edd5 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs @@ -0,0 +1,23 @@ +using Data.SeaHavenIndustries; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderCommentProjection + { + public static WorkOrderCommentDto ToDto(Comments comment) + { + return new WorkOrderCommentDto + { + Id = comment.Id, + AuthorId = comment.UserId, + Text = comment.Commenttext ?? "", + Time = (comment.CreatedDate ?? DateTime.UtcNow).ToUniversalTime().ToString("o"), + Documents = comment.Documents + }; + } + + public static List ToDtos(IEnumerable comments) + => comments.Select(ToDto).ToList(); + } +} 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/WorkOrderAuditService.cs b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs index 73f871f..86087de 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs @@ -120,10 +120,16 @@ namespace SeaHaven.Services.Implementation private void StageFieldLock(int workOrderId, string fieldName, string? userId) { - var alreadyTracked = _context.WorkOrderFieldLocks.Local - .Any(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName); + if (_context.WorkOrderFieldLocks.Local.Any(l => + l.WorkOrderId == workOrderId && + string.Equals(l.FieldName, fieldName, StringComparison.OrdinalIgnoreCase))) + return; - if (alreadyTracked) + var existsInDb = _context.WorkOrderFieldLocks.Any(l => + l.WorkOrderId == workOrderId && + l.FieldName == fieldName); + + if (existsInDb) return; _context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock 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..ccea868 --- /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; + + _auditService.StageFieldChanged(workOrderId, "SignOffAttachment", oldAttachment, fileUrl, actorId); + if (oldDocStatus != DocStatus.Yes.ToString()) + _auditService.StageFieldChanged(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..34aa8ac --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -0,0 +1,149 @@ +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; + + public WorkOrderDetailService( + IWorkOrderBoardService boardService, + IWorkOrderDetailDataService detailData, + ICompletionDocTemplateDataService templateData) + { + _boardService = boardService; + _detailData = detailData; + _templateData = templateData; + } + + 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); + + return new WorkOrderDetailDto + { + Info = MapInfo(boardRow, extended), + Completion = MapCompletion(extended, boardRow.DocStatus, template, signoffs), + Comments = WorkOrderCommentProjection.ToDtos(comments), + Audit = WorkOrderAuditProjection.ToDtos(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 WorkOrderAuditProjection.ToDtos(logs); + } + + 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, + DocStatus = row.DocStatus, + 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 + }; + } + + 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/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/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); + } +} From f8511ce732a0248293ae75991a9acede00bb2d4b Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 13 Jul 2026 13:13:09 -0300 Subject: [PATCH 10/33] test(work-orders): cover Phase 6 slide-over and completion doc flows --- .../WorkOrderPhase6Tests.cs | 405 ++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs new file mode 100644 index 0000000..343337e --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -0,0 +1,405 @@ +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 service = new WorkOrderDetailService(boardService, detailData, templateData); + 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); + } +} From e67254ed7eaf6257d658cd810fa5eccc1e33cc84 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 13 Jul 2026 13:14:51 -0300 Subject: [PATCH 11/33] feat(work-orders): add ExternalWorkOrderId unique index for ingest idempotency --- .../Auth/ApplicationDbContext.cs | 9 ++++ ...120000_Phase7_ExternalWorkOrderIdUnique.cs | 49 +++++++++++++++++++ .../ApplicationDbContextModelSnapshot.cs | 7 ++- 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260713120000_Phase7_ExternalWorkOrderIdUnique.cs diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index 7c45127..1cea35d 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -61,6 +61,15 @@ 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(); 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/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 2245ad2..d97ab9b 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1960,7 +1960,8 @@ namespace Data.SeaHavenIndustries.Migrations .HasColumnType("datetime2"); b.Property("ExternalWorkOrderId") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); b.Property("InternalWONumber") .HasColumnType("nvarchar(max)"); @@ -2088,6 +2089,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"); From 1074ed860b53752e9b1b46caae026d6b7a169225 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 13 Jul 2026 13:22:11 -0300 Subject: [PATCH 12/33] feat(work-orders): add work order ingest API with X-Ingest-Key --- .../Controllers/WorkOrderIngestController.cs | 50 ++++ .../Filters/IngestApiKeyFilter.cs | 67 ++++++ .../Options/WorkOrderIngestOptions.cs | 13 ++ SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs | 54 +++++ .../Helpers/WorkOrderIngestFieldMapper.cs | 27 +++ .../Implementation/WorkOrderIngestService.cs | 216 ++++++++++++++++++ .../Interfaces/IWorkOrderPhase7Services.cs | 16 ++ 7 files changed, 443 insertions(+) create mode 100644 Api.SeaHavenIndustries/Controllers/WorkOrderIngestController.cs create mode 100644 Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs create mode 100644 Api.SeaHavenIndustries/Options/WorkOrderIngestOptions.cs create mode 100644 SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderIngestService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderPhase7Services.cs 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/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/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/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/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/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/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); + } +} From 89cb520db9c8be2c41b2aa0d38ce35185dc1a019 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 13 Jul 2026 13:22:12 -0300 Subject: [PATCH 13/33] feat(work-orders): add Sync kill-switch and ops health coexistence endpoints --- .../Controllers/SyncController.cs | 51 ++++++++++-- .../Controllers/WorkOrderOpsController.cs | 29 +++++++ .../PastDueCacheHostedService.cs | 14 ++-- .../WorkOrderWeekRolledHostedService.cs | 14 ++-- .../Options/LegacyEndpointOptions.cs | 12 +++ Api.SeaHavenIndustries/Options/SyncOptions.cs | 10 +++ .../Options/WorkOrderJobRunState.cs | 11 --- Api.SeaHavenIndustries/Program.cs | 32 +++++++- .../WorkOrderOpsHealthService.cs | 81 +++++++++++++++++++ 9 files changed, 221 insertions(+), 33 deletions(-) create mode 100644 Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs create mode 100644 Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs create mode 100644 Api.SeaHavenIndustries/Options/SyncOptions.cs delete mode 100644 Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderOpsHealthService.cs diff --git a/Api.SeaHavenIndustries/Controllers/SyncController.cs b/Api.SeaHavenIndustries/Controllers/SyncController.cs index 5d7f778..d246273 100644 --- a/Api.SeaHavenIndustries/Controllers/SyncController.cs +++ b/Api.SeaHavenIndustries/Controllers/SyncController.cs @@ -1,32 +1,49 @@ using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.Model; +using Api.SeaHavenIndustries.Options; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace Api.SeaHavenIndustries.Controllers { - [Authorize] + [Authorize(Roles = "Admin")] [ApiController] [Route("api/[controller]")] public class SyncController : Controller { private readonly ApplicationDbContext _db; private readonly AmazonDynamoDBClient _dynamo; + private readonly SyncOptions _syncOptions; private const string WO_TABLE = "WorkOrders"; private const string COMMENTS_TABLE = "WorkOrderComments"; private const string VENDOR_REPLIES_TABLE = "VendorReplies"; - public SyncController(ApplicationDbContext db) + public SyncController(ApplicationDbContext db, IOptions syncOptions) { _db = db; + _syncOptions = syncOptions.Value; _dynamo = new AmazonDynamoDBClient(Amazon.RegionEndpoint.USEast1); } + private IActionResult? SyncDisabledResult() + { + if (_syncOptions.Enabled) + return null; + + return StatusCode(StatusCodes.Status503ServiceUnavailable, new + { + message = "DynamoDB sync bridge is disabled. Use POST /api/workorders/ingest." + }); + } + [HttpPost("WorkOrders")] public async Task SyncWorkOrders() { + if (SyncDisabledResult() is { } disabled) + return disabled; var synced = 0; var created = 0; var updated = 0; @@ -62,12 +79,12 @@ namespace Api.SeaHavenIndustries.Controllers var existing = await _db.workOrders .FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalId); - var locationId = await ResolveLocationId( + var (locationId, locationWasCreated) = await ResolveLocationId( GetString(item, "site_code"), GetString(item, "building"), GetString(item, "address")); - if (locationId.HasValue && !locationsCreated.Equals(0)) + if (locationWasCreated) locationsCreated++; if (existing == null) @@ -138,6 +155,9 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost("Comments")] public async Task SyncComments() { + if (SyncDisabledResult() is { } disabled) + return disabled; + var synced = 0; var created = 0; var skipped = 0; @@ -212,6 +232,9 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost("BackfillInternalWONumbers")] public async Task BackfillInternalWONumbers() { + if (SyncDisabledResult() is { } disabled) + return disabled; + var wosMissing = await _db.workOrders .Where(w => w.InternalWONumber == null || w.InternalWONumber == "") .OrderBy(w => w.Id) @@ -243,6 +266,9 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost("BackfillDispatchNumbers")] public async Task BackfillDispatchNumbers() { + if (SyncDisabledResult() is { } disabled) + return disabled; + var missing = await _db.Dispatches .Where(d => d.DispatchNumber == null || d.DispatchNumber == "") .OrderBy(d => d.Id) @@ -270,6 +296,9 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost("VendorReplies")] public async Task SyncVendorReplies() { + if (SyncDisabledResult() is { } disabled) + return disabled; + var synced = 0; var created = 0; var skipped = 0; @@ -341,6 +370,9 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost("BackfillCommentTypes")] public async Task BackfillCommentTypes() { + if (SyncDisabledResult() is { } disabled) + return disabled; + var updated = await _db.Database.ExecuteSqlRawAsync( "UPDATE Comments SET CommentType = 'customer' WHERE ExternalCommentId IS NOT NULL AND (CommentType IS NULL OR CommentType = '')"); return Ok(new { updated }); @@ -349,6 +381,9 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost("All")] public async Task SyncAll() { + if (SyncDisabledResult() is { } disabled) + return disabled; + var woResult = await SyncWorkOrders() as OkObjectResult; var commentResult = await SyncComments() as OkObjectResult; @@ -359,17 +394,17 @@ namespace Api.SeaHavenIndustries.Controllers }); } - private async Task ResolveLocationId(string? siteCode, string? building, string? address) + private async Task<(int? LocationId, bool Created)> ResolveLocationId(string? siteCode, string? building, string? address) { if (string.IsNullOrWhiteSpace(siteCode) && string.IsNullOrWhiteSpace(building)) - return null; + return (null, false); var matchCode = siteCode ?? building; var existing = await _db.Locations .FirstOrDefaultAsync(l => l.Name == matchCode || l.Title == matchCode); if (existing != null) - return existing.Id; + return (existing.Id, false); var location = new Locations { @@ -382,7 +417,7 @@ namespace Api.SeaHavenIndustries.Controllers _db.Locations.Add(location); await _db.SaveChangesAsync(); - return location.Id; + return (location.Id, true); } private static string? GetString(Dictionary item, string key) diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs new file mode 100644 index 0000000..dfbff62 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers +{ + /// Ops health and coexistence metrics (Fase 7). + [Authorize(Roles = "Admin")] + [ApiController] + [Route("api/workorders/ops")] + public class WorkOrderOpsController : ControllerBase + { + private readonly IWorkOrderOpsHealthService _healthService; + + public WorkOrderOpsController(IWorkOrderOpsHealthService healthService) + { + _healthService = healthService; + } + + [HttpGet("health")] + [ProducesResponseType(typeof(WorkOrderOpsHealthDto), StatusCodes.Status200OK)] + public async Task> GetHealth(CancellationToken cancellationToken) + { + var health = await _healthService.GetHealthAsync(cancellationToken); + return Ok(health); + } + } +} diff --git a/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs b/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs index 67b8cba..6daf1f7 100644 --- a/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs +++ b/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs @@ -1,6 +1,7 @@ -using Api.SeaHavenIndustries.Options; using Microsoft.Extensions.Options; +using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; +using Api.SeaHavenIndustries.Options; namespace Api.SeaHavenIndustries.HostedServices { @@ -33,8 +34,8 @@ namespace Api.SeaHavenIndustries.HostedServices var today = DateOnly.FromDateTime(DateTime.UtcNow); if (_lastRunDate != today) { - _lastRunDate = today; - await RunJobAsync(stoppingToken); + if (await RunJobAsync(stoppingToken)) + _lastRunDate = today; } } @@ -42,7 +43,7 @@ namespace Api.SeaHavenIndustries.HostedServices } } - private async Task RunJobAsync(CancellationToken stoppingToken) + private async Task RunJobAsync(CancellationToken stoppingToken) { try { @@ -52,11 +53,13 @@ namespace Api.SeaHavenIndustries.HostedServices _runState.LastPastDueCacheRunUtc = DateTime.UtcNow; _runState.LastPastDueCacheError = null; + return true; } catch (Exception ex) when (ex is not OperationCanceledException) { _runState.LastPastDueCacheError = ex.Message; _logger.LogError(ex, "PastDue cache hosted job failed."); + return false; } } @@ -67,8 +70,7 @@ namespace Api.SeaHavenIndustries.HostedServices if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt)) runAt = new TimeOnly(0, 10); - var current = TimeOnly.FromDateTime(now); - return current >= runAt && current < runAt.AddMinutes(1); + return TimeOnly.FromDateTime(now) >= runAt; } } } diff --git a/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs b/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs index 7caf0a2..dc5a309 100644 --- a/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs +++ b/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs @@ -1,7 +1,8 @@ -using Api.SeaHavenIndustries.Options; using Microsoft.Extensions.Options; using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; +using Api.SeaHavenIndustries.Options; namespace Api.SeaHavenIndustries.HostedServices { @@ -34,8 +35,8 @@ namespace Api.SeaHavenIndustries.HostedServices var today = DateOnly.FromDateTime(DateTime.UtcNow); if (_lastRunDate != today) { - _lastRunDate = today; - await RunJobAsync(stoppingToken); + if (await RunJobAsync(stoppingToken)) + _lastRunDate = today; } } @@ -43,7 +44,7 @@ namespace Api.SeaHavenIndustries.HostedServices } } - private async Task RunJobAsync(CancellationToken stoppingToken) + private async Task RunJobAsync(CancellationToken stoppingToken) { try { @@ -55,11 +56,13 @@ namespace Api.SeaHavenIndustries.HostedServices _runState.LastWeekRolledRunUtc = DateTime.UtcNow; _runState.LastWeekRolledError = null; + return true; } catch (Exception ex) when (ex is not OperationCanceledException) { _runState.LastWeekRolledError = ex.Message; _logger.LogError(ex, "WeekRolled hosted job failed."); + return false; } } @@ -75,8 +78,7 @@ namespace Api.SeaHavenIndustries.HostedServices if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt)) runAt = new TimeOnly(0, 5); - var current = TimeOnly.FromDateTime(now); - return current >= runAt && current < runAt.AddMinutes(1); + return TimeOnly.FromDateTime(now) >= runAt; } } } diff --git a/Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs b/Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs new file mode 100644 index 0000000..ffd01ef --- /dev/null +++ b/Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs @@ -0,0 +1,12 @@ +namespace Api.SeaHavenIndustries.Options +{ + public class LegacyEndpointOptions + { + public const string SectionName = "LegacyEndpoints"; + + public bool DeprecationEnabled { get; set; } + + /// ISO date returned in Sunset header (e.g. 2026-12-31). + public string? SunsetDate { get; set; } + } +} diff --git a/Api.SeaHavenIndustries/Options/SyncOptions.cs b/Api.SeaHavenIndustries/Options/SyncOptions.cs new file mode 100644 index 0000000..230e16c --- /dev/null +++ b/Api.SeaHavenIndustries/Options/SyncOptions.cs @@ -0,0 +1,10 @@ +namespace Api.SeaHavenIndustries.Options +{ + public class SyncOptions + { + public const string SectionName = "Sync"; + + /// When false, SyncController endpoints return 503 (post-Lambda cutover). Default false to avoid dual-run with ingest. + public bool Enabled { get; set; } + } +} diff --git a/Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs b/Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs deleted file mode 100644 index dd95de5..0000000 --- a/Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Api.SeaHavenIndustries.Options -{ - /// In-memory last-run timestamps for hosted domain jobs (ops health). - public class WorkOrderJobRunState - { - public DateTime? LastWeekRolledRunUtc { get; set; } - public DateTime? LastPastDueCacheRunUtc { get; set; } - public string? LastWeekRolledError { get; set; } - public string? LastPastDueCacheError { get; set; } - } -} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 922582e..e897f24 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -1,4 +1,4 @@ -using Api.SeaHavenIndustries.Helper; +using Api.SeaHavenIndustries.Helper; using Api.SeaHavenIndustries.HostedServices; using Api.SeaHavenIndustries.Options; using Data.SeaHavenIndustries; @@ -11,6 +11,7 @@ using Microsoft.OpenApi.Models; using System.Text; using SeaHaven.DataServices.DependencyInjection; using SeaHaven.Services.DependencyInjection; +using SeaHaven.Services.Implementation; var builder = WebApplication.CreateBuilder(args); ConfigurationManager configuration = builder.Configuration; @@ -55,9 +56,35 @@ builder.Services.AddBusinessServices(); builder.Services.Configure( builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName)); -builder.Services.AddSingleton(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(WorkOrderIngestOptions.SectionName)) + .Validate( + o => !o.Enabled + || (!string.IsNullOrWhiteSpace(o.ApiKey) + && !o.ApiKey.Contains("${", StringComparison.Ordinal) + && o.ApiKey.Length >= 32), + "WorkOrderIngest:ApiKey must be a real secret (env/user-secrets, length >= 32) when ingest is enabled.") + .ValidateOnStart(); +builder.Services.Configure( + builder.Configuration.GetSection(SyncOptions.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(LegacyEndpointOptions.SectionName)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService().State); +builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddOptions() + .Configure, + Microsoft.Extensions.Options.IOptions, + Microsoft.Extensions.Options.IOptions>( + (health, sync, ingest, legacy) => + { + health.SyncEnabled = sync.Value.Enabled; + health.IngestEnabled = ingest.Value.Enabled; + health.LegacyDeprecationEnabled = legacy.Value.DeprecationEnabled; + health.LegacySunsetDate = legacy.Value.SunsetDate; + }); builder.Services.AddAuthentication(options => { @@ -186,3 +213,4 @@ app.MapControllers(); //} app.Run(); + diff --git a/SeaHaven.Services/Implementation/WorkOrderOpsHealthService.cs b/SeaHaven.Services/Implementation/WorkOrderOpsHealthService.cs new file mode 100644 index 0000000..11656ed --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderOpsHealthService.cs @@ -0,0 +1,81 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderOpsHealthService : IWorkOrderOpsHealthService + { + private readonly ApplicationDbContext _db; + private readonly WorkOrderJobRunStateAccessor _runState; + private readonly WorkOrderOpsHealthOptions _options; + + public WorkOrderOpsHealthService( + ApplicationDbContext db, + WorkOrderJobRunStateAccessor runState, + IOptions options) + { + _db = db; + _runState = runState; + _options = options.Value; + } + + public async Task GetHealthAsync(CancellationToken cancellationToken = default) + { + var since = DateTime.UtcNow.AddHours(-24); + + var syncRejected = await _db.WorkOrderAuditLogs + .CountAsync(l => l.Action == "SyncRejected" && l.CreatedAt >= since, cancellationToken); + + var fieldLocks = await _db.WorkOrderFieldLocks.CountAsync(cancellationToken); + + var state = _runState.State; + DateTime? ledgerWeekRolled = null; + if (await _db.WorkOrderWeekRolledLedgers.AnyAsync(cancellationToken)) + { + ledgerWeekRolled = await _db.WorkOrderWeekRolledLedgers + .MaxAsync(l => (DateTime?)l.ProcessedAt, cancellationToken); + } + + return new WorkOrderOpsHealthDto + { + LastWeekRolledRunUtc = state.LastWeekRolledRunUtc ?? ledgerWeekRolled, + LastPastDueCacheRunUtc = state.LastPastDueCacheRunUtc, + LastWeekRolledError = state.LastWeekRolledError, + LastPastDueCacheError = state.LastPastDueCacheError, + SyncRejectedLast24h = syncRejected, + FieldLockCount = fieldLocks, + SyncEnabled = _options.SyncEnabled, + IngestEnabled = _options.IngestEnabled, + LegacyDeprecationEnabled = _options.LegacyDeprecationEnabled, + LegacySunsetDate = _options.LegacySunsetDate, + CheckedAtUtc = DateTime.UtcNow + }; + } + } + + /// Options bridged from API configuration for health snapshots. + public class WorkOrderOpsHealthOptions + { + public bool SyncEnabled { get; set; } = true; + public bool IngestEnabled { get; set; } = true; + public bool LegacyDeprecationEnabled { get; set; } + public string? LegacySunsetDate { get; set; } + } + + /// Shared in-memory job run timestamps (API hosted services + ops health). + public class WorkOrderJobRunStateAccessor + { + public WorkOrderJobRunState State { get; } = new(); + } + + public class WorkOrderJobRunState + { + public DateTime? LastWeekRolledRunUtc { get; set; } + public DateTime? LastPastDueCacheRunUtc { get; set; } + public string? LastWeekRolledError { get; set; } + public string? LastPastDueCacheError { get; set; } + } +} From 64ca9e0bdfb53782be989777677062b5c09ae023 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 13 Jul 2026 13:22:53 -0300 Subject: [PATCH 14/33] feat(work-orders): add legacy deprecation middleware and Blazor WO sunset guard --- .../Middleware/LegacyDeprecationMiddleware.cs | 57 +++++++++++++++++++ Api.SeaHavenIndustries/Program.cs | 5 +- .../Data/Services/WorkorderService.cs | 12 +++- .../Helper/BlazorWorkOrderSunsetGuard.cs | 26 +++++++++ .../Options/BlazorWorkOrderSunsetOptions.cs | 11 ++++ SeaHavenIndustries/Program.cs | 3 + 6 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 Api.SeaHavenIndustries/Middleware/LegacyDeprecationMiddleware.cs create mode 100644 SeaHavenIndustries/Helper/BlazorWorkOrderSunsetGuard.cs create mode 100644 SeaHavenIndustries/Options/BlazorWorkOrderSunsetOptions.cs 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/Program.cs b/Api.SeaHavenIndustries/Program.cs index e897f24..c85271b 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -1,5 +1,6 @@ -using Api.SeaHavenIndustries.Helper; +using Api.SeaHavenIndustries.Helper; using Api.SeaHavenIndustries.HostedServices; +using Api.SeaHavenIndustries.Middleware; using Api.SeaHavenIndustries.Options; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -151,6 +152,7 @@ if (!app.Environment.IsDevelopment()) app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseCors(); +app.UseMiddleware(); app.UseAuthentication(); app.UseAuthorization(); @@ -213,4 +215,3 @@ app.MapControllers(); //} app.Run(); - 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 => { From 03dc04ad0b3931a96b9bea16110065c698cf8386 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 13 Jul 2026 13:23:07 -0300 Subject: [PATCH 15/33] test(work-orders): add Phase 7 coexistence coverage --- .../WorkOrderPhase7CoexistenceTests.cs | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs 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); + } +} From ec6d69a9336f4282298c942ed5ae4e53c0372be8 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 13 Jul 2026 13:23:52 -0300 Subject: [PATCH 16/33] docs(work-orders): add Phase 7 rollout gates Lambda mapping and CloudWatch alerts --- TODO.md | 8 ++++ docs/work-orders/phase-7/cloudwatch-alerts.md | 32 +++++++++----- .../phase-7/lambda-ingest-discovery.md | 2 + .../phase-7/phase-7-gates-signoff.md | 43 +++++++++++++++---- 4 files changed, 66 insertions(+), 19 deletions(-) diff --git a/TODO.md b/TODO.md index 68fc21b..19fa447 100644 --- a/TODO.md +++ b/TODO.md @@ -50,3 +50,11 @@ - [ ] When SHOC deploys, switch workorder-ingest Lambda from DynamoDB to direct SHOC API calls - [ ] Retire DynamoDB sync bridge - [ ] GitHub branch protection — requires paid plan for private repos + +### Work Orders Board — Phase 7 ops (after engineering on `feat/work-orders-phase-7`) +- [ ] Staging: dry-run migrations Phase0–Phase6 + smoke multi-consumidor +- [ ] Staging: E2E SHOC (board + slide-over + completion-doc) +- [ ] Create CloudWatch alerts from `docs/work-orders/phase-7/cloudwatch-alerts.md` +- [ ] Name Lambda cutover owner; dual-run then `Sync:Enabled=false` +- [ ] Piloto 1–2 dispatchers → UAT → 100% rollout; Blazor sunset date +- [ ] Sign GO/DONE in `docs/work-orders/phase-7/phase-7-gates-signoff.md` diff --git a/docs/work-orders/phase-7/cloudwatch-alerts.md b/docs/work-orders/phase-7/cloudwatch-alerts.md index d83e929..07605d6 100644 --- a/docs/work-orders/phase-7/cloudwatch-alerts.md +++ b/docs/work-orders/phase-7/cloudwatch-alerts.md @@ -19,14 +19,28 @@ ## Alertas recomendados (Elastic Beanstalk / CloudWatch) -| Alerta | Condição | Severidade | -|--------|----------|------------| -| WeekRolled stale | `lastWeekRolledRunUtc` > 8 dias | P1 | -| WeekRolled job error | `lastWeekRolledError` não nulo | P1 | -| SyncRejected spike | `syncRejectedLast24h` > 50 | P2 | -| API 5xx board | ALB/Beanstalk 5xx rate > 1% em `/api/workorders/board` | P1 | -| Ingest auth failures | 401 em `/api/workorders/ingest` > 10/h | P2 | -| Sync disabled em prod sem cutover | `syncEnabled=false` e ingest não validado | P2 | +| Alerta | Condição | Severidade | Status ops | +|--------|----------|------------|------------| +| WeekRolled stale | `lastWeekRolledRunUtc` > 8 dias | P1 | [ ] criar | +| WeekRolled job error | `lastWeekRolledError` não nulo | P1 | [ ] criar | +| SyncRejected spike | `syncRejectedLast24h` > 50 | P2 | [ ] criar | +| API 5xx board | ALB/Beanstalk 5xx rate > 1% em `/api/workorders/board` | P1 | [ ] criar | +| Ingest auth failures | 401 em `/api/workorders/ingest` > 10/h | P2 | [ ] criar | +| Sync disabled em prod sem cutover | `syncEnabled=false` e ingest não validado | P2 | [ ] criar | + +--- + +## Runbook de criação (ops) + +1. Deploy API com `WorkOrderIngest` + `Sync` + jobs habilitados conforme ambiente. +2. Obter token Admin e validar: + ```powershell + curl -H "Authorization: Bearer " https:///api/workorders/ops/health + ``` +3. Criar EventBridge/cron (ex.: a cada 15 min) que chama o health endpoint **ou** publica métricas customizadas a partir do JSON. +4. Criar os alarmes da tabela acima no CloudWatch (ALB 5xx + métricas derivadas do health). +5. Agendar checagem diária manual durante piloto e rollout. +6. Marcar status ops na tabela quando cada alarme estiver ativo em staging e prod. --- @@ -36,5 +50,3 @@ # Com token Admin curl -H "Authorization: Bearer " https:///api/workorders/ops/health ``` - -Agendar checagem diária durante piloto e rollout. diff --git a/docs/work-orders/phase-7/lambda-ingest-discovery.md b/docs/work-orders/phase-7/lambda-ingest-discovery.md index b07d789..61765c8 100644 --- a/docs/work-orders/phase-7/lambda-ingest-discovery.md +++ b/docs/work-orders/phase-7/lambda-ingest-discovery.md @@ -91,3 +91,5 @@ Lambda deve enviar o header em cada `POST /api/workorders/ingest`. Não usar JWT - [ ] Auth ingest testada em staging - [ ] Zero drift em amostra de 100 WOs - [ ] Owner Lambda assinou runbook +- [ ] Dual-run validado (ops) +- [ ] `Sync:Enabled=false` em prod (ops) diff --git a/docs/work-orders/phase-7/phase-7-gates-signoff.md b/docs/work-orders/phase-7/phase-7-gates-signoff.md index 16021e0..4bb68bd 100644 --- a/docs/work-orders/phase-7/phase-7-gates-signoff.md +++ b/docs/work-orders/phase-7/phase-7-gates-signoff.md @@ -5,6 +5,31 @@ --- +## Engineering readiness (código — branch `feat/work-orders-phase-7`) + +Itens de engenharia entregues no repositório. Não substituem gates de staging/ops abaixo. + +| # | Item | Evidência | Status | +|---|------|-----------|--------| +| E1 | `GET /api/workorders/ops/health` | `WorkOrderOpsController` + `WorkOrderOpsHealthService` | [x] | +| E2 | `POST /api/workorders/ingest` + `X-Ingest-Key` | `WorkOrderIngestController` + `IngestApiKeyFilter` | [x] | +| E3 | `Sync:Enabled` → 503 no SyncController | `SyncOptions` + checks em WorkOrders/Comments/VendorReplies/All | [x] | +| E4 | Legacy deprecation middleware | `LegacyDeprecationMiddleware` + `LegacyEndpoints` config | [x] | +| E5 | Blazor sunset guard | `BlazorWorkOrderSunsetOptions` + guard em `WorkorderService` | [x] | +| E6 | Alertas documentados | [cloudwatch-alerts.md](cloudwatch-alerts.md) | [x] | +| E7 | Lambda mapping documentado | [lambda-ingest-discovery.md](lambda-ingest-discovery.md) | [x] | +| E8 | Testes coexistência | `dotnet test --filter FullyQualifiedName~WorkOrderPhase7` | [x] | +| E9 | Fase 6 slide-over API | `GET detail/audit/comments/media`, completion-doc, templates | [x] | + +**Config flags (default seguro em prod):** + +- `WorkOrderIngest:Enabled=true` (requer `ApiKey`) +- `Sync:Enabled=true` até cutover +- `LegacyEndpoints:DeprecationEnabled=false` até anúncio +- `BlazorWorkOrderSunset:Enabled=false` até data PO + +--- + ## Gate de entrada (GO Fase 7) Bloqueia piloto em produção até todos os itens estarem verdes. @@ -37,15 +62,15 @@ Bloqueia piloto em produção até todos os itens estarem verdes. ## Gates de saída (DONE Fase 7) -| Gate | Critério | -|------|----------| -| Piloto | 1–2 dispatchers SHOC em prod; UAT assinado | -| Rollout | 100% dispatchers no board SHOC | -| Estabilidade | Zero P1 por 2 semanas consecutivas | -| Blazor | Módulo WO sunset (`BlazorWorkOrderSunset:Enabled`) | -| Lambda | Ingest direto `POST /api/workorders/ingest`; Dynamo/Sync desligados | -| Monitoramento | `GET /api/workorders/ops/health` + alertas documentados | -| Legado | Headers `Sunset`/`Deprecation` nos endpoints legado | +| Gate | Critério | Status | +|------|----------|--------| +| Piloto | 1–2 dispatchers SHOC em prod; UAT assinado | [ ] | +| Rollout | 100% dispatchers no board SHOC | [ ] | +| Estabilidade | Zero P1 por 2 semanas consecutivas | [ ] | +| Blazor | Módulo WO sunset (`BlazorWorkOrderSunset:Enabled`) | [ ] | +| Lambda | Ingest direto `POST /api/workorders/ingest`; Dynamo/Sync desligados | [ ] | +| Monitoramento | `GET /api/workorders/ops/health` + alertas CloudWatch criados | [ ] | +| Legado | Headers `Sunset`/`Deprecation` nos endpoints legado | [ ] | **Assinatura DONE Fase 7** From 5d2a2d0ed0ab7fdcffb59b602235d865c125eefe Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 13 Jul 2026 13:23:54 -0300 Subject: [PATCH 17/33] chore(config): document Phase 7 feature flags in appsettings and .env.example --- .env.example | 17 +++++++++++++++-- Api.SeaHavenIndustries/appsettings.json | 19 ++++++++++++++++--- SeaHavenIndustries/appsettings.json | 13 +++++-------- 3 files changed, 36 insertions(+), 13 deletions(-) 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/appsettings.json b/Api.SeaHavenIndustries/appsettings.json index f023879..85941e0 100644 --- a/Api.SeaHavenIndustries/appsettings.json +++ b/Api.SeaHavenIndustries/appsettings.json @@ -1,4 +1,4 @@ -{ +{ "ConnectionStrings": { //"DefaultConnection": "Server=DESKTOP-64LC14O\SQLEXPRESS;Database=SeahavenIndustries;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=true", @@ -27,5 +27,18 @@ "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/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" + } } From 4355c07e45c7b63d0972b01e87af0b3c3fdb582b Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 13 Jul 2026 13:51:34 -0300 Subject: [PATCH 18/33] chore(docs): remove Phase 7 ops markdown from PR scope --- TODO.md | 8 -- docs/work-orders/phase-7/cloudwatch-alerts.md | 52 ---------- .../phase-7/lambda-ingest-discovery.md | 95 ------------------- .../phase-7/phase-7-gates-signoff.md | 80 ---------------- 4 files changed, 235 deletions(-) delete mode 100644 docs/work-orders/phase-7/cloudwatch-alerts.md delete mode 100644 docs/work-orders/phase-7/lambda-ingest-discovery.md delete mode 100644 docs/work-orders/phase-7/phase-7-gates-signoff.md diff --git a/TODO.md b/TODO.md index 19fa447..68fc21b 100644 --- a/TODO.md +++ b/TODO.md @@ -50,11 +50,3 @@ - [ ] When SHOC deploys, switch workorder-ingest Lambda from DynamoDB to direct SHOC API calls - [ ] Retire DynamoDB sync bridge - [ ] GitHub branch protection — requires paid plan for private repos - -### Work Orders Board — Phase 7 ops (after engineering on `feat/work-orders-phase-7`) -- [ ] Staging: dry-run migrations Phase0–Phase6 + smoke multi-consumidor -- [ ] Staging: E2E SHOC (board + slide-over + completion-doc) -- [ ] Create CloudWatch alerts from `docs/work-orders/phase-7/cloudwatch-alerts.md` -- [ ] Name Lambda cutover owner; dual-run then `Sync:Enabled=false` -- [ ] Piloto 1–2 dispatchers → UAT → 100% rollout; Blazor sunset date -- [ ] Sign GO/DONE in `docs/work-orders/phase-7/phase-7-gates-signoff.md` diff --git a/docs/work-orders/phase-7/cloudwatch-alerts.md b/docs/work-orders/phase-7/cloudwatch-alerts.md deleted file mode 100644 index 07605d6..0000000 --- a/docs/work-orders/phase-7/cloudwatch-alerts.md +++ /dev/null @@ -1,52 +0,0 @@ -# Alertas Operacionais — Fase 7 - -**Endpoint de saúde:** `GET /api/workorders/ops/health` (Admin JWT) - ---- - -## Métricas expostas - -| Campo | Fonte | Uso | -|-------|-------|-----| -| `lastWeekRolledRunUtc` | `WorkOrderJobRunState` + ledger | Job segunda-feira | -| `lastPastDueCacheRunUtc` | `WorkOrderJobRunState` | Cache opcional PastDue | -| `syncRejectedLast24h` | `WorkOrderAuditLogs` Action=SyncRejected | Conflito SHOC vs ingest | -| `fieldLockCount` | `WorkOrderFieldLocks` | Adoção edição manual | -| `syncEnabled` | `Sync:Enabled` | Estado ponte Dynamo | -| `ingestEnabled` | `WorkOrderIngest:Enabled` | Ingest direto ativo | - ---- - -## Alertas recomendados (Elastic Beanstalk / CloudWatch) - -| Alerta | Condição | Severidade | Status ops | -|--------|----------|------------|------------| -| WeekRolled stale | `lastWeekRolledRunUtc` > 8 dias | P1 | [ ] criar | -| WeekRolled job error | `lastWeekRolledError` não nulo | P1 | [ ] criar | -| SyncRejected spike | `syncRejectedLast24h` > 50 | P2 | [ ] criar | -| API 5xx board | ALB/Beanstalk 5xx rate > 1% em `/api/workorders/board` | P1 | [ ] criar | -| Ingest auth failures | 401 em `/api/workorders/ingest` > 10/h | P2 | [ ] criar | -| Sync disabled em prod sem cutover | `syncEnabled=false` e ingest não validado | P2 | [ ] criar | - ---- - -## Runbook de criação (ops) - -1. Deploy API com `WorkOrderIngest` + `Sync` + jobs habilitados conforme ambiente. -2. Obter token Admin e validar: - ```powershell - curl -H "Authorization: Bearer " https:///api/workorders/ops/health - ``` -3. Criar EventBridge/cron (ex.: a cada 15 min) que chama o health endpoint **ou** publica métricas customizadas a partir do JSON. -4. Criar os alarmes da tabela acima no CloudWatch (ALB 5xx + métricas derivadas do health). -5. Agendar checagem diária manual durante piloto e rollout. -6. Marcar status ops na tabela quando cada alarme estiver ativo em staging e prod. - ---- - -## Verificação manual (ops) - -```powershell -# Com token Admin -curl -H "Authorization: Bearer " https:///api/workorders/ops/health -``` diff --git a/docs/work-orders/phase-7/lambda-ingest-discovery.md b/docs/work-orders/phase-7/lambda-ingest-discovery.md deleted file mode 100644 index 61765c8..0000000 --- a/docs/work-orders/phase-7/lambda-ingest-discovery.md +++ /dev/null @@ -1,95 +0,0 @@ -# Lambda Ingest — Descoberta e Mapeamento de Campos - -**Fase:** 7 -**Status:** Documentação de cutover (código Lambda fora deste repositório) - ---- - -## 1. Localização da Lambda - -| Item | Valor | -|------|-------| -| Nome referenciado | `workorder-ingest` | -| Referência código | [TODO.md](../../../TODO.md) L50–51 | -| Fluxo atual | Lambda → DynamoDB (`WorkOrders`) → `POST api/Sync/WorkOrders` | -| Fluxo alvo | Lambda → `POST api/workorders/ingest` (API key) | - -**Ação pendente ops:** localizar repositório/infra AWS (SAM, Terraform, console Lambda) e preencher owner na [phase-7-gates-signoff.md](phase-7-gates-signoff.md). - ---- - -## 2. Tabelas DynamoDB (SyncController) - -| Tabela | Endpoint sync | Uso | -|--------|---------------|-----| -| `WorkOrders` | `POST api/Sync/WorkOrders` | Upsert WO por `work_order_id` | -| `WorkOrderComments` | `POST api/Sync/Comments` | Comentários cliente | -| `VendorReplies` | `POST api/Sync/VendorReplies` | Respostas vendor | - -Cutover Fase 7 foca em **WorkOrders**; comments/replies permanecem no Sync até migração separada. - ---- - -## 3. Mapeamento DynamoDB → API ingest - -Campos lidos em [SyncController.cs](../../../Api.SeaHavenIndustries/Controllers/SyncController.cs) e espelhados em `WorkOrderIngestPayload`: - -| Campo Dynamo | Campo API ingest | Campo SQL | Merge policy (update) | -|--------------|------------------|-----------|------------------------| -| `work_order_id` | `externalWorkOrderId` | `ExternalWorkOrderId` | Chave idempotente | -| `description` | `description` | `Description`, `WorkerOrderTitle` | Sim se não locked | -| `wo_status` | `woStatus` | `Status` (mapeado) | Sim se não locked | -| `severity` | `severity` | `Priority`, `Severity` | Sim se não locked | -| `customer` | `customer` | `Customer` | Direto na criação | -| `site_code` | `siteCode` | `SiteCode` | Sim se não locked | -| `building` | `building` | `Building` | Sim se não locked | -| `address` | `address` | `Locations` (resolve/create) | LocationId | -| `due_date` | `dueDate` | `DueDate` | Sim se não locked | -| `date_reported` | `dateReported` | `DateReported` | Direto | -| `scheduled_start` | `scheduledStart` | `ScheduledStart` | Direto | -| `source_email_s3_key` | `sourceEmailS3Key` | `SourceEmailS3Key` | Direto | -| `created_at` | `createdAt` | `CreatedDate` | Criação only | - -### Mapeamento `wo_status` → SQL `Status` - -| Dynamo | SQL | -|--------|-----| -| `new`, `assigned`, `unknown` | `Open` | -| `in_progress` | `In Progress` | -| `on_hold` | `On Hold` | -| `completed` | `Done` | -| `cancelled` | `Cancelled` | - -### Mapeamento `severity` → `Priority` - -`Sev {severity}` (ex.: `3` → `Sev 3`) - ---- - -## 4. Auth serviço-a-serviço - -| Header | Config | -|--------|--------| -| `X-Ingest-Key` | `WorkOrderIngest:ApiKey` (env `WorkOrderIngest__ApiKey`) | - -Lambda deve enviar o header em cada `POST /api/workorders/ingest`. Não usar JWT de usuário dispatcher. - ---- - -## 5. Estratégia dual-run - -1. **Semana 1–2:** Lambda grava DynamoDB **e** chama API ingest -2. **Validação:** `GET /api/workorders/ops/health` + script amostra `ExternalWorkOrderId` -3. **Cutover:** Lambda só API; `Sync:Enabled=false` -4. **Retire:** backup DynamoDB → desativar tabelas - ---- - -## 6. Critérios de cutover - -- [ ] `POST /api/workorders/board` estável (criação SHOC) -- [ ] Auth ingest testada em staging -- [ ] Zero drift em amostra de 100 WOs -- [ ] Owner Lambda assinou runbook -- [ ] Dual-run validado (ops) -- [ ] `Sync:Enabled=false` em prod (ops) diff --git a/docs/work-orders/phase-7/phase-7-gates-signoff.md b/docs/work-orders/phase-7/phase-7-gates-signoff.md deleted file mode 100644 index 4bb68bd..0000000 --- a/docs/work-orders/phase-7/phase-7-gates-signoff.md +++ /dev/null @@ -1,80 +0,0 @@ -# Phase 7 — Gates Sign-Off Checklist - -**Programa:** Work Orders Board -**Fase:** 7 — Rollout e Produção - ---- - -## Engineering readiness (código — branch `feat/work-orders-phase-7`) - -Itens de engenharia entregues no repositório. Não substituem gates de staging/ops abaixo. - -| # | Item | Evidência | Status | -|---|------|-----------|--------| -| E1 | `GET /api/workorders/ops/health` | `WorkOrderOpsController` + `WorkOrderOpsHealthService` | [x] | -| E2 | `POST /api/workorders/ingest` + `X-Ingest-Key` | `WorkOrderIngestController` + `IngestApiKeyFilter` | [x] | -| E3 | `Sync:Enabled` → 503 no SyncController | `SyncOptions` + checks em WorkOrders/Comments/VendorReplies/All | [x] | -| E4 | Legacy deprecation middleware | `LegacyDeprecationMiddleware` + `LegacyEndpoints` config | [x] | -| E5 | Blazor sunset guard | `BlazorWorkOrderSunsetOptions` + guard em `WorkorderService` | [x] | -| E6 | Alertas documentados | [cloudwatch-alerts.md](cloudwatch-alerts.md) | [x] | -| E7 | Lambda mapping documentado | [lambda-ingest-discovery.md](lambda-ingest-discovery.md) | [x] | -| E8 | Testes coexistência | `dotnet test --filter FullyQualifiedName~WorkOrderPhase7` | [x] | -| E9 | Fase 6 slide-over API | `GET detail/audit/comments/media`, completion-doc, templates | [x] | - -**Config flags (default seguro em prod):** - -- `WorkOrderIngest:Enabled=true` (requer `ApiKey`) -- `Sync:Enabled=true` até cutover -- `LegacyEndpoints:DeprecationEnabled=false` até anúncio -- `BlazorWorkOrderSunset:Enabled=false` até data PO - ---- - -## Gate de entrada (GO Fase 7) - -Bloqueia piloto em produção até todos os itens estarem verdes. - -| # | Gate | Artefato / evidência | Status | -|---|------|----------------------|--------| -| 1 | Fases 0–6 validadas em staging | `docs/work-orders/phase-0` … `phase-6` READMEs | [ ] | -| 2 | Sign-off CTO/PO Fase 0 | [phase-0-gates-signoff.md](../phase-0/phase-0-gates-signoff.md) | [ ] | -| 3 | Dry-run migrations Phase0–Phase6 | [migration-dry-run-report.md](../phase-0/migration-dry-run-report.md) | [ ] | -| 4 | Smoke multi-consumidor | [smoke-checklist.md](../phase-0/smoke-checklist.md) | [ ] | -| 5 | Vendor Portal regression | Checklist dispatch checklist/signoff | [ ] | -| 6 | SHOC E2E staging | Contratos phase-1 … phase-6 integrados | [ ] | -| 7 | Tier volume confirmado | [volume-discovery-report.md](../phase-0/volume-discovery-report.md) | [ ] | - -### Checklist PO ([consumer-inventory.md](../phase-0/consumer-inventory.md)) - -- [ ] App mobile externo confirmado (sim/não/N/A) -- [ ] Data sunset Blazor WO definida: _______________ -- [ ] `FrontendBaseUrl` produção → SHOC configurado -- [ ] Owner Lambda cutover nomeado: _______________ - -**Assinatura GO Fase 7** - -| Papel | Nome | Data | -|-------|------|------| -| CTO | | | -| PO | | | - ---- - -## Gates de saída (DONE Fase 7) - -| Gate | Critério | Status | -|------|----------|--------| -| Piloto | 1–2 dispatchers SHOC em prod; UAT assinado | [ ] | -| Rollout | 100% dispatchers no board SHOC | [ ] | -| Estabilidade | Zero P1 por 2 semanas consecutivas | [ ] | -| Blazor | Módulo WO sunset (`BlazorWorkOrderSunset:Enabled`) | [ ] | -| Lambda | Ingest direto `POST /api/workorders/ingest`; Dynamo/Sync desligados | [ ] | -| Monitoramento | `GET /api/workorders/ops/health` + alertas CloudWatch criados | [ ] | -| Legado | Headers `Sunset`/`Deprecation` nos endpoints legado | [ ] | - -**Assinatura DONE Fase 7** - -| Papel | Nome | Data | -|-------|------|------| -| CTO | | | -| PO | | | From 2c26ead193fdea833f88615466383caf4d08e1de Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 13 Jul 2026 13:55:05 -0300 Subject: [PATCH 19/33] chore(docs): remove Phase 7 README from PR scope --- docs/work-orders/phase-7/README.md | 245 ----------------------------- 1 file changed, 245 deletions(-) delete mode 100644 docs/work-orders/phase-7/README.md diff --git a/docs/work-orders/phase-7/README.md b/docs/work-orders/phase-7/README.md deleted file mode 100644 index 61cfe2b..0000000 --- a/docs/work-orders/phase-7/README.md +++ /dev/null @@ -1,245 +0,0 @@ -# Fase 7 — Rollout e Produção - -**Programa:** Work Orders Board -**Objetivo:** Piloto SHOC, rollout gradual, monitoramento de coexistência, sunset Blazor WO, cutover Lambda→API e deprecação legado. - -**Depende de:** [Fase 0](../phase-0/README.md) … [Fase 6](../phase-6/README.md) concluídas e validadas em staging. - ---- - -## Gates - -- **Entrada:** [phase-7-gates-signoff.md](phase-7-gates-signoff.md) -- **Saída:** piloto UAT, 100% SHOC, zero P1 × 2 semanas, Blazor sunset, Lambda cutover, ops health ativo - ---- - -## Runbook de rollout (ordem obrigatória) - -1. Fechar gates de entrada Fase 7 -2. Deploy API com ops health + ingest (ingest desabilitado em prod inicialmente) -3. SHOC: feature flag board em staging → piloto 1–2 dispatchers em prod -4. UAT dispatchers ([roteiros abaixo](#uat-dispatchers)) -5. Rollout gradual SHOC até 100% -6. Habilitar dual-run Lambda (`ingest` + Dynamo) -7. Validar drift → cutover Lambda só API -8. `Sync:Enabled=false` -9. `BlazorWorkOrderSunset:Enabled=true` -10. `LegacyEndpoints:DeprecationEnabled=true` + data Sunset -11. Remover Sync/legado após período de aviso (30 dias) - ---- - -## Endpoints Fase 7 - -### `GET /api/workorders/ops/health` - -Saúde operacional para coexistência multi-consumidor. - -**Auth:** Admin JWT - -```json -{ - "lastWeekRolledRunUtc": "2026-06-23T00:05:12Z", - "lastPastDueCacheRunUtc": null, - "lastWeekRolledError": null, - "lastPastDueCacheError": null, - "syncRejectedLast24h": 3, - "fieldLockCount": 142, - "syncEnabled": true, - "ingestEnabled": true, - "legacyDeprecationEnabled": false, - "legacySunsetDate": null, - "checkedAtUtc": "2026-06-25T12:00:00Z" -} -``` - -Ver [cloudwatch-alerts.md](cloudwatch-alerts.md). - ---- - -### `POST /api/workorders/ingest` - -Ingest direto idempotente (substitui ponte DynamoDB para WOs). - -**Auth:** header `X-Ingest-Key` (config `WorkOrderIngest:ApiKey`) - -**Body (single ou batch):** - -```json -{ - "externalWorkOrderId": "ext-wo-12345", - "description": "HVAC unit not cooling", - "woStatus": "new", - "severity": "2", - "customer": "Acme Corp", - "siteCode": "BK5", - "building": "Building A", - "address": "123 Main St", - "dueDate": "2026-07-01T00:00:00Z", - "dateReported": "2026-06-20T10:00:00Z", - "scheduledStart": null, - "sourceEmailS3Key": null, - "createdAt": "2026-06-20T10:00:00Z" -} -``` - -**Batch:** - -```json -{ - "items": [ { "externalWorkOrderId": "...", "description": "..." } ] -} -``` - -**Response:** - -```json -{ - "created": 1, - "updated": 0, - "results": [{ "externalWorkOrderId": "ext-wo-12345", "workOrderId": 42, "created": true }] -} -``` - -Mapeamento completo: [lambda-ingest-discovery.md](lambda-ingest-discovery.md) - ---- - -## Matriz de endpoints - -| Tipo | Exemplos | Política Fase 7 | -|------|----------|-----------------| -| Board (SHOC) | `GET board`, `PATCH {id}/board`, `POST board` | **Produção** | -| Slide-over | `GET {id}/detail`, completion-doc, media | **Produção** | -| Ingest | `POST ingest` | Dual-run → produção | -| Sync | `POST api/Sync/WorkOrders` | Desligar após cutover (`Sync:Enabled`) | -| Legado | `GetWorkOrderList`, `AddWorkorder`, `ChangeStatus` | Deprecation headers | -| Jobs | `POST jobs/week-rolled` | Ops Admin | - ---- - -## Configuração - -### API (`Api.SeaHavenIndustries`) - -```json -{ - "FrontendBaseUrl": "https://shoc.seahaven.com", - "WorkOrderIngest": { - "Enabled": true, - "ApiKey": "${WORKORDER_INGEST_API_KEY}" - }, - "Sync": { - "Enabled": true - }, - "LegacyEndpoints": { - "DeprecationEnabled": false, - "SunsetDate": "2026-12-31" - } -} -``` - -Env vars: ver [.env.example](../../../.env.example) - -### Blazor (`SeaHavenIndustries`) - -```json -{ - "BlazorWorkOrderSunset": { - "Enabled": false, - "ShocBaseUrl": "https://shoc.seahaven.com" - } -} -``` - -Quando `Enabled=true`: nav Work Orders aponta para SHOC; mutações EF bloqueadas; banner nas páginas WO. - ---- - -## UAT dispatchers - -Roteiro manual — marcar cada item antes de expandir rollout. - -| # | Cenário | Pass | -|---|---------|------| -| 1 | Login SHOC; carregar semana (Mon–Fri + Unscheduled) | [ ] | -| 2 | Filtros: dispatcher, My WOs, tipo, busca contextual | [ ] | -| 3 | Inline edit célula (status, assignee, due) | [ ] | -| 4 | Conflito 409 — outro usuário editou; mensagem clara | [ ] | -| 5 | Criar WO wizard (Incomplete → Scheduled auto) | [ ] | -| 6 | Cancel soft; campos read-only pós-cancel | [ ] | -| 7 | Advanced search cross-week | [ ] | -| 8 | Slide-over: detail, comments, audit, media | [ ] | -| 9 | Upload completion doc; docStatus no board | [ ] | -| 10 | Badge PastDue / CarriedOver após segunda-feira | [ ] | - -**Sign-off UAT** - -| Dispatcher | Data | PO | -|------------|------|-----| -| | | | - ---- - -## Piloto e rollout SHOC - -Controle **somente no frontend** (feature flags). Backend não filtra dispatchers. - -Critérios para avançar: - -- Zero P1 na semana do piloto -- `GET board` latência aceitável (Tier S) -- Vendor Portal regression verde - ---- - -## Sunset Blazor - -Ver [data-ownership-model.md](../phase-0/data-ownership-model.md) §3. - -1. Congelar `WorkorderService` (bugfix only) até data PO -2. Habilitar `BlazorWorkOrderSunset:Enabled` -3. Smoke final [smoke-checklist.md](../phase-0/smoke-checklist.md) Blazor EF - ---- - -## Cutover Lambda - -Ver [lambda-ingest-discovery.md](lambda-ingest-discovery.md). - ---- - -## Testes automatizados - -```powershell -dotnet test SeaHavenIndustries.Tests --filter "FullyQualifiedName~WorkOrderPhase7" -``` - -Suite: `WorkOrderPhase7CoexistenceTests.cs` - ---- - -## Critérios de aceite Fase 7 - -- [x] `GET /api/workorders/ops/health` -- [x] `POST /api/workorders/ingest` com API key -- [x] `Sync:Enabled` feature flag -- [x] Legacy deprecation middleware -- [x] Blazor sunset config + guard mutações -- [x] Documentação gates, UAT, Lambda mapping -- [ ] Piloto prod (ops/PO) -- [ ] 100% dispatchers SHOC (ops/PO) -- [ ] Zero P1 × 2 semanas (ops/PO) -- [ ] Lambda cutover executado (ops) - ---- - -## Comunicação - -| Audiência | Mensagem | Quando | -|-----------|----------|--------| -| Dispatchers piloto | Novo board SHOC; suporte dedicado | Início piloto | -| Todos dispatchers | Rollout gradual; treinamento | Durante rollout | -| Usuários Blazor | WO migrou para SHOC; link direto | Sunset Blazor | -| Engenharia | Sync desligado; usar ingest API | Pós-cutover | From 3e2952f8a0c62c6c0fc58c249ff8ad367854a7d9 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 10:36:32 -0300 Subject: [PATCH 20/33] fix(migrations): make Phase6 completion slide-over migration idempotent Use IF EXISTS / IF COL_LENGTH guards and explicit EF migration metadata so partial environments can apply Phase 6 safely. --- ...260625120000_Phase6_CompletionSlideOver.cs | 87 +++++++++++-------- 1 file changed, 49 insertions(+), 38 deletions(-) diff --git a/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs b/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs index 08ff98d..f52940d 100644 --- a/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs +++ b/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs @@ -1,4 +1,5 @@ -using System; +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -6,45 +7,52 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Data.SeaHavenIndustries.Migrations { /// + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260625120000_Phase6_CompletionSlideOver")] public partial class Phase6_CompletionSlideOver : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.AddColumn( - name: "Category", - table: "workOrderAttachments", - type: "int", - nullable: true); + migrationBuilder.Sql(@" + IF COL_LENGTH('workOrderAttachments', 'Category') IS NULL + BEGIN + ALTER TABLE workOrderAttachments ADD Category int NULL; + END + "); - migrationBuilder.CreateTable( - name: "CompletionDocTemplates", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Name = table.Column(type: "nvarchar(max)", nullable: false), - ServiceKey = table.Column(type: "nvarchar(450)", nullable: false), - WorkOrderType = table.Column(type: "int", nullable: true), - TemplateUrl = table.Column(type: "nvarchar(max)", nullable: false), - IsActive = table.Column(type: "bit", nullable: false), - IsDeleted = table.Column(type: "bit", nullable: true), - createdby = table.Column(type: "nvarchar(max)", nullable: true), - DeleterUserId = table.Column(type: "nvarchar(max)", nullable: true), - DeletionTime = table.Column(type: "datetime2", nullable: true), - CreatedDate = table.Column(type: "datetime2", nullable: true), - LastModificationTime = table.Column(type: "datetime2", nullable: true), - LastModifierUserId = table.Column(type: "int", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_CompletionDocTemplates", x => x.Id); - }); + 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.CreateIndex( - name: "IX_CompletionDocTemplates_ServiceKey_IsActive", - table: "CompletionDocTemplates", - columns: new[] { "ServiceKey", "IsActive" }); + 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 @@ -66,12 +74,15 @@ namespace Data.SeaHavenIndustries.Migrations AND LTRIM(RTRIM(SignOffAttachment)) <> ''; "); - migrationBuilder.DropTable( - name: "CompletionDocTemplates"); + migrationBuilder.Sql(@" + IF OBJECT_ID(N'dbo.CompletionDocTemplates', N'U') IS NOT NULL + DROP TABLE CompletionDocTemplates; + "); - migrationBuilder.DropColumn( - name: "Category", - table: "workOrderAttachments"); + migrationBuilder.Sql(@" + IF COL_LENGTH('workOrderAttachments', 'Category') IS NOT NULL + ALTER TABLE workOrderAttachments DROP COLUMN Category; + "); } } } From 3983f7667d50edb041169c66336e1fa0284b0fd5 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 10:36:32 -0300 Subject: [PATCH 21/33] feat(locations): add sites options endpoint and vendor contactName Expose GET /api/locations/sites for the WO create wizard and include vendor contactName on dropdown responses. --- .../LocationControllerSitesTests.cs | 109 ++++++++++++++++++ .../Controllers/LocationController.cs | 7 ++ .../Controllers/VendorController.cs | 2 + .../Implementation/LocationDataService.cs | 18 +++ .../Interfaces/ILocationDataService.cs | 4 + SeaHaven.Services/DTOs/LocationDTOs.cs | 8 ++ .../Implementation/LocationService.cs | 14 +++ .../Interfaces/ILocationService.cs | 1 + 8 files changed, 163 insertions(+) create mode 100644 Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs 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/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/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index 9f6afc7..9c25ceb 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -174,6 +174,7 @@ namespace Api.SeaHavenIndustries.Controllers { v.Id, v.CompanyName, + v.ContactName, v.TradeSpecialties, v.Address, v.City, @@ -191,6 +192,7 @@ namespace Api.SeaHavenIndustries.Controllers { v.Id, v.CompanyName, + v.ContactName, v.TradeSpecialties, address = string.Join(", ", addr), distanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null diff --git a/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/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.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/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/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); From ecc1db3726911e311faf89e285964e02d691e4b8 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 10:37:23 -0300 Subject: [PATCH 22/33] feat(work-orders): add structured service notes POC and vendor fields Persist primary/extra services, service notes, free-text POC, and tech/vendor notes on create/patch/board/detail instead of embedding them in description. --- ...15160000_WoServiceNotesStructuredFields.cs | 121 ++++++++ .../ApplicationDbContextModelSnapshot.cs | 40 +++ Data.SeaHavenIndustries/Models/Dispatch.cs | 7 + Data.SeaHavenIndustries/Models/WorkerOrder.cs | 23 ++ .../Helpers/WorkOrderBoardProjection.cs | 97 +++--- .../WorkOrderDetailDataService.cs | 7 +- .../Interfaces/WorkOrderBoardModels.cs | 3 + .../Interfaces/WorkOrderDetailModels.cs | 5 +- SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 4 + .../DTOs/WorkOrderBoardRequestDTOs.cs | 21 +- SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs | 2 + .../Helpers/WorkOrderBoardFieldNames.cs | 14 +- .../WorkOrderBoardCreateService.cs | 104 ++++++- .../Implementation/WorkOrderBoardService.cs | 19 ++ .../WorkOrderBoardUpdateService.cs | 111 +++++++ .../Implementation/WorkOrderDetailService.cs | 43 ++- .../WorkOrderBoardCreateValidation.cs | 48 +++ .../WorkOrderBoardCreateServiceTests.cs | 169 +++++++++++ .../WorkOrderBoardUpdateServiceTests.cs | 279 ++++++++++++++++++ 19 files changed, 1064 insertions(+), 53 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260715160000_WoServiceNotesStructuredFields.cs 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/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index d97ab9b..608754d 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -739,9 +739,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"); @@ -1963,6 +1971,14 @@ namespace Data.SeaHavenIndustries.Migrations .HasMaxLength(450) .HasColumnType("nvarchar(450)"); + b.Property("ExtraServices") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FlagColor") + .HasMaxLength(7) + .HasColumnType("nvarchar(7)"); + b.Property("InternalWONumber") .HasColumnType("nvarchar(max)"); @@ -1996,6 +2012,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"); @@ -2029,6 +2057,10 @@ namespace Data.SeaHavenIndustries.Migrations .HasMaxLength(128) .HasColumnType("nvarchar(128)"); + b.Property("ServiceNotes") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + b.Property("Severity") .HasColumnType("nvarchar(max)"); @@ -2062,12 +2094,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"); diff --git a/Data.SeaHavenIndustries/Models/Dispatch.cs b/Data.SeaHavenIndustries/Models/Dispatch.cs index 831cc17..7fe14f3 100644 --- a/Data.SeaHavenIndustries/Models/Dispatch.cs +++ b/Data.SeaHavenIndustries/Models/Dispatch.cs @@ -28,6 +28,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/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index 18e51bf..052c2d3 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -32,6 +32,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; } @@ -51,6 +70,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 e2ad8d7..5520791 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/Implementation/WorkOrderDetailDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs index 8ea9358..e2526ab 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs @@ -32,7 +32,12 @@ namespace SeaHaven.DataServices.Implementation w.CompletedDate, w.SignOffName, w.SignOffAttachment, - w.SignOffSignature)) + w.SignOffSignature, + w.ExtraServices, + w.ServiceNotes, + w.PrimaryDispatch != null && w.PrimaryDispatch.VendorNotes != null + ? w.PrimaryDispatch.VendorNotes + : w.VendorNotes)) .FirstOrDefaultAsync(); } 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 index cab956b..433b080 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs @@ -12,7 +12,10 @@ namespace SeaHaven.DataServices.Interfaces DateTime? CompletedDate, string? SignOffName, string? SignOffAttachment, - string? SignOffSignature); + string? SignOffSignature, + string? ExtraServices, + string? ServiceNotes, + string? VendorNotes); public record DispatchSignoffRow( int DispatchId, 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..b78be76 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,14 +33,18 @@ 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. public class WorkOrderAdvancedSearchQueryDto { - public int Page { get; set; } = 1; - public int PageSize { get; set; } = 50; + public int Page { get; set; } = 0; + public int PageSize { get; set; } = 100; public string? Search { get; set; } public WorkOrderAdvancedSearchDatePreset? DatePreset { get; set; } public DateOnly? DateFrom { get; set; } diff --git a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs index 7ac1437..105e112 100644 --- a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs @@ -20,6 +20,7 @@ namespace SeaHaven.Services.DTOs public DateOnly? OriginalWeek { get; set; } public DateOnly? OriginalDate { get; set; } public DateTime? CompletedDate { get; set; } + public string? VendorNotes { get; set; } } public class CompletionDocTemplateDto @@ -63,6 +64,7 @@ namespace SeaHaven.Services.DTOs { 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; } 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/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index 77bdb27..7f75baa 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)); } _context.workOrders.Add(workOrder); @@ -140,7 +191,7 @@ namespace SeaHaven.Services.Implementation { WorkorderId = workOrder.Id, ContactId = request.PocContactId, - Notes = string.IsNullOrWhiteSpace(request.PocNotes) ? null : request.PocNotes.Trim() + Notes = pocNotes }); } @@ -150,6 +201,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 0a82f76..56c576e 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -115,7 +115,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 @@ -124,5 +127,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 98239bb..c924009 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; @@ -157,6 +158,10 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardFieldNames.ApptDate => new List { ApplyApptDate(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptTime => new List { ApplyApptTime(workOrder, dispatch!, value, auditField) }, 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.") }; } @@ -222,6 +227,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}"); @@ -248,6 +258,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); @@ -456,6 +549,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/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs index 34aa8ac..849c5f6 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -10,15 +10,18 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderBoardService _boardService; private readonly IWorkOrderDetailDataService _detailData; private readonly ICompletionDocTemplateDataService _templateData; + private readonly IUserDataService _userDataService; public WorkOrderDetailService( IWorkOrderBoardService boardService, IWorkOrderDetailDataService detailData, - ICompletionDocTemplateDataService templateData) + ICompletionDocTemplateDataService templateData, + IUserDataService userDataService) { _boardService = boardService; _detailData = detailData; _templateData = templateData; + _userDataService = userDataService; } public async Task GetDetailAsync(int workOrderId) @@ -41,12 +44,15 @@ namespace SeaHaven.Services.Implementation 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), - Audit = WorkOrderAuditProjection.ToDtos(audit), + Comments = WorkOrderCommentProjection.ToDtos(comments, authorNames), + Audit = await MapAuditAsync(audit), Media = workOrder == null ? new List() : WorkOrderMediaProjection.ProjectAll(workOrder, attachments) @@ -59,7 +65,14 @@ namespace SeaHaven.Services.Implementation return null; var logs = await _detailData.GetAuditLogsAsync(workOrderId, limit); - return WorkOrderAuditProjection.ToDtos(logs); + 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( @@ -98,7 +111,10 @@ namespace SeaHaven.Services.Implementation 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, @@ -108,10 +124,27 @@ namespace SeaHaven.Services.Implementation SubTrade = extended?.SubTrade, OriginalWeek = extended?.OriginalWeek, OriginalDate = extended?.OriginalDate, - CompletedDate = extended?.CompletedDate + 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, 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 59b180c..ecaba65 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -105,6 +105,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() { @@ -224,4 +251,256 @@ public class WorkOrderBoardUpdateServiceTests 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); + } } From 62572d8e032cae22fffcfaf7543bf6da2266b64b Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 10:37:24 -0300 Subject: [PATCH 23/33] feat(work-orders): add board flagColor with fixed palette Support PATCH flagColor with FE-aligned #RRGGBB palette and expose the value on board/detail rows. --- .../Migrations/20260715180000_WoFlagColor.cs | 33 ++++++++++++ .../Helpers/WorkOrderFlagColors.cs | 50 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 Data.SeaHavenIndustries/Migrations/20260715180000_WoFlagColor.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderFlagColors.cs 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/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); + } +} From a80a71b16af6fe93e9121c4314e21950b7858ad2 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 13:18:43 -0300 Subject: [PATCH 24/33] fix(work-orders): make stacked tip build for CI quality gates --- .../WorkOrderControllerSearchTests.cs | 6 +- .../Implementation/UserDataService.cs | 22 ++++++++ .../Interfaces/IUserDataService.cs | 1 + .../Helpers/WorkOrderAuditProjection.cs | 56 +++++++++++++++++-- .../Helpers/WorkOrderCommentProjection.cs | 35 +++++++++++- .../Helpers/WorkOrderFlagColors.cs | 50 +++++++++++++++++ 6 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 SeaHaven.Services/Helpers/WorkOrderFlagColors.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs index 61d37c9..17826af 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs @@ -59,7 +59,11 @@ 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/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/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.Services/Helpers/WorkOrderAuditProjection.cs b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs index 8b56b47..6a7e295 100644 --- a/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs +++ b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs @@ -5,23 +5,69 @@ namespace SeaHaven.Services.Helpers { public static class WorkOrderAuditProjection { - public static WorkOrderAuditEntryDto ToDto(WorkOrderAuditLog log) + 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 = log.OldValue ?? "", - NewValue = log.NewValue ?? "", + OldValue = oldValue, + NewValue = newValue, Time = log.CreatedAt.ToString("o"), DispatchId = log.DispatchId }; } - public static List ToDtos(IEnumerable logs) - => logs.Select(ToDto).ToList(); + 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) { diff --git a/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs index 096edd5..cb4a2d5 100644 --- a/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs +++ b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs @@ -5,19 +5,48 @@ namespace SeaHaven.Services.Helpers { public static class WorkOrderCommentProjection { - public static WorkOrderCommentDto ToDto(Comments comment) + 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) - => comments.Select(ToDto).ToList(); + 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); + } +} From fa1eca8f894b12b01a0514e5fb18ab45d0dbabbd Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 13:18:46 -0300 Subject: [PATCH 25/33] fix(work-orders): make stacked tip build for CI quality gates --- .../WorkOrderControllerSearchTests.cs | 6 +- .../Implementation/UserDataService.cs | 22 ++++++++ .../Interfaces/IUserDataService.cs | 1 + .../Helpers/WorkOrderAuditProjection.cs | 56 +++++++++++++++++-- .../Helpers/WorkOrderCommentProjection.cs | 35 +++++++++++- 5 files changed, 111 insertions(+), 9 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs index 61d37c9..17826af 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs @@ -59,7 +59,11 @@ 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/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/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.Services/Helpers/WorkOrderAuditProjection.cs b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs index 8b56b47..6a7e295 100644 --- a/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs +++ b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs @@ -5,23 +5,69 @@ namespace SeaHaven.Services.Helpers { public static class WorkOrderAuditProjection { - public static WorkOrderAuditEntryDto ToDto(WorkOrderAuditLog log) + 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 = log.OldValue ?? "", - NewValue = log.NewValue ?? "", + OldValue = oldValue, + NewValue = newValue, Time = log.CreatedAt.ToString("o"), DispatchId = log.DispatchId }; } - public static List ToDtos(IEnumerable logs) - => logs.Select(ToDto).ToList(); + 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) { diff --git a/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs index 096edd5..cb4a2d5 100644 --- a/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs +++ b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs @@ -5,19 +5,48 @@ namespace SeaHaven.Services.Helpers { public static class WorkOrderCommentProjection { - public static WorkOrderCommentDto ToDto(Comments comment) + 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) - => comments.Select(ToDto).ToList(); + 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; + } } } From c722d37a8fb20f82f4c9d2e44fdf9b78acdeb93a Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 13:38:55 -0300 Subject: [PATCH 26/33] fix(work-orders): align stacked services with CI build --- SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 343337e..85d0a98 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -92,7 +92,8 @@ public class WorkOrderDetailServiceTests var boardService = new WorkOrderBoardService(boardData); var detailData = new WorkOrderDetailDataService(context); var templateData = new CompletionDocTemplateDataService(context); - var service = new WorkOrderDetailService(boardService, detailData, templateData); + var userData = new UserDataService(context); + var service = new WorkOrderDetailService(boardService, detailData, templateData, userData); return (context, service); } From d59f92910e62228f15908c38598becfd735821b1 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 13:38:56 -0300 Subject: [PATCH 27/33] fix(work-orders): align stacked services with CI build --- SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 343337e..85d0a98 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -92,7 +92,8 @@ public class WorkOrderDetailServiceTests var boardService = new WorkOrderBoardService(boardData); var detailData = new WorkOrderDetailDataService(context); var templateData = new CompletionDocTemplateDataService(context); - var service = new WorkOrderDetailService(boardService, detailData, templateData); + var userData = new UserDataService(context); + var service = new WorkOrderDetailService(boardService, detailData, templateData, userData); return (context, service); } From 33574c233b94af729e986b3095cb008a9555ae5c Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 21 Jul 2026 09:38:34 -0300 Subject: [PATCH 28/33] fix(work-orders): keep FlagColor out of structured-notes slice Remove FlagColor model/API/tests from this PR so schema and EF model stay aligned with the structured-fields migration. FlagColor remains in the follow-on PR that adds its migration. --- .../ApplicationDbContextModelSnapshot.cs | 4 - Data.SeaHavenIndustries/Models/WorkerOrder.cs | 4 - .../Helpers/WorkOrderBoardProjection.cs | 2 - .../Interfaces/WorkOrderBoardModels.cs | 1 - SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 2 - .../Helpers/WorkOrderBoardFieldNames.cs | 5 +- .../Helpers/WorkOrderFlagColors.cs | 50 ------ .../Implementation/WorkOrderBoardService.cs | 1 - .../WorkOrderBoardUpdateService.cs | 19 --- .../Implementation/WorkOrderDetailService.cs | 1 - .../WorkOrderBoardUpdateServiceTests.cs | 153 ------------------ 11 files changed, 1 insertion(+), 241 deletions(-) delete mode 100644 SeaHaven.Services/Helpers/WorkOrderFlagColors.cs diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 608754d..3da8116 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1975,10 +1975,6 @@ namespace Data.SeaHavenIndustries.Migrations .HasMaxLength(2000) .HasColumnType("nvarchar(2000)"); - b.Property("FlagColor") - .HasMaxLength(7) - .HasColumnType("nvarchar(7)"); - b.Property("InternalWONumber") .HasColumnType("nvarchar(max)"); diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index 052c2d3..cb30f29 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -70,10 +70,6 @@ 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 5520791..71c417a 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -66,7 +66,6 @@ namespace SeaHaven.DataServices.Helpers w.ServiceNotes, w.ExtraServices, w.DocStatus, - w.FlagColor, w.PrimaryDispatchId, w.RowVersion, DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null @@ -116,7 +115,6 @@ namespace SeaHaven.DataServices.Helpers w.ServiceNotes, w.ExtraServices, w.DocStatus, - w.FlagColor, w.PrimaryDispatchId, w.RowVersion, w.DispatchRowVersion, diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index c46ff55..ce96697 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -45,7 +45,6 @@ namespace SeaHaven.DataServices.Interfaces string? ServiceNotes, string? ExtraServices, DocStatus? DocStatus, - string? FlagColor, int? PrimaryDispatchId, byte[]? RowVersion, byte[]? DispatchRowVersion, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index f0b13f9..1f1b549 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -54,8 +54,6 @@ namespace SeaHaven.Services.DTOs 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/Helpers/WorkOrderBoardFieldNames.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs index a76a7fe..233090e 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs @@ -18,7 +18,6 @@ namespace SeaHaven.Services.Helpers 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) @@ -45,8 +44,7 @@ namespace SeaHaven.Services.Helpers DocStatus, Pm, ServiceNotes, - ExtraServices, - FlagColor + ExtraServices }; public static bool IsEditable(string field) => Canonicalize(field) != null; @@ -86,7 +84,6 @@ namespace SeaHaven.Services.Helpers Pm => "Trade", ServiceNotes => "ServiceNotes", ExtraServices => "ExtraServices", - FlagColor => "FlagColor", _ => canonical }; } diff --git a/SeaHaven.Services/Helpers/WorkOrderFlagColors.cs b/SeaHaven.Services/Helpers/WorkOrderFlagColors.cs deleted file mode 100644 index 6892bd8..0000000 --- a/SeaHaven.Services/Helpers/WorkOrderFlagColors.cs +++ /dev/null @@ -1,50 +0,0 @@ -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/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 56c576e..076f090 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -118,7 +118,6 @@ namespace SeaHaven.Services.Implementation ServiceNotes = row.ServiceNotes, ExtraServices = ParseExtraServices(row.ExtraServices), DocStatus = row.DocStatus, - FlagColor = row.FlagColor, PrimaryDispatchId = row.PrimaryDispatchId, RowVersion = row.RowVersion, DispatchRowVersion = row.DispatchRowVersion diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index c924009..90b57fb 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -161,7 +161,6 @@ namespace SeaHaven.Services.Implementation 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.") }; } @@ -549,24 +548,6 @@ 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/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs index 849c5f6..0f7b5d0 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -114,7 +114,6 @@ namespace SeaHaven.Services.Implementation 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, diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index ecaba65..94ba33d 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -350,157 +350,4 @@ public class WorkOrderBoardUpdateServiceTests 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); - } } From cc37a0283babd0de0ff60041660aeb6b5adb86e9 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 21 Jul 2026 09:42:10 -0300 Subject: [PATCH 29/33] fix(work-orders): keep FlagColor feature on this slice with its migration Restore FlagColor model/API/tests here after the parent structured-notes PR dropped them, so this PR remains the coherent home for the FlagColor column and palette. --- .../ApplicationDbContextModelSnapshot.cs | 4 + Data.SeaHavenIndustries/Models/WorkerOrder.cs | 4 + .../Helpers/WorkOrderBoardProjection.cs | 2 + .../Interfaces/WorkOrderBoardModels.cs | 1 + SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 2 + .../Helpers/WorkOrderBoardFieldNames.cs | 5 +- .../Implementation/WorkOrderBoardService.cs | 1 + .../WorkOrderBoardUpdateService.cs | 19 +++ .../Implementation/WorkOrderDetailService.cs | 1 + .../WorkOrderBoardUpdateServiceTests.cs | 153 ++++++++++++++++++ 10 files changed, 191 insertions(+), 1 deletion(-) diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 3da8116..608754d 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1975,6 +1975,10 @@ namespace Data.SeaHavenIndustries.Migrations .HasMaxLength(2000) .HasColumnType("nvarchar(2000)"); + b.Property("FlagColor") + .HasMaxLength(7) + .HasColumnType("nvarchar(7)"); + b.Property("InternalWONumber") .HasColumnType("nvarchar(max)"); diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index cb30f29..052c2d3 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -70,6 +70,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 71c417a..5520791 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -66,6 +66,7 @@ namespace SeaHaven.DataServices.Helpers w.ServiceNotes, w.ExtraServices, w.DocStatus, + w.FlagColor, w.PrimaryDispatchId, w.RowVersion, DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null @@ -115,6 +116,7 @@ namespace SeaHaven.DataServices.Helpers w.ServiceNotes, w.ExtraServices, w.DocStatus, + w.FlagColor, w.PrimaryDispatchId, w.RowVersion, w.DispatchRowVersion, diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index ce96697..c46ff55 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -45,6 +45,7 @@ namespace SeaHaven.DataServices.Interfaces string? ServiceNotes, string? ExtraServices, DocStatus? DocStatus, + string? FlagColor, int? PrimaryDispatchId, byte[]? RowVersion, byte[]? DispatchRowVersion, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index 1f1b549..f0b13f9 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -54,6 +54,8 @@ namespace SeaHaven.Services.DTOs 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/Helpers/WorkOrderBoardFieldNames.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs index 233090e..a76a7fe 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs @@ -18,6 +18,7 @@ namespace SeaHaven.Services.Helpers 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) @@ -44,7 +45,8 @@ namespace SeaHaven.Services.Helpers DocStatus, Pm, ServiceNotes, - ExtraServices + ExtraServices, + FlagColor }; public static bool IsEditable(string field) => Canonicalize(field) != null; @@ -84,6 +86,7 @@ namespace SeaHaven.Services.Helpers Pm => "Trade", ServiceNotes => "ServiceNotes", ExtraServices => "ExtraServices", + FlagColor => "FlagColor", _ => canonical }; } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 076f090..56c576e 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -118,6 +118,7 @@ namespace SeaHaven.Services.Implementation ServiceNotes = row.ServiceNotes, ExtraServices = ParseExtraServices(row.ExtraServices), DocStatus = row.DocStatus, + FlagColor = row.FlagColor, PrimaryDispatchId = row.PrimaryDispatchId, RowVersion = row.RowVersion, DispatchRowVersion = row.DispatchRowVersion diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 90b57fb..c924009 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -161,6 +161,7 @@ namespace SeaHaven.Services.Implementation 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.") }; } @@ -548,6 +549,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/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs index 0f7b5d0..849c5f6 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -114,6 +114,7 @@ namespace SeaHaven.Services.Implementation 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, diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 94ba33d..ecaba65 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -350,4 +350,157 @@ public class WorkOrderBoardUpdateServiceTests 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); + } } From af1c0d19b4c6dbaa4551d5494d1e4fec955b4adf Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 22 Jul 2026 17:35:55 -0300 Subject: [PATCH 30/33] fix(migrations): make Phase6 completion slide-over migration idempotent (#19) * fix(migrations): make Phase6 completion slide-over migration idempotent Use IF EXISTS / IF COL_LENGTH guards and explicit EF migration metadata so partial environments can apply Phase 6 safely. * fix(work-orders): make stacked tip build for CI quality gates --------- Co-authored-by: Arthur Bassi --- .../WorkOrderControllerSearchTests.cs | 6 +- ...260625120000_Phase6_CompletionSlideOver.cs | 87 +++++++++++-------- 2 files changed, 54 insertions(+), 39 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs index 61d37c9..17826af 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs @@ -59,7 +59,11 @@ 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/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs b/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs index 08ff98d..f52940d 100644 --- a/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs +++ b/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs @@ -1,4 +1,5 @@ -using System; +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -6,45 +7,52 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Data.SeaHavenIndustries.Migrations { /// + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260625120000_Phase6_CompletionSlideOver")] public partial class Phase6_CompletionSlideOver : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.AddColumn( - name: "Category", - table: "workOrderAttachments", - type: "int", - nullable: true); + migrationBuilder.Sql(@" + IF COL_LENGTH('workOrderAttachments', 'Category') IS NULL + BEGIN + ALTER TABLE workOrderAttachments ADD Category int NULL; + END + "); - migrationBuilder.CreateTable( - name: "CompletionDocTemplates", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Name = table.Column(type: "nvarchar(max)", nullable: false), - ServiceKey = table.Column(type: "nvarchar(450)", nullable: false), - WorkOrderType = table.Column(type: "int", nullable: true), - TemplateUrl = table.Column(type: "nvarchar(max)", nullable: false), - IsActive = table.Column(type: "bit", nullable: false), - IsDeleted = table.Column(type: "bit", nullable: true), - createdby = table.Column(type: "nvarchar(max)", nullable: true), - DeleterUserId = table.Column(type: "nvarchar(max)", nullable: true), - DeletionTime = table.Column(type: "datetime2", nullable: true), - CreatedDate = table.Column(type: "datetime2", nullable: true), - LastModificationTime = table.Column(type: "datetime2", nullable: true), - LastModifierUserId = table.Column(type: "int", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_CompletionDocTemplates", x => x.Id); - }); + 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.CreateIndex( - name: "IX_CompletionDocTemplates_ServiceKey_IsActive", - table: "CompletionDocTemplates", - columns: new[] { "ServiceKey", "IsActive" }); + 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 @@ -66,12 +74,15 @@ namespace Data.SeaHavenIndustries.Migrations AND LTRIM(RTRIM(SignOffAttachment)) <> ''; "); - migrationBuilder.DropTable( - name: "CompletionDocTemplates"); + migrationBuilder.Sql(@" + IF OBJECT_ID(N'dbo.CompletionDocTemplates', N'U') IS NOT NULL + DROP TABLE CompletionDocTemplates; + "); - migrationBuilder.DropColumn( - name: "Category", - table: "workOrderAttachments"); + migrationBuilder.Sql(@" + IF COL_LENGTH('workOrderAttachments', 'Category') IS NOT NULL + ALTER TABLE workOrderAttachments DROP COLUMN Category; + "); } } } From 8c4102b1cf4cd93b137d0e38bd633147979e39fc Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 22 Jul 2026 17:36:11 -0300 Subject: [PATCH 31/33] feat(locations): add sites options endpoint and vendor contactName (#20) * fix(migrations): make Phase6 completion slide-over migration idempotent Use IF EXISTS / IF COL_LENGTH guards and explicit EF migration metadata so partial environments can apply Phase 6 safely. * feat(locations): add sites options endpoint and vendor contactName Expose GET /api/locations/sites for the WO create wizard and include vendor contactName on dropdown responses. * fix(work-orders): make stacked tip build for CI quality gates --------- Co-authored-by: Arthur Bassi --- .../LocationControllerSitesTests.cs | 109 ++++++++++++++++++ .../Controllers/LocationController.cs | 7 ++ .../Controllers/VendorController.cs | 2 + .../Implementation/LocationDataService.cs | 18 +++ .../Interfaces/ILocationDataService.cs | 4 + SeaHaven.Services/DTOs/LocationDTOs.cs | 8 ++ .../Implementation/LocationService.cs | 14 +++ .../Interfaces/ILocationService.cs | 1 + 8 files changed, 163 insertions(+) create mode 100644 Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs 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/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/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index 9f6afc7..9c25ceb 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -174,6 +174,7 @@ namespace Api.SeaHavenIndustries.Controllers { v.Id, v.CompanyName, + v.ContactName, v.TradeSpecialties, v.Address, v.City, @@ -191,6 +192,7 @@ namespace Api.SeaHavenIndustries.Controllers { v.Id, v.CompanyName, + v.ContactName, v.TradeSpecialties, address = string.Join(", ", addr), distanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null diff --git a/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/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.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/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/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); From f4985378fc8d09fcfd140417ec0610aa7459a513 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 22 Jul 2026 17:36:46 -0300 Subject: [PATCH 32/33] feat(work-orders): add structured service notes POC and vendor fields (#21) * fix(migrations): make Phase6 completion slide-over migration idempotent Use IF EXISTS / IF COL_LENGTH guards and explicit EF migration metadata so partial environments can apply Phase 6 safely. * feat(locations): add sites options endpoint and vendor contactName Expose GET /api/locations/sites for the WO create wizard and include vendor contactName on dropdown responses. * feat(work-orders): add structured service notes POC and vendor fields Persist primary/extra services, service notes, free-text POC, and tech/vendor notes on create/patch/board/detail instead of embedding them in description. * fix(work-orders): make stacked tip build for CI quality gates * fix(work-orders): align stacked services with CI build * fix(work-orders): keep FlagColor out of structured-notes slice Remove FlagColor model/API/tests from this PR so schema and EF model stay aligned with the structured-fields migration. FlagColor remains in the follow-on PR that adds its migration. --------- Co-authored-by: Arthur Bassi --- ...15160000_WoServiceNotesStructuredFields.cs | 121 +++++++++++++ .../ApplicationDbContextModelSnapshot.cs | 36 ++++ Data.SeaHavenIndustries/Models/Dispatch.cs | 7 + Data.SeaHavenIndustries/Models/WorkerOrder.cs | 19 ++ .../Helpers/WorkOrderBoardProjection.cs | 95 ++++++---- .../Implementation/UserDataService.cs | 22 +++ .../WorkOrderDetailDataService.cs | 7 +- .../Interfaces/IUserDataService.cs | 1 + .../Interfaces/WorkOrderBoardModels.cs | 2 + .../Interfaces/WorkOrderDetailModels.cs | 5 +- SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 2 + .../DTOs/WorkOrderBoardRequestDTOs.cs | 21 ++- SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs | 2 + .../Helpers/WorkOrderAuditProjection.cs | 56 +++++- .../Helpers/WorkOrderBoardFieldNames.cs | 11 +- .../Helpers/WorkOrderCommentProjection.cs | 35 +++- .../WorkOrderBoardCreateService.cs | 104 ++++++++++- .../Implementation/WorkOrderBoardService.cs | 18 ++ .../WorkOrderBoardUpdateService.cs | 92 ++++++++++ .../Implementation/WorkOrderDetailService.cs | 42 ++++- .../WorkOrderBoardCreateValidation.cs | 48 +++++ .../WorkOrderBoardCreateServiceTests.cs | 169 ++++++++++++++++++ .../WorkOrderBoardUpdateServiceTests.cs | 126 +++++++++++++ .../WorkOrderPhase6Tests.cs | 3 +- 24 files changed, 982 insertions(+), 62 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260715160000_WoServiceNotesStructuredFields.cs 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/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index d97ab9b..3da8116 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -739,9 +739,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"); @@ -1963,6 +1971,10 @@ namespace Data.SeaHavenIndustries.Migrations .HasMaxLength(450) .HasColumnType("nvarchar(450)"); + b.Property("ExtraServices") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + b.Property("InternalWONumber") .HasColumnType("nvarchar(max)"); @@ -1996,6 +2008,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"); @@ -2029,6 +2053,10 @@ namespace Data.SeaHavenIndustries.Migrations .HasMaxLength(128) .HasColumnType("nvarchar(128)"); + b.Property("ServiceNotes") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + b.Property("Severity") .HasColumnType("nvarchar(max)"); @@ -2062,12 +2090,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"); diff --git a/Data.SeaHavenIndustries/Models/Dispatch.cs b/Data.SeaHavenIndustries/Models/Dispatch.cs index 831cc17..7fe14f3 100644 --- a/Data.SeaHavenIndustries/Models/Dispatch.cs +++ b/Data.SeaHavenIndustries/Models/Dispatch.cs @@ -28,6 +28,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/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index 18e51bf..cb30f29 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -32,6 +32,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; } diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index e2ad8d7..71c417a 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,12 +55,16 @@ 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.PrimaryDispatchId, w.RowVersion, @@ -65,42 +72,54 @@ namespace SeaHaven.DataServices.Helpers }) .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.PrimaryDispatchId, + w.RowVersion, + w.DispatchRowVersion, + isUnscheduled); + }).ToList(); } } } 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 index 8ea9358..e2526ab 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs @@ -32,7 +32,12 @@ namespace SeaHaven.DataServices.Implementation w.CompletedDate, w.SignOffName, w.SignOffAttachment, - w.SignOffSignature)) + w.SignOffSignature, + w.ExtraServices, + w.ServiceNotes, + w.PrimaryDispatch != null && w.PrimaryDispatch.VendorNotes != null + ? w.PrimaryDispatch.VendorNotes + : w.VendorNotes)) .FirstOrDefaultAsync(); } 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/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index 0173d07..ce96697 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -42,6 +42,8 @@ namespace SeaHaven.DataServices.Interfaces DateTime? DispatchApptDate, string? Trade, string? Problem, + string? ServiceNotes, + string? ExtraServices, DocStatus? DocStatus, int? PrimaryDispatchId, byte[]? RowVersion, diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs index cab956b..433b080 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs @@ -12,7 +12,10 @@ namespace SeaHaven.DataServices.Interfaces DateTime? CompletedDate, string? SignOffName, string? SignOffAttachment, - string? SignOffSignature); + string? SignOffSignature, + string? ExtraServices, + string? ServiceNotes, + string? VendorNotes); public record DispatchSignoffRow( int DispatchId, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index b35c303..1f1b549 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -51,6 +51,8 @@ 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; } public int? PrimaryDispatchId { get; set; } public byte[]? RowVersion { get; set; } diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 31fae59..b78be76 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,14 +33,18 @@ 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. public class WorkOrderAdvancedSearchQueryDto { - public int Page { get; set; } = 1; - public int PageSize { get; set; } = 50; + public int Page { get; set; } = 0; + public int PageSize { get; set; } = 100; public string? Search { get; set; } public WorkOrderAdvancedSearchDatePreset? DatePreset { get; set; } public DateOnly? DateFrom { get; set; } diff --git a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs index 7ac1437..105e112 100644 --- a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs @@ -20,6 +20,7 @@ namespace SeaHaven.Services.DTOs public DateOnly? OriginalWeek { get; set; } public DateOnly? OriginalDate { get; set; } public DateTime? CompletedDate { get; set; } + public string? VendorNotes { get; set; } } public class CompletionDocTemplateDto @@ -63,6 +64,7 @@ namespace SeaHaven.Services.DTOs { 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; } diff --git a/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs index 8b56b47..6a7e295 100644 --- a/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs +++ b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs @@ -5,23 +5,69 @@ namespace SeaHaven.Services.Helpers { public static class WorkOrderAuditProjection { - public static WorkOrderAuditEntryDto ToDto(WorkOrderAuditLog log) + 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 = log.OldValue ?? "", - NewValue = log.NewValue ?? "", + OldValue = oldValue, + NewValue = newValue, Time = log.CreatedAt.ToString("o"), DispatchId = log.DispatchId }; } - public static List ToDtos(IEnumerable logs) - => logs.Select(ToDto).ToList(); + 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) { diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs index cf6e4e8..233090e 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs @@ -15,6 +15,9 @@ 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 RescheduleCount = "RescheduleCount"; private static readonly HashSet DispatchFields = new(StringComparer.OrdinalIgnoreCase) @@ -38,7 +41,10 @@ namespace SeaHaven.Services.Helpers VendorId, ApptDate, ApptTime, - DocStatus + DocStatus, + Pm, + ServiceNotes, + ExtraServices }; public static bool IsEditable(string field) => Canonicalize(field) != null; @@ -75,6 +81,9 @@ namespace SeaHaven.Services.Helpers ApptDate => "ApptDate", ApptTime => "ApptTime", DocStatus => "DocStatus", + Pm => "Trade", + ServiceNotes => "ServiceNotes", + ExtraServices => "ExtraServices", _ => canonical }; } diff --git a/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs index 096edd5..cb4a2d5 100644 --- a/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs +++ b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs @@ -5,19 +5,48 @@ namespace SeaHaven.Services.Helpers { public static class WorkOrderCommentProjection { - public static WorkOrderCommentDto ToDto(Comments comment) + 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) - => comments.Select(ToDto).ToList(); + 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/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index 77bdb27..7f75baa 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)); } _context.workOrders.Add(workOrder); @@ -140,7 +191,7 @@ namespace SeaHaven.Services.Implementation { WorkorderId = workOrder.Id, ContactId = request.PocContactId, - Notes = string.IsNullOrWhiteSpace(request.PocNotes) ? null : request.PocNotes.Trim() + Notes = pocNotes }); } @@ -150,6 +201,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 0a82f76..076f090 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -115,6 +115,8 @@ 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, PrimaryDispatchId = row.PrimaryDispatchId, RowVersion = row.RowVersion, @@ -124,5 +126,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 98239bb..90b57fb 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; @@ -157,6 +158,9 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardFieldNames.ApptDate => new List { ApplyApptDate(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptTime => new List { ApplyApptTime(workOrder, dispatch!, value, auditField) }, 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) }, _ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.") }; } @@ -222,6 +226,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}"); @@ -248,6 +257,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); diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs index 34aa8ac..0f7b5d0 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -10,15 +10,18 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderBoardService _boardService; private readonly IWorkOrderDetailDataService _detailData; private readonly ICompletionDocTemplateDataService _templateData; + private readonly IUserDataService _userDataService; public WorkOrderDetailService( IWorkOrderBoardService boardService, IWorkOrderDetailDataService detailData, - ICompletionDocTemplateDataService templateData) + ICompletionDocTemplateDataService templateData, + IUserDataService userDataService) { _boardService = boardService; _detailData = detailData; _templateData = templateData; + _userDataService = userDataService; } public async Task GetDetailAsync(int workOrderId) @@ -41,12 +44,15 @@ namespace SeaHaven.Services.Implementation 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), - Audit = WorkOrderAuditProjection.ToDtos(audit), + Comments = WorkOrderCommentProjection.ToDtos(comments, authorNames), + Audit = await MapAuditAsync(audit), Media = workOrder == null ? new List() : WorkOrderMediaProjection.ProjectAll(workOrder, attachments) @@ -59,7 +65,14 @@ namespace SeaHaven.Services.Implementation return null; var logs = await _detailData.GetAuditLogsAsync(workOrderId, limit); - return WorkOrderAuditProjection.ToDtos(logs); + 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( @@ -98,6 +111,8 @@ namespace SeaHaven.Services.Implementation ApptDate = row.ApptDate, ApptTime = row.ApptTime, Pm = row.Pm, + ServiceNotes = extended?.ServiceNotes ?? row.ServiceNotes, + ExtraServices = ParseExtraServices(extended?.ExtraServices) ?? row.ExtraServices, DocStatus = row.DocStatus, PrimaryDispatchId = row.PrimaryDispatchId, RowVersion = row.RowVersion, @@ -108,10 +123,27 @@ namespace SeaHaven.Services.Implementation SubTrade = extended?.SubTrade, OriginalWeek = extended?.OriginalWeek, OriginalDate = extended?.OriginalDate, - CompletedDate = extended?.CompletedDate + 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, 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 59b180c..94ba33d 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -105,6 +105,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() { @@ -224,4 +251,103 @@ public class WorkOrderBoardUpdateServiceTests 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); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 343337e..85d0a98 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -92,7 +92,8 @@ public class WorkOrderDetailServiceTests var boardService = new WorkOrderBoardService(boardData); var detailData = new WorkOrderDetailDataService(context); var templateData = new CompletionDocTemplateDataService(context); - var service = new WorkOrderDetailService(boardService, detailData, templateData); + var userData = new UserDataService(context); + var service = new WorkOrderDetailService(boardService, detailData, templateData, userData); return (context, service); } From 78abf84b0d34ee4d113ffb783396ba3ea0f46405 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 24 Jul 2026 14:27:52 -0300 Subject: [PATCH 33/33] fix(work-orders): finalize PR 22 integration compile fixes --- .../DTOs/WorkOrderBoardRequestDTOs.cs | 21 ------------------- .../WorkOrderBoardUpdateServiceTests.cs | 2 ++ 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 468e559..d93e123 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -40,27 +40,6 @@ namespace SeaHaven.Services.DTOs public string? VendorNotes { get; set; } } - /// Advanced search / filter query for the board. - public class WorkOrderAdvancedSearchQueryDto - { - public int Page { get; set; } = 0; - public int PageSize { get; set; } = 100; - public string? Search { get; set; } - public WorkOrderAdvancedSearchDatePreset? DatePreset { get; set; } - public DateOnly? DateFrom { get; set; } - public DateOnly? DateTo { get; set; } - public List? Sites { get; set; } - public List? Types { get; set; } - public List? Dispatchers { get; set; } - public List? Statuses { get; set; } - public List? PmTypes { get; set; } - public List? VendorIds { get; set; } - public List? DocStatuses { get; set; } - public bool MyWorkOrders { get; set; } - public string? SortBy { get; set; } - public string? SortDir { get; set; } - } - /// Advanced search / filter query for the board. public class WorkOrderAdvancedSearchQueryDto { diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index db28526..20f27e3 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -2,10 +2,12 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Constants; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; namespace SeaHavenIndustries.Tests;