fix(work-orders): apply account scope across create and reads [SH-221]

Stamp WorkOrder.AccountId on all create paths and filter board/list/search/detail by server-derived account claims so scoped callers cannot cross accounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Arthur Bassi 2026-08-11 10:45:37 -03:00
parent 446e43b2c6
commit 1edcf479ae
49 changed files with 968 additions and 190 deletions

View file

@ -52,7 +52,11 @@ public class SyncServiceBehaviorTests
external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny<CancellationToken>())) external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny<CancellationToken>()))
.Returns(items.ToAsyncEnumerable()); .Returns(items.ToAsyncEnumerable());
var service = new SyncService(data.Object, external.Object); var accountResolver = new Mock<IWorkOrderAccountResolver>();
accountResolver
.Setup(r => r.TryResolveFromCustomerAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((int?)null);
var service = new SyncService(data.Object, external.Object, accountResolver.Object);
var result = await service.SyncWorkOrdersAsync(CancellationToken.None); var result = await service.SyncWorkOrdersAsync(CancellationToken.None);
Assert.Equal(2, result.Synced); Assert.Equal(2, result.Synced);
@ -81,7 +85,11 @@ public class SyncServiceBehaviorTests
}; };
external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny<CancellationToken>())).Returns(items.ToAsyncEnumerable()); external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny<CancellationToken>())).Returns(items.ToAsyncEnumerable());
await new SyncService(data.Object, external.Object).SyncWorkOrdersAsync(CancellationToken.None); var accountResolver = new Mock<IWorkOrderAccountResolver>();
accountResolver
.Setup(r => r.TryResolveFromCustomerAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((int?)null);
await new SyncService(data.Object, external.Object, accountResolver.Object).SyncWorkOrdersAsync(CancellationToken.None);
data.Verify(d => d.EnqueueWorkOrder(It.Is<WorkOrder>(w => w.Status == "Done" && w.Priority == "Sev 2" && w.Severity == "2")), Times.Once); data.Verify(d => d.EnqueueWorkOrder(It.Is<WorkOrder>(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<CancellationToken>())).Returns(items.ToAsyncEnumerable()); external.Setup(e => e.ScanCommentsAsync(It.IsAny<CancellationToken>())).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<IWorkOrderAccountResolver>()).SyncCommentsAsync(CancellationToken.None);
Assert.Equal(2, result.Synced); Assert.Equal(2, result.Synced);
Assert.Equal(1, result.Created); Assert.Equal(1, result.Created);
@ -276,6 +284,7 @@ public class WorkOrderServiceBehaviorTests
(quotesData ?? new Mock<IQuotesDataService>()).Object, (quotesData ?? new Mock<IQuotesDataService>()).Object,
Mock.Of<IFileStoragePort>(), Mock.Of<IFileStoragePort>(),
Mock.Of<ICreateWorkOrderValidation>(), Mock.Of<ICreateWorkOrderValidation>(),
Mock.Of<IUpdateWorkOrderValidation>()); Mock.Of<IUpdateWorkOrderValidation>(),
Mock.Of<IWorkOrderAccountResolver>());
} }
} }

View file

@ -48,7 +48,8 @@ public class WorkOrderControllerSearchTests
public async Task SearchBoard_WhenCustomPresetMissingDates_ReturnsBadRequest() public async Task SearchBoard_WhenCustomPresetMissingDates_ReturnsBadRequest()
{ {
var advancedSearch = new WorkOrderAdvancedSearchService( var advancedSearch = new WorkOrderAdvancedSearchService(
Mock.Of<IWorkOrderAdvancedSearchDataService>()); Mock.Of<IWorkOrderAdvancedSearchDataService>(),
Mock.Of<IWorkOrderAccountResolver>());
var controller = NewController(advancedSearch); var controller = NewController(advancedSearch);
var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto

View file

@ -75,9 +75,17 @@ namespace Api.SeaHavenIndustries.Controllers
Search = search Search = search
}; };
var result = await _workOrderBoardService.GetBoardAsync(query, userId); var result = await _workOrderBoardService.GetBoardAsync(query, User, userId);
return Ok(result); 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) catch (ArgumentException ex)
{ {
return BadRequest(ex.Message); return BadRequest(ex.Message);
@ -90,9 +98,17 @@ namespace Api.SeaHavenIndustries.Controllers
try try
{ {
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var result = await _advancedSearchService.SearchAsync(query, userId); var result = await _advancedSearchService.SearchAsync(query, User, userId);
return Ok(result); 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) catch (ArgumentException ex)
{ {
return BadRequest(new Response { Status = "Error", Message = ex.Message }); return BadRequest(new Response { Status = "Error", Message = ex.Message });
@ -154,7 +170,7 @@ namespace Api.SeaHavenIndustries.Controllers
try try
{ {
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var row = await _boardCreateService.CreateAsync(request, actorId); var row = await _boardCreateService.CreateAsync(request, User, actorId);
return Ok(row); return Ok(row);
} }
catch (ValidationException vex) catch (ValidationException vex)
@ -162,6 +178,14 @@ namespace Api.SeaHavenIndustries.Controllers
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors }); 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") catch (WorkOrderBoardValidationException ex) when (ex.Code == "DuplicateWoNumber")
{ {
return Conflict(new WorkOrderBoardValidationErrorDto return Conflict(new WorkOrderBoardValidationErrorDto
@ -194,9 +218,17 @@ namespace Api.SeaHavenIndustries.Controllers
try try
{ {
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var row = await _boardCancelService.CancelAsync(id, actorId); var row = await _boardCancelService.CancelAsync(id, User, actorId);
return Ok(row); 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") catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{ {
return NotFound(new WorkOrderBoardValidationErrorDto return NotFound(new WorkOrderBoardValidationErrorDto

View file

@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SeaHaven.DataServices.Models; using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
using System.Security.Claims; using System.Security.Claims;
@ -19,13 +20,16 @@ namespace Api.SeaHavenIndustries.Controllers
public class WorkOrderController : Controller public class WorkOrderController : Controller
{ {
private readonly IWorkOrderService _workOrderService; private readonly IWorkOrderService _workOrderService;
private readonly IWorkOrderAccountResolver _accountResolver;
private readonly ILogger<WorkOrderController> _logger; private readonly ILogger<WorkOrderController> _logger;
public WorkOrderController( public WorkOrderController(
IWorkOrderService workOrderService, IWorkOrderService workOrderService,
IWorkOrderAccountResolver accountResolver,
ILogger<WorkOrderController> logger) ILogger<WorkOrderController> logger)
{ {
_workOrderService = workOrderService; _workOrderService = workOrderService;
_accountResolver = accountResolver;
_logger = logger; _logger = logger;
} }
@ -49,10 +53,11 @@ namespace Api.SeaHavenIndustries.Controllers
Attachments = model.Attachments, Attachments = model.Attachments,
BeforPhotoAttachment = model.BeforPhotoAttachment, BeforPhotoAttachment = model.BeforPhotoAttachment,
AfterPhotoAttachment = model.AfterPhotoAttachment, 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 }); return Ok(new DataResponse { Message = "Work order created successfully", Status = "200", Data = workOrderId });
} }
catch (ValidationException vex) catch (ValidationException vex)
@ -60,6 +65,14 @@ namespace Api.SeaHavenIndustries.Controllers
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors }); 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) catch (Exception ex)
{ {
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
@ -116,19 +129,27 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet("GetWorkOrderList")] [HttpGet("GetWorkOrderList")]
public async Task<IActionResult> GetWorkOrderList(string? search = "", int page = 1, int pageSize = 12, string? sortBy = "lastUpdated", string? sortDir = "desc", string? status = "", string? assignee = "", int? locationId = null) public async Task<IActionResult> 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( try
page, pageSize, search, status, assignee, locationId, sortBy, sortDir);
var viewModel = new Pagination_DTO
{ {
Data = data, var accountId = _accountResolver.ResolveAccountFilter(User);
PageNumber = page, var (data, totalCount) = await _workOrderService.GetWorkOrderListPagedAsync(
PageSize = pageSize, page, pageSize, search, status, assignee, locationId, sortBy, sortDir, accountId);
TotalCount = totalCount,
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize)
};
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")] [Authorize(Roles = "Admin")]

View file

@ -29,19 +29,33 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet("{id:int}/detail")] [HttpGet("{id:int}/detail")]
public async Task<IActionResult> GetDetail(int id) public async Task<IActionResult> GetDetail(int id)
{ {
var detail = await _workOrderDetailService.GetDetailAsync(id); try
if (detail == null) {
return NotFound(new Response { Status = "Error", Message = "Work order not found." }); var detail = await _workOrderDetailService.GetDetailAsync(id, User);
return Ok(detail); 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")] [HttpGet("{id:int}/audit")]
public async Task<IActionResult> GetAudit(int id, [FromQuery] int limit = 50) public async Task<IActionResult> GetAudit(int id, [FromQuery] int limit = 50)
{ {
var audit = await _workOrderDetailService.GetAuditAsync(id, limit); try
if (audit == null) {
return NotFound(new Response { Status = "Error", Message = "Work order not found." }); var audit = await _workOrderDetailService.GetAuditAsync(id, User, limit);
return Ok(audit); 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")] [HttpGet("{id:int}/comments")]

View file

@ -25,6 +25,7 @@ namespace Api.SeaHavenIndustries.DTOs
public string? SignOffSignature { get; set; } public string? SignOffSignature { get; set; }
public List<int>? ContactIds { get; set; } public List<int>? ContactIds { get; set; }
public List<int>? CategoryIds { get; set; } public List<int>? CategoryIds { get; set; }
public string? Customer { get; set; }
public static WorkOrder MapToEntity(Workorder_DTO dto, string userId) public static WorkOrder MapToEntity(Workorder_DTO dto, string userId)
{ {

View file

@ -96,5 +96,25 @@ namespace SeaHaven.DataServices.Implementation
{ {
return await _context.Accounts.CountAsync(); return await _context.Accounts.CountAsync();
} }
public async Task<int?> 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;
}
} }
} }

View file

@ -17,6 +17,8 @@ namespace SeaHaven.DataServices.Implementation
public async Task<WorkOrderAdvancedSearchResult> SearchAsync(WorkOrderAdvancedSearchQuery query) public async Task<WorkOrderAdvancedSearchResult> SearchAsync(WorkOrderAdvancedSearchQuery query)
{ {
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); 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.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo);
baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites); baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites);
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue); baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue);

View file

@ -21,6 +21,8 @@ namespace SeaHaven.DataServices.Implementation
var weekEndDate = query.WeekEnd.ToDateTime(TimeOnly.MinValue); var weekEndDate = query.WeekEnd.ToDateTime(TimeOnly.MinValue);
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (query.AccountId.HasValue)
baseQuery = WorkOrderBoardQueryFilters.ApplyAccountScope(baseQuery, query.AccountId.Value);
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter( baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId); baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue); baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue);
@ -70,11 +72,12 @@ namespace SeaHaven.DataServices.Implementation
.ToListAsync(); .ToListAsync();
} }
public async Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId) public async Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId, int? accountId = null)
{ {
var query = _context.workOrders var query = WorkOrderBoardQueryFilters.ApplyBaseScope(
.AsNoTracking() _context.workOrders.AsNoTracking().Where(w => w.Id == workOrderId));
.Where(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(query, isUnscheduled: false); var rows = await WorkOrderBoardProjection.ProjectRowsAsync(query, isUnscheduled: false);
return rows.FirstOrDefault(); return rows.FirstOrDefault();

View file

@ -1,5 +1,6 @@
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models; using SeaHaven.DataServices.Models;
@ -110,7 +111,8 @@ namespace SeaHaven.DataServices.Implementation
string? assignee = null, string? assignee = null,
int? locationId = null, int? locationId = null,
string? sortBy = "lastUpdated", string? sortBy = "lastUpdated",
string? sortDir = "desc") string? sortDir = "desc",
int? accountId = null)
{ {
search ??= ""; search ??= "";
@ -119,6 +121,9 @@ namespace SeaHaven.DataServices.Implementation
.Include(w => w.AssignToUser) .Include(w => w.AssignToUser)
.Where(w => w.istemplate != true); .Where(w => w.istemplate != true);
if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
if (locationId.HasValue) if (locationId.HasValue)
query = query.Where(w => w.LocationId == locationId.Value); query = query.Where(w => w.LocationId == locationId.Value);

View file

@ -33,6 +33,13 @@ namespace SeaHaven.DataServices.Implementation
if (workOrder == null) 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); var internalNumber = await AllocateInternalNumberAsync(cancellationToken);
workOrder = new WorkOrder workOrder = new WorkOrder
{ {
@ -43,6 +50,8 @@ namespace SeaHaven.DataServices.Implementation
WorkerOrderTitle = mutation.Title ?? mutation.Description WorkerOrderTitle = mutation.Title ?? mutation.Description
?? $"Imported work order {mutation.ExternalWorkOrderId}", ?? $"Imported work order {mutation.ExternalWorkOrderId}",
Description = mutation.Description, Description = mutation.Description,
Customer = mutation.Customer,
AccountId = resolvedAccountId,
Source = mutation.Source, Source = mutation.Source,
istemplate = false, istemplate = false,
CreatedDate = mutation.CreatedAt ?? mutation.ProcessedAt.UtcDateTime CreatedDate = mutation.CreatedAt ?? mutation.ProcessedAt.UtcDateTime
@ -284,6 +293,26 @@ namespace SeaHaven.DataServices.Implementation
return true; return true;
} }
private async Task<int?> 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( private static bool IsNewer(
DateTimeOffset candidateUpdatedAt, DateTimeOffset candidateUpdatedAt,
string candidateHash, string candidateHash,

View file

@ -13,5 +13,13 @@ namespace SeaHaven.DataServices.Interfaces
Task DeleteAsync(int id); Task DeleteAsync(int id);
Task<bool> ExistsAsync(int id); Task<bool> ExistsAsync(int id);
Task<int> CountAsync(); Task<int> CountAsync();
/// <summary>
/// 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.
/// </summary>
Task<int?> TryGetUniqueActiveIdByExactNameAsync(
string? name,
CancellationToken cancellationToken = default);
} }
} }

