From 62572d8e032cae22fffcfaf7543bf6da2266b64b Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 10:37:24 -0300 Subject: [PATCH 1/4] 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 fa1eca8f894b12b01a0514e5fb18ab45d0dbabbd Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 13:18:46 -0300 Subject: [PATCH 2/4] 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 3/4] 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 cc37a0283babd0de0ff60041660aeb6b5adb86e9 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 21 Jul 2026 09:42:10 -0300 Subject: [PATCH 4/4] 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); + } }