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 { /// /// 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). /// 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 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 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); } }