View file

@ -6,7 +6,7 @@ namespace SeaHaven.DataServices.Interfaces
{ {
Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query); Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query);
Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync(); Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync();
Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId); Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId, int? accountId = null);
Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId); Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId);
} }
} }

View file

@ -32,7 +32,8 @@ namespace SeaHaven.DataServices.Interfaces
string? assignee = null, string? assignee = null,
int? locationId = null, int? locationId = null,
string? sortBy = "lastUpdated", string? sortBy = "lastUpdated",
string? sortDir = "desc"); string? sortDir = "desc",
int? accountId = null);
// Command operations // Command operations
Task<WorkOrder> AddAsync(WorkOrder workOrder); Task<WorkOrder> AddAsync(WorkOrder workOrder);

View file

@ -50,7 +50,8 @@ namespace SeaHaven.DataServices.Interfaces
{ {
Applied, Applied,
Duplicate, Duplicate,
HashConflict HashConflict,
AccountUnresolved
} }
public sealed record WorkOrderWebhookPersistenceResult( public sealed record WorkOrderWebhookPersistenceResult(

View file

@ -20,7 +20,8 @@ namespace SeaHaven.DataServices.Interfaces
int Page, int Page,
int PageSize, int PageSize,
string SortBy, string SortBy,
string SortDir); string SortDir,
int? AccountId = null);
public record WorkOrderAdvancedSearchResult( public record WorkOrderAdvancedSearchResult(
IReadOnlyList<WorkOrderBoardRawRow> Rows, IReadOnlyList<WorkOrderBoardRawRow> Rows,

View file

@ -10,7 +10,8 @@ namespace SeaHaven.DataServices.Interfaces
IReadOnlyList<WorkOrderType>? Types, IReadOnlyList<WorkOrderType>? Types,
bool Overdue, bool Overdue,
string? Search, string? Search,
string? CurrentUserId); string? CurrentUserId,
int? AccountId = null);
public record WorkOrderBoardRawRow( public record WorkOrderBoardRawRow(
int Id, int Id,

View file

@ -15,6 +15,11 @@ namespace SeaHaven.Services.DTOs
public string? WoNumber { get; set; } public string? WoNumber { get; set; }
public WorkOrderType WorkOrderType { get; set; } public WorkOrderType WorkOrderType { get; set; }
public string? SiteCode { get; set; } public string? SiteCode { get; set; }
/// <summary>
/// Optional CRM customer name. Required when the caller is org-wide (no account_id)
/// so AccountId can be resolved server-side.
/// </summary>
public string? Customer { get; set; }
public string? Description { get; set; } public string? Description { get; set; }
/// <summary>Legacy alias for primary service; prefer <see cref="PrimaryService"/>.</summary> /// <summary>Legacy alias for primary service; prefer <see cref="PrimaryService"/>.</summary>
public string? Trade { get; set; } public string? Trade { get; set; }

View file

@ -80,6 +80,8 @@ namespace SeaHaven.Services.DTOs
public int? LocationId { get; set; } public int? LocationId { get; set; }
public string? AssignTo { get; set; } public string? AssignTo { get; set; }
public DateTime? DueDate { get; set; } public DateTime? DueDate { get; set; }
/// <summary>Optional CRM customer name for org-wide AccountId resolution.</summary>
public string? Customer { get; set; }
public static WorkOrder MapToEntity(CreateWorkOrderDTO dto) public static WorkOrder MapToEntity(CreateWorkOrderDTO dto)
{ {
@ -94,6 +96,7 @@ namespace SeaHaven.Services.DTOs
LocationId = dto.LocationId, LocationId = dto.LocationId,
AssignTo = dto.AssignTo, AssignTo = dto.AssignTo,
DueDate = dto.DueDate, DueDate = dto.DueDate,
Customer = string.IsNullOrWhiteSpace(dto.Customer) ? null : dto.Customer.Trim(),
CreatedDate = DateTime.UtcNow CreatedDate = DateTime.UtcNow
}; };
} }

View file

@ -18,6 +18,8 @@ namespace SeaHaven.Services.DTOs
public IFormFile? BeforPhotoAttachment { get; set; } public IFormFile? BeforPhotoAttachment { get; set; }
public IFormFile? AfterPhotoAttachment { get; set; } public IFormFile? AfterPhotoAttachment { get; set; }
public IFormFile? SignOffAttachment { get; set; } public IFormFile? SignOffAttachment { get; set; }
/// <summary>Optional CRM customer name for org-wide AccountId resolution.</summary>
public string? Customer { get; set; }
} }
public class UpdateWorkOrderWithDetailsInput public class UpdateWorkOrderWithDetailsInput

View file

