shoc-backend/SeaHaven.Services/Implementation/WorkOrderPocService.cs
Alexandre Brandizzi 582af8fb2c fix(workorders): compare manual POC against the followed contact, not any site contact
The manual POC "follow the site" clear-rule compared the edit against every
live Site contact. A work order only ever displays one of them, so editing to
a different live contact (Site has Alice primary and Bob; WO shows Alice; edit
to Bob) matched, cleared the override, and left the row showing Alice with no
audit row written — the SH-379 symptom on a different input. A work order with
a linked WorkOrderContacts POC hit the same bug when the dispatcher typed the
Site's primary: the override cleared and the linked contact showed instead.

Compare the edit against the single contact the work order actually follows —
the linked WorkOrderContacts POC, or else the Site primary (ResolvePrimary) —
matching the board projection's override -> linked -> site precedence, and
store the override whenever the edit differs from it. The create path in
WorkOrderBoardCreateService had the same any-contact rule and gets the same
fix; a supplied PocContactId that is not a live Site contact leaves no follow
target, so the typed POC is stored.

Replaces MatchesAnySiteContact with Matches(name, phone, contact); comment and
PR-body wording updated to state the followed-contact rule. Adds tests for the
second-site-contact edit, the linked-contact-differs edit, and the
second-site-contact create case.
2026-09-18 18:56:29 -03:00

176 lines
9.1 KiB
C#

