diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs index d02febc..a458ecf 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs @@ -23,6 +23,7 @@ public class WorkOrderControllerSearchTests Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), advancedSearch); controller.ControllerContext = new ControllerContext diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs index 1bb17e5..fa023ea 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs @@ -71,6 +71,7 @@ public class WorkOrderRouteContractTests "PATCH {id:int}/board", "PATCH {id:int}/comments/{commentId:int}", "PATCH {id:int}/media/{mediaId:int}", + "PATCH {id:int}/poc", "POST AddChecklistItem", "POST AddComment", "POST AddCommentJson", diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs index f991f56..37e39ea 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs @@ -24,6 +24,7 @@ namespace Api.SeaHavenIndustries.Controllers private readonly IWorkOrderBoardUpdateService _boardUpdateService; private readonly IWorkOrderBoardCreateService _boardCreateService; private readonly IWorkOrderBoardCancelService _boardCancelService; + private readonly IWorkOrderPocService _pocService; private readonly IWorkOrderAdvancedSearchService _advancedSearchService; public WorkOrderBoardController( @@ -31,12 +32,14 @@ namespace Api.SeaHavenIndustries.Controllers IWorkOrderBoardUpdateService boardUpdateService, IWorkOrderBoardCreateService boardCreateService, IWorkOrderBoardCancelService boardCancelService, + IWorkOrderPocService pocService, IWorkOrderAdvancedSearchService advancedSearchService) { _workOrderBoardService = workOrderBoardService; _boardUpdateService = boardUpdateService; _boardCreateService = boardCreateService; _boardCancelService = boardCancelService; + _pocService = pocService; _advancedSearchService = advancedSearchService; } @@ -165,6 +168,56 @@ namespace Api.SeaHavenIndustries.Controllers } } + /// + /// SH-379: replaces the WO-level POC (name, phone, notes) with audit entries. + /// Blank name+phone clears the override so the work order follows the Site. + /// + [HttpPatch("{id:int}/poc")] + public async Task UpdateWorkOrderPoc(int id, [FromBody] WorkOrderPocUpdateRequestDto request) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var row = await _pocService.UpdatePocAsync(id, request, User, actorId); + return Ok(row); + } + catch (WorkOrderBoardConcurrencyException ex) + { + return Conflict(new WorkOrderBoardConflictDto + { + CurrentState = ex.CurrentState + }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + catch (ArgumentException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } + } + [HttpPost("board")] public async Task CreateBoardWorkOrder( [FromBody] WorkOrderBoardCreateRequestDto request, diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index db054de..d4557f0 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -52,6 +52,20 @@ namespace SeaHaven.DataServices.Helpers c.Notes }) .FirstOrDefault(), + // SH-379/SH-190: the Site's live primary contact backs the + // follow-the-site POC display when no override or link exists. + SitePoc = w.Locations != null + ? w.Locations.Contacts! + .Where(c => c.IsDeleted != true) + .OrderBy(c => c.SiteContactOrder) + .ThenBy(c => c.Id) + .Select(c => new + { + Name = ((c.FirstName ?? "") + " " + (c.LastName ?? "")).Trim(), + c.PhoneNumber + }) + .FirstOrDefault() + : null, w.LifecycleStatus, w.LegacyStatus, w.Status, @@ -116,11 +130,11 @@ namespace SeaHaven.DataServices.Helpers : null; var primaryFrozenPoc = frozenPoc?.Contacts.FirstOrDefault(); var pocName = primaryFrozenPoc?.Name - ?? (!string.IsNullOrWhiteSpace(w.WoPocName) ? w.WoPocName : w.ContactPoc?.Name); + ?? FirstNotBlank(w.WoPocName, w.ContactPoc?.Name, w.SitePoc?.Name); var pocPhone = primaryFrozenPoc?.Phone - ?? (!string.IsNullOrWhiteSpace(w.WoPocPhone) ? w.WoPocPhone : w.ContactPoc?.PhoneNumber); + ?? FirstNotBlank(w.WoPocPhone, w.ContactPoc?.PhoneNumber, w.SitePoc?.PhoneNumber); var pocNotes = frozenPoc?.Notes - ?? (!string.IsNullOrWhiteSpace(w.WoPocNotes) ? w.WoPocNotes : w.ContactPoc?.Notes); + ?? FirstNotBlank(w.WoPocNotes, w.ContactPoc?.Notes); var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone) ? w.DispatchTechPhone : (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone); @@ -188,10 +202,15 @@ namespace SeaHaven.DataServices.Helpers w.MediaCount, frozenSite, frozenCompany, - frozenPoc); + frozenPoc, + w.WoPocName, + w.WoPocPhone); }).ToList(); } + private static string? FirstNotBlank(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim(); + private static T? Deserialize(string? json) { if (string.IsNullOrWhiteSpace(json)) diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs index 81e0fda..4c8d66d 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs @@ -21,7 +21,8 @@ namespace SeaHaven.DataServices.Implementation public Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => _context.workOrders - .Include(workOrder => workOrder.Locations) + .Include(workOrder => workOrder.Locations!) + .ThenInclude(location => location.Contacts) .Include(workOrder => workOrder.WorkOrderContacts!) .ThenInclude(contact => contact.POC) .Include(workOrder => workOrder.PrimaryDispatch!) diff --git a/SeaHaven.DataServices/Implementation/WorkOrderPocDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderPocDataService.cs new file mode 100644 index 0000000..8177a3e --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderPocDataService.cs @@ -0,0 +1,47 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderPocDataService : IWorkOrderPocDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderPocDataService(ApplicationDbContext context) + { + _context = context; + } + + public Task GetTrackedWorkOrderAsync( + int workOrderId, + int? accountId, + CancellationToken cancellationToken) + => _context.workOrders + .Include(workOrder => workOrder.Locations!) + .ThenInclude(location => location.Contacts) + .Include(workOrder => workOrder.WorkOrderContacts!) + .ThenInclude(contact => contact.POC) + .FirstOrDefaultAsync( + w => w.Id == workOrderId + && w.istemplate != true + && (w.IsDeleted != true || w.IsDeleted == null) + && (accountId == null || w.AccountId == accountId), + cancellationToken); + + public async Task> GetSiteContactsAsync( + int? locationId, + CancellationToken cancellationToken) + { + if (locationId == null) + return Array.Empty(); + + return await _context.Contacts + .AsNoTracking() + .Where(c => c.LocationId == locationId && c.IsDeleted != true) + .OrderBy(c => c.SiteContactOrder) + .ThenBy(c => c.Id) + .ToListAsync(cancellationToken); + } + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderPocDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderPocDataService.cs new file mode 100644 index 0000000..c940a49 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderPocDataService.cs @@ -0,0 +1,28 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + /// + /// SH-379: data access for the work-order-level POC override. + /// + public interface IWorkOrderPocDataService + { + /// + /// SH-221 account-scoped tracked load for POC updates. Includes the site's + /// contacts and the linked WorkOrderContacts POC. Null when the work order + /// does not exist or is outside the caller's account scope. + /// + Task GetTrackedWorkOrderAsync( + int workOrderId, + int? accountId, + CancellationToken cancellationToken); + + /// + /// The site's live contacts for a location ordered by SiteContactOrder then Id. + /// Deleted contacts are excluded. Empty when the location has none. + /// + Task> GetSiteContactsAsync( + int? locationId, + CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index 03cbdae..1aea2d8 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -71,7 +71,9 @@ namespace SeaHaven.DataServices.Interfaces int MediaCount = 0, WorkOrderFrozenSite? FrozenSite = null, WorkOrderFrozenCompany? FrozenCompany = null, - WorkOrderFrozenPoc? FrozenPoc = null); + WorkOrderFrozenPoc? FrozenPoc = null, + string? WoPocName = null, + string? WoPocPhone = null); public record WorkOrderBoardQueryResult( IReadOnlyList ScheduledRows, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index 4070b4b..b6ca553 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -92,6 +92,8 @@ namespace SeaHaven.Services.DTOs public string? PocName { get; set; } public string? PocPhone { get; set; } public string? PocNotes { get; set; } + /// SH-190: a manual WO-level POC override is present (locked away from the Site). + public bool PocCustomized { get; set; } public List? AdditionalContacts { get; set; } public WorkOrderFrozenSiteDto? FrozenSite { get; set; } public WorkOrderFrozenCompanyDto? FrozenCompany { get; set; } diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 540beba..8f2c9df 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -58,6 +58,20 @@ namespace SeaHaven.Services.DTOs public int? Severity { get; set; } } + /// + /// SH-379: replaces the work-order-level POC override (PocName/PocPhone/PocNotes). + /// Blank name and phone clear the override so the work order follows the Site's + /// live contact again. An edit equal to a live Site contact does not lock (SH-190). + /// + public class WorkOrderPocUpdateRequestDto + { + public string? PocName { get; set; } + public string? PocPhone { get; set; } + public string? PocNotes { get; set; } + /// Base64 row version, same concurrency token as the board patch. + public string? WorkOrderVersion { get; set; } + } + /// Advanced search / filter query for the board. public class WorkOrderAdvancedSearchQueryDto { diff --git a/SeaHaven.Services/Helpers/WorkOrderCompletionSnapshotMapper.cs b/SeaHaven.Services/Helpers/WorkOrderCompletionSnapshotMapper.cs index 28ef673..798a978 100644 --- a/SeaHaven.Services/Helpers/WorkOrderCompletionSnapshotMapper.cs +++ b/SeaHaven.Services/Helpers/WorkOrderCompletionSnapshotMapper.cs @@ -17,10 +17,14 @@ namespace SeaHaven.Services.Helpers var contact = workOrder.WorkOrderContacts? .OrderBy(item => item.Id) .FirstOrDefault(); + // SH-379/SH-190: a work order without an override or link freezes the + // Site's live primary contact, matching the board display precedence. + var sitePrimary = WorkOrderPocSiteContact.ResolvePrimary(location?.Contacts); var pocName = FirstNotBlank( workOrder.PocName, - JoinName(contact?.POC?.FirstName, contact?.POC?.MiddleName, contact?.POC?.LastName)); - var pocPhone = FirstNotBlank(workOrder.PocPhone, contact?.POC?.PhoneNumber); + JoinName(contact?.POC?.FirstName, contact?.POC?.MiddleName, contact?.POC?.LastName), + WorkOrderPocSiteContact.DisplayName(sitePrimary)); + var pocPhone = FirstNotBlank(workOrder.PocPhone, contact?.POC?.PhoneNumber, sitePrimary?.PhoneNumber); var pocNotes = FirstNotBlank(workOrder.PocNotes, contact?.Notes); var contacts = new List(); diff --git a/SeaHaven.Services/Helpers/WorkOrderPocSiteContact.cs b/SeaHaven.Services/Helpers/WorkOrderPocSiteContact.cs new file mode 100644 index 0000000..6fafb0d --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderPocSiteContact.cs @@ -0,0 +1,54 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.Services.Helpers +{ + /// + /// SH-379 / SH-190: single resolution for "the Site's live contact" used by the + /// manual POC override. The same rules back the board projection fallback, the + /// completion snapshot fallback, the create-time baseline, and the + /// "changing away from the Site's live contact" lock comparison. + /// + public static class WorkOrderPocSiteContact + { + /// + /// The Site's primary live contact: first non-deleted contact ordered by + /// SiteContactOrder then Id. Null when the site has no live contacts. + /// + public static Contacts? ResolvePrimary(IEnumerable? contacts) + => contacts? + .Where(c => c.IsDeleted != true) + .OrderBy(c => c.SiteContactOrder) + .ThenBy(c => c.Id) + .FirstOrDefault(); + + /// Display name matching the board projection: trimmed First + Last. + public static string? DisplayName(Contacts? contact) + => contact == null + ? null + : Normalize(((contact.FirstName ?? "") + " " + (contact.LastName ?? "")).Trim()); + + /// + /// SH-190: a manual POC equal to one of the Site's live contacts (trimmed + /// name + phone) is not a change away from the Site and must not lock. + /// + public static bool MatchesAnySiteContact( + string? name, + string? phone, + IEnumerable? contacts) + { + if (contacts == null) + return false; + + var normalizedName = Normalize(name); + var normalizedPhone = Normalize(phone); + + return contacts + .Where(c => c.IsDeleted != true) + .Any(c => string.Equals(Normalize(((c.FirstName ?? "") + " " + (c.LastName ?? "")).Trim()), normalizedName, StringComparison.Ordinal) + && string.Equals(Normalize(c.PhoneNumber), normalizedPhone, StringComparison.Ordinal)); + } + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index a0d68d0..a7fa4bc 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -20,6 +20,7 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderAuditService _auditService; private readonly IWorkOrderBoardCreateValidation _validator; private readonly IWorkOrderAccountResolver _accountResolver; + private readonly IWorkOrderPocDataService _pocData; private readonly IServicesRegistryService? _servicesRegistryService; public WorkOrderBoardCreateService( @@ -29,6 +30,7 @@ namespace SeaHaven.Services.Implementation IWorkOrderAuditService auditService, IWorkOrderBoardCreateValidation validator, IWorkOrderAccountResolver accountResolver, + IWorkOrderPocDataService pocData, IServicesRegistryService? servicesRegistryService = null) { _boardDataService = boardDataService; @@ -37,6 +39,7 @@ namespace SeaHaven.Services.Implementation _auditService = auditService; _validator = validator; _accountResolver = accountResolver; + _pocData = pocData; _servicesRegistryService = servicesRegistryService; } @@ -64,6 +67,15 @@ namespace SeaHaven.Services.Implementation var pocName = TrimOrNull(request.PocName); var pocPhone = TrimOrNull(request.PocPhone); var pocNotes = TrimOrNull(request.PocNotes); + // SH-190 AC3: a create-time POC equal to one of the Site's live contacts + // is not an override. Store nothing so the work order keeps following + // the Site's live contact until a dispatcher edits away from it. + var siteContacts = await _pocData.GetSiteContactsAsync(request.LocationId, cancellationToken); + if (WorkOrderPocSiteContact.MatchesAnySiteContact(pocName, pocPhone, siteContacts)) + { + pocName = null; + pocPhone = null; + } var additionalContactsJson = WorkOrderAdditionalContactsMapper.SerializeForStorage(request.AdditionalContacts); var techPhone = TrimOrNull(request.TechPhone); var vendorNotes = TrimOrNull(request.VendorNotes); diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 7a7f1b7..f21a21c 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -116,6 +116,7 @@ namespace SeaHaven.Services.Implementation PocName = row.PocName, PocPhone = row.PocPhone, PocNotes = row.PocNotes, + PocCustomized = !string.IsNullOrWhiteSpace(row.WoPocName) || !string.IsNullOrWhiteSpace(row.WoPocPhone), FrozenSite = MapFrozenSite(row.FrozenSite), FrozenCompany = MapFrozenCompany(row.FrozenCompany), FrozenPoc = MapFrozenPoc(row.FrozenPoc), diff --git a/SeaHaven.Services/Implementation/WorkOrderPocService.cs b/SeaHaven.Services/Implementation/WorkOrderPocService.cs new file mode 100644 index 0000000..ba47748 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderPocService.cs @@ -0,0 +1,169 @@ +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 one of the Site's live contacts 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 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 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); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderPocService.cs b/SeaHaven.Services/Interfaces/IWorkOrderPocService.cs new file mode 100644 index 0000000..8c9b49a --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderPocService.cs @@ -0,0 +1,18 @@ +using System.Security.Claims; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + /// + /// SH-379: persists a manual POC override on an existing work order with audit + /// entries, and applies the SH-190 follow-site / lock semantics. + /// + public interface IWorkOrderPocService + { + Task UpdatePocAsync( + int workOrderId, + WorkOrderPocUpdateRequestDto request, + ClaimsPrincipal user, + string? actorId); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs index 5acde4c..59bc227 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs @@ -33,7 +33,7 @@ public class WorkOrderAccountScopeTests var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); return new WorkOrderBoardCreateService( - boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver); + boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver, new WorkOrderPocDataService(context)); } [Fact] @@ -232,7 +232,7 @@ public class WorkOrderAccountScopeTests var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var create = new WorkOrderBoardCreateService( - boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver); + boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver, new WorkOrderPocDataService(context)); using var cts = new CancellationTokenSource(); await create.CreateAsync( diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs index fe1aee0..3269309 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs @@ -49,7 +49,8 @@ public class WorkOrderBoardCreateRelationalTests boardService, audit, new WorkOrderBoardCreateValidation(), - resolver); + resolver, + new WorkOrderPocDataService(context)); var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto { @@ -111,7 +112,8 @@ public class WorkOrderBoardCreateRelationalTests boardService, audit, new WorkOrderBoardCreateValidation(), - resolver); + resolver, + new WorkOrderPocDataService(context)); await Assert.ThrowsAsync(() => service.CreateAsync(new WorkOrderBoardCreateRequestDto diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index 8a327e4..a7141b4 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -27,7 +27,7 @@ public class WorkOrderBoardCreateServiceTests var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var validator = new WorkOrderBoardCreateValidation(); - var service = new WorkOrderBoardCreateService(boardData, mutationData, boardService, audit, validator, resolver); + var service = new WorkOrderBoardCreateService(boardData, mutationData, boardService, audit, validator, resolver, new WorkOrderPocDataService(context)); return (context, service); } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs index 62ff194..d74c178 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs @@ -32,7 +32,8 @@ public class WorkOrderBoardCreateSyncLockTests boardService, audit, new WorkOrderBoardCreateValidation(), - resolver); + resolver, + new WorkOrderPocDataService(context)); var policy = new SyncFieldMergePolicy(fieldLocks); await createService.CreateAsync(new WorkOrderBoardCreateRequestDto @@ -81,7 +82,8 @@ public class WorkOrderBoardCreateSyncLockTests boardService, audit, new WorkOrderBoardCreateValidation(), - resolver); + resolver, + new WorkOrderPocDataService(context)); var policy = new SyncFieldMergePolicy(fieldLocks); await createService.CreateAsync(new WorkOrderBoardCreateRequestDto diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs index 31ac539..156b714 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs @@ -74,7 +74,7 @@ public class WorkOrderPhase7CoexistenceTests var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var createService = new WorkOrderBoardCreateService( - boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver); + boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver, new WorkOrderPocDataService(context)); var policy = new SyncFieldMergePolicy(fieldLocks); var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), policy, fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context)); diff --git a/SeaHavenIndustries.Tests/WorkOrderPocServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderPocServiceTests.cs new file mode 100644 index 0000000..8ac314f --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderPocServiceTests.cs @@ -0,0 +1,518 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +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; + +public class WorkOrderPocServiceTests +{ + [Fact] + public async Task ManualOverridePersistsIsAuditedAndLocks() + { + await using var context = CreateContext(); + await SeedScopeAsync(context); + var workOrder = NewWorkOrder(context); + await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100"); + await context.SaveChangesAsync(); + + var service = CreatePocService(context); + var row = await service.UpdatePocAsync( + workOrder.Id, + Poc("Manual POC", "312-555-0001", "Call first", workOrder), + WorkOrderAccountTestHelpers.AccountUser(), + "actor-1"); + + var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id); + Assert.Equal("Manual POC", persisted.PocName); + Assert.Equal("312-555-0001", persisted.PocPhone); + Assert.Equal("Call first", persisted.PocNotes); + Assert.Equal("Manual POC", row.PocName); + Assert.Equal("312-555-0001", row.PocPhone); + Assert.True(row.PocCustomized); + + var auditName = await context.WorkOrderAuditLogs.SingleAsync(a => + a.WorkOrderId == workOrder.Id && a.FieldName == "PocName"); + Assert.Equal("Alice Site", auditName.OldValue); + Assert.Equal("Manual POC", auditName.NewValue); + Assert.Equal("FieldChanged", auditName.Action); + Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => + l.WorkOrderId == workOrder.Id && l.FieldName == "PocName")); + Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => + l.WorkOrderId == workOrder.Id && l.FieldName == "PocPhone")); + } + + [Fact] + public async Task ManualOverrideSurvivesCompleteAndLaterSiteChanges() + { + await using var context = CreateContext(); + await SeedScopeAsync(context); + var workOrder = NewWorkOrder(context); + workOrder.LifecycleStatus = LifecycleStatus.Scheduled; + workOrder.ScheduledDate = DateTime.UtcNow.Date.AddDays(1); + await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100"); + await context.SaveChangesAsync(); + + var service = CreatePocService(context); + await service.UpdatePocAsync( + workOrder.Id, + Poc("Manual POC", "312-555-0001", null, workOrder), + WorkOrderAccountTestHelpers.AccountUser(), + "actor-1"); + + var updateService = CreateUpdateService(context); + var completed = await updateService.PatchFieldAsync( + workOrder.Id, + Patch(WorkOrderBoardFieldNames.LifecycleStatus, "Complete", workOrder), + "actor-1"); + Assert.Equal("Manual POC", completed.PocName); + + var siteContact = await context.Contacts.SingleAsync(c => c.FirstName == "Alice"); + siteContact.FirstName = "Changed"; + siteContact.PhoneNumber = "999-555-0100"; + await context.SaveChangesAsync(); + + var row = await CreateBoardService(context).GetBoardRowAsync( + workOrder.Id, WorkOrderAccountTestHelpers.AccountUser()); + Assert.Equal("Manual POC", row!.PocName); + Assert.Equal("312-555-0001", row.PocPhone); + } + + [Fact] + public async Task SiteContactUpdateDoesNotOverwriteManualOverride() + { + await using var context = CreateContext(); + await SeedScopeAsync(context); + var workOrder = NewWorkOrder(context); + var siteContact = await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100"); + await context.SaveChangesAsync(); + + var service = CreatePocService(context); + await service.UpdatePocAsync( + workOrder.Id, + Poc("Manual POC", "312-555-0001", null, workOrder), + WorkOrderAccountTestHelpers.AccountUser(), + "actor-1"); + + siteContact.FirstName = "Rotated"; + siteContact.PhoneNumber = "999-555-0100"; + await context.SaveChangesAsync(); + + var row = await CreateBoardService(context).GetBoardRowAsync( + workOrder.Id, WorkOrderAccountTestHelpers.AccountUser()); + Assert.Equal("Manual POC", row!.PocName); + Assert.Equal("312-555-0001", row.PocPhone); + Assert.True(row.PocCustomized); + } + + [Fact] + public async Task UnlockedWorkOrderFollowsSiteContact() + { + await using var context = CreateContext(); + await SeedScopeAsync(context); + var workOrder = NewWorkOrder(context); + var primary = await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100", order: 0); + await SeedSiteContactAsync(context, firstName: "Bob", lastName: "Backup", phone: "312-555-0200", order: 1); + await context.SaveChangesAsync(); + + var boardService = CreateBoardService(context); + var before = await boardService.GetBoardRowAsync(workOrder.Id, WorkOrderAccountTestHelpers.AccountUser()); + Assert.Equal("Alice Site", before!.PocName); + Assert.Equal("312-555-0100", before.PocPhone); + Assert.False(before.PocCustomized); + + primary.PhoneNumber = "312-555-0999"; + await context.SaveChangesAsync(); + + var after = await boardService.GetBoardRowAsync(workOrder.Id, WorkOrderAccountTestHelpers.AccountUser()); + Assert.Equal("Alice Site", after!.PocName); + Assert.Equal("312-555-0999", after.PocPhone); + Assert.False(after.PocCustomized); + } + + [Fact] + public async Task EditEqualToSiteContactDoesNotLock() + { + await using var context = CreateContext(); + await SeedScopeAsync(context); + var workOrder = NewWorkOrder(context); + await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100"); + await context.SaveChangesAsync(); + + var service = CreatePocService(context); + await service.UpdatePocAsync( + workOrder.Id, + Poc("Manual POC", "312-555-0001", null, workOrder), + WorkOrderAccountTestHelpers.AccountUser(), + "actor-1"); + + var row = await service.UpdatePocAsync( + workOrder.Id, + Poc("Alice Site", "312-555-0100", null, workOrder), + WorkOrderAccountTestHelpers.AccountUser(), + "actor-1"); + + var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id); + Assert.Null(persisted.PocName); + Assert.Null(persisted.PocPhone); + Assert.Equal("Alice Site", row.PocName); + Assert.Equal("312-555-0100", row.PocPhone); + Assert.False(row.PocCustomized); + + var auditName = await context.WorkOrderAuditLogs.Where(a => + a.WorkOrderId == workOrder.Id && a.FieldName == "PocName") + .OrderByDescending(a => a.Id) + .FirstAsync(); + Assert.Equal("Manual POC", auditName.OldValue); + Assert.Equal("Alice Site", auditName.NewValue); + } + + [Fact] + public async Task ClearingBothFieldsFollowsSiteAgain() + { + await using var context = CreateContext(); + await SeedScopeAsync(context); + var workOrder = NewWorkOrder(context); + await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100"); + await context.SaveChangesAsync(); + + var service = CreatePocService(context); + await service.UpdatePocAsync( + workOrder.Id, + Poc("Manual POC", "312-555-0001", "keep notes", workOrder), + WorkOrderAccountTestHelpers.AccountUser(), + "actor-1"); + var row = await service.UpdatePocAsync( + workOrder.Id, + Poc(null, null, null, workOrder), + WorkOrderAccountTestHelpers.AccountUser(), + "actor-1"); + + var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id); + Assert.Null(persisted.PocName); + Assert.Null(persisted.PocPhone); + Assert.Equal("Alice Site", row.PocName); + Assert.False(row.PocCustomized); + } + + [Fact] + public async Task CrossTenantUpdateIsRejected() + { + await using var context = CreateContext(); + await SeedScopeAsync(context); + var workOrder = NewWorkOrder(context, accountId: 2); + await context.SaveChangesAsync(); + + var service = CreatePocService(context); + var ex = await Assert.ThrowsAsync(() => + service.UpdatePocAsync( + workOrder.Id, + Poc("Manual POC", "312-555-0001", null, workOrder), + WorkOrderAccountTestHelpers.AccountUser(accountId: 1), + "actor-1")); + + Assert.Equal("NotFound", ex.Code); + var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id); + Assert.Null(persisted.PocName); + Assert.Empty(await context.WorkOrderAuditLogs.ToListAsync()); + } + + [Fact] + public async Task MissingAccountScopeIsForbidden() + { + await using var context = CreateContext(); + await SeedScopeAsync(context); + var workOrder = NewWorkOrder(context); + await context.SaveChangesAsync(); + + var service = CreatePocService(context); + var ex = await Assert.ThrowsAsync(() => + service.UpdatePocAsync( + workOrder.Id, + Poc("Manual POC", "312-555-0001", null, workOrder), + WorkOrderAccountTestHelpers.MissingScope(), + "actor-1")); + + Assert.Equal("Forbidden", ex.Code); + } + + [Fact] + public async Task CompletedWorkOrderIsReadOnly() + { + await using var context = CreateContext(); + await SeedScopeAsync(context); + var workOrder = NewWorkOrder(context); + workOrder.LifecycleStatus = LifecycleStatus.Completed; + workOrder.CompletedDate = DateTime.UtcNow; + await context.SaveChangesAsync(); + + var service = CreatePocService(context); + var ex = await Assert.ThrowsAsync(() => + service.UpdatePocAsync( + workOrder.Id, + Poc("Manual POC", "312-555-0001", null, workOrder), + WorkOrderAccountTestHelpers.AccountUser(), + "actor-1")); + + Assert.Equal("ReadOnly", ex.Code); + var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id); + Assert.Null(persisted.PocName); + } + + [Fact] + public async Task CanceledWorkOrderIsReadOnly() + { + await using var context = CreateContext(); + await SeedScopeAsync(context); + var workOrder = NewWorkOrder(context); + workOrder.LifecycleStatus = LifecycleStatus.Canceled; + await context.SaveChangesAsync(); + + var service = CreatePocService(context); + var ex = await Assert.ThrowsAsync(() => + service.UpdatePocAsync( + workOrder.Id, + Poc("Manual POC", "312-555-0001", null, workOrder), + WorkOrderAccountTestHelpers.AccountUser(), + "actor-1")); + + Assert.Equal("CanceledReadOnly", ex.Code); + } + + [Fact] + public async Task StaleVersionThrowsConflict() + { + await using var context = CreateContext(); + await SeedScopeAsync(context); + var workOrder = NewWorkOrder(context); + await context.SaveChangesAsync(); + + var service = CreatePocService(context); + var stale = new WorkOrderPocUpdateRequestDto + { + PocName = "Manual POC", + PocPhone = "312-555-0001", + WorkOrderVersion = Convert.ToBase64String(new byte[] { 9, 9, 9, 9 }) + }; + + await Assert.ThrowsAsync(() => + service.UpdatePocAsync(workOrder.Id, stale, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); + + var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id); + Assert.Null(persisted.PocName); + } + + [Fact] + public async Task CompletionFreezesSiteContactWhenNoOverrideOrLink() + { + await using var context = CreateContext(); + await SeedScopeAsync(context); + var workOrder = NewWorkOrder(context); + workOrder.LifecycleStatus = LifecycleStatus.Scheduled; + workOrder.ScheduledDate = DateTime.UtcNow.Date.AddDays(1); + await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100"); + await context.SaveChangesAsync(); + + var updateService = CreateUpdateService(context); + var completed = await updateService.PatchFieldAsync( + workOrder.Id, + Patch(WorkOrderBoardFieldNames.LifecycleStatus, "Complete", workOrder), + "actor-1"); + + Assert.Equal(LifecycleStatus.Completed, completed.LifecycleStatus); + Assert.Equal("Alice Site", completed.PocName); + Assert.Equal("312-555-0100", completed.PocPhone); + + var contact = await context.Contacts.SingleAsync(); + contact.PhoneNumber = "999-555-0100"; + await context.SaveChangesAsync(); + + var row = await CreateBoardService(context).GetBoardRowAsync( + workOrder.Id, WorkOrderAccountTestHelpers.AccountUser()); + Assert.Equal("Alice Site", row!.PocName); + Assert.Equal("312-555-0100", row.PocPhone); + } + + [Fact] + public async Task BoardCreateWithPocEqualToSiteContactFollowsSite() + { + await using var context = CreateContext(); + WorkOrderAccountTestHelpers.SeedBoardCreateScope(context); + context.Contacts.Add(new Contacts + { + FirstName = "Alice", + LastName = "Site", + PhoneNumber = "312-555-0100", + LocationId = 1, + SiteContactOrder = 0 + }); + await context.SaveChangesAsync(); + + var service = CreateCreateService(context); + var row = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + LocationId = 1, + PocName = "Alice Site", + PocPhone = "312-555-0100" + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + + var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id); + Assert.Null(persisted.PocName); + Assert.Null(persisted.PocPhone); + Assert.Equal("Alice Site", row.PocName); + Assert.False(row.PocCustomized); + } + + [Fact] + public async Task BoardCreateWithPocAwayFromSiteContactIsStored() + { + await using var context = CreateContext(); + WorkOrderAccountTestHelpers.SeedBoardCreateScope(context); + context.Contacts.Add(new Contacts + { + FirstName = "Alice", + LastName = "Site", + PhoneNumber = "312-555-0100", + LocationId = 1, + SiteContactOrder = 0 + }); + await context.SaveChangesAsync(); + + var service = CreateCreateService(context); + var row = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + LocationId = 1, + PocName = "Manual POC", + PocPhone = "312-555-0001" + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + + var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id); + Assert.Equal("Manual POC", persisted.PocName); + Assert.Equal("312-555-0001", persisted.PocPhone); + Assert.True(row.PocCustomized); + } + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static async Task SeedScopeAsync(ApplicationDbContext context) + { + WorkOrderAccountTestHelpers.SeedBoardCreateScope(context); + await context.SaveChangesAsync(); + } + + private static WorkOrder NewWorkOrder(ApplicationDbContext context, int accountId = 1) + { + var location = context.Locations.Local.Single(l => l.Id == 1); + var workOrder = new WorkOrder + { + Id = 1, + InternalWONumber = "00000012345", + SiteCode = "BK5", + LocationId = location.Id, + Locations = location, + AccountId = accountId, + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = Version() + }; + context.workOrders.Add(workOrder); + return workOrder; + } + + private static async Task SeedSiteContactAsync( + ApplicationDbContext context, + string firstName, + string lastName, + string phone, + int order = 0) + { + var contact = new Contacts + { + FirstName = firstName, + LastName = lastName, + PhoneNumber = phone, + LocationId = 1, + SiteContactOrder = order + }; + context.Contacts.Add(contact); + await context.SaveChangesAsync(); + return contact; + } + + private static WorkOrderPocService CreatePocService(ApplicationDbContext context) + => new( + new WorkOrderPocDataService(context), + new WorkOrderBoardMutationDataService(context), + CreateBoardService(context), + new WorkOrderAuditService( + new WorkOrderAuditDataService(context), + new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context))), + WorkOrderAccountTestHelpers.Resolver(context)); + + private static WorkOrderBoardService CreateBoardService(ApplicationDbContext context) + => new( + new WorkOrderBoardDataService(context), + WorkOrderAccountTestHelpers.Resolver(context)); + + private static WorkOrderBoardUpdateService CreateUpdateService(ApplicationDbContext context) + { + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + return new WorkOrderBoardUpdateService( + new WorkOrderBoardDataService(context), + new WorkOrderBoardMutationDataService(context), + new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks)); + } + + private static WorkOrderBoardCreateService CreateCreateService(ApplicationDbContext context) + => new( + new WorkOrderBoardDataService(context), + new WorkOrderBoardMutationDataService(context), + CreateBoardService(context), + new WorkOrderAuditService( + new WorkOrderAuditDataService(context), + new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context))), + new WorkOrderBoardCreateValidation(), + WorkOrderAccountTestHelpers.Resolver(context), + new WorkOrderPocDataService(context)); + + private static WorkOrderPocUpdateRequestDto Poc( + string? name, + string? phone, + string? notes, + WorkOrder workOrder) + => new() + { + PocName = name, + PocPhone = phone, + PocNotes = notes, + WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion ?? Version()) + }; + + private static WorkOrderBoardPatchRequestDto Patch( + string field, + string value, + WorkOrder workOrder) + => new() + { + Field = field, + Value = value, + WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion ?? Version()) + }; + + private static byte[] Version() => new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }; +}