@ -9,11 +9,16 @@ namespace SeaHaven.Services.Implementation
{ {
private readonly ISyncDataService _data; private readonly ISyncDataService _data;
private readonly ISyncExternalSource _external; private readonly ISyncExternalSource _external;
private readonly IWorkOrderAccountResolver _accountResolver;
public SyncService(ISyncDataService data, ISyncExternalSource external) public SyncService(
ISyncDataService data,
ISyncExternalSource external,
IWorkOrderAccountResolver accountResolver)
{ {
_data = data; _data = data;
_external = external; _external = external;
_accountResolver = accountResolver;
} }
public async Task<SyncWorkOrdersResult> SyncWorkOrdersAsync(CancellationToken cancellationToken) public async Task<SyncWorkOrdersResult> SyncWorkOrdersAsync(CancellationToken cancellationToken)
@ -42,6 +47,11 @@ namespace SeaHaven.Services.Implementation
if (existing == null) 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 var wo = new WorkOrder
{ {
InternalWONumber = (nextInternal++).ToString("D8"), InternalWONumber = (nextInternal++).ToString("D8"),
@ -52,7 +62,8 @@ namespace SeaHaven.Services.Implementation
Status = MapStatus(Get(item, "wo_status")), Status = MapStatus(Get(item, "wo_status")),
Priority = MapSeverityToPriority(Get(item, "severity")), Priority = MapSeverityToPriority(Get(item, "severity")),
Severity = Get(item, "severity"), Severity = Get(item, "severity"),
Customer = Get(item, "customer"), Customer = customer,
AccountId = resolvedAccountId,
SiteCode = Get(item, "site_code"), SiteCode = Get(item, "site_code"),
Building = Get(item, "building"), Building = Get(item, "building"),
LocationId = locationId, LocationId = locationId,

View file

@ -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<int> 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<int> ResolveForUnauthenticatedCreateAsync(
string? customer,
CancellationToken cancellationToken = default)
=> ResolveRequiredFromCustomerAsync(customer, cancellationToken);
public Task<int?> TryResolveFromCustomerAsync(
string? customer,
CancellationToken cancellationToken = default)
=> _accounts.TryGetUniqueActiveIdByExactNameAsync(customer, cancellationToken);
private async Task<int> 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.");
}
}
}

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
@ -12,16 +13,22 @@ namespace SeaHaven.Services.Implementation
private const int MaxPageSize = 100; private const int MaxPageSize = 100;
private readonly IWorkOrderAdvancedSearchDataService _searchDataService; private readonly IWorkOrderAdvancedSearchDataService _searchDataService;
private readonly IWorkOrderAccountResolver _accountResolver;
public WorkOrderAdvancedSearchService(IWorkOrderAdvancedSearchDataService searchDataService) public WorkOrderAdvancedSearchService(
IWorkOrderAdvancedSearchDataService searchDataService,
IWorkOrderAccountResolver accountResolver)
{ {
_searchDataService = searchDataService; _searchDataService = searchDataService;
_accountResolver = accountResolver;
} }
public async Task<PagedResult<WorkOrderBoardRowDto>> SearchAsync( public async Task<PagedResult<WorkOrderBoardRowDto>> SearchAsync(
WorkOrderAdvancedSearchQueryDto query, WorkOrderAdvancedSearchQueryDto query,
ClaimsPrincipal user,
string? currentUserId) string? currentUserId)
{ {
var accountId = _accountResolver.ResolveAccountFilter(user);
var page = query.Page < 0 ? 0 : query.Page; var page = query.Page < 0 ? 0 : query.Page;
var pageSize = query.PageSize < 1 ? DefaultPageSize : Math.Min(query.PageSize, MaxPageSize); var pageSize = query.PageSize < 1 ? DefaultPageSize : Math.Min(query.PageSize, MaxPageSize);
@ -49,7 +56,8 @@ namespace SeaHaven.Services.Implementation
page, page,
pageSize, pageSize,
sortBy, sortBy,
query.SortDir ?? "asc"); query.SortDir ?? "asc",
accountId);
var result = await _searchDataService.SearchAsync(dataQuery); var result = await _searchDataService.SearchAsync(dataQuery);
var utcNow = DateTime.UtcNow; var utcNow = DateTime.UtcNow;

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
@ -23,7 +24,10 @@ namespace SeaHaven.Services.Implementation
_auditService = auditService; _auditService = auditService;
} }
public async Task<WorkOrderBoardRowDto> CancelAsync(int workOrderId, string? actorId) public async Task<WorkOrderBoardRowDto> CancelAsync(
int workOrderId,
ClaimsPrincipal user,
string? actorId)
{ {
var workOrder = await _mutationData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); var workOrder = await _mutationData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None);
@ -32,7 +36,7 @@ namespace SeaHaven.Services.Implementation
if (workOrder.LifecycleStatus == LifecycleStatus.Canceled) 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."); 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 _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId);
await _mutationData.SaveAsync(CancellationToken.None); 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."); return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
} }
} }

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
@ -18,27 +19,38 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderBoardService _boardService; private readonly IWorkOrderBoardService _boardService;
private readonly IWorkOrderAuditService _auditService; private readonly IWorkOrderAuditService _auditService;
private readonly IWorkOrderBoardCreateValidation _validator; private readonly IWorkOrderBoardCreateValidation _validator;
private readonly IWorkOrderAccountResolver _accountResolver;
public WorkOrderBoardCreateService( public WorkOrderBoardCreateService(
IWorkOrderBoardDataService boardDataService, IWorkOrderBoardDataService boardDataService,
IWorkOrderBoardMutationDataService mutationData, IWorkOrderBoardMutationDataService mutationData,
IWorkOrderBoardService boardService, IWorkOrderBoardService boardService,
IWorkOrderAuditService auditService, IWorkOrderAuditService auditService,
IWorkOrderBoardCreateValidation validator) IWorkOrderBoardCreateValidation validator,
IWorkOrderAccountResolver accountResolver)
{ {
_boardDataService = boardDataService; _boardDataService = boardDataService;
_mutationData = mutationData; _mutationData = mutationData;
_boardService = boardService; _boardService = boardService;
_auditService = auditService; _auditService = auditService;
_validator = validator; _validator = validator;
_accountResolver = accountResolver;
} }
public async Task<WorkOrderBoardRowDto> CreateAsync(WorkOrderBoardCreateRequestDto request, string? actorId) public async Task<WorkOrderBoardRowDto> CreateAsync(
WorkOrderBoardCreateRequestDto request,
ClaimsPrincipal user,
string? actorId)
{ {
var validationResult = await _validator.ValidateAsync(request); var validationResult = await _validator.ValidateAsync(request);
if (!validationResult.IsValid) if (!validationResult.IsValid)
throw new ValidationException(validationResult.Errors); throw new ValidationException(validationResult.Errors);
var accountId = await _accountResolver.ResolveForAuthenticatedCreateAsync(
user,
request.Customer,
CancellationToken.None);
var woNumber = await ResolveWoNumberAsync(request.WoNumber); var woNumber = await ResolveWoNumberAsync(request.WoNumber);
var siteCode = request.SiteCode!.Trim(); var siteCode = request.SiteCode!.Trim();
var primaryService = ResolvePrimaryService(request); var primaryService = ResolvePrimaryService(request);
@ -49,6 +61,7 @@ namespace SeaHaven.Services.Implementation
var pocNotes = TrimOrNull(request.PocNotes); var pocNotes = TrimOrNull(request.PocNotes);
var techPhone = TrimOrNull(request.TechPhone); var techPhone = TrimOrNull(request.TechPhone);
var vendorNotes = TrimOrNull(request.VendorNotes); var vendorNotes = TrimOrNull(request.VendorNotes);
var customer = TrimOrNull(request.Customer);
var hasVendorDispatch = request.VendorId.HasValue var hasVendorDispatch = request.VendorId.HasValue
|| request.ApptDate.HasValue || request.ApptDate.HasValue
|| !string.IsNullOrWhiteSpace(request.ApptTime); || !string.IsNullOrWhiteSpace(request.ApptTime);
@ -65,6 +78,8 @@ namespace SeaHaven.Services.Implementation
WorkerOrderNumber = woNumber, WorkerOrderNumber = woNumber,
WorkOrderType = request.WorkOrderType, WorkOrderType = request.WorkOrderType,
SiteCode = siteCode, SiteCode = siteCode,
Customer = customer,
AccountId = accountId,
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
Trade = primaryService, Trade = primaryService,
ExtraServices = extraServicesJson, ExtraServices = extraServicesJson,
@ -197,7 +212,7 @@ namespace SeaHaven.Services.Implementation
await _mutationData.SaveAsync(ct); await _mutationData.SaveAsync(ct);
}, CancellationToken.None); }, 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."); return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
} }

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
@ -9,16 +10,22 @@ namespace SeaHaven.Services.Implementation
public class WorkOrderBoardService : IWorkOrderBoardService public class WorkOrderBoardService : IWorkOrderBoardService
{ {
private readonly IWorkOrderBoardDataService _boardDataService; private readonly IWorkOrderBoardDataService _boardDataService;
private readonly IWorkOrderAccountResolver _accountResolver;
public WorkOrderBoardService(IWorkOrderBoardDataService boardDataService) public WorkOrderBoardService(
IWorkOrderBoardDataService boardDataService,
IWorkOrderAccountResolver accountResolver)
{ {
_boardDataService = boardDataService; _boardDataService = boardDataService;
_accountResolver = accountResolver;
} }
public async Task<WorkOrderBoardResponseDto> GetBoardAsync( public async Task<WorkOrderBoardResponseDto> GetBoardAsync(
WorkOrderBoardQueryDto query, WorkOrderBoardQueryDto query,
ClaimsPrincipal user,
string? currentUserId) string? currentUserId)
{ {
var accountId = _accountResolver.ResolveAccountFilter(user);
var weekStart = query.WeekStart; var weekStart = query.WeekStart;
var weekEnd = query.WeekEnd ?? weekStart.AddDays(4); var weekEnd = query.WeekEnd ?? weekStart.AddDays(4);
@ -34,7 +41,8 @@ namespace SeaHaven.Services.Implementation
query.Types, query.Types,
query.Overdue, query.Overdue,
query.Search, query.Search,
currentUserId); currentUserId,
accountId);
var result = await _boardDataService.GetBoardRowsAsync(dataQuery); var result = await _boardDataService.GetBoardRowsAsync(dataQuery);
var utcNow = DateTime.UtcNow; var utcNow = DateTime.UtcNow;
@ -65,9 +73,10 @@ namespace SeaHaven.Services.Implementation
}).ToList(); }).ToList();
} }
public async Task<WorkOrderBoardRowDto?> GetBoardRowAsync(int workOrderId) public async Task<WorkOrderBoardRowDto?> 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); return raw == null ? null : MapRawRow(raw, DateTime.UtcNow);
} }

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers; using SeaHaven.Services.Helpers;
@ -11,25 +12,29 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderDetailDataService _detailData; private readonly IWorkOrderDetailDataService _detailData;
private readonly ICompletionDocTemplateDataService _templateData; private readonly ICompletionDocTemplateDataService _templateData;
private readonly IUserDataService _userDataService; private readonly IUserDataService _userDataService;
private readonly IWorkOrderAccountResolver _accountResolver;
public WorkOrderDetailService( public WorkOrderDetailService(
IWorkOrderBoardService boardService, IWorkOrderBoardService boardService,
IWorkOrderDetailDataService detailData, IWorkOrderDetailDataService detailData,
ICompletionDocTemplateDataService templateData, ICompletionDocTemplateDataService templateData,
IUserDataService userDataService) IUserDataService userDataService,
IWorkOrderAccountResolver accountResolver)
{ {
_boardService = boardService; _boardService = boardService;
_detailData = detailData; _detailData = detailData;
_templateData = templateData; _templateData = templateData;
_userDataService = userDataService; _userDataService = userDataService;
_accountResolver = accountResolver;
} }
public async Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId) public async Task<WorkOrderDetailDto?> 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; return null;
var boardRow = await _boardService.GetBoardRowAsync(workOrderId); var boardRow = await _boardService.GetBoardRowAsync(workOrderId, user);
if (boardRow == null) if (boardRow == null)
return null; return null;
@ -38,7 +43,8 @@ namespace SeaHaven.Services.Implementation
var audit = await _detailData.GetAuditLogsAsync(workOrderId, 50); var audit = await _detailData.GetAuditLogsAsync(workOrderId, 50);
var attachments = await _detailData.GetAttachmentsAsync(workOrderId); var attachments = await _detailData.GetAttachmentsAsync(workOrderId);
var signoffs = await _detailData.GetDispatchSignoffsAsync(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( var template = await _templateData.ResolveForWorkOrderAsync(
extended?.Trade ?? boardRow.Pm, extended?.Trade ?? boardRow.Pm,
@ -59,9 +65,13 @@ namespace SeaHaven.Services.Implementation
}; };
} }
public async Task<IReadOnlyList<WorkOrderAuditEntryDto>?> GetAuditAsync(int workOrderId, int limit = 50) public async Task<IReadOnlyList<WorkOrderAuditEntryDto>?> 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; return null;
var logs = await _detailData.GetAuditLogsAsync(workOrderId, limit); var logs = await _detailData.GetAuditLogsAsync(workOrderId, limit);