using System.Security.Claims;
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
/// <summary>
/// SH-379: persists a manual POC override on an existing work order. The manual
/// value is stored in the WO-level PocName/PocPhone/PocNotes fields, staged as
/// FieldChanged audit entries (which also write field locks so later syncs never
/// overwrite a manual POC, SH-190 AC2), and captured by the completion freeze.
/// An edit equal to the contact the work order currently follows (its linked
/// WorkOrderContacts POC, or else the Site primary) stores no override, so a
/// never-overridden work order keeps following the Site (SH-190 AC3).
/// </summary>
public class WorkOrderPocService : IWorkOrderPocService
{
private readonly IWorkOrderPocDataService _pocData;
private readonly IWorkOrderBoardMutationDataService _mutationData;
private readonly IWorkOrderBoardService _boardService;
private readonly IWorkOrderAuditService _auditService;
private readonly IWorkOrderAccountResolver _accountResolver;
public WorkOrderPocService(
IWorkOrderPocDataService pocData,
IWorkOrderBoardMutationDataService mutationData,
IWorkOrderBoardService boardService,
IWorkOrderAuditService auditService,
IWorkOrderAccountResolver accountResolver)
{
_pocData = pocData;
_mutationData = mutationData;
_boardService = boardService;
_auditService = auditService;
_accountResolver = accountResolver;
}
public async Task<WorkOrderBoardRowDto> UpdatePocAsync(
int workOrderId,
WorkOrderPocUpdateRequestDto request,
ClaimsPrincipal user,
string? actorId)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var workOrderVersion = ParseRowVersion(request.WorkOrderVersion);
if (workOrderVersion == null)
throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required.");
ValidateLengths(request);
await _mutationData.ExecuteTransactionalAsync(async ct =>
{
var workOrder = await _pocData.GetTrackedWorkOrderAsync(workOrderId, accountId, ct);
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (!RowVersionsMatch(workOrder.RowVersion, workOrderVersion))
throw new WorkOrderBoardConcurrencyException(await LoadBoardRowAsync(workOrderId, user));
if (WorkOrderBoardMutationRules.IsFullyLocked(workOrder.LifecycleStatus))
throw new WorkOrderBoardValidationException("CanceledReadOnly", "Work order is read-only in its current status.");
if (WorkOrderBoardMutationRules.IsCoreLocked(workOrder.LifecycleStatus))
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
var sitePrimary = WorkOrderPocSiteContact.ResolvePrimary(workOrder.Locations?.Contacts);
var linkedContact = workOrder.WorkOrderContacts?
.OrderBy(item => item.Id)
.FirstOrDefault()?.POC;
var newName = TrimOrNull(request.PocName);
var newPhone = TrimOrNull(request.PocPhone);
var newNotes = TrimOrNull(request.PocNotes);
// SH-190: changing away from the contact the work order follows
// locks the POC. An edit that equals that single contact — the
// linked WorkOrderContacts POC, or else the Site primary — (and
// blanking both fields) stores no override, so the work order keeps
// following it. Comparing against the followed contact rather than
// any live Site contact means editing to a *different* Site contact
// is stored as an override, since the board only ever shows the one
// the work order follows.
var followContact = linkedContact ?? sitePrimary;
var overrideName = newName;
var overridePhone = newPhone;
if ((overrideName != null || overridePhone != null)
&& WorkOrderPocSiteContact.Matches(overrideName, overridePhone, followContact))
{
overrideName = null;
overridePhone = null;
}
var oldEffectiveName = FirstNotBlank(workOrder.PocName, ContactDisplayName(linkedContact), WorkOrderPocSiteContact.DisplayName(sitePrimary));
var oldEffectivePhone = FirstNotBlank(workOrder.PocPhone, linkedContact?.PhoneNumber, sitePrimary?.PhoneNumber);
var newEffectiveName = FirstNotBlank(overrideName, ContactDisplayName(linkedContact), WorkOrderPocSiteContact.DisplayName(sitePrimary));
var newEffectivePhone = FirstNotBlank(overridePhone, linkedContact?.PhoneNumber, sitePrimary?.PhoneNumber);
var storedChanged = !string.Equals(workOrder.PocName, overrideName, StringComparison.Ordinal)
|| !string.Equals(workOrder.PocPhone, overridePhone, StringComparison.Ordinal)
|| !string.Equals(workOrder.PocNotes, newNotes, StringComparison.Ordinal);
var oldNotes = workOrder.PocNotes;
workOrder.PocName = overrideName;
workOrder.PocPhone = overridePhone;
workOrder.PocNotes = newNotes;
if (!storedChanged)
return;
_mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion);
// Audit effective old -> new values with the same field names create
// uses, so the trail reads consistently across create and update.
if (!string.Equals(oldEffectiveName, newEffectiveName, StringComparison.Ordinal))
await _auditService.StageFieldChangedAsync(workOrderId, "PocName", oldEffectiveName, newEffectiveName, actorId);
if (!string.Equals(oldEffectivePhone, newEffectivePhone, StringComparison.Ordinal))
await _auditService.StageFieldChangedAsync(workOrderId, "PocPhone", oldEffectivePhone, newEffectivePhone, actorId);
if (!string.Equals(TrimOrNull(oldNotes), newNotes, StringComparison.Ordinal))
await _auditService.StageFieldChangedAsync(workOrderId, "PocNotes", TrimOrNull(oldNotes), newNotes, actorId);
var outcome = await _mutationData.SaveAsync(ct);
if (outcome == BoardSaveOutcome.ConcurrencyConflict)
throw new WorkOrderBoardConcurrencyException(await LoadBoardRowAsync(workOrderId, user));
}, CancellationToken.None);
var row = await LoadBoardRowAsync(workOrderId, user);
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
private async Task<WorkOrderBoardRowDto?> LoadBoardRowAsync(int workOrderId, ClaimsPrincipal user)
=> await _boardService.GetBoardRowAsync(workOrderId, user);
private static void ValidateLengths(WorkOrderPocUpdateRequestDto request)
{
if (request.PocName?.Length > 200)
throw new WorkOrderBoardValidationException("InvalidValue", "pocName must be at most 200 characters.");
if (request.PocPhone?.Length > 50)
throw new WorkOrderBoardValidationException("InvalidValue", "pocPhone must be at most 50 characters.");
if (request.PocNotes?.Length > 2000)
throw new WorkOrderBoardValidationException("InvalidValue", "pocNotes must be at most 2000 characters.");
}
private static string? ContactDisplayName(Contacts? contact)
=> contact == null
? null
: TrimOrNull(((contact.FirstName ?? "") + " " + (contact.LastName ?? "")).Trim());
private static string? FirstNotBlank(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
private static string? TrimOrNull(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static byte[]? ParseRowVersion(string? base64)
{
if (string.IsNullOrWhiteSpace(base64))
return null;
try
{
return Convert.FromBase64String(base64);
}
catch (FormatException)
{
throw new WorkOrderBoardValidationException("InvalidRowVersion", "Invalid workOrderVersion format.");
}
}
private static bool RowVersionsMatch(byte[]? current, byte[] expected)
=> current != null && current.AsSpan().SequenceEqual(expected);
}
}