From 36093659398a22653f170128f7b0c22ae80cfab1 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 18 Aug 2026 10:03:12 -0300 Subject: [PATCH] fix(work-orders): map additionalContacts on detail GET (SH-218) Copy contacts into MapInfo so slide-over round-trips create/PATCH, and require name plus phone on retained entries while dropping blank placeholders. --- .../WorkOrderAdditionalContactsMapper.cs | 7 ++ .../Implementation/WorkOrderDetailService.cs | 1 + .../WorkOrderBoardCreateValidation.cs | 14 ++- .../WorkOrderBoardCreateServiceTests.cs | 108 ++++++++++++++++++ .../WorkOrderBoardUpdateServiceTests.cs | 98 ++++++++++++++++ .../WorkOrderPhase6Tests.cs | 65 +++++++++++ 6 files changed, 291 insertions(+), 2 deletions(-) diff --git a/SeaHaven.Services/Helpers/WorkOrderAdditionalContactsMapper.cs b/SeaHaven.Services/Helpers/WorkOrderAdditionalContactsMapper.cs index 4fc2bc9..be94839 100644 --- a/SeaHaven.Services/Helpers/WorkOrderAdditionalContactsMapper.cs +++ b/SeaHaven.Services/Helpers/WorkOrderAdditionalContactsMapper.cs @@ -87,6 +87,13 @@ namespace SeaHaven.Services.Helpers if (string.IsNullOrEmpty(name) && string.IsNullOrEmpty(phone)) continue; + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(phone)) + { + throw new WorkOrderBoardValidationException( + "InvalidValue", + "Each additional contact must include a name and phone."); + } + if (name.Length > MaxNameLength) { throw new WorkOrderBoardValidationException( diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs index 0bb6eac..92e581d 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -102,6 +102,7 @@ namespace SeaHaven.Services.Implementation PocName = row.PocName, PocPhone = row.PocPhone, PocNotes = row.PocNotes, + AdditionalContacts = row.AdditionalContacts, LifecycleStatus = row.LifecycleStatus, LifecycleStatusLabel = row.LifecycleStatusLabel, LegacyStatus = row.LegacyStatus, diff --git a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs index 48842c7..2025b4b 100644 --- a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs +++ b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs @@ -92,8 +92,8 @@ namespace SeaHaven.Services.Validation .MaximumLength(2000) .When(c => !string.IsNullOrEmpty(c.Notes)); contact.RuleFor(c => c) - .Must(c => !string.IsNullOrWhiteSpace(c.Name) || !string.IsNullOrWhiteSpace(c.Phone)) - .WithMessage("Each additional contact must include a name or phone."); + .Must(HasNameAndPhoneWhenPresent) + .WithMessage("Each additional contact must include a name and phone."); }) .When(x => x.AdditionalContacts != null); } @@ -108,5 +108,15 @@ namespace SeaHaven.Services.Validation return !string.IsNullOrWhiteSpace(request.PrimaryService) || !string.IsNullOrWhiteSpace(request.Trade); } + + private static bool HasNameAndPhoneWhenPresent(WorkOrderAdditionalContactDto contact) + { + var hasName = !string.IsNullOrWhiteSpace(contact.Name); + var hasPhone = !string.IsNullOrWhiteSpace(contact.Phone); + if (!hasName && !hasPhone) + return true; + + return hasName && hasPhone; + } } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index 7bc43da..136159b 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -383,4 +383,112 @@ public class WorkOrderBoardCreateServiceTests Assert.Contains("Backup Lead", wo.AdditionalContacts); Assert.Contains("Security Desk", wo.AdditionalContacts); } + + [Fact] + public async Task Create_AdditionalContacts_EmptyList_SucceedsWithoutContacts() + { + var (_, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + AdditionalContacts = new List() + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + + Assert.Null(result.AdditionalContacts); + } + + [Fact] + public async Task Create_AdditionalContacts_DropsBlankRows_PreservesOrder() + { + var (_, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.Reactive, + SiteCode = "BK5", + AdditionalContacts = new List + { + new("", "", null), + new("Backup Lead", "+1 555-0101", "After hours"), + new(" ", " ", null), + new("Security Desk", "+1 555-0102", null) + } + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + + Assert.NotNull(result.AdditionalContacts); + Assert.Equal(2, result.AdditionalContacts!.Count); + Assert.Equal("Backup Lead", result.AdditionalContacts[0].Name); + Assert.Equal("Security Desk", result.AdditionalContacts[1].Name); + } + + [Fact] + public async Task Create_AdditionalContacts_NameOnly_Throws() + { + var (_, service) = CreateSut(); + + var ex = await Assert.ThrowsAsync(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + AdditionalContacts = new List + { + new("Backup Lead", "", null) + } + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); + + Assert.Contains("name and phone", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Create_AdditionalContacts_PhoneOnly_Throws() + { + var (_, service) = CreateSut(); + + await Assert.ThrowsAsync(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + AdditionalContacts = new List + { + new("", "+1 555-0101", null) + } + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); + } + + [Fact] + public async Task Create_AdditionalContacts_PersistsAndReturnsOnDetail() + { + var (context, service) = CreateSut(); + var created = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.Reactive, + SiteCode = "BK5", + AdditionalContacts = new List + { + new("Backup Lead", "+1 555-0101", "After hours"), + new("Security Desk", "+1 555-0102", null) + } + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var detail = new WorkOrderDetailService( + new WorkOrderBoardService(new WorkOrderBoardDataService(context), resolver), + new WorkOrderDetailDataService(context), + new CompletionDocTemplateDataService(context), + new UserDataService(context), + resolver); + + var result = await detail.GetDetailAsync(created.Id, WorkOrderAccountTestHelpers.AccountUser()); + + Assert.NotNull(result); + Assert.NotNull(result!.Info.AdditionalContacts); + Assert.Equal(2, result.Info.AdditionalContacts!.Count); + Assert.Equal("Backup Lead", result.Info.AdditionalContacts[0].Name); + Assert.Equal("+1 555-0101", result.Info.AdditionalContacts[0].Phone); + Assert.Equal("Security Desk", result.Info.AdditionalContacts[1].Name); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index f7705bf..0a2822e 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -404,6 +404,104 @@ public class WorkOrderBoardUpdateServiceTests Assert.Equal("InvalidValue", ex.Code); } + [Fact] + public async Task PatchField_AdditionalContacts_NameOnly_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.AdditionalContacts, + Value = "[{\"name\":\"Jane Backup\",\"phone\":\"\"}]", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("InvalidValue", ex.Code); + } + + [Fact] + public async Task PatchField_AdditionalContacts_PhoneOnly_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.AdditionalContacts, + Value = "[{\"name\":\"\",\"phone\":\"+1 555-0101\"}]", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("InvalidValue", ex.Code); + } + + [Fact] + public async Task PatchField_AdditionalContacts_DropsBlankRows_PreservesOrder() + { + 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.AdditionalContacts, + Value = "[{\"name\":\"\",\"phone\":\"\"},{\"name\":\"Jane Backup\",\"phone\":\"+1 555-0101\"},{\"name\":\"Security Desk\",\"phone\":\"+1 555-0102\"}]", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(2, result.AdditionalContacts!.Count); + Assert.Equal("Jane Backup", result.AdditionalContacts[0].Name); + Assert.Equal("Security Desk", result.AdditionalContacts[1].Name); + } + + [Fact] + public async Task PatchField_AdditionalContacts_EmptyArray_Clears() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + AdditionalContacts = "[{\"name\":\"Jane Backup\",\"phone\":\"+1 555-0101\"}]", + 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.AdditionalContacts, + Value = "[]", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Null(result.AdditionalContacts); + Assert.Null((await context.workOrders.FindAsync(1))!.AdditionalContacts); + } + [Fact] public async Task PatchField_SetsFlagColor_CanonicalUppercase() { diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index d6bd47e..e9f4f62 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -210,6 +210,71 @@ public class WorkOrderDetailServiceTests Assert.Equal(1, detail!.Info.PendingUpliftCount); } + [Fact] + public async Task GetDetail_ReturnsAdditionalContactsFromBoardRow() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000218", + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = DateTime.UtcNow.AddDays(1), + AdditionalContacts = + "[{\"name\":\"Backup Lead\",\"phone\":\"+1 555-0101\",\"notes\":\"After hours\"},{\"name\":\"Security Desk\",\"phone\":\"+1 555-0102\"}]", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context.SaveChangesAsync(); + + var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); + + Assert.NotNull(detail); + Assert.NotNull(detail!.Info.AdditionalContacts); + Assert.Equal(2, detail.Info.AdditionalContacts!.Count); + Assert.Equal("Backup Lead", detail.Info.AdditionalContacts[0].Name); + Assert.Equal("+1 555-0101", detail.Info.AdditionalContacts[0].Phone); + Assert.Equal("After hours", detail.Info.AdditionalContacts[0].Notes); + Assert.Equal("Security Desk", detail.Info.AdditionalContacts[1].Name); + Assert.Equal("+1 555-0102", detail.Info.AdditionalContacts[1].Phone); + } + + [Fact] + public async Task GetDetail_ReflectsPatchedAdditionalContacts() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + InternalWONumber = "10000000219", + LifecycleStatus = LifecycleStatus.Incomplete, + AdditionalContacts = "[{\"name\":\"Old Backup\",\"phone\":\"+1 555-0000\"}]", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var mutationData = new WorkOrderBoardMutationDataService(context); + var boardData = new WorkOrderBoardDataService(context); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit); + + await update.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.AdditionalContacts, + Value = "[{\"name\":\"Jane Backup\",\"phone\":\"+1 555-0101\",\"notes\":\"Call first\"}]", + WorkOrderVersion = Convert.ToBase64String(wo.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }) + }, "actor-1"); + + var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); + + Assert.NotNull(detail); + Assert.Single(detail!.Info.AdditionalContacts!); + Assert.Equal("Jane Backup", detail.Info.AdditionalContacts![0].Name); + Assert.Equal("+1 555-0101", detail.Info.AdditionalContacts[0].Phone); + Assert.Equal("Call first", detail.Info.AdditionalContacts[0].Notes); + } + [Fact] public async Task GetAudit_ResolvesAssignToUserIdsToDisplayNames() {