View file

@ -12,17 +12,20 @@ namespace SeaHaven.Services.Implementation
private readonly ISyncFieldMergePolicy _mergePolicy; private readonly ISyncFieldMergePolicy _mergePolicy;
private readonly IWorkOrderFieldLockService _fieldLocks; private readonly IWorkOrderFieldLockService _fieldLocks;
private readonly IWorkOrderAuditService _audit; private readonly IWorkOrderAuditService _audit;
private readonly IWorkOrderAccountResolver _accountResolver;
public WorkOrderIngestService( public WorkOrderIngestService(
IWorkOrderIngestDataService ingestData, IWorkOrderIngestDataService ingestData,
ISyncFieldMergePolicy mergePolicy, ISyncFieldMergePolicy mergePolicy,
IWorkOrderFieldLockService fieldLocks, IWorkOrderFieldLockService fieldLocks,
IWorkOrderAuditService audit) IWorkOrderAuditService audit,
IWorkOrderAccountResolver accountResolver)
{ {
_ingestData = ingestData; _ingestData = ingestData;
_mergePolicy = mergePolicy; _mergePolicy = mergePolicy;
_fieldLocks = fieldLocks; _fieldLocks = fieldLocks;
_audit = audit; _audit = audit;
_accountResolver = accountResolver;
} }
public async Task<WorkOrderIngestBatchResultDto> UpsertBatchAsync( public async Task<WorkOrderIngestBatchResultDto> UpsertBatchAsync(
@ -45,13 +48,21 @@ namespace SeaHaven.Services.Implementation
continue; continue;
} }
var (upsert, updatedSeed) = await UpsertOneAsync(item, nextSeed, cancellationToken); try
nextSeed = updatedSeed; {
result.Results.Add(upsert); var (upsert, updatedSeed) = await UpsertOneAsync(item, nextSeed, cancellationToken);
if (upsert.Created) nextSeed = updatedSeed;
result.Created++; result.Results.Add(upsert);
else if (upsert.Created)
result.Updated++; result.Created++;
else
result.Updated++;
}
catch (Exceptions.WorkOrderBoardValidationException ex)
when (ex.Code == "AccountUnresolved")
{
result.Skipped++;
}
} }
await _ingestData.SaveAsync(cancellationToken); await _ingestData.SaveAsync(cancellationToken);
@ -72,6 +83,8 @@ namespace SeaHaven.Services.Implementation
if (existing == null) if (existing == null)
{ {
var accountId = await _accountResolver.ResolveForUnauthenticatedCreateAsync(
item.Customer, cancellationToken);
var (woNumber, updatedSeed) = AllocateInternalWoNumber(nextSeed); var (woNumber, updatedSeed) = AllocateInternalWoNumber(nextSeed);
var wo = new WorkOrder var wo = new WorkOrder
{ {
@ -84,6 +97,7 @@ namespace SeaHaven.Services.Implementation
Priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity), Priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity),
Severity = item.Severity, Severity = item.Severity,
Customer = item.Customer, Customer = item.Customer,
AccountId = accountId,
SiteCode = item.SiteCode, SiteCode = item.SiteCode,
Building = item.Building, Building = item.Building,
LocationId = locationId, LocationId = locationId,

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using FluentValidation; using FluentValidation;
@ -18,6 +19,7 @@ namespace SeaHaven.Services.Implementation
private readonly IFileStoragePort _fileStorage; private readonly IFileStoragePort _fileStorage;
private readonly ICreateWorkOrderValidation _createValidator; private readonly ICreateWorkOrderValidation _createValidator;
private readonly IUpdateWorkOrderValidation _updateValidator; private readonly IUpdateWorkOrderValidation _updateValidator;
private readonly IWorkOrderAccountResolver _accountResolver;
public WorkOrderService( public WorkOrderService(
IWorkOrderDataService workOrderDataService, IWorkOrderDataService workOrderDataService,
@ -26,7 +28,8 @@ namespace SeaHaven.Services.Implementation
IQuotesDataService quotesDataService, IQuotesDataService quotesDataService,
IFileStoragePort fileStorage, IFileStoragePort fileStorage,
ICreateWorkOrderValidation createValidator, ICreateWorkOrderValidation createValidator,
IUpdateWorkOrderValidation updateValidator) IUpdateWorkOrderValidation updateValidator,
IWorkOrderAccountResolver accountResolver)
{ {
_workOrderDataService = workOrderDataService; _workOrderDataService = workOrderDataService;
_commentDataService = commentDataService; _commentDataService = commentDataService;
@ -35,6 +38,7 @@ namespace SeaHaven.Services.Implementation
_fileStorage = fileStorage; _fileStorage = fileStorage;
_createValidator = createValidator; _createValidator = createValidator;
_updateValidator = updateValidator; _updateValidator = updateValidator;
_accountResolver = accountResolver;
} }
public async Task<WorkOrderDTO?> GetWorkOrderByIdAsync(int id) public async Task<WorkOrderDTO?> GetWorkOrderByIdAsync(int id)
@ -67,13 +71,17 @@ namespace SeaHaven.Services.Implementation
}; };
} }
public async Task<int> CreateWorkOrderAsync(CreateWorkOrderDTO dto, string userId) public async Task<int> CreateWorkOrderAsync(CreateWorkOrderDTO dto, ClaimsPrincipal user, string userId)
{ {
var validationResult = await _createValidator.ValidateAsync(dto); var validationResult = await _createValidator.ValidateAsync(dto);
if (!validationResult.IsValid) if (!validationResult.IsValid)
throw new ValidationException(validationResult.Errors); throw new ValidationException(validationResult.Errors);
var accountId = await _accountResolver.ResolveForAuthenticatedCreateAsync(
user, dto.Customer, CancellationToken.None);
var workOrder = CreateWorkOrderDTO.MapToEntity(dto); var workOrder = CreateWorkOrderDTO.MapToEntity(dto);
workOrder.AccountId = accountId;
workOrder.createdby = userId; workOrder.createdby = userId;
workOrder.CreatedDate = DateTime.UtcNow; workOrder.CreatedDate = DateTime.UtcNow;
workOrder.InternalWONumber = await _workOrderDataService.GenerateNextInternalWONumberAsync(); workOrder.InternalWONumber = await _workOrderDataService.GenerateNextInternalWONumberAsync();
@ -124,10 +132,10 @@ namespace SeaHaven.Services.Implementation
public async Task<(IEnumerable<WorkOrderListItemReadModel> Items, int TotalCount)> GetWorkOrderListPagedAsync( public async Task<(IEnumerable<WorkOrderListItemReadModel> Items, int TotalCount)> GetWorkOrderListPagedAsync(
int page, int pageSize, string? search, string? status, string? assignee, 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( return await _workOrderDataService.GetWorkOrderListPagedAsync(
page, pageSize, search, status, assignee, locationId, sortBy, sortDir); page, pageSize, search, status, assignee, locationId, sortBy, sortDir, accountId);
} }
public async Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id) public async Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id)
@ -354,7 +362,10 @@ namespace SeaHaven.Services.Implementation
}; };
} }
public async Task<int> CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, string userId) public async Task<int> CreateWorkOrderWithDetailsAsync(
CreateWorkOrderWithDetailsInput input,
ClaimsPrincipal user,
string userId)
{ {
var createDto = new CreateWorkOrderDTO var createDto = new CreateWorkOrderDTO
{ {
@ -364,10 +375,11 @@ namespace SeaHaven.Services.Implementation
Priority = Enum.TryParse<WorkOrderPriority>(input.Priority, out var p) ? p : WorkOrderPriority.Medium, Priority = Enum.TryParse<WorkOrderPriority>(input.Priority, out var p) ? p : WorkOrderPriority.Medium,
LocationId = input.LocationId, LocationId = input.LocationId,
AssignTo = input.AssignTo, 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()) if (input.ContactIds != null && input.ContactIds.Any())
await _workOrderDataService.AddWorkOrderContactsAsync(workOrderId, input.ContactIds); await _workOrderDataService.AddWorkOrderContactsAsync(workOrderId, input.ContactIds);

View file

@ -161,6 +161,12 @@ namespace SeaHaven.Services.Implementation
"Work-order webhook delivery {DeliveryId} conflicts with an existing delivery.", "Work-order webhook delivery {DeliveryId} conflicts with an existing delivery.",
mutation!.DeliveryId); mutation!.DeliveryId);
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.HashConflict); 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: default:
throw new InvalidOperationException("Unknown persistence result."); throw new InvalidOperationException("Unknown persistence result.");
} }

