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.
This commit is contained in:
Arthur Bassi 2026-08-18 10:03:12 -03:00
parent 36ef0b00f5
commit 3609365939
6 changed files with 291 additions and 2 deletions

View file

@ -87,6 +87,13 @@ namespace SeaHaven.Services.Helpers
if (string.IsNullOrEmpty(name) && string.IsNullOrEmpty(phone)) if (string.IsNullOrEmpty(name) && string.IsNullOrEmpty(phone))
continue; 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) if (name.Length > MaxNameLength)
{ {
throw new WorkOrderBoardValidationException( throw new WorkOrderBoardValidationException(

View file

@ -102,6 +102,7 @@ namespace SeaHaven.Services.Implementation
PocName = row.PocName, PocName = row.PocName,
PocPhone = row.PocPhone, PocPhone = row.PocPhone,
PocNotes = row.PocNotes, PocNotes = row.PocNotes,
AdditionalContacts = row.AdditionalContacts,
LifecycleStatus = row.LifecycleStatus, LifecycleStatus = row.LifecycleStatus,
LifecycleStatusLabel = row.LifecycleStatusLabel, LifecycleStatusLabel = row.LifecycleStatusLabel,
LegacyStatus = row.LegacyStatus, LegacyStatus = row.LegacyStatus,

View file

@ -92,8 +92,8 @@ namespace SeaHaven.Services.Validation
.MaximumLength(2000) .MaximumLength(2000)
.When(c => !string.IsNullOrEmpty(c.Notes)); .When(c => !string.IsNullOrEmpty(c.Notes));
contact.RuleFor(c => c) contact.RuleFor(c => c)
.Must(c => !string.IsNullOrWhiteSpace(c.Name) || !string.IsNullOrWhiteSpace(c.Phone)) .Must(HasNameAndPhoneWhenPresent)
.WithMessage("Each additional contact must include a name or phone."); .WithMessage("Each additional contact must include a name and phone.");
}) })
.When(x => x.AdditionalContacts != null); .When(x => x.AdditionalContacts != null);
} }
@ -108,5 +108,15 @@ namespace SeaHaven.Services.Validation
return !string.IsNullOrWhiteSpace(request.PrimaryService) return !string.IsNullOrWhiteSpace(request.PrimaryService)
|| !string.IsNullOrWhiteSpace(request.Trade); || !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;
}
} }
} }

View file

@ -383,4 +383,112 @@ public class WorkOrderBoardCreateServiceTests
Assert.Contains("Backup Lead", wo.AdditionalContacts); Assert.Contains("Backup Lead", wo.AdditionalContacts);
Assert.Contains("Security Desk", 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<WorkOrderAdditionalContactDto>()
}, 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<WorkOrderAdditionalContactDto>
{
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<FluentValidation.ValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
AdditionalContacts = new List<WorkOrderAdditionalContactDto>
{
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<FluentValidation.ValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
AdditionalContacts = new List<WorkOrderAdditionalContactDto>
{
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<WorkOrderAdditionalContactDto>
{
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);
}
} }

View file

@ -404,6 +404,104 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal("InvalidValue", ex.Code); 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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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] [Fact]
public async Task PatchField_SetsFlagColor_CanonicalUppercase() public async Task PatchField_SetsFlagColor_CanonicalUppercase()
{ {

View file

@ -210,6 +210,71 @@ public class WorkOrderDetailServiceTests
Assert.Equal(1, detail!.Info.PendingUpliftCount); 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] [Fact]
public async Task GetAudit_ResolvesAssignToUserIdsToDisplayNames() public async Task GetAudit_ResolvesAssignToUserIdsToDisplayNames()
{ {