diff --git a/Data.SeaHavenIndustries/Migrations/20260813160000_SH218_WorkOrderAdditionalContacts.cs b/Data.SeaHavenIndustries/Migrations/20260813160000_SH218_WorkOrderAdditionalContacts.cs
new file mode 100644
index 0000000..2ae18e8
--- /dev/null
+++ b/Data.SeaHavenIndustries/Migrations/20260813160000_SH218_WorkOrderAdditionalContacts.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("20260813160000_SH218_WorkOrderAdditionalContacts")]
+ public partial class SH218_WorkOrderAdditionalContacts : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "AdditionalContacts",
+ table: "workOrders",
+ type: "nvarchar(4000)",
+ maxLength: 4000,
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "AdditionalContacts",
+ table: "workOrders");
+ }
+ }
+}
diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs
index a87db56..0efc712 100644
--- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -2531,6 +2531,10 @@ namespace Data.SeaHavenIndustries.Migrations
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
+ b.Property("AdditionalContacts")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
b.Property("PocPhone")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs
index b2d1989..733b01e 100644
--- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs
+++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs
@@ -54,6 +54,9 @@ namespace Data.SeaHavenIndustries
public string? PocPhone { get; set; }
[MaxLength(2000)]
public string? PocNotes { get; set; }
+ /// JSON array of additional POC contacts beyond primary (name, phone, notes?).
+ [MaxLength(4000)]
+ public string? AdditionalContacts { get; set; }
/// JSON array of extra service labels beyond primary (Trade).
[MaxLength(2000)]
public string? ExtraServices { get; set; }
diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
index 0213219..04feeca 100644
--- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
+++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
@@ -67,6 +67,7 @@ namespace SeaHaven.DataServices.Helpers
w.Problem,
w.ServiceNotes,
w.ExtraServices,
+ w.AdditionalContacts,
w.DocStatus,
w.CompletedDate,
w.FlagColor,
@@ -158,6 +159,7 @@ namespace SeaHaven.DataServices.Helpers
w.Problem,
w.ServiceNotes,
w.ExtraServices,
+ w.AdditionalContacts,
w.DocStatus,
w.CompletedDate,
w.FlagColor,
diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
index 6c452b9..f122889 100644
--- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
+++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
@@ -47,6 +47,7 @@ namespace SeaHaven.DataServices.Interfaces
string? Problem,
string? ServiceNotes,
string? ExtraServices,
+ string? AdditionalContacts,
DocStatus? DocStatus,
DateTime? CompletedDate,
string? FlagColor,
diff --git a/SeaHaven.Services/DTOs/WorkOrderAdditionalContactDto.cs b/SeaHaven.Services/DTOs/WorkOrderAdditionalContactDto.cs
new file mode 100644
index 0000000..25c33d1
--- /dev/null
+++ b/SeaHaven.Services/DTOs/WorkOrderAdditionalContactDto.cs
@@ -0,0 +1,4 @@
+namespace SeaHaven.Services.DTOs
+{
+ public record WorkOrderAdditionalContactDto(string Name, string Phone, string? Notes);
+}
diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
index 6ed0366..e59464c 100644
--- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
+++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
@@ -43,6 +43,7 @@ namespace SeaHaven.Services.DTOs
public string? PocName { get; set; }
public string? PocPhone { get; set; }
public string? PocNotes { get; set; }
+ public List? AdditionalContacts { get; set; }
public LifecycleStatus? LifecycleStatus { get; set; }
public string? LifecycleStatusLabel { get; set; }
public string? LegacyStatus { get; set; }
diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
index 0f7d976..9a5de5d 100644
--- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
+++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
@@ -46,6 +46,7 @@ namespace SeaHaven.Services.DTOs
public string? PocName { get; set; }
public string? PocPhone { get; set; }
public string? PocNotes { get; set; }
+ public List? AdditionalContacts { get; set; }
public string? TechPhone { get; set; }
public string? VendorNotes { get; set; }
}
diff --git a/SeaHaven.Services/Helpers/WorkOrderAdditionalContactsMapper.cs b/SeaHaven.Services/Helpers/WorkOrderAdditionalContactsMapper.cs
new file mode 100644
index 0000000..be94839
--- /dev/null
+++ b/SeaHaven.Services/Helpers/WorkOrderAdditionalContactsMapper.cs
@@ -0,0 +1,127 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using SeaHaven.Services.DTOs;
+using SeaHaven.Services.Exceptions;
+
+namespace SeaHaven.Services.Helpers
+{
+ public static class WorkOrderAdditionalContactsMapper
+ {
+ private const int MaxNameLength = 200;
+ private const int MaxPhoneLength = 50;
+ private const int MaxNotesLength = 2000;
+ private const int MaxSerializedLength = 4000;
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
+ public static List? ParseJson(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return null;
+
+ try
+ {
+ var parsed = JsonSerializer.Deserialize>(json, JsonOptions);
+ return parsed is { Count: > 0 } ? parsed : null;
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ public static string? SerializeForStorage(IReadOnlyList? contacts)
+ {
+ var normalized = NormalizeList(contacts);
+ if (normalized.Count == 0)
+ return null;
+
+ var json = JsonSerializer.Serialize(normalized, JsonOptions);
+ if (json.Length > MaxSerializedLength)
+ {
+ throw new WorkOrderBoardValidationException(
+ "InvalidValue",
+ "additionalContacts must serialize to at most 4000 characters.");
+ }
+
+ return json;
+ }
+
+ public static string? SerializeForPatch(string? jsonValue)
+ {
+ if (string.IsNullOrWhiteSpace(jsonValue))
+ return null;
+
+ List? parsed;
+ try
+ {
+ parsed = JsonSerializer.Deserialize>(jsonValue.Trim(), JsonOptions);
+ }
+ catch (JsonException)
+ {
+ throw new WorkOrderBoardValidationException(
+ "InvalidValue",
+ "additionalContacts must be a JSON array of { name, phone, notes? }.");
+ }
+
+ return SerializeForStorage(parsed);
+ }
+
+ public static List NormalizeList(IReadOnlyList? contacts)
+ {
+ var normalized = new List();
+ if (contacts == null || contacts.Count == 0)
+ return normalized;
+
+ foreach (var raw in contacts)
+ {
+ var name = raw.Name?.Trim() ?? "";
+ var phone = raw.Phone?.Trim() ?? "";
+ var notes = raw.Notes?.Trim();
+
+ 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(
+ "InvalidValue",
+ "Each additional contact name must be at most 200 characters.");
+ }
+
+ if (phone.Length > MaxPhoneLength)
+ {
+ throw new WorkOrderBoardValidationException(
+ "InvalidValue",
+ "Each additional contact phone must be at most 50 characters.");
+ }
+
+ if (notes != null && notes.Length > MaxNotesLength)
+ {
+ throw new WorkOrderBoardValidationException(
+ "InvalidValue",
+ "Each additional contact notes must be at most 2000 characters.");
+ }
+
+ normalized.Add(new WorkOrderAdditionalContactDto(
+ name,
+ phone,
+ string.IsNullOrEmpty(notes) ? null : notes));
+ }
+
+ return normalized;
+ }
+ }
+}
diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs
index 97f7744..45ae58f 100644
--- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs
+++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs
@@ -19,6 +19,7 @@ namespace SeaHaven.Services.Helpers
public const string Pm = "pm";
public const string ServiceNotes = "serviceNotes";
public const string ExtraServices = "extraServices";
+ public const string AdditionalContacts = "additionalContacts";
public const string FlagColor = "flagColor";
public const string RescheduleCount = "RescheduleCount";
@@ -48,6 +49,7 @@ namespace SeaHaven.Services.Helpers
Pm,
ServiceNotes,
ExtraServices,
+ AdditionalContacts,
FlagColor
};
@@ -89,6 +91,7 @@ namespace SeaHaven.Services.Helpers
Pm => "Trade",
ServiceNotes => "ServiceNotes",
ExtraServices => "ExtraServices",
+ AdditionalContacts => "AdditionalContacts",
FlagColor => "FlagColor",
_ => canonical
};
diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
index acba7d6..161e212 100644
--- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
@@ -59,6 +59,7 @@ namespace SeaHaven.Services.Implementation
var pocName = TrimOrNull(request.PocName);
var pocPhone = TrimOrNull(request.PocPhone);
var pocNotes = TrimOrNull(request.PocNotes);
+ var additionalContactsJson = WorkOrderAdditionalContactsMapper.SerializeForStorage(request.AdditionalContacts);
var techPhone = TrimOrNull(request.TechPhone);
var vendorNotes = TrimOrNull(request.VendorNotes);
var customer = TrimOrNull(request.Customer);
@@ -87,6 +88,7 @@ namespace SeaHaven.Services.Implementation
PocName = pocName,
PocPhone = pocPhone,
PocNotes = pocNotes,
+ AdditionalContacts = additionalContactsJson,
LocationId = request.LocationId,
DueDate = request.DueDate?.Date,
AssignTo = string.IsNullOrWhiteSpace(request.AssignTo) ? null : request.AssignTo.Trim(),
@@ -134,6 +136,8 @@ namespace SeaHaven.Services.Implementation
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocPhone", "", workOrder.PocPhone));
if (workOrder.PocNotes != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocNotes", "", workOrder.PocNotes));
+ if (workOrder.AdditionalContacts != null)
+ changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("AdditionalContacts", "", workOrder.AdditionalContacts));
if (workOrder.TechPhone != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("TechPhone", "", workOrder.TechPhone));
if (workOrder.VendorNotes != null)
diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs
index c4de860..4aa70ad 100644
--- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs
@@ -122,6 +122,7 @@ namespace SeaHaven.Services.Implementation
Pm = row.Trade ?? row.Problem,
ServiceNotes = row.ServiceNotes,
ExtraServices = ParseExtraServices(row.ExtraServices),
+ AdditionalContacts = WorkOrderAdditionalContactsMapper.ParseJson(row.AdditionalContacts),
DocStatus = row.DocStatus,
CompletedDate = row.CompletedDate,
FlagColor = row.FlagColor,
diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
index 126d01e..d0ea7f7 100644
--- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
@@ -157,6 +157,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.AdditionalContacts => new List { ApplyAdditionalContacts(workOrder, value, auditField) },
WorkOrderBoardFieldNames.FlagColor => new List { ApplyFlagColor(workOrder, value, auditField) },
_ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.")
};
@@ -321,6 +322,18 @@ namespace SeaHaven.Services.Implementation
return FieldChange.ForField(auditField, old, normalizedJson);
}
+ private static FieldChange ApplyAdditionalContacts(WorkOrder workOrder, string? value, string auditField)
+ {
+ var normalizedJson = WorkOrderAdditionalContactsMapper.SerializeForPatch(value);
+
+ var old = workOrder.AdditionalContacts;
+ if (old == normalizedJson)
+ return FieldChange.Unchanged(auditField);
+
+ workOrder.AdditionalContacts = normalizedJson;
+ return FieldChange.ForField(auditField, old, normalizedJson);
+ }
+
private static List NormalizeExtraServiceList(List? extras)
{
var normalized = new List();
diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs
index 2e4f78b..8a94e26 100644
--- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs
@@ -103,6 +103,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 8d7b1d5..2ed2868 100644
--- a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs
+++ b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs
@@ -81,6 +81,24 @@ namespace SeaHaven.Services.Validation
RuleForEach(x => x.ExtraServices)
.MaximumLength(128)
.When(x => x.ExtraServices != null);
+
+ RuleForEach(x => x.AdditionalContacts)
+ .ChildRules(contact =>
+ {
+ contact.RuleFor(c => c.Name)
+ .MaximumLength(200)
+ .When(c => !string.IsNullOrEmpty(c.Name));
+ contact.RuleFor(c => c.Phone)
+ .MaximumLength(50)
+ .When(c => !string.IsNullOrEmpty(c.Phone));
+ contact.RuleFor(c => c.Notes)
+ .MaximumLength(2000)
+ .When(c => !string.IsNullOrEmpty(c.Notes));
+ contact.RuleFor(c => c)
+ .Must(HasNameAndPhoneWhenPresent)
+ .WithMessage("Each additional contact must include a name and phone.");
+ })
+ .When(x => x.AdditionalContacts != null);
}
private static bool HasPrimaryWhenExtras(WorkOrderBoardCreateRequestDto request)
@@ -93,5 +111,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 16cd08d..a85376a 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs
@@ -4,7 +4,9 @@ using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
+using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
+using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
namespace SeaHavenIndustries.Tests;
@@ -424,4 +426,142 @@ public class WorkOrderBoardCreateServiceTests
Assert.Contains(ex.Errors, e => e.PropertyName == "WorkOrderType");
}
+
+ [Fact]
+ public async Task Create_AdditionalContacts_PersistsAndReturnsOnBoardRow()
+ {
+ var (context, service) = CreateSut();
+
+ var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
+ {
+ WorkOrderType = WorkOrderType.Reactive,
+ SiteCode = "BK5",
+ PocName = "Primary Lead",
+ AdditionalContacts = new List
+ {
+ new("Backup Lead", "+1 555-0101", "After hours"),
+ new("Security Desk", "+1 555-0102", null)
+ }
+ }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
+
+ Assert.Equal("Primary Lead", result.PocName);
+ Assert.NotNull(result.AdditionalContacts);
+ Assert.Equal(2, result.AdditionalContacts!.Count);
+ Assert.Equal("Backup Lead", result.AdditionalContacts[0].Name);
+ Assert.Equal("+1 555-0101", result.AdditionalContacts[0].Phone);
+ Assert.Equal("After hours", result.AdditionalContacts[0].Notes);
+
+ var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
+ Assert.NotNull(wo.AdditionalContacts);
+ 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 c76632a..0c61a84 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
@@ -502,6 +502,154 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal("InvalidValue", ex.Code);
}
+ [Fact]
+ public async Task PatchField_UpdatesAdditionalContacts()
+ {
+ 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\":\"Jane Backup\",\"phone\":\"+1 555-0101\",\"notes\":\"Call first\"}]",
+ WorkOrderVersion = ToVersion(wo)
+ }, "actor-1");
+
+ Assert.NotNull(result.AdditionalContacts);
+ Assert.Single(result.AdditionalContacts!);
+ Assert.Equal("Jane Backup", result.AdditionalContacts![0].Name);
+ Assert.Contains("Jane Backup", (await context.workOrders.FindAsync(1))!.AdditionalContacts);
+ }
+
+ [Fact]
+ public async Task PatchField_InvalidAdditionalContactsJson_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 = "not-json",
+ WorkOrderVersion = ToVersion(wo)
+ }, "actor-1"));
+
+ 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 d93d40b..34a3cbb 100644
--- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs
@@ -216,6 +216,71 @@ public class WorkOrderDetailServiceTests
Assert.Equal(1500m, detail.Info.UpliftSummary.Amount);
}
+ [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()
{