View file

@ -0,0 +1,40 @@
using System.Security.Claims;
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// Server-derived work-order account scope (SH-221): claims for authenticated callers,
/// unique Accounts.Name ↔ Customer for org-wide / unauthenticated creates.
/// </summary>
public interface IWorkOrderAccountResolver
{
/// <summary>
/// Account filter for reads. Null = org-wide (skip ApplyAccountScope).
/// Throws Forbidden when scope is Missing.
/// </summary>
int? ResolveAccountFilter(ClaimsPrincipal user);
/// <summary>
/// Authenticated create: claim account_id, or org-wide Customer unique match.
/// Missing scope → Forbidden. Unresolvable org-wide Customer → AccountUnresolved.
/// </summary>
Task<int> ResolveForAuthenticatedCreateAsync(
ClaimsPrincipal user,
string? customer,
CancellationToken cancellationToken = default);
/// <summary>
/// Ingest/webhook/sync create: unique Customer → Accounts.Id or AccountUnresolved.
/// </summary>
Task<int> ResolveForUnauthenticatedCreateAsync(
string? customer,
CancellationToken cancellationToken = default);
/// <summary>
/// Best-effort Customer lookup (no throw). Null when blank, missing, or ambiguous.
/// </summary>
Task<int?> TryResolveFromCustomerAsync(
string? customer,
CancellationToken cancellationToken = default);
}
}

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces namespace SeaHaven.Services.Interfaces
@ -6,6 +7,7 @@ namespace SeaHaven.Services.Interfaces
{ {
Task<PagedResult<WorkOrderBoardRowDto>> SearchAsync( Task<PagedResult<WorkOrderBoardRowDto>> SearchAsync(
WorkOrderAdvancedSearchQueryDto query, WorkOrderAdvancedSearchQueryDto query,
ClaimsPrincipal user,
string? currentUserId); string? currentUserId);
} }
} }

View file

@ -1,9 +1,10 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces namespace SeaHaven.Services.Interfaces
{ {
public interface IWorkOrderBoardCancelService public interface IWorkOrderBoardCancelService
{ {
Task<WorkOrderBoardRowDto> CancelAsync(int workOrderId, string? actorId); Task<WorkOrderBoardRowDto> CancelAsync(int workOrderId, ClaimsPrincipal user, string? actorId);
} }
} }

View file

@ -1,9 +1,13 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces namespace SeaHaven.Services.Interfaces
{ {
public interface IWorkOrderBoardCreateService public interface IWorkOrderBoardCreateService
{ {
Task<WorkOrderBoardRowDto> CreateAsync(WorkOrderBoardCreateRequestDto request, string? actorId); Task<WorkOrderBoardRowDto> CreateAsync(
WorkOrderBoardCreateRequestDto request,
ClaimsPrincipal user,
string? actorId);
} }
} }

View file

@ -1,11 +1,15 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces namespace SeaHaven.Services.Interfaces
{ {
public interface IWorkOrderBoardService public interface IWorkOrderBoardService
{ {
Task<WorkOrderBoardResponseDto> GetBoardAsync(WorkOrderBoardQueryDto query, string? currentUserId); Task<WorkOrderBoardResponseDto> GetBoardAsync(
WorkOrderBoardQueryDto query,
ClaimsPrincipal user,
string? currentUserId);
Task<IReadOnlyList<DispatcherLookupDto>> GetDispatcherLookupsAsync(); Task<IReadOnlyList<DispatcherLookupDto>> GetDispatcherLookupsAsync();
Task<WorkOrderBoardRowDto?> GetBoardRowAsync(int workOrderId); Task<WorkOrderBoardRowDto?> GetBoardRowAsync(int workOrderId, ClaimsPrincipal user);
} }
} }

View file

@ -1,10 +1,14 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces namespace SeaHaven.Services.Interfaces
{ {
public interface IWorkOrderDetailService public interface IWorkOrderDetailService
{ {
Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId); Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId, ClaimsPrincipal user);
Task<IReadOnlyList<WorkOrderAuditEntryDto>?> GetAuditAsync(int workOrderId, int limit = 50); Task<IReadOnlyList<WorkOrderAuditEntryDto>?> GetAuditAsync(
int workOrderId,
ClaimsPrincipal user,
int limit = 50);
} }
} }

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@ -12,7 +13,7 @@ namespace SeaHaven.Services.Interfaces
Task<WorkOrderDTO?> GetWorkOrderByIdWithDetailsAsync(int id); Task<WorkOrderDTO?> GetWorkOrderByIdWithDetailsAsync(int id);
Task<IEnumerable<WorkOrderDTO>> GetAllWorkOrdersAsync(); Task<IEnumerable<WorkOrderDTO>> GetAllWorkOrdersAsync();
Task<PagedResult<WorkOrderDTO>> GetWorkOrdersPagedAsync(int page, int pageSize, string? search = null, string? status = null, string? priority = null); Task<PagedResult<WorkOrderDTO>> GetWorkOrdersPagedAsync(int page, int pageSize, string? search = null, string? status = null, string? priority = null);
Task<int> CreateWorkOrderAsync(CreateWorkOrderDTO dto, string userId); Task<int> CreateWorkOrderAsync(CreateWorkOrderDTO dto, ClaimsPrincipal user, string userId);
Task<bool> UpdateWorkOrderAsync(int id, UpdateWorkOrderDTO dto, string userId); Task<bool> UpdateWorkOrderAsync(int id, UpdateWorkOrderDTO dto, string userId);
Task<bool> DeleteWorkOrderAsync(int id, string userId); Task<bool> DeleteWorkOrderAsync(int id, string userId);
Task<bool> WorkOrderExistsAsync(int id); Task<bool> WorkOrderExistsAsync(int id);
@ -21,7 +22,7 @@ namespace SeaHaven.Services.Interfaces
Task<(IEnumerable<WorkOrderListItemReadModel> Items, int TotalCount)> GetWorkOrderListPagedAsync( Task<(IEnumerable<WorkOrderListItemReadModel> Items, int TotalCount)> GetWorkOrderListPagedAsync(
int page, int pageSize, string? search, string? status, string? assignee, 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<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id); Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id);
Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrdersAsync( Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrdersAsync(
string[] assignto, int[]? location, string[]? priority, 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); string[]? status, string[]? duedate, string search, string sort, string sortby, int page, int pageSize);
Task<WorkOrderDTO?> ChangeStatusAsync(int id, string status, string userId); Task<WorkOrderDTO?> ChangeStatusAsync(int id, string status, string userId);
Task<ChangeAssignmentResult?> ChangeAssignmentAsync(int id, string? assignTo, string userId); Task<ChangeAssignmentResult?> ChangeAssignmentAsync(int id, string? assignTo, string userId);
Task<int> CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, string userId); Task<int> CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, ClaimsPrincipal user, string userId);
Task<bool> UpdateWorkOrderWithDetailsAsync(UpdateWorkOrderWithDetailsInput input, string userId); Task<bool> UpdateWorkOrderWithDetailsAsync(UpdateWorkOrderWithDetailsInput input, string userId);
Task<bool> DeleteWorkOrderCascadeAsync(int id, string userId); Task<bool> DeleteWorkOrderCascadeAsync(int id, string userId);
Task<Comments> AddCommentAsync(AddCommentInput input, string userId); Task<Comments> AddCommentAsync(AddCommentInput input, string userId);

