From 36ef0b00f5eb387d6b2fdfa8ade947e5b830f854 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 13 Aug 2026 16:11:36 -0300 Subject: [PATCH] feat(work-orders): persist additionalContacts on create, board GET and PATCH (SH-218) Add JSON column, DTO/mapper, create + PATCH field, board projection, FluentValidation, and regression tests for additional POC contacts round-trip. --- ...60000_SH218_WorkOrderAdditionalContacts.cs | 33 +++++ .../ApplicationDbContextModelSnapshot.cs | 4 + Data.SeaHavenIndustries/Models/WorkerOrder.cs | 3 + .../Helpers/WorkOrderBoardProjection.cs | 2 + .../Interfaces/WorkOrderBoardModels.cs | 1 + .../DTOs/WorkOrderAdditionalContactDto.cs | 4 + SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 1 + .../DTOs/WorkOrderBoardRequestDTOs.cs | 1 + .../WorkOrderAdditionalContactsMapper.cs | 120 ++++++++++++++++++ .../Helpers/WorkOrderBoardFieldNames.cs | 3 + .../WorkOrderBoardCreateService.cs | 4 + .../Implementation/WorkOrderBoardService.cs | 1 + .../WorkOrderBoardUpdateService.cs | 13 ++ .../WorkOrderBoardCreateValidation.cs | 18 +++ .../WorkOrderBoardCreateServiceTests.cs | 32 +++++ .../WorkOrderBoardUpdateServiceTests.cs | 50 ++++++++ 16 files changed, 290 insertions(+) create mode 100644 Data.SeaHavenIndustries/Migrations/20260813160000_SH218_WorkOrderAdditionalContacts.cs create mode 100644 SeaHaven.Services/DTOs/WorkOrderAdditionalContactDto.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderAdditionalContactsMapper.cs 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 813853c..45bd51c 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2496,6 +2496,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 59f2390..ec2e450 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 3d43fe3..afb9a82 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -66,6 +66,7 @@ namespace SeaHaven.DataServices.Helpers w.Problem, w.ServiceNotes, w.ExtraServices, + w.AdditionalContacts, w.DocStatus, w.CompletedDate, w.FlagColor, @@ -127,6 +128,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 ca27ca0..cc78030 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -46,6 +46,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 de3fe67..b720a9e 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -34,6 +34,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 3ca672c..0087293 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -41,6 +41,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..4fc2bc9 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderAdditionalContactsMapper.cs @@ -0,0 +1,120 @@ +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 (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 ee32e53..593b67b 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(), @@ -128,6 +130,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 04bf63c..50d4261 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -121,6 +121,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 3e8c108..5b737dc 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/Validation/WorkOrderBoardCreateValidation.cs b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs index 8d0564d..48842c7 100644 --- a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs +++ b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs @@ -78,6 +78,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(c => !string.IsNullOrWhiteSpace(c.Name) || !string.IsNullOrWhiteSpace(c.Phone)) + .WithMessage("Each additional contact must include a name or phone."); + }) + .When(x => x.AdditionalContacts != null); } private static bool HasPrimaryWhenExtras(WorkOrderBoardCreateRequestDto request) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index bd5f2bf..7bc43da 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; @@ -351,4 +353,34 @@ public class WorkOrderBoardCreateServiceTests Assert.Equal("VendorNotFound", ex.Code); } + + [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); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index b775ee6..f7705bf 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -354,6 +354,56 @@ 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_SetsFlagColor_CanonicalUppercase() {