diff --git a/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs b/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs index 343d5f7..10333d9 100644 --- a/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs +++ b/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs @@ -52,7 +52,11 @@ public class SyncServiceBehaviorTests external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny())) .Returns(items.ToAsyncEnumerable()); - var service = new SyncService(data.Object, external.Object); + var accountResolver = new Mock(); + accountResolver + .Setup(r => r.TryResolveFromCustomerAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int?)null); + var service = new SyncService(data.Object, external.Object, accountResolver.Object); var result = await service.SyncWorkOrdersAsync(CancellationToken.None); Assert.Equal(2, result.Synced); @@ -81,7 +85,11 @@ public class SyncServiceBehaviorTests }; external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny())).Returns(items.ToAsyncEnumerable()); - await new SyncService(data.Object, external.Object).SyncWorkOrdersAsync(CancellationToken.None); + var accountResolver = new Mock(); + accountResolver + .Setup(r => r.TryResolveFromCustomerAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int?)null); + await new SyncService(data.Object, external.Object, accountResolver.Object).SyncWorkOrdersAsync(CancellationToken.None); data.Verify(d => d.EnqueueWorkOrder(It.Is(w => w.Status == "Done" && w.Priority == "Sev 2" && w.Severity == "2")), Times.Once); } @@ -107,7 +115,7 @@ public class SyncServiceBehaviorTests }; external.Setup(e => e.ScanCommentsAsync(It.IsAny())).Returns(items.ToAsyncEnumerable()); - var result = await new SyncService(data.Object, external.Object).SyncCommentsAsync(CancellationToken.None); + var result = await new SyncService(data.Object, external.Object, Mock.Of()).SyncCommentsAsync(CancellationToken.None); Assert.Equal(2, result.Synced); Assert.Equal(1, result.Created); @@ -276,6 +284,7 @@ public class WorkOrderServiceBehaviorTests (quotesData ?? new Mock()).Object, Mock.Of(), Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of()); } } diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs index c2a11aa..d02febc 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs @@ -48,7 +48,8 @@ public class WorkOrderControllerSearchTests public async Task SearchBoard_WhenCustomPresetMissingDates_ReturnsBadRequest() { var advancedSearch = new WorkOrderAdvancedSearchService( - Mock.Of()); + Mock.Of(), + Mock.Of()); var controller = NewController(advancedSearch); var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs index 85f6698..33cec3f 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs @@ -75,9 +75,17 @@ namespace Api.SeaHavenIndustries.Controllers Search = search }; - var result = await _workOrderBoardService.GetBoardAsync(query, userId); + var result = await _workOrderBoardService.GetBoardAsync(query, User, userId); return Ok(result); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } catch (ArgumentException ex) { return BadRequest(ex.Message); @@ -90,9 +98,17 @@ namespace Api.SeaHavenIndustries.Controllers try { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var result = await _advancedSearchService.SearchAsync(query, userId); + var result = await _advancedSearchService.SearchAsync(query, User, userId); return Ok(result); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } catch (ArgumentException ex) { return BadRequest(new Response { Status = "Error", Message = ex.Message }); @@ -154,7 +170,7 @@ namespace Api.SeaHavenIndustries.Controllers try { var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var row = await _boardCreateService.CreateAsync(request, actorId); + var row = await _boardCreateService.CreateAsync(request, User, actorId); return Ok(row); } catch (ValidationException vex) @@ -162,6 +178,14 @@ namespace Api.SeaHavenIndustries.Controllers var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); return BadRequest(new Response { Status = "Validation Error", Message = errors }); } + 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 == "DuplicateWoNumber") { return Conflict(new WorkOrderBoardValidationErrorDto @@ -194,9 +218,17 @@ namespace Api.SeaHavenIndustries.Controllers try { var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var row = await _boardCancelService.CancelAsync(id, actorId); + var row = await _boardCancelService.CancelAsync(id, User, actorId); return Ok(row); } + 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 diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 9d99e2e..fef6d6f 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; using System.Security.Claims; @@ -19,13 +20,16 @@ namespace Api.SeaHavenIndustries.Controllers public class WorkOrderController : Controller { private readonly IWorkOrderService _workOrderService; + private readonly IWorkOrderAccountResolver _accountResolver; private readonly ILogger _logger; public WorkOrderController( IWorkOrderService workOrderService, + IWorkOrderAccountResolver accountResolver, ILogger logger) { _workOrderService = workOrderService; + _accountResolver = accountResolver; _logger = logger; } @@ -49,10 +53,11 @@ namespace Api.SeaHavenIndustries.Controllers Attachments = model.Attachments, BeforPhotoAttachment = model.BeforPhotoAttachment, AfterPhotoAttachment = model.AfterPhotoAttachment, - SignOffAttachment = model.SignOffAttachment + SignOffAttachment = model.SignOffAttachment, + Customer = model.Customer }; - var workOrderId = await _workOrderService.CreateWorkOrderWithDetailsAsync(input, userId!); + var workOrderId = await _workOrderService.CreateWorkOrderWithDetailsAsync(input, User, userId!); return Ok(new DataResponse { Message = "Work order created successfully", Status = "200", Data = workOrderId }); } catch (ValidationException vex) @@ -60,6 +65,14 @@ namespace Api.SeaHavenIndustries.Controllers var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); return BadRequest(new Response { Status = "Validation Error", Message = errors }); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new Response { Status = "Error", Message = ex.Message }); + } catch (Exception ex) { return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); @@ -116,19 +129,27 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("GetWorkOrderList")] public async Task GetWorkOrderList(string? search = "", int page = 1, int pageSize = 12, string? sortBy = "lastUpdated", string? sortDir = "desc", string? status = "", string? assignee = "", int? locationId = null) { - var (data, totalCount) = await _workOrderService.GetWorkOrderListPagedAsync( - page, pageSize, search, status, assignee, locationId, sortBy, sortDir); - - var viewModel = new Pagination_DTO + try { - Data = data, - PageNumber = page, - PageSize = pageSize, - TotalCount = totalCount, - TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) - }; + var accountId = _accountResolver.ResolveAccountFilter(User); + var (data, totalCount) = await _workOrderService.GetWorkOrderListPagedAsync( + page, pageSize, search, status, assignee, locationId, sortBy, sortDir, accountId); - return Ok(viewModel); + var viewModel = new Pagination_DTO + { + Data = data, + PageNumber = page, + PageSize = pageSize, + TotalCount = totalCount, + TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) + }; + + return Ok(viewModel); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message }); + } } [Authorize(Roles = "Admin")] diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs index 3701db9..489e106 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs @@ -29,19 +29,33 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("{id:int}/detail")] public async Task GetDetail(int id) { - var detail = await _workOrderDetailService.GetDetailAsync(id); - if (detail == null) - return NotFound(new Response { Status = "Error", Message = "Work order not found." }); - return Ok(detail); + try + { + var detail = await _workOrderDetailService.GetDetailAsync(id, User); + if (detail == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(detail); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message }); + } } [HttpGet("{id:int}/audit")] public async Task GetAudit(int id, [FromQuery] int limit = 50) { - var audit = await _workOrderDetailService.GetAuditAsync(id, limit); - if (audit == null) - return NotFound(new Response { Status = "Error", Message = "Work order not found." }); - return Ok(audit); + try + { + var audit = await _workOrderDetailService.GetAuditAsync(id, User, limit); + if (audit == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(audit); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message }); + } } [HttpGet("{id:int}/comments")] diff --git a/Api.SeaHavenIndustries/DTOs/Workorder_DTO.cs b/Api.SeaHavenIndustries/DTOs/Workorder_DTO.cs index 32d6532..a1b2d18 100644 --- a/Api.SeaHavenIndustries/DTOs/Workorder_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Workorder_DTO.cs @@ -25,6 +25,7 @@ namespace Api.SeaHavenIndustries.DTOs public string? SignOffSignature { get; set; } public List? ContactIds { get; set; } public List? CategoryIds { get; set; } + public string? Customer { get; set; } public static WorkOrder MapToEntity(Workorder_DTO dto, string userId) { diff --git a/SeaHaven.DataServices/Implementation/AccountDataService.cs b/SeaHaven.DataServices/Implementation/AccountDataService.cs index fefc93a..6e46330 100644 --- a/SeaHaven.DataServices/Implementation/AccountDataService.cs +++ b/SeaHaven.DataServices/Implementation/AccountDataService.cs @@ -96,5 +96,25 @@ namespace SeaHaven.DataServices.Implementation { return await _context.Accounts.CountAsync(); } + + public async Task TryGetUniqueActiveIdByExactNameAsync( + string? name, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(name)) + return null; + + var trimmed = name.Trim(); + var matches = await _context.Accounts + .AsNoTracking() + .Where(a => + a.Name == trimmed + && (a.IsDeleted == false || a.IsDeleted == null)) + .Select(a => a.Id) + .Take(2) + .ToListAsync(cancellationToken); + + return matches.Count == 1 ? matches[0] : null; + } } } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs index ab995c7..7935751 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs @@ -17,6 +17,8 @@ namespace SeaHaven.DataServices.Implementation public async Task SearchAsync(WorkOrderAdvancedSearchQuery query) { var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); + if (query.AccountId.HasValue) + baseQuery = WorkOrderBoardQueryFilters.ApplyAccountScope(baseQuery, query.AccountId.Value); baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo); baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites); baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue); diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs index 0695be4..00d4772 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs @@ -21,6 +21,8 @@ namespace SeaHaven.DataServices.Implementation var weekEndDate = query.WeekEnd.ToDateTime(TimeOnly.MinValue); var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); + if (query.AccountId.HasValue) + baseQuery = WorkOrderBoardQueryFilters.ApplyAccountScope(baseQuery, query.AccountId.Value); baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter( baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId); baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue); @@ -70,11 +72,12 @@ namespace SeaHaven.DataServices.Implementation .ToListAsync(); } - public async Task GetBoardRowByIdAsync(int workOrderId) + public async Task GetBoardRowByIdAsync(int workOrderId, int? accountId = null) { - var query = _context.workOrders - .AsNoTracking() - .Where(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); + var query = WorkOrderBoardQueryFilters.ApplyBaseScope( + _context.workOrders.AsNoTracking().Where(w => w.Id == workOrderId)); + if (accountId.HasValue) + query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value); var rows = await WorkOrderBoardProjection.ProjectRowsAsync(query, isUnscheduled: false); return rows.FirstOrDefault(); diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs index a382d80..cbd896b 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs @@ -1,5 +1,6 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; @@ -110,7 +111,8 @@ namespace SeaHaven.DataServices.Implementation string? assignee = null, int? locationId = null, string? sortBy = "lastUpdated", - string? sortDir = "desc") + string? sortDir = "desc", + int? accountId = null) { search ??= ""; @@ -119,6 +121,9 @@ namespace SeaHaven.DataServices.Implementation .Include(w => w.AssignToUser) .Where(w => w.istemplate != true); + if (accountId.HasValue) + query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value); + if (locationId.HasValue) query = query.Where(w => w.LocationId == locationId.Value); diff --git a/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs index adc40c9..f32ebd0 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs @@ -33,6 +33,13 @@ namespace SeaHaven.DataServices.Implementation if (workOrder == null) { + var accountId = await TryResolveAccountIdAsync(mutation.Customer, cancellationToken); + if (accountId is not int resolvedAccountId) + { + return new WorkOrderWebhookPersistenceResult( + WorkOrderWebhookPersistenceStatus.AccountUnresolved); + } + var internalNumber = await AllocateInternalNumberAsync(cancellationToken); workOrder = new WorkOrder { @@ -43,6 +50,8 @@ namespace SeaHaven.DataServices.Implementation WorkerOrderTitle = mutation.Title ?? mutation.Description ?? $"Imported work order {mutation.ExternalWorkOrderId}", Description = mutation.Description, + Customer = mutation.Customer, + AccountId = resolvedAccountId, Source = mutation.Source, istemplate = false, CreatedDate = mutation.CreatedAt ?? mutation.ProcessedAt.UtcDateTime @@ -284,6 +293,26 @@ namespace SeaHaven.DataServices.Implementation return true; } + private async Task TryResolveAccountIdAsync( + string? customer, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(customer)) + return null; + + var trimmed = customer.Trim(); + var matches = await _context.Accounts + .AsNoTracking() + .Where(a => + a.Name == trimmed + && (a.IsDeleted == false || a.IsDeleted == null)) + .Select(a => a.Id) + .Take(2) + .ToListAsync(cancellationToken); + + return matches.Count == 1 ? matches[0] : null; + } + private static bool IsNewer( DateTimeOffset candidateUpdatedAt, string candidateHash, diff --git a/SeaHaven.DataServices/Interfaces/IAccountDataService.cs b/SeaHaven.DataServices/Interfaces/IAccountDataService.cs index af0c8f5..e2cb1d2 100644 --- a/SeaHaven.DataServices/Interfaces/IAccountDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IAccountDataService.cs @@ -13,5 +13,13 @@ namespace SeaHaven.DataServices.Interfaces Task DeleteAsync(int id); Task ExistsAsync(int id); Task CountAsync(); + + /// + /// Returns the id when exactly one non-deleted account has this exact Name + /// (same rule as SH-221 backfill). Null when name is blank, zero matches, or ambiguous. + /// + Task TryGetUniqueActiveIdByExactNameAsync( + string? name, + CancellationToken cancellationToken = default); } } diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs index 4f23d5a..ee1d2ef 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs @@ -6,7 +6,7 @@ namespace SeaHaven.DataServices.Interfaces { Task GetBoardRowsAsync(WorkOrderBoardQuery query); Task> GetDispatcherLookupsAsync(); - Task GetBoardRowByIdAsync(int workOrderId); + Task GetBoardRowByIdAsync(int workOrderId, int? accountId = null); Task InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId); } } diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs index 3edbd12..744042d 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs @@ -32,7 +32,8 @@ namespace SeaHaven.DataServices.Interfaces string? assignee = null, int? locationId = null, string? sortBy = "lastUpdated", - string? sortDir = "desc"); + string? sortDir = "desc", + int? accountId = null); // Command operations Task AddAsync(WorkOrder workOrder); diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderWebhookDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderWebhookDataService.cs index 75e9f7a..419a5dc 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderWebhookDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderWebhookDataService.cs @@ -50,7 +50,8 @@ namespace SeaHaven.DataServices.Interfaces { Applied, Duplicate, - HashConflict + HashConflict, + AccountUnresolved } public sealed record WorkOrderWebhookPersistenceResult( diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs index 5dc30c7..d1ef77f 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs @@ -20,7 +20,8 @@ namespace SeaHaven.DataServices.Interfaces int Page, int PageSize, string SortBy, - string SortDir); + string SortDir, + int? AccountId = null); public record WorkOrderAdvancedSearchResult( IReadOnlyList Rows, diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index 71ad944..88ea6e8 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -10,7 +10,8 @@ namespace SeaHaven.DataServices.Interfaces IReadOnlyList? Types, bool Overdue, string? Search, - string? CurrentUserId); + string? CurrentUserId, + int? AccountId = null); public record WorkOrderBoardRawRow( int Id, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index eee8b53..3ca672c 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -15,6 +15,11 @@ namespace SeaHaven.Services.DTOs public string? WoNumber { get; set; } public WorkOrderType WorkOrderType { get; set; } public string? SiteCode { get; set; } + /// + /// Optional CRM customer name. Required when the caller is org-wide (no account_id) + /// so AccountId can be resolved server-side. + /// + public string? Customer { get; set; } public string? Description { get; set; } /// Legacy alias for primary service; prefer . public string? Trade { get; set; } diff --git a/SeaHaven.Services/DTOs/WorkOrderDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDTOs.cs index ef73e7d..f4f6003 100644 --- a/SeaHaven.Services/DTOs/WorkOrderDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderDTOs.cs @@ -80,6 +80,8 @@ namespace SeaHaven.Services.DTOs public int? LocationId { get; set; } public string? AssignTo { get; set; } public DateTime? DueDate { get; set; } + /// Optional CRM customer name for org-wide AccountId resolution. + public string? Customer { get; set; } public static WorkOrder MapToEntity(CreateWorkOrderDTO dto) { @@ -94,6 +96,7 @@ namespace SeaHaven.Services.DTOs LocationId = dto.LocationId, AssignTo = dto.AssignTo, DueDate = dto.DueDate, + Customer = string.IsNullOrWhiteSpace(dto.Customer) ? null : dto.Customer.Trim(), CreatedDate = DateTime.UtcNow }; } diff --git a/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs index 705306f..7665da4 100644 --- a/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs @@ -18,6 +18,8 @@ namespace SeaHaven.Services.DTOs public IFormFile? BeforPhotoAttachment { get; set; } public IFormFile? AfterPhotoAttachment { get; set; } public IFormFile? SignOffAttachment { get; set; } + /// Optional CRM customer name for org-wide AccountId resolution. + public string? Customer { get; set; } } public class UpdateWorkOrderWithDetailsInput diff --git a/SeaHaven.Services/Implementation/SyncService.cs b/SeaHaven.Services/Implementation/SyncService.cs index f9268c6..c964b21 100644 --- a/SeaHaven.Services/Implementation/SyncService.cs +++ b/SeaHaven.Services/Implementation/SyncService.cs @@ -9,11 +9,16 @@ namespace SeaHaven.Services.Implementation { private readonly ISyncDataService _data; private readonly ISyncExternalSource _external; + private readonly IWorkOrderAccountResolver _accountResolver; - public SyncService(ISyncDataService data, ISyncExternalSource external) + public SyncService( + ISyncDataService data, + ISyncExternalSource external, + IWorkOrderAccountResolver accountResolver) { _data = data; _external = external; + _accountResolver = accountResolver; } public async Task SyncWorkOrdersAsync(CancellationToken cancellationToken) @@ -42,6 +47,11 @@ namespace SeaHaven.Services.Implementation if (existing == null) { + var customer = Get(item, "customer"); + var accountId = await _accountResolver.TryResolveFromCustomerAsync(customer, cancellationToken); + if (accountId is not int resolvedAccountId) + continue; + var wo = new WorkOrder { InternalWONumber = (nextInternal++).ToString("D8"), @@ -52,7 +62,8 @@ namespace SeaHaven.Services.Implementation Status = MapStatus(Get(item, "wo_status")), Priority = MapSeverityToPriority(Get(item, "severity")), Severity = Get(item, "severity"), - Customer = Get(item, "customer"), + Customer = customer, + AccountId = resolvedAccountId, SiteCode = Get(item, "site_code"), Building = Get(item, "building"), LocationId = locationId, diff --git a/SeaHaven.Services/Implementation/WorkOrderAccountResolver.cs b/SeaHaven.Services/Implementation/WorkOrderAccountResolver.cs new file mode 100644 index 0000000..d6a9419 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderAccountResolver.cs @@ -0,0 +1,73 @@ +using System.Security.Claims; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderAccountResolver : IWorkOrderAccountResolver + { + private readonly IAccountDataService _accounts; + + public WorkOrderAccountResolver(IAccountDataService accounts) + { + _accounts = accounts; + } + + public int? ResolveAccountFilter(ClaimsPrincipal user) + { + return WorkOrderMediaAuthorization.ResolveMediaScope(user) switch + { + MediaAccountScope.Account account => account.AccountId, + MediaAccountScope.OrgWide => null, + _ => throw new WorkOrderBoardValidationException( + "Forbidden", + "You are not allowed to access work orders without account scope.") + }; + } + + public async Task ResolveForAuthenticatedCreateAsync( + ClaimsPrincipal user, + string? customer, + CancellationToken cancellationToken = default) + { + switch (WorkOrderMediaAuthorization.ResolveMediaScope(user)) + { + case MediaAccountScope.Account account: + return account.AccountId; + + case MediaAccountScope.OrgWide: + return await ResolveRequiredFromCustomerAsync(customer, cancellationToken); + + default: + throw new WorkOrderBoardValidationException( + "Forbidden", + "You are not allowed to create work orders without account scope."); + } + } + + public Task ResolveForUnauthenticatedCreateAsync( + string? customer, + CancellationToken cancellationToken = default) + => ResolveRequiredFromCustomerAsync(customer, cancellationToken); + + public Task TryResolveFromCustomerAsync( + string? customer, + CancellationToken cancellationToken = default) + => _accounts.TryGetUniqueActiveIdByExactNameAsync(customer, cancellationToken); + + private async Task ResolveRequiredFromCustomerAsync( + string? customer, + CancellationToken cancellationToken) + { + var resolved = await TryResolveFromCustomerAsync(customer, cancellationToken); + if (resolved is int accountId) + return accountId; + + throw new WorkOrderBoardValidationException( + "AccountUnresolved", + "Work order account could not be resolved from customer."); + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs index cb5dd22..a4d332d 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; @@ -12,16 +13,22 @@ namespace SeaHaven.Services.Implementation private const int MaxPageSize = 100; private readonly IWorkOrderAdvancedSearchDataService _searchDataService; + private readonly IWorkOrderAccountResolver _accountResolver; - public WorkOrderAdvancedSearchService(IWorkOrderAdvancedSearchDataService searchDataService) + public WorkOrderAdvancedSearchService( + IWorkOrderAdvancedSearchDataService searchDataService, + IWorkOrderAccountResolver accountResolver) { _searchDataService = searchDataService; + _accountResolver = accountResolver; } public async Task> SearchAsync( WorkOrderAdvancedSearchQueryDto query, + ClaimsPrincipal user, string? currentUserId) { + var accountId = _accountResolver.ResolveAccountFilter(user); var page = query.Page < 0 ? 0 : query.Page; var pageSize = query.PageSize < 1 ? DefaultPageSize : Math.Min(query.PageSize, MaxPageSize); @@ -49,7 +56,8 @@ namespace SeaHaven.Services.Implementation page, pageSize, sortBy, - query.SortDir ?? "asc"); + query.SortDir ?? "asc", + accountId); var result = await _searchDataService.SearchAsync(dataQuery); var utcNow = DateTime.UtcNow; diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs index 7902978..ec6d73d 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using Data.SeaHavenIndustries.Enums; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; @@ -23,7 +24,10 @@ namespace SeaHaven.Services.Implementation _auditService = auditService; } - public async Task CancelAsync(int workOrderId, string? actorId) + public async Task CancelAsync( + int workOrderId, + ClaimsPrincipal user, + string? actorId) { var workOrder = await _mutationData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); @@ -32,7 +36,7 @@ namespace SeaHaven.Services.Implementation if (workOrder.LifecycleStatus == LifecycleStatus.Canceled) { - var existing = await _boardService.GetBoardRowAsync(workOrderId); + var existing = await _boardService.GetBoardRowAsync(workOrderId, user); return existing ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); } @@ -48,7 +52,7 @@ namespace SeaHaven.Services.Implementation await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId); await _mutationData.SaveAsync(CancellationToken.None); - var row = await _boardService.GetBoardRowAsync(workOrderId); + var row = await _boardService.GetBoardRowAsync(workOrderId, user); return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); } } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index b7ad1ee..ee32e53 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using System.Text.Json; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; @@ -18,27 +19,38 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderBoardService _boardService; private readonly IWorkOrderAuditService _auditService; private readonly IWorkOrderBoardCreateValidation _validator; + private readonly IWorkOrderAccountResolver _accountResolver; public WorkOrderBoardCreateService( IWorkOrderBoardDataService boardDataService, IWorkOrderBoardMutationDataService mutationData, IWorkOrderBoardService boardService, IWorkOrderAuditService auditService, - IWorkOrderBoardCreateValidation validator) + IWorkOrderBoardCreateValidation validator, + IWorkOrderAccountResolver accountResolver) { _boardDataService = boardDataService; _mutationData = mutationData; _boardService = boardService; _auditService = auditService; _validator = validator; + _accountResolver = accountResolver; } - public async Task CreateAsync(WorkOrderBoardCreateRequestDto request, string? actorId) + public async Task CreateAsync( + WorkOrderBoardCreateRequestDto request, + ClaimsPrincipal user, + string? actorId) { var validationResult = await _validator.ValidateAsync(request); if (!validationResult.IsValid) throw new ValidationException(validationResult.Errors); + var accountId = await _accountResolver.ResolveForAuthenticatedCreateAsync( + user, + request.Customer, + CancellationToken.None); + var woNumber = await ResolveWoNumberAsync(request.WoNumber); var siteCode = request.SiteCode!.Trim(); var primaryService = ResolvePrimaryService(request); @@ -49,6 +61,7 @@ namespace SeaHaven.Services.Implementation var pocNotes = TrimOrNull(request.PocNotes); var techPhone = TrimOrNull(request.TechPhone); var vendorNotes = TrimOrNull(request.VendorNotes); + var customer = TrimOrNull(request.Customer); var hasVendorDispatch = request.VendorId.HasValue || request.ApptDate.HasValue || !string.IsNullOrWhiteSpace(request.ApptTime); @@ -65,6 +78,8 @@ namespace SeaHaven.Services.Implementation WorkerOrderNumber = woNumber, WorkOrderType = request.WorkOrderType, SiteCode = siteCode, + Customer = customer, + AccountId = accountId, Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), Trade = primaryService, ExtraServices = extraServicesJson, @@ -197,7 +212,7 @@ namespace SeaHaven.Services.Implementation await _mutationData.SaveAsync(ct); }, CancellationToken.None); - var row = await _boardService.GetBoardRowAsync(workOrder.Id); + var row = await _boardService.GetBoardRowAsync(workOrder.Id, user); return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded."); } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index aa4b4dd..ea1443c 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using Data.SeaHavenIndustries.Enums; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; @@ -9,16 +10,22 @@ namespace SeaHaven.Services.Implementation public class WorkOrderBoardService : IWorkOrderBoardService { private readonly IWorkOrderBoardDataService _boardDataService; + private readonly IWorkOrderAccountResolver _accountResolver; - public WorkOrderBoardService(IWorkOrderBoardDataService boardDataService) + public WorkOrderBoardService( + IWorkOrderBoardDataService boardDataService, + IWorkOrderAccountResolver accountResolver) { _boardDataService = boardDataService; + _accountResolver = accountResolver; } public async Task GetBoardAsync( WorkOrderBoardQueryDto query, + ClaimsPrincipal user, string? currentUserId) { + var accountId = _accountResolver.ResolveAccountFilter(user); var weekStart = query.WeekStart; var weekEnd = query.WeekEnd ?? weekStart.AddDays(4); @@ -34,7 +41,8 @@ namespace SeaHaven.Services.Implementation query.Types, query.Overdue, query.Search, - currentUserId); + currentUserId, + accountId); var result = await _boardDataService.GetBoardRowsAsync(dataQuery); var utcNow = DateTime.UtcNow; @@ -65,9 +73,10 @@ namespace SeaHaven.Services.Implementation }).ToList(); } - public async Task GetBoardRowAsync(int workOrderId) + public async Task GetBoardRowAsync(int workOrderId, ClaimsPrincipal user) { - var raw = await _boardDataService.GetBoardRowByIdAsync(workOrderId); + var accountId = _accountResolver.ResolveAccountFilter(user); + var raw = await _boardDataService.GetBoardRowByIdAsync(workOrderId, accountId); return raw == null ? null : MapRawRow(raw, DateTime.UtcNow); } diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs index 849c5f6..c8f47e7 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; @@ -11,25 +12,29 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderDetailDataService _detailData; private readonly ICompletionDocTemplateDataService _templateData; private readonly IUserDataService _userDataService; + private readonly IWorkOrderAccountResolver _accountResolver; public WorkOrderDetailService( IWorkOrderBoardService boardService, IWorkOrderDetailDataService detailData, ICompletionDocTemplateDataService templateData, - IUserDataService userDataService) + IUserDataService userDataService, + IWorkOrderAccountResolver accountResolver) { _boardService = boardService; _detailData = detailData; _templateData = templateData; _userDataService = userDataService; + _accountResolver = accountResolver; } - public async Task GetDetailAsync(int workOrderId) + public async Task GetDetailAsync(int workOrderId, ClaimsPrincipal user) { - if (!await _detailData.ExistsAsync(workOrderId)) + var accountId = _accountResolver.ResolveAccountFilter(user); + if (!await _detailData.ExistsAsync(workOrderId, CancellationToken.None, accountId)) return null; - var boardRow = await _boardService.GetBoardRowAsync(workOrderId); + var boardRow = await _boardService.GetBoardRowAsync(workOrderId, user); if (boardRow == null) return null; @@ -38,7 +43,8 @@ namespace SeaHaven.Services.Implementation var audit = await _detailData.GetAuditLogsAsync(workOrderId, 50); var attachments = await _detailData.GetAttachmentsAsync(workOrderId); var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId); - var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId); + var workOrder = await _detailData.GetWorkOrderForMediaAsync( + workOrderId, CancellationToken.None, accountId); var template = await _templateData.ResolveForWorkOrderAsync( extended?.Trade ?? boardRow.Pm, @@ -59,9 +65,13 @@ namespace SeaHaven.Services.Implementation }; } - public async Task?> GetAuditAsync(int workOrderId, int limit = 50) + public async Task?> GetAuditAsync( + int workOrderId, + ClaimsPrincipal user, + int limit = 50) { - if (!await _detailData.ExistsAsync(workOrderId)) + var accountId = _accountResolver.ResolveAccountFilter(user); + if (!await _detailData.ExistsAsync(workOrderId, CancellationToken.None, accountId)) return null; var logs = await _detailData.GetAuditLogsAsync(workOrderId, limit); diff --git a/SeaHaven.Services/Implementation/WorkOrderIngestService.cs b/SeaHaven.Services/Implementation/WorkOrderIngestService.cs index 3a8bffb..bdadab1 100644 --- a/SeaHaven.Services/Implementation/WorkOrderIngestService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderIngestService.cs @@ -12,17 +12,20 @@ namespace SeaHaven.Services.Implementation private readonly ISyncFieldMergePolicy _mergePolicy; private readonly IWorkOrderFieldLockService _fieldLocks; private readonly IWorkOrderAuditService _audit; + private readonly IWorkOrderAccountResolver _accountResolver; public WorkOrderIngestService( IWorkOrderIngestDataService ingestData, ISyncFieldMergePolicy mergePolicy, IWorkOrderFieldLockService fieldLocks, - IWorkOrderAuditService audit) + IWorkOrderAuditService audit, + IWorkOrderAccountResolver accountResolver) { _ingestData = ingestData; _mergePolicy = mergePolicy; _fieldLocks = fieldLocks; _audit = audit; + _accountResolver = accountResolver; } public async Task UpsertBatchAsync( @@ -45,13 +48,21 @@ namespace SeaHaven.Services.Implementation continue; } - var (upsert, updatedSeed) = await UpsertOneAsync(item, nextSeed, cancellationToken); - nextSeed = updatedSeed; - result.Results.Add(upsert); - if (upsert.Created) - result.Created++; - else - result.Updated++; + try + { + var (upsert, updatedSeed) = await UpsertOneAsync(item, nextSeed, cancellationToken); + nextSeed = updatedSeed; + result.Results.Add(upsert); + if (upsert.Created) + result.Created++; + else + result.Updated++; + } + catch (Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "AccountUnresolved") + { + result.Skipped++; + } } await _ingestData.SaveAsync(cancellationToken); @@ -72,6 +83,8 @@ namespace SeaHaven.Services.Implementation if (existing == null) { + var accountId = await _accountResolver.ResolveForUnauthenticatedCreateAsync( + item.Customer, cancellationToken); var (woNumber, updatedSeed) = AllocateInternalWoNumber(nextSeed); var wo = new WorkOrder { @@ -84,6 +97,7 @@ namespace SeaHaven.Services.Implementation Priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity), Severity = item.Severity, Customer = item.Customer, + AccountId = accountId, SiteCode = item.SiteCode, Building = item.Building, LocationId = locationId, diff --git a/SeaHaven.Services/Implementation/WorkOrderService.cs b/SeaHaven.Services/Implementation/WorkOrderService.cs index 88632dc..39b3c63 100644 --- a/SeaHaven.Services/Implementation/WorkOrderService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderService.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentValidation; @@ -18,6 +19,7 @@ namespace SeaHaven.Services.Implementation private readonly IFileStoragePort _fileStorage; private readonly ICreateWorkOrderValidation _createValidator; private readonly IUpdateWorkOrderValidation _updateValidator; + private readonly IWorkOrderAccountResolver _accountResolver; public WorkOrderService( IWorkOrderDataService workOrderDataService, @@ -26,7 +28,8 @@ namespace SeaHaven.Services.Implementation IQuotesDataService quotesDataService, IFileStoragePort fileStorage, ICreateWorkOrderValidation createValidator, - IUpdateWorkOrderValidation updateValidator) + IUpdateWorkOrderValidation updateValidator, + IWorkOrderAccountResolver accountResolver) { _workOrderDataService = workOrderDataService; _commentDataService = commentDataService; @@ -35,6 +38,7 @@ namespace SeaHaven.Services.Implementation _fileStorage = fileStorage; _createValidator = createValidator; _updateValidator = updateValidator; + _accountResolver = accountResolver; } public async Task GetWorkOrderByIdAsync(int id) @@ -67,13 +71,17 @@ namespace SeaHaven.Services.Implementation }; } - public async Task CreateWorkOrderAsync(CreateWorkOrderDTO dto, string userId) + public async Task CreateWorkOrderAsync(CreateWorkOrderDTO dto, ClaimsPrincipal user, string userId) { var validationResult = await _createValidator.ValidateAsync(dto); if (!validationResult.IsValid) throw new ValidationException(validationResult.Errors); + var accountId = await _accountResolver.ResolveForAuthenticatedCreateAsync( + user, dto.Customer, CancellationToken.None); + var workOrder = CreateWorkOrderDTO.MapToEntity(dto); + workOrder.AccountId = accountId; workOrder.createdby = userId; workOrder.CreatedDate = DateTime.UtcNow; workOrder.InternalWONumber = await _workOrderDataService.GenerateNextInternalWONumberAsync(); @@ -124,10 +132,10 @@ namespace SeaHaven.Services.Implementation public async Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( int page, int pageSize, string? search, string? status, string? assignee, - int? locationId, string? sortBy, string? sortDir) + int? locationId, string? sortBy, string? sortDir, int? accountId = null) { return await _workOrderDataService.GetWorkOrderListPagedAsync( - page, pageSize, search, status, assignee, locationId, sortBy, sortDir); + page, pageSize, search, status, assignee, locationId, sortBy, sortDir, accountId); } public async Task GetWorkOrderDetailAsync(int id) @@ -354,7 +362,10 @@ namespace SeaHaven.Services.Implementation }; } - public async Task CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, string userId) + public async Task CreateWorkOrderWithDetailsAsync( + CreateWorkOrderWithDetailsInput input, + ClaimsPrincipal user, + string userId) { var createDto = new CreateWorkOrderDTO { @@ -364,10 +375,11 @@ namespace SeaHaven.Services.Implementation Priority = Enum.TryParse(input.Priority, out var p) ? p : WorkOrderPriority.Medium, LocationId = input.LocationId, AssignTo = input.AssignTo, - DueDate = input.DueDate + DueDate = input.DueDate, + Customer = input.Customer }; - var workOrderId = await CreateWorkOrderAsync(createDto, userId); + var workOrderId = await CreateWorkOrderAsync(createDto, user, userId); if (input.ContactIds != null && input.ContactIds.Any()) await _workOrderDataService.AddWorkOrderContactsAsync(workOrderId, input.ContactIds); diff --git a/SeaHaven.Services/Implementation/WorkOrderWebhookService.cs b/SeaHaven.Services/Implementation/WorkOrderWebhookService.cs index 7756afc..82a17fc 100644 --- a/SeaHaven.Services/Implementation/WorkOrderWebhookService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderWebhookService.cs @@ -161,6 +161,12 @@ namespace SeaHaven.Services.Implementation "Work-order webhook delivery {DeliveryId} conflicts with an existing delivery.", mutation!.DeliveryId); return new WorkOrderWebhookResult(WorkOrderWebhookStatus.HashConflict); + case WorkOrderWebhookPersistenceStatus.AccountUnresolved: + Invalid.Add(1); + _logger.LogWarning( + "Work-order webhook delivery {DeliveryId} could not resolve account from customer.", + mutation!.DeliveryId); + return new WorkOrderWebhookResult(WorkOrderWebhookStatus.InvalidEnvelope); default: throw new InvalidOperationException("Unknown persistence result."); } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderAccountResolver.cs b/SeaHaven.Services/Interfaces/IWorkOrderAccountResolver.cs new file mode 100644 index 0000000..8fd209c --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderAccountResolver.cs @@ -0,0 +1,40 @@ +using System.Security.Claims; + +namespace SeaHaven.Services.Interfaces +{ + /// + /// Server-derived work-order account scope (SH-221): claims for authenticated callers, + /// unique Accounts.Name ↔ Customer for org-wide / unauthenticated creates. + /// + public interface IWorkOrderAccountResolver + { + /// + /// Account filter for reads. Null = org-wide (skip ApplyAccountScope). + /// Throws Forbidden when scope is Missing. + /// + int? ResolveAccountFilter(ClaimsPrincipal user); + + /// + /// Authenticated create: claim account_id, or org-wide Customer unique match. + /// Missing scope → Forbidden. Unresolvable org-wide Customer → AccountUnresolved. + /// + Task ResolveForAuthenticatedCreateAsync( + ClaimsPrincipal user, + string? customer, + CancellationToken cancellationToken = default); + + /// + /// Ingest/webhook/sync create: unique Customer → Accounts.Id or AccountUnresolved. + /// + Task ResolveForUnauthenticatedCreateAsync( + string? customer, + CancellationToken cancellationToken = default); + + /// + /// Best-effort Customer lookup (no throw). Null when blank, missing, or ambiguous. + /// + Task TryResolveFromCustomerAsync( + string? customer, + CancellationToken cancellationToken = default); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs index 8da8835..301dad6 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using SeaHaven.Services.DTOs; namespace SeaHaven.Services.Interfaces @@ -6,6 +7,7 @@ namespace SeaHaven.Services.Interfaces { Task> SearchAsync( WorkOrderAdvancedSearchQueryDto query, + ClaimsPrincipal user, string? currentUserId); } } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderBoardCancelService.cs b/SeaHaven.Services/Interfaces/IWorkOrderBoardCancelService.cs index d0b647b..573d3d1 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderBoardCancelService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderBoardCancelService.cs @@ -1,9 +1,10 @@ +using System.Security.Claims; using SeaHaven.Services.DTOs; namespace SeaHaven.Services.Interfaces { public interface IWorkOrderBoardCancelService { - Task CancelAsync(int workOrderId, string? actorId); + Task CancelAsync(int workOrderId, ClaimsPrincipal user, string? actorId); } } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderBoardCreateService.cs b/SeaHaven.Services/Interfaces/IWorkOrderBoardCreateService.cs index 9818053..8745ced 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderBoardCreateService.cs @@ -1,9 +1,13 @@ +using System.Security.Claims; using SeaHaven.Services.DTOs; namespace SeaHaven.Services.Interfaces { public interface IWorkOrderBoardCreateService { - Task CreateAsync(WorkOrderBoardCreateRequestDto request, string? actorId); + Task CreateAsync( + WorkOrderBoardCreateRequestDto request, + ClaimsPrincipal user, + string? actorId); } } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs b/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs index 2a964cf..9aa53d9 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs @@ -1,11 +1,15 @@ +using System.Security.Claims; using SeaHaven.Services.DTOs; namespace SeaHaven.Services.Interfaces { public interface IWorkOrderBoardService { - Task GetBoardAsync(WorkOrderBoardQueryDto query, string? currentUserId); + Task GetBoardAsync( + WorkOrderBoardQueryDto query, + ClaimsPrincipal user, + string? currentUserId); Task> GetDispatcherLookupsAsync(); - Task GetBoardRowAsync(int workOrderId); + Task GetBoardRowAsync(int workOrderId, ClaimsPrincipal user); } } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs b/SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs index c59f968..3f71271 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs @@ -1,10 +1,14 @@ +using System.Security.Claims; using SeaHaven.Services.DTOs; namespace SeaHaven.Services.Interfaces { public interface IWorkOrderDetailService { - Task GetDetailAsync(int workOrderId); - Task?> GetAuditAsync(int workOrderId, int limit = 50); + Task GetDetailAsync(int workOrderId, ClaimsPrincipal user); + Task?> GetAuditAsync( + int workOrderId, + ClaimsPrincipal user, + int limit = 50); } } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderService.cs b/SeaHaven.Services/Interfaces/IWorkOrderService.cs index 3c60e46..1cf7ef9 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderService.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Http; @@ -12,7 +13,7 @@ namespace SeaHaven.Services.Interfaces Task GetWorkOrderByIdWithDetailsAsync(int id); Task> GetAllWorkOrdersAsync(); Task> GetWorkOrdersPagedAsync(int page, int pageSize, string? search = null, string? status = null, string? priority = null); - Task CreateWorkOrderAsync(CreateWorkOrderDTO dto, string userId); + Task CreateWorkOrderAsync(CreateWorkOrderDTO dto, ClaimsPrincipal user, string userId); Task UpdateWorkOrderAsync(int id, UpdateWorkOrderDTO dto, string userId); Task DeleteWorkOrderAsync(int id, string userId); Task WorkOrderExistsAsync(int id); @@ -21,7 +22,7 @@ namespace SeaHaven.Services.Interfaces Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( int page, int pageSize, string? search, string? status, string? assignee, - int? locationId, string? sortBy, string? sortDir); + int? locationId, string? sortBy, string? sortDir, int? accountId = null); Task GetWorkOrderDetailAsync(int id); Task GetFilteredWorkOrdersAsync( string[] assignto, int[]? location, string[]? priority, @@ -31,7 +32,7 @@ namespace SeaHaven.Services.Interfaces string[]? status, string[]? duedate, string search, string sort, string sortby, int page, int pageSize); Task ChangeStatusAsync(int id, string status, string userId); Task ChangeAssignmentAsync(int id, string? assignTo, string userId); - Task CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, string userId); + Task CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, ClaimsPrincipal user, string userId); Task UpdateWorkOrderWithDetailsAsync(UpdateWorkOrderWithDetailsInput input, string userId); Task DeleteWorkOrderCascadeAsync(int id, string userId); Task AddCommentAsync(AddCommentInput input, string userId); diff --git a/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs new file mode 100644 index 0000000..51f461f --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs @@ -0,0 +1,304 @@ +using System.Security.Claims; +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.Validation; +using Xunit; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderAccountScopeTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Fact] + public async Task BoardCreate_WithAccountClaim_StampsAccountId() + { + await using var context = CreateContext(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 7, "Scoped Co"); + + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); + var boardService = new WorkOrderBoardService(boardData, resolver); + 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); + + var user = WorkOrderAccountTestHelpers.AccountUser("actor-1", 7, "Dispatcher"); + var row = await create.CreateAsync( + new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5" + }, + user, + "actor-1"); + + var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id); + Assert.Equal(7, wo.AccountId); + } + + [Fact] + public async Task BoardCreate_MissingScope_ThrowsForbidden() + { + await using var context = CreateContext(); + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); + var boardService = new WorkOrderBoardService(boardData, resolver); + 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); + + var ex = await Assert.ThrowsAsync(() => + create.CreateAsync( + new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5" + }, + WorkOrderAccountTestHelpers.MissingScope(), + "actor-1")); + + Assert.Equal("Forbidden", ex.Code); + Assert.Empty(context.workOrders); + } + + [Fact] + public async Task BoardCreate_OrgWide_WithUniqueCustomer_StampsAccountId() + { + await using var context = CreateContext(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 3, "Unique Customer"); + + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); + var boardService = new WorkOrderBoardService(boardData, resolver); + 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); + + var row = await create.CreateAsync( + new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PO, + SiteCode = "DAL", + Customer = "Unique Customer" + }, + WorkOrderAccountTestHelpers.OrgWideAdmin(), + "admin-1"); + + var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id); + Assert.Equal(3, wo.AccountId); + Assert.Equal("Unique Customer", wo.Customer); + } + + [Fact] + public async Task BoardCreate_OrgWide_UnresolvedCustomer_ThrowsAccountUnresolved() + { + await using var context = CreateContext(); + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); + var boardService = new WorkOrderBoardService(boardData, resolver); + 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); + + var ex = await Assert.ThrowsAsync(() => + create.CreateAsync( + new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5" + }, + WorkOrderAccountTestHelpers.OrgWideAdmin(), + "admin-1")); + + Assert.Equal("AccountUnresolved", ex.Code); + Assert.Empty(context.workOrders); + } + + [Fact] + public async Task Board_ScopedUser_HidesOtherAccountAndNullAccountRows() + { + await using var context = CreateContext(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A"); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B"); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + InternalWONumber = "00000000001", + AccountId = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 8, 10), + istemplate = false + }, + new WorkOrder + { + Id = 2, + InternalWONumber = "00000000002", + AccountId = 2, + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 8, 11), + istemplate = false + }, + new WorkOrder + { + Id = 3, + InternalWONumber = "00000000003", + AccountId = null, + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 8, 12), + istemplate = false + }); + await context.SaveChangesAsync(); + + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(context), resolver); + + var scoped = await boardService.GetBoardAsync( + new WorkOrderBoardQueryDto + { + WeekStart = new DateOnly(2026, 8, 10), + WeekEnd = new DateOnly(2026, 8, 14) + }, + WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"), + "disp-1"); + + Assert.Single(scoped.Scheduled); + Assert.Equal(1, scoped.Scheduled[0].Id); + + var orgWide = await boardService.GetBoardAsync( + new WorkOrderBoardQueryDto + { + WeekStart = new DateOnly(2026, 8, 10), + WeekEnd = new DateOnly(2026, 8, 14) + }, + WorkOrderAccountTestHelpers.OrgWideAdmin(), + "admin-1"); + + Assert.Equal(3, orgWide.Scheduled.Count); + } + + [Fact] + public async Task Detail_CrossAccount_ReturnsNull() + { + await using var context = CreateContext(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A"); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B"); + + context.workOrders.Add(new WorkOrder + { + Id = 10, + InternalWONumber = "00000000010", + AccountId = 2, + LifecycleStatus = LifecycleStatus.Incomplete, + istemplate = false + }); + await context.SaveChangesAsync(); + + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(context), resolver); + var detail = new WorkOrderDetailService( + boardService, + new WorkOrderDetailDataService(context), + new CompletionDocTemplateDataService(context), + new UserDataService(context), + resolver); + + var result = await detail.GetDetailAsync( + 10, + WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher")); + + Assert.Null(result); + } + + [Fact] + public async Task Ingest_UnresolvedCustomer_SkipsCreate() + { + await using var context = CreateContext(); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var ingest = new WorkOrderIngestService( + new WorkOrderIngestDataService(context), + new SyncFieldMergePolicy(fieldLocks), + fieldLocks, + audit, + WorkOrderAccountTestHelpers.Resolver(context)); + + var result = await ingest.UpsertBatchAsync(new[] + { + new WorkOrderIngestPayloadDto + { + ExternalWorkOrderId = "EXT-1", + Description = "No account", + Customer = "Unknown Customer" + } + }); + + Assert.Equal(1, result.Skipped); + Assert.Equal(0, result.Created); + Assert.Empty(context.workOrders); + } + + [Fact] + public async Task Ingest_UniqueCustomer_StampsAccountId() + { + await using var context = CreateContext(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 9, "Ingest Customer"); + + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var ingest = new WorkOrderIngestService( + new WorkOrderIngestDataService(context), + new SyncFieldMergePolicy(fieldLocks), + fieldLocks, + audit, + WorkOrderAccountTestHelpers.Resolver(context)); + + var result = await ingest.UpsertBatchAsync(new[] + { + new WorkOrderIngestPayloadDto + { + ExternalWorkOrderId = "EXT-9", + Description = "Has account", + Customer = "Ingest Customer" + } + }); + + Assert.Equal(1, result.Created); + var wo = Assert.Single(context.workOrders); + Assert.Equal(9, wo.AccountId); + } + + [Fact] + public async Task ResolveAccountFilter_MissingScope_ThrowsForbidden() + { + await using var context = CreateContext(); + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + + var ex = Assert.Throws(() => + resolver.ResolveAccountFilter(WorkOrderAccountTestHelpers.MissingScope())); + + Assert.Equal("Forbidden", ex.Code); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs b/SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs new file mode 100644 index 0000000..471c0ea --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs @@ -0,0 +1,68 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; + +namespace SeaHavenIndustries.Tests; + +internal static class WorkOrderAccountTestHelpers +{ + public static IWorkOrderAccountResolver Resolver(ApplicationDbContext context) + => new WorkOrderAccountResolver(new AccountDataService(context)); + + public static ClaimsPrincipal AccountUser( + string userId = "actor-1", + int accountId = 1, + params string[] roles) + { + var effectiveRoles = roles.Length == 0 ? new[] { "Admin" } : roles; + var claims = new List + { + new(ClaimTypes.NameIdentifier, userId), + new(SeaHavenClaimTypes.AccountId, accountId.ToString()) + }; + claims.AddRange(effectiveRoles.Select(r => new Claim(ClaimTypes.Role, r))); + return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "test")); + } + + public static ClaimsPrincipal OrgWideAdmin(string userId = "actor-1") + { + var claims = new List + { + new(ClaimTypes.NameIdentifier, userId), + new(ClaimTypes.Role, "Admin"), + new(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll) + }; + return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "test")); + } + + public static ClaimsPrincipal MissingScope(string userId = "actor-1", string role = "Dispatcher") + { + var claims = new List + { + new(ClaimTypes.NameIdentifier, userId), + new(ClaimTypes.Role, role) + }; + return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "test")); + } + + public static async Task EnsureAccountAsync( + ApplicationDbContext context, + int id = 1, + string name = "Acme Corp") + { + if (await context.Accounts.AnyAsync(a => a.Id == id)) + return; + + context.Accounts.Add(new Accounts + { + Id = id, + Name = name, + IsDeleted = false + }); + await context.SaveChangesAsync(); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index efad8f4..0538e90 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -18,7 +18,7 @@ public class WorkOrderBoardCancelServiceTests var context = new ApplicationDbContext(options); var boardData = new WorkOrderBoardDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context); - var boardService = new WorkOrderBoardService(boardData); + var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit); @@ -44,7 +44,7 @@ public class WorkOrderBoardCancelServiceTests context.workOrders.Add(wo); await context.SaveChangesAsync(); - var result = await cancel.CancelAsync(1, "actor-1"); + var result = await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"); Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus); Assert.Equal("Canceled", result.LifecycleStatusLabel); @@ -65,7 +65,7 @@ public class WorkOrderBoardCancelServiceTests await context.SaveChangesAsync(); var before = await context.WorkOrderAuditLogs.CountAsync(); - var result = await cancel.CancelAsync(1, "actor-1"); + var result = await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"); var after = await context.WorkOrderAuditLogs.CountAsync(); Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus); @@ -84,7 +84,7 @@ public class WorkOrderBoardCancelServiceTests }); await context.SaveChangesAsync(); - var ex = await Assert.ThrowsAsync(() => cancel.CancelAsync(1, "actor-1")); + var ex = await Assert.ThrowsAsync(() => cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1")); Assert.Equal("CancelNotAllowed", ex.Code); } @@ -100,7 +100,7 @@ public class WorkOrderBoardCancelServiceTests }); await context.SaveChangesAsync(); - var ex = await Assert.ThrowsAsync(() => cancel.CancelAsync(1, "actor-1")); + var ex = await Assert.ThrowsAsync(() => cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1")); Assert.Equal("CancelNotAllowed", ex.Code); } @@ -117,7 +117,7 @@ public class WorkOrderBoardCancelServiceTests }); await context.SaveChangesAsync(); - await cancel.CancelAsync(1, "actor-1"); + await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"); var log = await context.WorkOrderAuditLogs.SingleAsync(l => l.Action == "StatusChanged"); Assert.Equal("Canceled", log.NewValue); @@ -137,7 +137,7 @@ public class WorkOrderBoardCancelServiceTests context.workOrders.Add(wo); await context.SaveChangesAsync(); - await cancel.CancelAsync(1, "actor-1"); + await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"); var ex = await Assert.ThrowsAsync(() => update.PatchFieldAsync(1, new SeaHaven.Services.DTOs.WorkOrderBoardPatchRequestDto diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs index bd098de..29be2eb 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs @@ -34,10 +34,12 @@ public class WorkOrderBoardCreateRelationalTests NormalizedEmail = "ACTOR@TEST.LOCAL" }); await context.SaveChangesAsync(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); var boardData = new WorkOrderBoardDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context); - var boardService = new WorkOrderBoardService(boardData); + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var boardService = new WorkOrderBoardService(boardData, resolver); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var service = new WorkOrderBoardCreateService( @@ -45,14 +47,15 @@ public class WorkOrderBoardCreateRelationalTests mutationData, boardService, audit, - new WorkOrderBoardCreateValidation()); + new WorkOrderBoardCreateValidation(), + resolver); var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", Description = "Relational create" - }, actorId); + }, WorkOrderAccountTestHelpers.AccountUser(actorId), actorId); Assert.True(result.Id > 0); @@ -91,10 +94,12 @@ public class WorkOrderBoardCreateRelationalTests NormalizedEmail = "ACTOR@TEST.LOCAL" }); await context.SaveChangesAsync(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); var boardData = new WorkOrderBoardDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context); - var boardService = new WorkOrderBoardService(boardData); + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var boardService = new WorkOrderBoardService(boardData, resolver); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var service = new WorkOrderBoardCreateService( @@ -102,7 +107,8 @@ public class WorkOrderBoardCreateRelationalTests mutationData, boardService, audit, - new WorkOrderBoardCreateValidation()); + new WorkOrderBoardCreateValidation(), + resolver); await Assert.ThrowsAsync(() => service.CreateAsync(new WorkOrderBoardCreateRequestDto @@ -111,7 +117,7 @@ public class WorkOrderBoardCreateRelationalTests SiteCode = "BK5", Description = "Should roll back", PocContactId = 999_999 - }, actorId)); + }, WorkOrderAccountTestHelpers.AccountUser(actorId), actorId)); // Fresh context on the same connection so we observe committed state only. await using var verify = new SqliteBoardTestDbContext(options); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index 9ac5114..bd5f2bf 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -19,11 +19,12 @@ public class WorkOrderBoardCreateServiceTests var context = new ApplicationDbContext(options); var boardData = new WorkOrderBoardDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context); - var boardService = new WorkOrderBoardService(boardData); + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var boardService = new WorkOrderBoardService(boardData, resolver); 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); + var service = new WorkOrderBoardCreateService(boardData, mutationData, boardService, audit, validator, resolver); return (context, service); } @@ -36,7 +37,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5" - }, "actor-1"); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus); Assert.Equal("BK5", result.SiteCode); @@ -55,7 +56,7 @@ public class WorkOrderBoardCreateServiceTests WoNumber = "12345", WorkOrderType = WorkOrderType.PO, SiteCode = "DAL" - }, "actor-1"); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.Equal("00000012345", result.WoNumber); } @@ -73,7 +74,7 @@ public class WorkOrderBoardCreateServiceTests WoNumber = "99999", WorkOrderType = WorkOrderType.PM, SiteCode = "BK5" - }, "actor-1")); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); Assert.Equal("DuplicateWoNumber", ex.Code); } @@ -89,7 +90,7 @@ public class WorkOrderBoardCreateServiceTests SiteCode = "BK5", AssignTo = "dispatcher-1", ScheduledDate = new DateTime(2026, 6, 25) - }, "actor-1"); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus); Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate); @@ -106,7 +107,7 @@ public class WorkOrderBoardCreateServiceTests SiteCode = "BK5", ScheduleWeekOnly = true, TargetWeek = new DateOnly(2026, 6, 22) - }, "actor-1"); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.True(result.ScheduleWeekOnly); Assert.Equal(new DateOnly(2026, 6, 22), result.TargetWeek); @@ -123,7 +124,7 @@ public class WorkOrderBoardCreateServiceTests WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", ScheduleWeekOnly = true - }, "actor-1")); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); } [Fact] @@ -139,7 +140,7 @@ public class WorkOrderBoardCreateServiceTests SiteCode = "BK5", VendorId = 5, ApptDate = new DateTime(2026, 6, 26) - }, "actor-1"); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.Equal(5, result.VendorId); Assert.NotNull(result.PrimaryDispatchId); @@ -155,7 +156,7 @@ public class WorkOrderBoardCreateServiceTests WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", Description = "Test WO" - }, "actor-1"); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); var locks = await context.WorkOrderFieldLocks.ToListAsync(); Assert.Contains(locks, l => l.FieldName == "SiteCode"); @@ -173,7 +174,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5" - }, "actor-1"); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); var logs = await context.WorkOrderAuditLogs.ToListAsync(); Assert.Contains(logs, l => l.Action == "Create"); @@ -194,7 +195,7 @@ public class WorkOrderBoardCreateServiceTests PrimaryService = "HVAC PM", ExtraServices = new List { "Filter change", "Coil clean", "Filter change" }, ServiceNotes = "Unit on roof" - }, "actor-1"); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.Equal("HVAC PM", result.Pm); Assert.Equal("Unit on roof", result.ServiceNotes); @@ -220,7 +221,7 @@ public class WorkOrderBoardCreateServiceTests SiteCode = "CHI", Trade = "Legacy Trade", PrimaryService = "Plumbing" - }, "actor-1"); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.Equal("Plumbing", result.Pm); } @@ -236,7 +237,7 @@ public class WorkOrderBoardCreateServiceTests WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", ExtraServices = new List { "Filter change" } - }, "actor-1")); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); } [Fact] @@ -251,7 +252,7 @@ public class WorkOrderBoardCreateServiceTests PocName = "Jane Site Lead", PocPhone = "+1 555-0100", PocNotes = "Call 30 min before" - }, "actor-1"); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.Equal("Jane Site Lead", result.PocName); Assert.Equal("+1 555-0100", result.PocPhone); @@ -273,7 +274,7 @@ public class WorkOrderBoardCreateServiceTests SiteCode = "BK5", TechPhone = "+1 555-0199", VendorNotes = "Gate code 4421" - }, "actor-1"); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.Equal("+1 555-0199", result.TechPhone); @@ -298,7 +299,7 @@ public class WorkOrderBoardCreateServiceTests ApptDate = new DateTime(2026, 7, 18), TechPhone = "+1 555-0199", VendorNotes = "Park behind dock" - }, "actor-1"); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.Equal("+1 555-0199", result.TechPhone); Assert.Equal(55, result.VendorId); @@ -322,7 +323,7 @@ public class WorkOrderBoardCreateServiceTests SiteCode = "BK5", Trade = "HVAC", Description = "pmNote\nPOC: Jane · +1 555\nVendor notes: gate" - }, "actor-1"); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.Equal("HVAC", result.Pm); Assert.Null(result.PocName); @@ -346,7 +347,7 @@ public class WorkOrderBoardCreateServiceTests WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", VendorId = 999 - }, "actor-1")); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); Assert.Equal("VendorNotFound", ex.Code); } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs index cf89a8c..b1e60f3 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs @@ -21,7 +21,8 @@ public class WorkOrderBoardCreateSyncLockTests var boardData = new WorkOrderBoardDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context); - var boardService = new WorkOrderBoardService(boardData); + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var boardService = new WorkOrderBoardService(boardData, resolver); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var createService = new WorkOrderBoardCreateService( @@ -29,14 +30,15 @@ public class WorkOrderBoardCreateSyncLockTests mutationData, boardService, audit, - new WorkOrderBoardCreateValidation()); + new WorkOrderBoardCreateValidation(), + resolver); var policy = new SyncFieldMergePolicy(fieldLocks); await createService.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5" - }, "dispatcher-1"); + }, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1"); var wo = await context.workOrders.SingleAsync(); var syncContext = new WorkOrderSyncContext @@ -66,7 +68,8 @@ public class WorkOrderBoardCreateSyncLockTests var boardData = new WorkOrderBoardDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context); - var boardService = new WorkOrderBoardService(boardData); + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var boardService = new WorkOrderBoardService(boardData, resolver); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var createService = new WorkOrderBoardCreateService( @@ -74,14 +77,15 @@ public class WorkOrderBoardCreateSyncLockTests mutationData, boardService, audit, - new WorkOrderBoardCreateValidation()); + new WorkOrderBoardCreateValidation(), + resolver); var policy = new SyncFieldMergePolicy(fieldLocks); await createService.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5" - }, "dispatcher-1"); + }, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1"); var wo = await context.workOrders.SingleAsync(); wo.Customer = "Original"; diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs index 0f4dd34..780c25c 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -212,7 +212,7 @@ public class WorkOrderAdvancedSearchServiceTests await context.SaveChangesAsync(); var dataService = new WorkOrderAdvancedSearchDataService(context); - var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context)); var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto { @@ -222,7 +222,7 @@ public class WorkOrderAdvancedSearchServiceTests Sites = new List { "BK5" }, Page = 0, PageSize = 50 - }, null); + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); Assert.Equal(1, result.TotalCount); Assert.Single(result.Items); @@ -234,13 +234,13 @@ public class WorkOrderAdvancedSearchServiceTests { await using var context = CreateContext(); var dataService = new WorkOrderAdvancedSearchDataService(context); - var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context)); var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto { DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek, PageSize = 500 - }, null); + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); Assert.Equal(100, result.PageSize); } @@ -272,7 +272,7 @@ public class WorkOrderAdvancedSearchServiceTests await context.SaveChangesAsync(); var dataService = new WorkOrderAdvancedSearchDataService(context); - var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context)); var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto { @@ -281,7 +281,7 @@ public class WorkOrderAdvancedSearchServiceTests DateTo = weekStart.AddDays(4), Page = 0, PageSize = 1 - }, null); + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); Assert.Equal(2, result.TotalCount); Assert.Single(result.Items); @@ -315,7 +315,7 @@ public class WorkOrderAdvancedSearchServiceTests await context.SaveChangesAsync(); var dataService = new WorkOrderAdvancedSearchDataService(context); - var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context)); var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto { @@ -323,7 +323,7 @@ public class WorkOrderAdvancedSearchServiceTests DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)), DateTo = DateOnly.FromDateTime(today.AddMonths(1)), Types = new List { WorkOrderType.Other } - }, null); + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); Assert.Equal(1, result.TotalCount); Assert.Equal(2, result.Items.First().Id); @@ -366,7 +366,7 @@ public class WorkOrderAdvancedSearchServiceTests await context.SaveChangesAsync(); var dataService = new WorkOrderAdvancedSearchDataService(context); - var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context)); var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto { @@ -374,7 +374,7 @@ public class WorkOrderAdvancedSearchServiceTests DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)), DateTo = DateOnly.FromDateTime(today.AddMonths(1)), Overdue = true - }, null); + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); Assert.Equal(1, result.TotalCount); Assert.Equal(1, result.Items.First().Id); @@ -416,7 +416,7 @@ public class WorkOrderAdvancedSearchServiceTests await context.SaveChangesAsync(); var dataService = new WorkOrderAdvancedSearchDataService(context); - var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context)); var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto { @@ -425,7 +425,7 @@ public class WorkOrderAdvancedSearchServiceTests DateTo = DateOnly.FromDateTime(today.AddMonths(1)), Types = new List { WorkOrderType.Other }, Overdue = true - }, null); + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); Assert.Equal(2, result.TotalCount); Assert.Contains(result.Items, i => i.Id == 1 && i.IsPastDue); @@ -476,7 +476,7 @@ public class WorkOrderAdvancedSearchServiceTests await context.SaveChangesAsync(); var dataService = new WorkOrderAdvancedSearchDataService(context); - var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context)); var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto { @@ -486,7 +486,7 @@ public class WorkOrderAdvancedSearchServiceTests Overdue = true, Page = 0, PageSize = 50 - }, null); + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); var overdue = Assert.Single(result.Items); Assert.Equal(0, result.Page); @@ -532,7 +532,7 @@ public class WorkOrderAdvancedSearchServiceTests await context.SaveChangesAsync(); var dataService = new WorkOrderAdvancedSearchDataService(context); - var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context)); var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto { @@ -542,7 +542,7 @@ public class WorkOrderAdvancedSearchServiceTests Types = new List { WorkOrderType.PM, WorkOrderType.Other }, Page = 0, PageSize = 50 - }, null); + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); Assert.Equal(2, result.TotalCount); Assert.Equal(0, result.Page); @@ -576,14 +576,14 @@ public class WorkOrderAdvancedSearchServiceTests await context.SaveChangesAsync(); var dataService = new WorkOrderAdvancedSearchDataService(context); - var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context)); var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto { DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, DateFrom = weekStart, DateTo = weekStart.AddDays(4) - }, null); + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); Assert.Equal(2, result.TotalCount); Assert.Contains(result.Items, i => i.Id == 2); @@ -594,14 +594,14 @@ public class WorkOrderAdvancedSearchServiceTests { await using var context = CreateContext(); var dataService = new WorkOrderAdvancedSearchDataService(context); - var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context)); var ex = await Assert.ThrowsAsync(() => service.SearchAsync( new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto { DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek, SortBy = "invalid" - }, null)); + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null)); Assert.Contains("sortBy", ex.Message); } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs index 2652ccd..da348fd 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -185,12 +185,12 @@ public class WorkOrderBoardServiceTests await context.SaveChangesAsync(); var dataService = new WorkOrderBoardDataService(context); - var boardService = new WorkOrderBoardService(dataService); + var boardService = new WorkOrderBoardService(dataService, WorkOrderAccountTestHelpers.Resolver(context)); var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto { WeekStart = weekStart - }, null); + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); Assert.Equal(1, response.Counts.Total); Assert.Single(response.Scheduled); @@ -202,12 +202,12 @@ public class WorkOrderBoardServiceTests public async Task GetBoardAsync_InvalidWeekWindow_Throws() { await using var context = CreateContext(); - var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(context)); + var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(context), WorkOrderAccountTestHelpers.Resolver(context)); await Assert.ThrowsAsync(() => boardService.GetBoardAsync(new WorkOrderBoardQueryDto { WeekStart = new DateOnly(2026, 6, 22), WeekEnd = new DateOnly(2026, 6, 15) - }, null)); + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null)); } } diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 4336f45..1d8b6c9 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -93,11 +93,12 @@ public class WorkOrderDetailServiceTests .Options; var context = new ApplicationDbContext(options); var boardData = new WorkOrderBoardDataService(context); - var boardService = new WorkOrderBoardService(boardData); + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var boardService = new WorkOrderBoardService(boardData, resolver); var detailData = new WorkOrderDetailDataService(context); var templateData = new CompletionDocTemplateDataService(context); var userData = new UserDataService(context); - var service = new WorkOrderDetailService(boardService, detailData, templateData, userData); + var service = new WorkOrderDetailService(boardService, detailData, templateData, userData, resolver); return (context, service); } @@ -152,7 +153,7 @@ public class WorkOrderDetailServiceTests }); await context.SaveChangesAsync(); - var detail = await service.GetDetailAsync(1); + var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); Assert.NotNull(detail); Assert.True(detail!.Info.IsPastDue); @@ -222,7 +223,7 @@ public class WorkOrderDetailServiceTests }); await context.SaveChangesAsync(); - var audit = await service.GetAuditAsync(1); + var audit = await service.GetAuditAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); Assert.NotNull(audit); Assert.Equal(2, audit!.Count); diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs index ce50f7e..2b90e5d 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs @@ -28,10 +28,11 @@ public class WorkOrderPhase7CoexistenceTests .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; await using var context = new ApplicationDbContext(options); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "Phase7 Customer"); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); - var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); + var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context)); var payload = new WorkOrderIngestPayloadDto { @@ -39,7 +40,8 @@ public class WorkOrderPhase7CoexistenceTests Description = "Initial", WoStatus = "new", Severity = "2", - SiteCode = "BK1" + SiteCode = "BK1", + Customer = "Phase7 Customer" }; var create = await ingest.UpsertBatchAsync(new[] { payload }); @@ -66,19 +68,20 @@ public class WorkOrderPhase7CoexistenceTests var boardData = new WorkOrderBoardDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context); - var boardService = new WorkOrderBoardService(boardData); + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var boardService = new WorkOrderBoardService(boardData, resolver); 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()); + boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver); var policy = new SyncFieldMergePolicy(fieldLocks); - var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), policy, fieldLocks, audit); + var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), policy, fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context)); await createService.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5" - }, "dispatcher-1"); + }, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1"); var wo = await context.workOrders.SingleAsync(); wo.ExternalWorkOrderId = "ext-shoc-1"; @@ -126,7 +129,7 @@ public class WorkOrderPhase7CoexistenceTests var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); - var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); + var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context)); await ingest.UpsertBatchAsync(new[] { @@ -151,15 +154,21 @@ public class WorkOrderPhase7CoexistenceTests .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; await using var context = new ApplicationDbContext(options); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "Phase7 Customer"); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); - var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); + var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context)); var result = await ingest.UpsertBatchAsync(new[] { new WorkOrderIngestPayloadDto { ExternalWorkOrderId = " ", Description = "bad" }, - new WorkOrderIngestPayloadDto { ExternalWorkOrderId = "ext-ok", Description = "good" } + new WorkOrderIngestPayloadDto + { + ExternalWorkOrderId = "ext-ok", + Description = "good", + Customer = "Phase7 Customer" + } }); Assert.Equal(1, result.Created); @@ -233,7 +242,7 @@ public class WorkOrderPhase7CoexistenceTests await context.SaveChangesAsync(); var boardData = new WorkOrderBoardDataService(context); - var boardService = new WorkOrderBoardService(boardData); + var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context)); var count = await context.workOrders.CountAsync(); Assert.Equal(1, count); diff --git a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs index e98ee23..bff60ca 100644 --- a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs @@ -291,8 +291,8 @@ public class WorkOrderWeekRolledTests await service.ProcessWeekRolledAsync(SourceWeekStart); var boardData = new WorkOrderBoardDataService(context); - var boardService = new WorkOrderBoardService(boardData); - var row = await boardService.GetBoardRowAsync(1); + var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context)); + var row = await boardService.GetBoardRowAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); Assert.NotNull(row); Assert.Equal(1, row!.CarriedOver); @@ -314,8 +314,8 @@ public class WorkOrderWeekRolledTests await context.SaveChangesAsync(); var boardData = new WorkOrderBoardDataService(context); - var boardService = new WorkOrderBoardService(boardData); - var row = await boardService.GetBoardRowAsync(1); + var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context)); + var row = await boardService.GetBoardRowAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); Assert.NotNull(row); Assert.True(row!.IsPastDue); @@ -338,8 +338,8 @@ public class WorkOrderWeekRolledTests await context.SaveChangesAsync(); var boardData = new WorkOrderBoardDataService(context); - var boardService = new WorkOrderBoardService(boardData); - var row = await boardService.GetBoardRowAsync(1); + var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context)); + var row = await boardService.GetBoardRowAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); Assert.NotNull(row); Assert.False(row!.IsPastDue); diff --git a/docs/adr/0001-work-order-single-org-scope.md b/docs/adr/0001-work-order-single-org-scope.md index 6b35e78..0788c61 100644 --- a/docs/adr/0001-work-order-single-org-scope.md +++ b/docs/adr/0001-work-order-single-org-scope.md @@ -1,20 +1,18 @@ -# ADR 0001: Work-order media uses single-org scope (board-aligned) +# ADR 0001: Work-order domain uses server-derived account scope ## Status -**Superseded** (2026-08-06) by the SH-221 media slice in PR #47, with -**fail-closed** account scope (follow-up on the same PR): +**Superseded** (historical Proposed single-org ADR). Current contract (PR #47 / SH-221): -- `WorkOrder.AccountId` / `ApplicationUser.AccountId` schema keys +- `WorkOrder.AccountId` / `ApplicationUser.AccountId` schema keys (nullable; legacy null fail-closed) - JWT `account_id` when `ApplicationUser.AccountId` is set - JWT `org_scope=all` when Admin has no AccountId (explicit signed elevation) -- Media loads: `ApplyBaseScope` + `ApplyAccountScope(int)` when account-scoped; - org-wide path skips account filter +- **Reads** (board, list, advanced search, detail, media): `ApplyBaseScope` + + `ApplyAccountScope(int)` when account-scoped; org-wide path skips account filter +- **Creates** (board, AddWorkorder, ingest, webhook/recon, sync): stamp `AccountId` + from claim or unique `Accounts.Name` ↔ `Customer` match; unresolvable → reject/skip - Missing/malformed scope → **Forbidden** (absence of claim does not elevate) -Board, detail, and search outside media still use base scope only until the -remainder of [SH-221](https://luby-us.atlassian.net/browse/SH-221) lands. - ## Context (historical) SH-116 requires that cross-tenant, unauthorized, and out-of-scope media access @@ -22,34 +20,44 @@ be rejected without metadata disclosure. An interim Proposed ADR allowed org-wide staff access via absence of an account claim; that path was rejected in review (fail-open) and replaced by the contract below. -## Current media contract (superseding) +## Current domain contract (superseding) 1. **Organization boundary** = `ApplyBaseScope` (non-deleted, non-template). 2. **Account boundary** = claim `account_id` → `WorkOrder.AccountId == claim`. 3. **Org-wide** = claim `org_scope=all` only (issued to Admin without AccountId). Not inferred from missing `account_id`. 4. **Fail-closed** = no valid account or org-scope claim → Forbidden. -5. **Authorization at service entry**: staff roles may read/mutate any resulting - work order; role `User` only when `AssignTo == actorId`; delete staff-only. -6. **User lifecycle** persists `AccountId` on create/edit so non-Admin principals - can receive `account_id`. +5. **Create stamp**: + - Authenticated + `account_id` → stamp claim (ignore client AccountId). + - Authenticated + `org_scope=all` → unique Customer→Accounts.Name; else + `AccountUnresolved`. + - Ingest / webhook / sync → same Customer resolution; unresolved create is + rejected or skipped (no null AccountId on new rows). +6. **Legacy rows** with `AccountId == null` are invisible to account-scoped + callers; only `org_scope=all` may read them. +7. **Authorization at service entry**: staff roles may read/mutate any resulting + work order; role `User` only when `AssignTo == actorId` (media); delete staff-only. +8. **User lifecycle** persists `AccountId` on Admin create/edit so non-Admin + principals can receive `account_id`. ## Consequences -- Cross-account and missing-scope media tests are required. -- Dispatcher/Manager/Supervisor/User without AccountId cannot access media until - AccountId is assigned (or they are Admin with `org_scope=all`). -- Board/search/detail without account filtering remain a SH-221 follow-up. +- Cross-account and missing-scope tests are required for create and read paths. +- Dispatcher/Manager/Supervisor/User without AccountId cannot access board, + detail, search, list, or media until AccountId is assigned (or they are Admin + with `org_scope=all`). +- Locations do not carry AccountId in the EF model; Customer name match is the + unauthenticated resolution path. ## Excepted rule -None for media. Hard rule **server-derived tenant scope** -(`ARCHITECTURE_AND_CODE_QUALITY.md` §2) is enforced via claims. +None. Hard rule **server-derived tenant scope** +(`ARCHITECTURE_AND_CODE_QUALITY.md` §2) is enforced via claims + AccountId. ## Review / expiry -Re-review when SH-221 closes remaining board/search/detail account filters, or -by **2027-02-04**. +Re-review by **2027-02-04**, or when AccountId becomes non-nullable with a +full backfill migration. ## References @@ -57,5 +65,5 @@ by **2027-02-04**. - SH-221 — Server-derived tenant/customer scope for Work Order domain - PR: Sea-Haven-Industries/shoc-backend#47 - `WorkOrderBoardQueryFilters.ApplyBaseScope` / `ApplyAccountScope` -- `WorkOrderMediaAuthorization` / `SeaHavenClaimTypes` +- `IWorkOrderAccountResolver` / `WorkOrderMediaAuthorization` / `SeaHavenClaimTypes` - `ARCHITECTURE_AND_CODE_QUALITY.md` §2, §10