View file

@ -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<ApplicationDbContext>()
.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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
resolver.ResolveAccountFilter(WorkOrderAccountTestHelpers.MissingScope()));
Assert.Equal("Forbidden", ex.Code);
}
}

View file

@ -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<Claim>
{
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<Claim>
{
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<Claim>
{
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();
}
}

View file

@ -18,7 +18,7 @@ public class WorkOrderBoardCancelServiceTests
var context = new ApplicationDbContext(options); var context = new ApplicationDbContext(options);
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(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 fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit); var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit);
@ -44,7 +44,7 @@ public class WorkOrderBoardCancelServiceTests
context.workOrders.Add(wo); context.workOrders.Add(wo);
await context.SaveChangesAsync(); 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(LifecycleStatus.Canceled, result.LifecycleStatus);
Assert.Equal("Canceled", result.LifecycleStatusLabel); Assert.Equal("Canceled", result.LifecycleStatusLabel);
@ -65,7 +65,7 @@ public class WorkOrderBoardCancelServiceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var before = await context.WorkOrderAuditLogs.CountAsync(); 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(); var after = await context.WorkOrderAuditLogs.CountAsync();
Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus); Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus);
@ -84,7 +84,7 @@ public class WorkOrderBoardCancelServiceTests
}); });
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => cancel.CancelAsync(1, "actor-1")); var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"));
Assert.Equal("CancelNotAllowed", ex.Code); Assert.Equal("CancelNotAllowed", ex.Code);
} }
@ -100,7 +100,7 @@ public class WorkOrderBoardCancelServiceTests
}); });
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => cancel.CancelAsync(1, "actor-1")); var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"));
Assert.Equal("CancelNotAllowed", ex.Code); Assert.Equal("CancelNotAllowed", ex.Code);
} }
@ -117,7 +117,7 @@ public class WorkOrderBoardCancelServiceTests
}); });
await context.SaveChangesAsync(); 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"); var log = await context.WorkOrderAuditLogs.SingleAsync(l => l.Action == "StatusChanged");
Assert.Equal("Canceled", log.NewValue); Assert.Equal("Canceled", log.NewValue);
@ -137,7 +137,7 @@ public class WorkOrderBoardCancelServiceTests
context.workOrders.Add(wo); context.workOrders.Add(wo);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
await cancel.CancelAsync(1, "actor-1"); await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1");
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
update.PatchFieldAsync(1, new SeaHaven.Services.DTOs.WorkOrderBoardPatchRequestDto update.PatchFieldAsync(1, new SeaHaven.Services.DTOs.WorkOrderBoardPatchRequestDto

View file

@ -34,10 +34,12 @@ public class WorkOrderBoardCreateRelationalTests
NormalizedEmail = "ACTOR@TEST.LOCAL" NormalizedEmail = "ACTOR@TEST.LOCAL"
}); });
await context.SaveChangesAsync(); await context.SaveChangesAsync();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(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 fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderBoardCreateService( var service = new WorkOrderBoardCreateService(
@ -45,14 +47,15 @@ public class WorkOrderBoardCreateRelationalTests
mutationData, mutationData,
boardService, boardService,
audit, audit,
new WorkOrderBoardCreateValidation()); new WorkOrderBoardCreateValidation(),
resolver);
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
Description = "Relational create" Description = "Relational create"
}, actorId); }, WorkOrderAccountTestHelpers.AccountUser(actorId), actorId);
Assert.True(result.Id > 0); Assert.True(result.Id > 0);
@ -91,10 +94,12 @@ public class WorkOrderBoardCreateRelationalTests
NormalizedEmail = "ACTOR@TEST.LOCAL" NormalizedEmail = "ACTOR@TEST.LOCAL"
}); });
await context.SaveChangesAsync(); await context.SaveChangesAsync();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(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 fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderBoardCreateService( var service = new WorkOrderBoardCreateService(
@ -102,7 +107,8 @@ public class WorkOrderBoardCreateRelationalTests
mutationData, mutationData,
boardService, boardService,
audit, audit,
new WorkOrderBoardCreateValidation()); new WorkOrderBoardCreateValidation(),
resolver);
await Assert.ThrowsAsync<DbUpdateException>(() => await Assert.ThrowsAsync<DbUpdateException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto service.CreateAsync(new WorkOrderBoardCreateRequestDto
@ -111,7 +117,7 @@ public class WorkOrderBoardCreateRelationalTests
SiteCode = "BK5", SiteCode = "BK5",
Description = "Should roll back", Description = "Should roll back",
PocContactId = 999_999 PocContactId = 999_999
}, actorId)); }, WorkOrderAccountTestHelpers.AccountUser(actorId), actorId));
// Fresh context on the same connection so we observe committed state only. // Fresh context on the same connection so we observe committed state only.
await using var verify = new SqliteBoardTestDbContext(options); await using var verify = new SqliteBoardTestDbContext(options);

View file

@ -19,11 +19,12 @@ public class WorkOrderBoardCreateServiceTests
var context = new ApplicationDbContext(options); var context = new ApplicationDbContext(options);
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(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 fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var validator = new WorkOrderBoardCreateValidation(); 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); return (context, service);
} }
@ -36,7 +37,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5"
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus); Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus);
Assert.Equal("BK5", result.SiteCode); Assert.Equal("BK5", result.SiteCode);
@ -55,7 +56,7 @@ public class WorkOrderBoardCreateServiceTests
WoNumber = "12345", WoNumber = "12345",
WorkOrderType = WorkOrderType.PO, WorkOrderType = WorkOrderType.PO,
SiteCode = "DAL" SiteCode = "DAL"
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal("00000012345", result.WoNumber); Assert.Equal("00000012345", result.WoNumber);
} }
@ -73,7 +74,7 @@ public class WorkOrderBoardCreateServiceTests
WoNumber = "99999", WoNumber = "99999",
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5"
}, "actor-1")); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
Assert.Equal("DuplicateWoNumber", ex.Code); Assert.Equal("DuplicateWoNumber", ex.Code);
} }
@ -89,7 +90,7 @@ public class WorkOrderBoardCreateServiceTests
SiteCode = "BK5", SiteCode = "BK5",
AssignTo = "dispatcher-1", AssignTo = "dispatcher-1",
ScheduledDate = new DateTime(2026, 6, 25) ScheduledDate = new DateTime(2026, 6, 25)
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus); Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate); Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate);
@ -106,7 +107,7 @@ public class WorkOrderBoardCreateServiceTests
SiteCode = "BK5", SiteCode = "BK5",
ScheduleWeekOnly = true, ScheduleWeekOnly = true,
TargetWeek = new DateOnly(2026, 6, 22) TargetWeek = new DateOnly(2026, 6, 22)
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.True(result.ScheduleWeekOnly); Assert.True(result.ScheduleWeekOnly);
Assert.Equal(new DateOnly(2026, 6, 22), result.TargetWeek); Assert.Equal(new DateOnly(2026, 6, 22), result.TargetWeek);
@ -123,7 +124,7 @@ public class WorkOrderBoardCreateServiceTests
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
ScheduleWeekOnly = true ScheduleWeekOnly = true
}, "actor-1")); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
} }
[Fact] [Fact]
@ -139,7 +140,7 @@ public class WorkOrderBoardCreateServiceTests
SiteCode = "BK5", SiteCode = "BK5",
VendorId = 5, VendorId = 5,
ApptDate = new DateTime(2026, 6, 26) ApptDate = new DateTime(2026, 6, 26)
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(5, result.VendorId); Assert.Equal(5, result.VendorId);
Assert.NotNull(result.PrimaryDispatchId); Assert.NotNull(result.PrimaryDispatchId);
@ -155,7 +156,7 @@ public class WorkOrderBoardCreateServiceTests
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
Description = "Test WO" Description = "Test WO"
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
var locks = await context.WorkOrderFieldLocks.ToListAsync(); var locks = await context.WorkOrderFieldLocks.ToListAsync();
Assert.Contains(locks, l => l.FieldName == "SiteCode"); Assert.Contains(locks, l => l.FieldName == "SiteCode");
@ -173,7 +174,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5"
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
var logs = await context.WorkOrderAuditLogs.ToListAsync(); var logs = await context.WorkOrderAuditLogs.ToListAsync();
Assert.Contains(logs, l => l.Action == "Create"); Assert.Contains(logs, l => l.Action == "Create");
@ -194,7 +195,7 @@ public class WorkOrderBoardCreateServiceTests
PrimaryService = "HVAC PM", PrimaryService = "HVAC PM",
ExtraServices = new List<string> { "Filter change", "Coil clean", "Filter change" }, ExtraServices = new List<string> { "Filter change", "Coil clean", "Filter change" },
ServiceNotes = "Unit on roof" ServiceNotes = "Unit on roof"
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal("HVAC PM", result.Pm); Assert.Equal("HVAC PM", result.Pm);
Assert.Equal("Unit on roof", result.ServiceNotes); Assert.Equal("Unit on roof", result.ServiceNotes);
@ -220,7 +221,7 @@ public class WorkOrderBoardCreateServiceTests
SiteCode = "CHI", SiteCode = "CHI",
Trade = "Legacy Trade", Trade = "Legacy Trade",
PrimaryService = "Plumbing" PrimaryService = "Plumbing"
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal("Plumbing", result.Pm); Assert.Equal("Plumbing", result.Pm);
} }
@ -236,7 +237,7 @@ public class WorkOrderBoardCreateServiceTests
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
ExtraServices = new List<string> { "Filter change" } ExtraServices = new List<string> { "Filter change" }
}, "actor-1")); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
} }
[Fact] [Fact]
@ -251,7 +252,7 @@ public class WorkOrderBoardCreateServiceTests
PocName = "Jane Site Lead", PocName = "Jane Site Lead",
PocPhone = "+1 555-0100", PocPhone = "+1 555-0100",
PocNotes = "Call 30 min before" PocNotes = "Call 30 min before"
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal("Jane Site Lead", result.PocName); Assert.Equal("Jane Site Lead", result.PocName);
Assert.Equal("+1 555-0100", result.PocPhone); Assert.Equal("+1 555-0100", result.PocPhone);
@ -273,7 +274,7 @@ public class WorkOrderBoardCreateServiceTests
SiteCode = "BK5", SiteCode = "BK5",
TechPhone = "+1 555-0199", TechPhone = "+1 555-0199",
VendorNotes = "Gate code 4421" VendorNotes = "Gate code 4421"
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal("+1 555-0199", result.TechPhone); Assert.Equal("+1 555-0199", result.TechPhone);
@ -298,7 +299,7 @@ public class WorkOrderBoardCreateServiceTests
ApptDate = new DateTime(2026, 7, 18), ApptDate = new DateTime(2026, 7, 18),
TechPhone = "+1 555-0199", TechPhone = "+1 555-0199",
VendorNotes = "Park behind dock" VendorNotes = "Park behind dock"
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal("+1 555-0199", result.TechPhone); Assert.Equal("+1 555-0199", result.TechPhone);
Assert.Equal(55, result.VendorId); Assert.Equal(55, result.VendorId);
@ -322,7 +323,7 @@ public class WorkOrderBoardCreateServiceTests
SiteCode = "BK5", SiteCode = "BK5",
Trade = "HVAC", Trade = "HVAC",
Description = "pmNote\nPOC: Jane · +1 555\nVendor notes: gate" Description = "pmNote\nPOC: Jane · +1 555\nVendor notes: gate"
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal("HVAC", result.Pm); Assert.Equal("HVAC", result.Pm);
Assert.Null(result.PocName); Assert.Null(result.PocName);
@ -346,7 +347,7 @@ public class WorkOrderBoardCreateServiceTests
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
VendorId = 999 VendorId = 999
}, "actor-1")); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
Assert.Equal("VendorNotFound", ex.Code); Assert.Equal("VendorNotFound", ex.Code);
} }

