shoc-backend/SeaHaven.Services/Implementation/WorkOrderPocService.cs
Alexandre Brandizzi cbecc7b4ea fix(workorders): persist manual POC override with audit and site-follow (SH-379)
Editing a work order's POC never reached the backend: no update path wrote
PocName/PocPhone/PocNotes, so the optimistic UI edit was lost on refetch and
the completion freeze captured the Site contact instead of the manual value,
and nothing was audited.

- Add tenant-scoped PATCH api/workorders/{id}/poc via new WorkOrderPocService
  + WorkOrderPocDataService: persists the override, stages FieldChanged audit
  entries (which also write field locks so sync never overwrites a manual POC),
  and enforces row-version concurrency and terminal-status read-only rules.
- Lock semantics (SH-190): a manual POC away from the Site's live contacts is
  stored WO-level; an edit equal to a live Site contact (or blanking name+phone)
  stores nothing so the WO follows the Site. PocCustomized exposes the state.
- Board projection, completion freeze and create now share one Site-contact
  fallback (first non-deleted contact by SiteContactOrder) so a never-overridden
  WO keeps following the Site, including at create when the wizard prefills it.
- Route contract baseline gains PATCH {id:int}/poc.
2026-09-18 14:30:54 -03:00

169 lines
8.6 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 one of the Site's live contacts 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 Site's live contact locks the POC.
// An edit that equals one of the Site's live contacts (and blanking
// both fields) stores no override, so the work order follows the Site.
var overrideName = newName;
var overridePhone = newPhone;
if ((overrideName != null || overridePhone != null)
&& WorkOrderPocSiteContact.MatchesAnySiteContact(overrideName, overridePhone, workOrder.Locations?.Contacts))
{
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);
}
}