View file

@ -21,7 +21,8 @@ public class WorkOrderBoardCreateSyncLockTests
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(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 fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var createService = new WorkOrderBoardCreateService( var createService = new WorkOrderBoardCreateService(
@ -29,14 +30,15 @@ public class WorkOrderBoardCreateSyncLockTests
mutationData, mutationData,
boardService, boardService,
audit, audit,
new WorkOrderBoardCreateValidation()); new WorkOrderBoardCreateValidation(),
resolver);
var policy = new SyncFieldMergePolicy(fieldLocks); var policy = new SyncFieldMergePolicy(fieldLocks);
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5"
}, "dispatcher-1"); }, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1");
var wo = await context.workOrders.SingleAsync(); var wo = await context.workOrders.SingleAsync();
var syncContext = new WorkOrderSyncContext var syncContext = new WorkOrderSyncContext
@ -66,7 +68,8 @@ public class WorkOrderBoardCreateSyncLockTests
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(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 fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var createService = new WorkOrderBoardCreateService( var createService = new WorkOrderBoardCreateService(
@ -74,14 +77,15 @@ public class WorkOrderBoardCreateSyncLockTests
mutationData, mutationData,
boardService, boardService,
audit, audit,
new WorkOrderBoardCreateValidation()); new WorkOrderBoardCreateValidation(),
resolver);
var policy = new SyncFieldMergePolicy(fieldLocks); var policy = new SyncFieldMergePolicy(fieldLocks);
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5"
}, "dispatcher-1"); }, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1");
var wo = await context.workOrders.SingleAsync(); var wo = await context.workOrders.SingleAsync();
wo.Customer = "Original"; wo.Customer = "Original";

View file

@ -212,7 +212,7 @@ public class WorkOrderAdvancedSearchServiceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context); 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 var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{ {
@ -222,7 +222,7 @@ public class WorkOrderAdvancedSearchServiceTests
Sites = new List<string> { "BK5" }, Sites = new List<string> { "BK5" },
Page = 0, Page = 0,
PageSize = 50 PageSize = 50
}, null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(1, result.TotalCount); Assert.Equal(1, result.TotalCount);
Assert.Single(result.Items); Assert.Single(result.Items);
@ -234,13 +234,13 @@ public class WorkOrderAdvancedSearchServiceTests
{ {
await using var context = CreateContext(); await using var context = CreateContext();
var dataService = new WorkOrderAdvancedSearchDataService(context); 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 var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{ {
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek, DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek,
PageSize = 500 PageSize = 500
}, null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(100, result.PageSize); Assert.Equal(100, result.PageSize);
} }
@ -272,7 +272,7 @@ public class WorkOrderAdvancedSearchServiceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context); 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 var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{ {
@ -281,7 +281,7 @@ public class WorkOrderAdvancedSearchServiceTests
DateTo = weekStart.AddDays(4), DateTo = weekStart.AddDays(4),
Page = 0, Page = 0,
PageSize = 1 PageSize = 1
}, null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(2, result.TotalCount); Assert.Equal(2, result.TotalCount);
Assert.Single(result.Items); Assert.Single(result.Items);
@ -315,7 +315,7 @@ public class WorkOrderAdvancedSearchServiceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context); 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 var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{ {
@ -323,7 +323,7 @@ public class WorkOrderAdvancedSearchServiceTests
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)), DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
DateTo = DateOnly.FromDateTime(today.AddMonths(1)), DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Types = new List<WorkOrderType> { WorkOrderType.Other } Types = new List<WorkOrderType> { WorkOrderType.Other }
}, null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(1, result.TotalCount); Assert.Equal(1, result.TotalCount);
Assert.Equal(2, result.Items.First().Id); Assert.Equal(2, result.Items.First().Id);
@ -366,7 +366,7 @@ public class WorkOrderAdvancedSearchServiceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context); 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 var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{ {
@ -374,7 +374,7 @@ public class WorkOrderAdvancedSearchServiceTests
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)), DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
DateTo = DateOnly.FromDateTime(today.AddMonths(1)), DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Overdue = true Overdue = true
}, null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(1, result.TotalCount); Assert.Equal(1, result.TotalCount);
Assert.Equal(1, result.Items.First().Id); Assert.Equal(1, result.Items.First().Id);
@ -416,7 +416,7 @@ public class WorkOrderAdvancedSearchServiceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context); 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 var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{ {
@ -425,7 +425,7 @@ public class WorkOrderAdvancedSearchServiceTests
DateTo = DateOnly.FromDateTime(today.AddMonths(1)), DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Types = new List<WorkOrderType> { WorkOrderType.Other }, Types = new List<WorkOrderType> { WorkOrderType.Other },
Overdue = true Overdue = true
}, null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(2, result.TotalCount); Assert.Equal(2, result.TotalCount);
Assert.Contains(result.Items, i => i.Id == 1 && i.IsPastDue); Assert.Contains(result.Items, i => i.Id == 1 && i.IsPastDue);
@ -476,7 +476,7 @@ public class WorkOrderAdvancedSearchServiceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context); 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 var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{ {
@ -486,7 +486,7 @@ public class WorkOrderAdvancedSearchServiceTests
Overdue = true, Overdue = true,
Page = 0, Page = 0,
PageSize = 50 PageSize = 50
}, null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
var overdue = Assert.Single(result.Items); var overdue = Assert.Single(result.Items);
Assert.Equal(0, result.Page); Assert.Equal(0, result.Page);
@ -532,7 +532,7 @@ public class WorkOrderAdvancedSearchServiceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context); 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 var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{ {
@ -542,7 +542,7 @@ public class WorkOrderAdvancedSearchServiceTests
Types = new List<WorkOrderType> { WorkOrderType.PM, WorkOrderType.Other }, Types = new List<WorkOrderType> { WorkOrderType.PM, WorkOrderType.Other },
Page = 0, Page = 0,
PageSize = 50 PageSize = 50
}, null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(2, result.TotalCount); Assert.Equal(2, result.TotalCount);
Assert.Equal(0, result.Page); Assert.Equal(0, result.Page);
@ -576,14 +576,14 @@ public class WorkOrderAdvancedSearchServiceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context); 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 var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{ {
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = weekStart, DateFrom = weekStart,
DateTo = weekStart.AddDays(4) DateTo = weekStart.AddDays(4)
}, null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(2, result.TotalCount); Assert.Equal(2, result.TotalCount);
Assert.Contains(result.Items, i => i.Id == 2); Assert.Contains(result.Items, i => i.Id == 2);
@ -594,14 +594,14 @@ public class WorkOrderAdvancedSearchServiceTests
{ {
await using var context = CreateContext(); await using var context = CreateContext();
var dataService = new WorkOrderAdvancedSearchDataService(context); 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<ArgumentException>(() => service.SearchAsync( var ex = await Assert.ThrowsAsync<ArgumentException>(() => service.SearchAsync(
new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{ {
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek, DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek,
SortBy = "invalid" SortBy = "invalid"
}, null)); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null));
Assert.Contains("sortBy", ex.Message); Assert.Contains("sortBy", ex.Message);
} }

View file

@ -185,12 +185,12 @@ public class WorkOrderBoardServiceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var dataService = new WorkOrderBoardDataService(context); 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 var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto
{ {
WeekStart = weekStart WeekStart = weekStart
}, null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(1, response.Counts.Total); Assert.Equal(1, response.Counts.Total);
Assert.Single(response.Scheduled); Assert.Single(response.Scheduled);
@ -202,12 +202,12 @@ public class WorkOrderBoardServiceTests
public async Task GetBoardAsync_InvalidWeekWindow_Throws() public async Task GetBoardAsync_InvalidWeekWindow_Throws()
{ {
await using var context = CreateContext(); 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<ArgumentException>(() => boardService.GetBoardAsync(new WorkOrderBoardQueryDto await Assert.ThrowsAsync<ArgumentException>(() => boardService.GetBoardAsync(new WorkOrderBoardQueryDto
{ {
WeekStart = new DateOnly(2026, 6, 22), WeekStart = new DateOnly(2026, 6, 22),
WeekEnd = new DateOnly(2026, 6, 15) WeekEnd = new DateOnly(2026, 6, 15)
}, null)); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null));
} }
} }

View file

@ -93,11 +93,12 @@ public class WorkOrderDetailServiceTests
.Options; .Options;
var context = new ApplicationDbContext(options); var context = new ApplicationDbContext(options);
var boardData = new WorkOrderBoardDataService(context); 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 detailData = new WorkOrderDetailDataService(context);
var templateData = new CompletionDocTemplateDataService(context); var templateData = new CompletionDocTemplateDataService(context);
var userData = new UserDataService(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); return (context, service);
} }
@ -152,7 +153,7 @@ public class WorkOrderDetailServiceTests
}); });
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var detail = await service.GetDetailAsync(1); var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(detail); Assert.NotNull(detail);
Assert.True(detail!.Info.IsPastDue); Assert.True(detail!.Info.IsPastDue);
@ -222,7 +223,7 @@ public class WorkOrderDetailServiceTests
}); });
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var audit = await service.GetAuditAsync(1); var audit = await service.GetAuditAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(audit); Assert.NotNull(audit);
Assert.Equal(2, audit!.Count); Assert.Equal(2, audit!.Count);

View file

@ -28,10 +28,11 @@ public class WorkOrderPhase7CoexistenceTests
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options; .Options;
await using var context = new ApplicationDbContext(options); await using var context = new ApplicationDbContext(options);
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "Phase7 Customer");
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); 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 var payload = new WorkOrderIngestPayloadDto
{ {
@ -39,7 +40,8 @@ public class WorkOrderPhase7CoexistenceTests
Description = "Initial", Description = "Initial",
WoStatus = "new", WoStatus = "new",
Severity = "2", Severity = "2",
SiteCode = "BK1" SiteCode = "BK1",
Customer = "Phase7 Customer"
}; };
var create = await ingest.UpsertBatchAsync(new[] { payload }); var create = await ingest.UpsertBatchAsync(new[] { payload });
@ -66,19 +68,20 @@ public class WorkOrderPhase7CoexistenceTests
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(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 fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var createService = new WorkOrderBoardCreateService( var createService = new WorkOrderBoardCreateService(
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation()); boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
var policy = new SyncFieldMergePolicy(fieldLocks); 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 await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5"
}, "dispatcher-1"); }, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1");
var wo = await context.workOrders.SingleAsync(); var wo = await context.workOrders.SingleAsync();
wo.ExternalWorkOrderId = "ext-shoc-1"; wo.ExternalWorkOrderId = "ext-shoc-1";
@ -126,7 +129,7 @@ public class WorkOrderPhase7CoexistenceTests
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); 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[] await ingest.UpsertBatchAsync(new[]
{ {
@ -151,15 +154,21 @@ public class WorkOrderPhase7CoexistenceTests
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options; .Options;
await using var context = new ApplicationDbContext(options); await using var context = new ApplicationDbContext(options);
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "Phase7 Customer");
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); 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[] var result = await ingest.UpsertBatchAsync(new[]
{ {
new WorkOrderIngestPayloadDto { ExternalWorkOrderId = " ", Description = "bad" }, 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); Assert.Equal(1, result.Created);
@ -233,7 +242,7 @@ public class WorkOrderPhase7CoexistenceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData); var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
var count = await context.workOrders.CountAsync(); var count = await context.workOrders.CountAsync();
Assert.Equal(1, count); Assert.Equal(1, count);

View file

@ -291,8 +291,8 @@ public class WorkOrderWeekRolledTests
await service.ProcessWeekRolledAsync(SourceWeekStart); await service.ProcessWeekRolledAsync(SourceWeekStart);
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData); var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
var row = await boardService.GetBoardRowAsync(1); var row = await boardService.GetBoardRowAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(row); Assert.NotNull(row);
Assert.Equal(1, row!.CarriedOver); Assert.Equal(1, row!.CarriedOver);
@ -314,8 +314,8 @@ public class WorkOrderWeekRolledTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData); var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
var row = await boardService.GetBoardRowAsync(1); var row = await boardService.GetBoardRowAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(row); Assert.NotNull(row);
Assert.True(row!.IsPastDue); Assert.True(row!.IsPastDue);
@ -338,8 +338,8 @@ public class WorkOrderWeekRolledTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData); var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
var row = await boardService.GetBoardRowAsync(1); var row = await boardService.GetBoardRowAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(row); Assert.NotNull(row);
Assert.False(row!.IsPastDue); Assert.False(row!.IsPastDue);

View file

@ -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 ## Status
**Superseded** (2026-08-06) by the SH-221 media slice in PR #47, with **Superseded** (historical Proposed single-org ADR). Current contract (PR #47 / SH-221):
**fail-closed** account scope (follow-up on the same PR):
- `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 `account_id` when `ApplicationUser.AccountId` is set
- JWT `org_scope=all` when Admin has no AccountId (explicit signed elevation) - JWT `org_scope=all` when Admin has no AccountId (explicit signed elevation)
- Media loads: `ApplyBaseScope` + `ApplyAccountScope(int)` when account-scoped; - **Reads** (board, list, advanced search, detail, media): `ApplyBaseScope` +
org-wide path skips account filter `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) - 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) ## Context (historical)
SH-116 requires that cross-tenant, unauthorized, and out-of-scope media access 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 org-wide staff access via absence of an account claim; that path was rejected
in review (fail-open) and replaced by the contract below. 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). 1. **Organization boundary** = `ApplyBaseScope` (non-deleted, non-template).
2. **Account boundary** = claim `account_id` → `WorkOrder.AccountId == claim`. 2. **Account boundary** = claim `account_id` → `WorkOrder.AccountId == claim`.
3. **Org-wide** = claim `org_scope=all` only (issued to Admin without 3. **Org-wide** = claim `org_scope=all` only (issued to Admin without
AccountId). Not inferred from missing `account_id`. AccountId). Not inferred from missing `account_id`.
4. **Fail-closed** = no valid account or org-scope claim → Forbidden. 4. **Fail-closed** = no valid account or org-scope claim → Forbidden.
5. **Authorization at service entry**: staff roles may read/mutate any resulting 5. **Create stamp**:
work order; role `User` only when `AssignTo == actorId`; delete staff-only. - Authenticated + `account_id` → stamp claim (ignore client AccountId).
6. **User lifecycle** persists `AccountId` on create/edit so non-Admin principals - Authenticated + `org_scope=all` → unique Customer→Accounts.Name; else
can receive `account_id`. `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 ## Consequences
- Cross-account and missing-scope media tests are required. - Cross-account and missing-scope tests are required for create and read paths.
- Dispatcher/Manager/Supervisor/User without AccountId cannot access media until - Dispatcher/Manager/Supervisor/User without AccountId cannot access board,
AccountId is assigned (or they are Admin with `org_scope=all`). detail, search, list, or media until AccountId is assigned (or they are Admin
- Board/search/detail without account filtering remain a SH-221 follow-up. with `org_scope=all`).
- Locations do not carry AccountId in the EF model; Customer name match is the
unauthenticated resolution path.
## Excepted rule ## Excepted rule
None for media. Hard rule **server-derived tenant scope** None. Hard rule **server-derived tenant scope**
(`ARCHITECTURE_AND_CODE_QUALITY.md` §2) is enforced via claims. (`ARCHITECTURE_AND_CODE_QUALITY.md` §2) is enforced via claims + AccountId.
## Review / expiry ## Review / expiry
Re-review when SH-221 closes remaining board/search/detail account filters, or Re-review by **2027-02-04**, or when AccountId becomes non-nullable with a
by **2027-02-04**. full backfill migration.
## References ## References
@ -57,5 +65,5 @@ by **2027-02-04**.
- SH-221 — Server-derived tenant/customer scope for Work Order domain - SH-221 — Server-derived tenant/customer scope for Work Order domain
- PR: Sea-Haven-Industries/shoc-backend#47 - PR: Sea-Haven-Industries/shoc-backend#47
- `WorkOrderBoardQueryFilters.ApplyBaseScope` / `ApplyAccountScope` - `WorkOrderBoardQueryFilters.ApplyBaseScope` / `ApplyAccountScope`
- `WorkOrderMediaAuthorization` / `SeaHavenClaimTypes` - `IWorkOrderAccountResolver` / `WorkOrderMediaAuthorization` / `SeaHavenClaimTypes`
- `ARCHITECTURE_AND_CODE_QUALITY.md` §2, §10 - `ARCHITECTURE_AND_CODE_QUALITY.md` §2, §10