mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
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:
parent
446e43b2c6
commit
1edcf479ae
49 changed files with 968 additions and 190 deletions
|
|
@ -52,7 +52,11 @@ public class SyncServiceBehaviorTests
|
|||
external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny<CancellationToken>()))
|
||||
.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);
|
||||
|
||||
Assert.Equal(2, result.Synced);
|
||||
|
|
@ -81,7 +85,11 @@ public class SyncServiceBehaviorTests
|
|||
};
|
||||
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);
|
||||
}
|
||||
|
|
@ -107,7 +115,7 @@ public class SyncServiceBehaviorTests
|
|||
};
|
||||
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(1, result.Created);
|
||||
|
|
@ -276,6 +284,7 @@ public class WorkOrderServiceBehaviorTests
|
|||
(quotesData ?? new Mock<IQuotesDataService>()).Object,
|
||||
Mock.Of<IFileStoragePort>(),
|
||||
Mock.Of<ICreateWorkOrderValidation>(),
|
||||
Mock.Of<IUpdateWorkOrderValidation>());
|
||||
Mock.Of<IUpdateWorkOrderValidation>(),
|
||||
Mock.Of<IWorkOrderAccountResolver>());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ public class WorkOrderControllerSearchTests
|
|||
public async Task SearchBoard_WhenCustomPresetMissingDates_ReturnsBadRequest()
|
||||
{
|
||||
var advancedSearch = new WorkOrderAdvancedSearchService(
|
||||
Mock.Of<IWorkOrderAdvancedSearchDataService>());
|
||||
Mock.Of<IWorkOrderAdvancedSearchDataService>(),
|
||||
Mock.Of<IWorkOrderAccountResolver>());
|
||||
var controller = NewController(advancedSearch);
|
||||
|
||||
var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto
|
||||
|
|
|
|||
|
|
@ -75,9 +75,17 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
Search = search
|
||||
};
|
||||
|
||||
var result = await _workOrderBoardService.GetBoardAsync(query, userId);
|
||||
var result = await _workOrderBoardService.GetBoardAsync(query, User, userId);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
|
|
@ -90,9 +98,17 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
try
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var result = await _advancedSearchService.SearchAsync(query, userId);
|
||||
var result = await _advancedSearchService.SearchAsync(query, User, userId);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||
|
|
@ -154,7 +170,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
try
|
||||
{
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var row = await _boardCreateService.CreateAsync(request, actorId);
|
||||
var row = await _boardCreateService.CreateAsync(request, User, actorId);
|
||||
return Ok(row);
|
||||
}
|
||||
catch (ValidationException vex)
|
||||
|
|
@ -162,6 +178,14 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
||||
return BadRequest(new Response { Status = "Validation Error", Message = errors });
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "DuplicateWoNumber")
|
||||
{
|
||||
return Conflict(new WorkOrderBoardValidationErrorDto
|
||||
|
|
@ -194,9 +218,17 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
try
|
||||
{
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var row = await _boardCancelService.CancelAsync(id, actorId);
|
||||
var row = await _boardCancelService.CancelAsync(id, User, actorId);
|
||||
return Ok(row);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||
using Microsoft.Extensions.Logging;
|
||||
using SeaHaven.DataServices.Models;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
|
||||
|
|
@ -19,13 +20,16 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
public class WorkOrderController : Controller
|
||||
{
|
||||
private readonly IWorkOrderService _workOrderService;
|
||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||
private readonly ILogger<WorkOrderController> _logger;
|
||||
|
||||
public WorkOrderController(
|
||||
IWorkOrderService workOrderService,
|
||||
IWorkOrderAccountResolver accountResolver,
|
||||
ILogger<WorkOrderController> logger)
|
||||
{
|
||||
_workOrderService = workOrderService;
|
||||
_accountResolver = accountResolver;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
|
|
@ -49,10 +53,11 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
Attachments = model.Attachments,
|
||||
BeforPhotoAttachment = model.BeforPhotoAttachment,
|
||||
AfterPhotoAttachment = model.AfterPhotoAttachment,
|
||||
SignOffAttachment = model.SignOffAttachment
|
||||
SignOffAttachment = model.SignOffAttachment,
|
||||
Customer = model.Customer
|
||||
};
|
||||
|
||||
var workOrderId = await _workOrderService.CreateWorkOrderWithDetailsAsync(input, userId!);
|
||||
var workOrderId = await _workOrderService.CreateWorkOrderWithDetailsAsync(input, User, userId!);
|
||||
return Ok(new DataResponse { Message = "Work order created successfully", Status = "200", Data = workOrderId });
|
||||
}
|
||||
catch (ValidationException vex)
|
||||
|
|
@ -60,6 +65,14 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
||||
return BadRequest(new Response { Status = "Validation Error", Message = errors });
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||
|
|
@ -116,19 +129,27 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[HttpGet("GetWorkOrderList")]
|
||||
public async Task<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(
|
||||
page, pageSize, search, status, assignee, locationId, sortBy, sortDir);
|
||||
|
||||
var viewModel = new Pagination_DTO
|
||||
try
|
||||
{
|
||||
Data = data,
|
||||
PageNumber = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = totalCount,
|
||||
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize)
|
||||
};
|
||||
var accountId = _accountResolver.ResolveAccountFilter(User);
|
||||
var (data, totalCount) = await _workOrderService.GetWorkOrderListPagedAsync(
|
||||
page, pageSize, search, status, assignee, locationId, sortBy, sortDir, accountId);
|
||||
|
||||
return Ok(viewModel);
|
||||
var viewModel = new Pagination_DTO
|
||||
{
|
||||
Data = data,
|
||||
PageNumber = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = totalCount,
|
||||
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize)
|
||||
};
|
||||
|
||||
return Ok(viewModel);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin")]
|
||||
|
|
|
|||
|
|
@ -29,19 +29,33 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[HttpGet("{id:int}/detail")]
|
||||
public async Task<IActionResult> GetDetail(int id)
|
||||
{
|
||||
var detail = await _workOrderDetailService.GetDetailAsync(id);
|
||||
if (detail == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||
return Ok(detail);
|
||||
try
|
||||
{
|
||||
var detail = await _workOrderDetailService.GetDetailAsync(id, User);
|
||||
if (detail == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||
return Ok(detail);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/audit")]
|
||||
public async Task<IActionResult> GetAudit(int id, [FromQuery] int limit = 50)
|
||||
{
|
||||
var audit = await _workOrderDetailService.GetAuditAsync(id, limit);
|
||||
if (audit == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||
return Ok(audit);
|
||||
try
|
||||
{
|
||||
var audit = await _workOrderDetailService.GetAuditAsync(id, User, limit);
|
||||
if (audit == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||
return Ok(audit);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/comments")]
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ namespace Api.SeaHavenIndustries.DTOs
|
|||
public string? SignOffSignature { get; set; }
|
||||
public List<int>? ContactIds { get; set; }
|
||||
public List<int>? CategoryIds { get; set; }
|
||||
public string? Customer { get; set; }
|
||||
|
||||
public static WorkOrder MapToEntity(Workorder_DTO dto, string userId)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -96,5 +96,25 @@ namespace SeaHaven.DataServices.Implementation
|
|||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
public async Task<WorkOrderAdvancedSearchResult> SearchAsync(WorkOrderAdvancedSearchQuery query)
|
||||
{
|
||||
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||
if (query.AccountId.HasValue)
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyAccountScope(baseQuery, query.AccountId.Value);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
var weekEndDate = query.WeekEnd.ToDateTime(TimeOnly.MinValue);
|
||||
|
||||
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||
if (query.AccountId.HasValue)
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyAccountScope(baseQuery, query.AccountId.Value);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
|
||||
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue);
|
||||
|
|
@ -70,11 +72,12 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId)
|
||||
public async Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId, int? accountId = null)
|
||||
{
|
||||
var query = _context.workOrders
|
||||
.AsNoTracking()
|
||||
.Where(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
||||
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(
|
||||
_context.workOrders.AsNoTracking().Where(w => w.Id == workOrderId));
|
||||
if (accountId.HasValue)
|
||||
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
|
||||
|
||||
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(query, isUnscheduled: false);
|
||||
return rows.FirstOrDefault();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Helpers;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.DataServices.Models;
|
||||
|
||||
|
|
@ -110,7 +111,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
string? assignee = null,
|
||||
int? locationId = null,
|
||||
string? sortBy = "lastUpdated",
|
||||
string? sortDir = "desc")
|
||||
string? sortDir = "desc",
|
||||
int? accountId = null)
|
||||
{
|
||||
search ??= "";
|
||||
|
||||
|
|
@ -119,6 +121,9 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.Include(w => w.AssignToUser)
|
||||
.Where(w => w.istemplate != true);
|
||||
|
||||
if (accountId.HasValue)
|
||||
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
|
||||
|
||||
if (locationId.HasValue)
|
||||
query = query.Where(w => w.LocationId == locationId.Value);
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,13 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
if (workOrder == null)
|
||||
{
|
||||
var accountId = await TryResolveAccountIdAsync(mutation.Customer, cancellationToken);
|
||||
if (accountId is not int resolvedAccountId)
|
||||
{
|
||||
return new WorkOrderWebhookPersistenceResult(
|
||||
WorkOrderWebhookPersistenceStatus.AccountUnresolved);
|
||||
}
|
||||
|
||||
var internalNumber = await AllocateInternalNumberAsync(cancellationToken);
|
||||
workOrder = new WorkOrder
|
||||
{
|
||||
|
|
@ -43,6 +50,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
WorkerOrderTitle = mutation.Title ?? mutation.Description
|
||||
?? $"Imported work order {mutation.ExternalWorkOrderId}",
|
||||
Description = mutation.Description,
|
||||
Customer = mutation.Customer,
|
||||
AccountId = resolvedAccountId,
|
||||
Source = mutation.Source,
|
||||
istemplate = false,
|
||||
CreatedDate = mutation.CreatedAt ?? mutation.ProcessedAt.UtcDateTime
|
||||
|
|
@ -284,6 +293,26 @@ namespace SeaHaven.DataServices.Implementation
|
|||
return true;
|
||||
}
|
||||
|
||||
private async Task<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(
|
||||
DateTimeOffset candidateUpdatedAt,
|
||||
string candidateHash,
|
||||
|
|
|
|||
|
|
@ -13,5 +13,13 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task DeleteAsync(int id);
|
||||
Task<bool> ExistsAsync(int id);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
{
|
||||
Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query);
|
||||
Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync();
|
||||
Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId);
|
||||
Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId, int? accountId = null);
|
||||
Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
string? assignee = null,
|
||||
int? locationId = null,
|
||||
string? sortBy = "lastUpdated",
|
||||
string? sortDir = "desc");
|
||||
string? sortDir = "desc",
|
||||
int? accountId = null);
|
||||
|
||||
// Command operations
|
||||
Task<WorkOrder> AddAsync(WorkOrder workOrder);
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
{
|
||||
Applied,
|
||||
Duplicate,
|
||||
HashConflict
|
||||
HashConflict,
|
||||
AccountUnresolved
|
||||
}
|
||||
|
||||
public sealed record WorkOrderWebhookPersistenceResult(
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
int Page,
|
||||
int PageSize,
|
||||
string SortBy,
|
||||
string SortDir);
|
||||
string SortDir,
|
||||
int? AccountId = null);
|
||||
|
||||
public record WorkOrderAdvancedSearchResult(
|
||||
IReadOnlyList<WorkOrderBoardRawRow> Rows,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
IReadOnlyList<WorkOrderType>? Types,
|
||||
bool Overdue,
|
||||
string? Search,
|
||||
string? CurrentUserId);
|
||||
string? CurrentUserId,
|
||||
int? AccountId = null);
|
||||
|
||||
public record WorkOrderBoardRawRow(
|
||||
int Id,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? WoNumber { get; set; }
|
||||
public WorkOrderType WorkOrderType { 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; }
|
||||
/// <summary>Legacy alias for primary service; prefer <see cref="PrimaryService"/>.</summary>
|
||||
public string? Trade { get; set; }
|
||||
|
|
|
|||
|
|
@ -80,6 +80,8 @@ namespace SeaHaven.Services.DTOs
|
|||
public int? LocationId { get; set; }
|
||||
public string? AssignTo { 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)
|
||||
{
|
||||
|
|
@ -94,6 +96,7 @@ namespace SeaHaven.Services.DTOs
|
|||
LocationId = dto.LocationId,
|
||||
AssignTo = dto.AssignTo,
|
||||
DueDate = dto.DueDate,
|
||||
Customer = string.IsNullOrWhiteSpace(dto.Customer) ? null : dto.Customer.Trim(),
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ namespace SeaHaven.Services.DTOs
|
|||
public IFormFile? BeforPhotoAttachment { get; set; }
|
||||
public IFormFile? AfterPhotoAttachment { 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
|
||||
|
|
|
|||
|
|
@ -9,11 +9,16 @@ namespace SeaHaven.Services.Implementation
|
|||
{
|
||||
private readonly ISyncDataService _data;
|
||||
private readonly ISyncExternalSource _external;
|
||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||
|
||||
public SyncService(ISyncDataService data, ISyncExternalSource external)
|
||||
public SyncService(
|
||||
ISyncDataService data,
|
||||
ISyncExternalSource external,
|
||||
IWorkOrderAccountResolver accountResolver)
|
||||
{
|
||||
_data = data;
|
||||
_external = external;
|
||||
_accountResolver = accountResolver;
|
||||
}
|
||||
|
||||
public async Task<SyncWorkOrdersResult> SyncWorkOrdersAsync(CancellationToken cancellationToken)
|
||||
|
|
@ -42,6 +47,11 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
if (existing == null)
|
||||
{
|
||||
var customer = Get(item, "customer");
|
||||
var accountId = await _accountResolver.TryResolveFromCustomerAsync(customer, cancellationToken);
|
||||
if (accountId is not int resolvedAccountId)
|
||||
continue;
|
||||
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
InternalWONumber = (nextInternal++).ToString("D8"),
|
||||
|
|
@ -52,7 +62,8 @@ namespace SeaHaven.Services.Implementation
|
|||
Status = MapStatus(Get(item, "wo_status")),
|
||||
Priority = MapSeverityToPriority(Get(item, "severity")),
|
||||
Severity = Get(item, "severity"),
|
||||
Customer = Get(item, "customer"),
|
||||
Customer = customer,
|
||||
AccountId = resolvedAccountId,
|
||||
SiteCode = Get(item, "site_code"),
|
||||
Building = Get(item, "building"),
|
||||
LocationId = locationId,
|
||||
|
|
|
|||
73
SeaHaven.Services/Implementation/WorkOrderAccountResolver.cs
Normal file
73
SeaHaven.Services/Implementation/WorkOrderAccountResolver.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Security.Claims;
|
||||
using SeaHaven.DataServices.Helpers;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
|
@ -12,16 +13,22 @@ namespace SeaHaven.Services.Implementation
|
|||
private const int MaxPageSize = 100;
|
||||
|
||||
private readonly IWorkOrderAdvancedSearchDataService _searchDataService;
|
||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||
|
||||
public WorkOrderAdvancedSearchService(IWorkOrderAdvancedSearchDataService searchDataService)
|
||||
public WorkOrderAdvancedSearchService(
|
||||
IWorkOrderAdvancedSearchDataService searchDataService,
|
||||
IWorkOrderAccountResolver accountResolver)
|
||||
{
|
||||
_searchDataService = searchDataService;
|
||||
_accountResolver = accountResolver;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<WorkOrderBoardRowDto>> SearchAsync(
|
||||
WorkOrderAdvancedSearchQueryDto query,
|
||||
ClaimsPrincipal user,
|
||||
string? currentUserId)
|
||||
{
|
||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||
var page = query.Page < 0 ? 0 : query.Page;
|
||||
var pageSize = query.PageSize < 1 ? DefaultPageSize : Math.Min(query.PageSize, MaxPageSize);
|
||||
|
||||
|
|
@ -49,7 +56,8 @@ namespace SeaHaven.Services.Implementation
|
|||
page,
|
||||
pageSize,
|
||||
sortBy,
|
||||
query.SortDir ?? "asc");
|
||||
query.SortDir ?? "asc",
|
||||
accountId);
|
||||
|
||||
var result = await _searchDataService.SearchAsync(dataQuery);
|
||||
var utcNow = DateTime.UtcNow;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
|
@ -23,7 +24,10 @@ namespace SeaHaven.Services.Implementation
|
|||
_auditService = auditService;
|
||||
}
|
||||
|
||||
public async Task<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);
|
||||
|
||||
|
|
@ -32,7 +36,7 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
if (workOrder.LifecycleStatus == LifecycleStatus.Canceled)
|
||||
{
|
||||
var existing = await _boardService.GetBoardRowAsync(workOrderId);
|
||||
var existing = await _boardService.GetBoardRowAsync(workOrderId, user);
|
||||
return existing ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
}
|
||||
|
||||
|
|
@ -48,7 +52,7 @@ namespace SeaHaven.Services.Implementation
|
|||
await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId);
|
||||
await _mutationData.SaveAsync(CancellationToken.None);
|
||||
|
||||
var row = await _boardService.GetBoardRowAsync(workOrderId);
|
||||
var row = await _boardService.GetBoardRowAsync(workOrderId, user);
|
||||
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
|
|
@ -18,27 +19,38 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly IWorkOrderBoardService _boardService;
|
||||
private readonly IWorkOrderAuditService _auditService;
|
||||
private readonly IWorkOrderBoardCreateValidation _validator;
|
||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||
|
||||
public WorkOrderBoardCreateService(
|
||||
IWorkOrderBoardDataService boardDataService,
|
||||
IWorkOrderBoardMutationDataService mutationData,
|
||||
IWorkOrderBoardService boardService,
|
||||
IWorkOrderAuditService auditService,
|
||||
IWorkOrderBoardCreateValidation validator)
|
||||
IWorkOrderBoardCreateValidation validator,
|
||||
IWorkOrderAccountResolver accountResolver)
|
||||
{
|
||||
_boardDataService = boardDataService;
|
||||
_mutationData = mutationData;
|
||||
_boardService = boardService;
|
||||
_auditService = auditService;
|
||||
_validator = validator;
|
||||
_accountResolver = accountResolver;
|
||||
}
|
||||
|
||||
public async Task<WorkOrderBoardRowDto> CreateAsync(WorkOrderBoardCreateRequestDto request, string? actorId)
|
||||
public async Task<WorkOrderBoardRowDto> CreateAsync(
|
||||
WorkOrderBoardCreateRequestDto request,
|
||||
ClaimsPrincipal user,
|
||||
string? actorId)
|
||||
{
|
||||
var validationResult = await _validator.ValidateAsync(request);
|
||||
if (!validationResult.IsValid)
|
||||
throw new ValidationException(validationResult.Errors);
|
||||
|
||||
var accountId = await _accountResolver.ResolveForAuthenticatedCreateAsync(
|
||||
user,
|
||||
request.Customer,
|
||||
CancellationToken.None);
|
||||
|
||||
var woNumber = await ResolveWoNumberAsync(request.WoNumber);
|
||||
var siteCode = request.SiteCode!.Trim();
|
||||
var primaryService = ResolvePrimaryService(request);
|
||||
|
|
@ -49,6 +61,7 @@ namespace SeaHaven.Services.Implementation
|
|||
var pocNotes = TrimOrNull(request.PocNotes);
|
||||
var techPhone = TrimOrNull(request.TechPhone);
|
||||
var vendorNotes = TrimOrNull(request.VendorNotes);
|
||||
var customer = TrimOrNull(request.Customer);
|
||||
var hasVendorDispatch = request.VendorId.HasValue
|
||||
|| request.ApptDate.HasValue
|
||||
|| !string.IsNullOrWhiteSpace(request.ApptTime);
|
||||
|
|
@ -65,6 +78,8 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkerOrderNumber = woNumber,
|
||||
WorkOrderType = request.WorkOrderType,
|
||||
SiteCode = siteCode,
|
||||
Customer = customer,
|
||||
AccountId = accountId,
|
||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
||||
Trade = primaryService,
|
||||
ExtraServices = extraServicesJson,
|
||||
|
|
@ -197,7 +212,7 @@ namespace SeaHaven.Services.Implementation
|
|||
await _mutationData.SaveAsync(ct);
|
||||
}, CancellationToken.None);
|
||||
|
||||
var row = await _boardService.GetBoardRowAsync(workOrder.Id);
|
||||
var row = await _boardService.GetBoardRowAsync(workOrder.Id, user);
|
||||
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
|
@ -9,16 +10,22 @@ namespace SeaHaven.Services.Implementation
|
|||
public class WorkOrderBoardService : IWorkOrderBoardService
|
||||
{
|
||||
private readonly IWorkOrderBoardDataService _boardDataService;
|
||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||
|
||||
public WorkOrderBoardService(IWorkOrderBoardDataService boardDataService)
|
||||
public WorkOrderBoardService(
|
||||
IWorkOrderBoardDataService boardDataService,
|
||||
IWorkOrderAccountResolver accountResolver)
|
||||
{
|
||||
_boardDataService = boardDataService;
|
||||
_accountResolver = accountResolver;
|
||||
}
|
||||
|
||||
public async Task<WorkOrderBoardResponseDto> GetBoardAsync(
|
||||
WorkOrderBoardQueryDto query,
|
||||
ClaimsPrincipal user,
|
||||
string? currentUserId)
|
||||
{
|
||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||
var weekStart = query.WeekStart;
|
||||
var weekEnd = query.WeekEnd ?? weekStart.AddDays(4);
|
||||
|
||||
|
|
@ -34,7 +41,8 @@ namespace SeaHaven.Services.Implementation
|
|||
query.Types,
|
||||
query.Overdue,
|
||||
query.Search,
|
||||
currentUserId);
|
||||
currentUserId,
|
||||
accountId);
|
||||
|
||||
var result = await _boardDataService.GetBoardRowsAsync(dataQuery);
|
||||
var utcNow = DateTime.UtcNow;
|
||||
|
|
@ -65,9 +73,10 @@ namespace SeaHaven.Services.Implementation
|
|||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Security.Claims;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
|
|
@ -11,25 +12,29 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly IWorkOrderDetailDataService _detailData;
|
||||
private readonly ICompletionDocTemplateDataService _templateData;
|
||||
private readonly IUserDataService _userDataService;
|
||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||
|
||||
public WorkOrderDetailService(
|
||||
IWorkOrderBoardService boardService,
|
||||
IWorkOrderDetailDataService detailData,
|
||||
ICompletionDocTemplateDataService templateData,
|
||||
IUserDataService userDataService)
|
||||
IUserDataService userDataService,
|
||||
IWorkOrderAccountResolver accountResolver)
|
||||
{
|
||||
_boardService = boardService;
|
||||
_detailData = detailData;
|
||||
_templateData = templateData;
|
||||
_userDataService = userDataService;
|
||||
_accountResolver = accountResolver;
|
||||
}
|
||||
|
||||
public async Task<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;
|
||||
|
||||
var boardRow = await _boardService.GetBoardRowAsync(workOrderId);
|
||||
var boardRow = await _boardService.GetBoardRowAsync(workOrderId, user);
|
||||
if (boardRow == null)
|
||||
return null;
|
||||
|
||||
|
|
@ -38,7 +43,8 @@ namespace SeaHaven.Services.Implementation
|
|||
var audit = await _detailData.GetAuditLogsAsync(workOrderId, 50);
|
||||
var attachments = await _detailData.GetAttachmentsAsync(workOrderId);
|
||||
var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId);
|
||||
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId);
|
||||
var workOrder = await _detailData.GetWorkOrderForMediaAsync(
|
||||
workOrderId, CancellationToken.None, accountId);
|
||||
|
||||
var template = await _templateData.ResolveForWorkOrderAsync(
|
||||
extended?.Trade ?? boardRow.Pm,
|
||||
|
|
@ -59,9 +65,13 @@ namespace SeaHaven.Services.Implementation
|
|||
};
|
||||
}
|
||||
|
||||
public async Task<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;
|
||||
|
||||
var logs = await _detailData.GetAuditLogsAsync(workOrderId, limit);
|
||||
|
|
|
|||
|
|
@ -12,17 +12,20 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly ISyncFieldMergePolicy _mergePolicy;
|
||||
private readonly IWorkOrderFieldLockService _fieldLocks;
|
||||
private readonly IWorkOrderAuditService _audit;
|
||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||
|
||||
public WorkOrderIngestService(
|
||||
IWorkOrderIngestDataService ingestData,
|
||||
ISyncFieldMergePolicy mergePolicy,
|
||||
IWorkOrderFieldLockService fieldLocks,
|
||||
IWorkOrderAuditService audit)
|
||||
IWorkOrderAuditService audit,
|
||||
IWorkOrderAccountResolver accountResolver)
|
||||
{
|
||||
_ingestData = ingestData;
|
||||
_mergePolicy = mergePolicy;
|
||||
_fieldLocks = fieldLocks;
|
||||
_audit = audit;
|
||||
_accountResolver = accountResolver;
|
||||
}
|
||||
|
||||
public async Task<WorkOrderIngestBatchResultDto> UpsertBatchAsync(
|
||||
|
|
@ -45,13 +48,21 @@ namespace SeaHaven.Services.Implementation
|
|||
continue;
|
||||
}
|
||||
|
||||
var (upsert, updatedSeed) = await UpsertOneAsync(item, nextSeed, cancellationToken);
|
||||
nextSeed = updatedSeed;
|
||||
result.Results.Add(upsert);
|
||||
if (upsert.Created)
|
||||
result.Created++;
|
||||
else
|
||||
result.Updated++;
|
||||
try
|
||||
{
|
||||
var (upsert, updatedSeed) = await UpsertOneAsync(item, nextSeed, cancellationToken);
|
||||
nextSeed = updatedSeed;
|
||||
result.Results.Add(upsert);
|
||||
if (upsert.Created)
|
||||
result.Created++;
|
||||
else
|
||||
result.Updated++;
|
||||
}
|
||||
catch (Exceptions.WorkOrderBoardValidationException ex)
|
||||
when (ex.Code == "AccountUnresolved")
|
||||
{
|
||||
result.Skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
await _ingestData.SaveAsync(cancellationToken);
|
||||
|
|
@ -72,6 +83,8 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
if (existing == null)
|
||||
{
|
||||
var accountId = await _accountResolver.ResolveForUnauthenticatedCreateAsync(
|
||||
item.Customer, cancellationToken);
|
||||
var (woNumber, updatedSeed) = AllocateInternalWoNumber(nextSeed);
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
|
|
@ -84,6 +97,7 @@ namespace SeaHaven.Services.Implementation
|
|||
Priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity),
|
||||
Severity = item.Severity,
|
||||
Customer = item.Customer,
|
||||
AccountId = accountId,
|
||||
SiteCode = item.SiteCode,
|
||||
Building = item.Building,
|
||||
LocationId = locationId,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentValidation;
|
||||
|
|
@ -18,6 +19,7 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly IFileStoragePort _fileStorage;
|
||||
private readonly ICreateWorkOrderValidation _createValidator;
|
||||
private readonly IUpdateWorkOrderValidation _updateValidator;
|
||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||
|
||||
public WorkOrderService(
|
||||
IWorkOrderDataService workOrderDataService,
|
||||
|
|
@ -26,7 +28,8 @@ namespace SeaHaven.Services.Implementation
|
|||
IQuotesDataService quotesDataService,
|
||||
IFileStoragePort fileStorage,
|
||||
ICreateWorkOrderValidation createValidator,
|
||||
IUpdateWorkOrderValidation updateValidator)
|
||||
IUpdateWorkOrderValidation updateValidator,
|
||||
IWorkOrderAccountResolver accountResolver)
|
||||
{
|
||||
_workOrderDataService = workOrderDataService;
|
||||
_commentDataService = commentDataService;
|
||||
|
|
@ -35,6 +38,7 @@ namespace SeaHaven.Services.Implementation
|
|||
_fileStorage = fileStorage;
|
||||
_createValidator = createValidator;
|
||||
_updateValidator = updateValidator;
|
||||
_accountResolver = accountResolver;
|
||||
}
|
||||
|
||||
public async Task<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);
|
||||
if (!validationResult.IsValid)
|
||||
throw new ValidationException(validationResult.Errors);
|
||||
|
||||
var accountId = await _accountResolver.ResolveForAuthenticatedCreateAsync(
|
||||
user, dto.Customer, CancellationToken.None);
|
||||
|
||||
var workOrder = CreateWorkOrderDTO.MapToEntity(dto);
|
||||
workOrder.AccountId = accountId;
|
||||
workOrder.createdby = userId;
|
||||
workOrder.CreatedDate = DateTime.UtcNow;
|
||||
workOrder.InternalWONumber = await _workOrderDataService.GenerateNextInternalWONumberAsync();
|
||||
|
|
@ -124,10 +132,10 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
public async Task<(IEnumerable<WorkOrderListItemReadModel> Items, int TotalCount)> GetWorkOrderListPagedAsync(
|
||||
int page, int pageSize, string? search, string? status, string? assignee,
|
||||
int? locationId, string? sortBy, string? sortDir)
|
||||
int? locationId, string? sortBy, string? sortDir, int? accountId = null)
|
||||
{
|
||||
return await _workOrderDataService.GetWorkOrderListPagedAsync(
|
||||
page, pageSize, search, status, assignee, locationId, sortBy, sortDir);
|
||||
page, pageSize, search, status, assignee, locationId, sortBy, sortDir, accountId);
|
||||
}
|
||||
|
||||
public async Task<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
|
||||
{
|
||||
|
|
@ -364,10 +375,11 @@ namespace SeaHaven.Services.Implementation
|
|||
Priority = Enum.TryParse<WorkOrderPriority>(input.Priority, out var p) ? p : WorkOrderPriority.Medium,
|
||||
LocationId = input.LocationId,
|
||||
AssignTo = input.AssignTo,
|
||||
DueDate = input.DueDate
|
||||
DueDate = input.DueDate,
|
||||
Customer = input.Customer
|
||||
};
|
||||
|
||||
var workOrderId = await CreateWorkOrderAsync(createDto, userId);
|
||||
var workOrderId = await CreateWorkOrderAsync(createDto, user, userId);
|
||||
|
||||
if (input.ContactIds != null && input.ContactIds.Any())
|
||||
await _workOrderDataService.AddWorkOrderContactsAsync(workOrderId, input.ContactIds);
|
||||
|
|
|
|||
|
|
@ -161,6 +161,12 @@ namespace SeaHaven.Services.Implementation
|
|||
"Work-order webhook delivery {DeliveryId} conflicts with an existing delivery.",
|
||||
mutation!.DeliveryId);
|
||||
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.HashConflict);
|
||||
case WorkOrderWebhookPersistenceStatus.AccountUnresolved:
|
||||
Invalid.Add(1);
|
||||
_logger.LogWarning(
|
||||
"Work-order webhook delivery {DeliveryId} could not resolve account from customer.",
|
||||
mutation!.DeliveryId);
|
||||
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.InvalidEnvelope);
|
||||
default:
|
||||
throw new InvalidOperationException("Unknown persistence result.");
|
||||
}
|
||||
|
|
|
|||
40
SeaHaven.Services/Interfaces/IWorkOrderAccountResolver.cs
Normal file
40
SeaHaven.Services/Interfaces/IWorkOrderAccountResolver.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Security.Claims;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
|
|
@ -6,6 +7,7 @@ namespace SeaHaven.Services.Interfaces
|
|||
{
|
||||
Task<PagedResult<WorkOrderBoardRowDto>> SearchAsync(
|
||||
WorkOrderAdvancedSearchQueryDto query,
|
||||
ClaimsPrincipal user,
|
||||
string? currentUserId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
using System.Security.Claims;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderBoardCancelService
|
||||
{
|
||||
Task<WorkOrderBoardRowDto> CancelAsync(int workOrderId, string? actorId);
|
||||
Task<WorkOrderBoardRowDto> CancelAsync(int workOrderId, ClaimsPrincipal user, string? actorId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
using System.Security.Claims;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderBoardCreateService
|
||||
{
|
||||
Task<WorkOrderBoardRowDto> CreateAsync(WorkOrderBoardCreateRequestDto request, string? actorId);
|
||||
Task<WorkOrderBoardRowDto> CreateAsync(
|
||||
WorkOrderBoardCreateRequestDto request,
|
||||
ClaimsPrincipal user,
|
||||
string? actorId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
using System.Security.Claims;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderBoardService
|
||||
{
|
||||
Task<WorkOrderBoardResponseDto> GetBoardAsync(WorkOrderBoardQueryDto query, string? currentUserId);
|
||||
Task<WorkOrderBoardResponseDto> GetBoardAsync(
|
||||
WorkOrderBoardQueryDto query,
|
||||
ClaimsPrincipal user,
|
||||
string? currentUserId);
|
||||
Task<IReadOnlyList<DispatcherLookupDto>> GetDispatcherLookupsAsync();
|
||||
Task<WorkOrderBoardRowDto?> GetBoardRowAsync(int workOrderId);
|
||||
Task<WorkOrderBoardRowDto?> GetBoardRowAsync(int workOrderId, ClaimsPrincipal user);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
using System.Security.Claims;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderDetailService
|
||||
{
|
||||
Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId);
|
||||
Task<IReadOnlyList<WorkOrderAuditEntryDto>?> GetAuditAsync(int workOrderId, int limit = 50);
|
||||
Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId, ClaimsPrincipal user);
|
||||
Task<IReadOnlyList<WorkOrderAuditEntryDto>?> GetAuditAsync(
|
||||
int workOrderId,
|
||||
ClaimsPrincipal user,
|
||||
int limit = 50);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
|
@ -12,7 +13,7 @@ namespace SeaHaven.Services.Interfaces
|
|||
Task<WorkOrderDTO?> GetWorkOrderByIdWithDetailsAsync(int id);
|
||||
Task<IEnumerable<WorkOrderDTO>> GetAllWorkOrdersAsync();
|
||||
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> DeleteWorkOrderAsync(int id, string userId);
|
||||
Task<bool> WorkOrderExistsAsync(int id);
|
||||
|
|
@ -21,7 +22,7 @@ namespace SeaHaven.Services.Interfaces
|
|||
|
||||
Task<(IEnumerable<WorkOrderListItemReadModel> Items, int TotalCount)> GetWorkOrderListPagedAsync(
|
||||
int page, int pageSize, string? search, string? status, string? assignee,
|
||||
int? locationId, string? sortBy, string? sortDir);
|
||||
int? locationId, string? sortBy, string? sortDir, int? accountId = null);
|
||||
Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id);
|
||||
Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrdersAsync(
|
||||
string[] assignto, int[]? location, string[]? priority,
|
||||
|
|
@ -31,7 +32,7 @@ namespace SeaHaven.Services.Interfaces
|
|||
string[]? status, string[]? duedate, string search, string sort, string sortby, int page, int pageSize);
|
||||
Task<WorkOrderDTO?> ChangeStatusAsync(int id, string status, 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> DeleteWorkOrderCascadeAsync(int id, string userId);
|
||||
Task<Comments> AddCommentAsync(AddCommentInput input, string userId);
|
||||
|
|
|
|||
304
SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs
Normal file
304
SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
68
SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs
Normal file
68
SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ public class WorkOrderBoardCancelServiceTests
|
|||
var context = new ApplicationDbContext(options);
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||
var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit);
|
||||
|
|
@ -44,7 +44,7 @@ public class WorkOrderBoardCancelServiceTests
|
|||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await cancel.CancelAsync(1, "actor-1");
|
||||
var result = await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus);
|
||||
Assert.Equal("Canceled", result.LifecycleStatusLabel);
|
||||
|
|
@ -65,7 +65,7 @@ public class WorkOrderBoardCancelServiceTests
|
|||
await context.SaveChangesAsync();
|
||||
|
||||
var before = await context.WorkOrderAuditLogs.CountAsync();
|
||||
var result = await cancel.CancelAsync(1, "actor-1");
|
||||
var result = await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1");
|
||||
var after = await context.WorkOrderAuditLogs.CountAsync();
|
||||
|
||||
Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus);
|
||||
|
|
@ -84,7 +84,7 @@ public class WorkOrderBoardCancelServiceTests
|
|||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => cancel.CancelAsync(1, "actor-1"));
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"));
|
||||
Assert.Equal("CancelNotAllowed", ex.Code);
|
||||
}
|
||||
|
||||
|
|
@ -100,7 +100,7 @@ public class WorkOrderBoardCancelServiceTests
|
|||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => cancel.CancelAsync(1, "actor-1"));
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"));
|
||||
Assert.Equal("CancelNotAllowed", ex.Code);
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +117,7 @@ public class WorkOrderBoardCancelServiceTests
|
|||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await cancel.CancelAsync(1, "actor-1");
|
||||
await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1");
|
||||
|
||||
var log = await context.WorkOrderAuditLogs.SingleAsync(l => l.Action == "StatusChanged");
|
||||
Assert.Equal("Canceled", log.NewValue);
|
||||
|
|
@ -137,7 +137,7 @@ public class WorkOrderBoardCancelServiceTests
|
|||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await cancel.CancelAsync(1, "actor-1");
|
||||
await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
update.PatchFieldAsync(1, new SeaHaven.Services.DTOs.WorkOrderBoardPatchRequestDto
|
||||
|
|
|
|||
|
|
@ -34,10 +34,12 @@ public class WorkOrderBoardCreateRelationalTests
|
|||
NormalizedEmail = "ACTOR@TEST.LOCAL"
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
|
||||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
||||
var boardService = new WorkOrderBoardService(boardData, resolver);
|
||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||
var service = new WorkOrderBoardCreateService(
|
||||
|
|
@ -45,14 +47,15 @@ public class WorkOrderBoardCreateRelationalTests
|
|||
mutationData,
|
||||
boardService,
|
||||
audit,
|
||||
new WorkOrderBoardCreateValidation());
|
||||
new WorkOrderBoardCreateValidation(),
|
||||
resolver);
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
Description = "Relational create"
|
||||
}, actorId);
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(actorId), actorId);
|
||||
|
||||
Assert.True(result.Id > 0);
|
||||
|
||||
|
|
@ -91,10 +94,12 @@ public class WorkOrderBoardCreateRelationalTests
|
|||
NormalizedEmail = "ACTOR@TEST.LOCAL"
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
|
||||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
||||
var boardService = new WorkOrderBoardService(boardData, resolver);
|
||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||
var service = new WorkOrderBoardCreateService(
|
||||
|
|
@ -102,7 +107,8 @@ public class WorkOrderBoardCreateRelationalTests
|
|||
mutationData,
|
||||
boardService,
|
||||
audit,
|
||||
new WorkOrderBoardCreateValidation());
|
||||
new WorkOrderBoardCreateValidation(),
|
||||
resolver);
|
||||
|
||||
await Assert.ThrowsAsync<DbUpdateException>(() =>
|
||||
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
|
|
@ -111,7 +117,7 @@ public class WorkOrderBoardCreateRelationalTests
|
|||
SiteCode = "BK5",
|
||||
Description = "Should roll back",
|
||||
PocContactId = 999_999
|
||||
}, actorId));
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(actorId), actorId));
|
||||
|
||||
// Fresh context on the same connection so we observe committed state only.
|
||||
await using var verify = new SqliteBoardTestDbContext(options);
|
||||
|
|
|
|||
|
|
@ -19,11 +19,12 @@ public class WorkOrderBoardCreateServiceTests
|
|||
var context = new ApplicationDbContext(options);
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
||||
var boardService = new WorkOrderBoardService(boardData, resolver);
|
||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||
var validator = new WorkOrderBoardCreateValidation();
|
||||
var service = new WorkOrderBoardCreateService(boardData, mutationData, boardService, audit, validator);
|
||||
var service = new WorkOrderBoardCreateService(boardData, mutationData, boardService, audit, validator, resolver);
|
||||
return (context, service);
|
||||
}
|
||||
|
||||
|
|
@ -36,7 +37,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5"
|
||||
}, "actor-1");
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus);
|
||||
Assert.Equal("BK5", result.SiteCode);
|
||||
|
|
@ -55,7 +56,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
WoNumber = "12345",
|
||||
WorkOrderType = WorkOrderType.PO,
|
||||
SiteCode = "DAL"
|
||||
}, "actor-1");
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
Assert.Equal("00000012345", result.WoNumber);
|
||||
}
|
||||
|
|
@ -73,7 +74,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
WoNumber = "99999",
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5"
|
||||
}, "actor-1"));
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
|
||||
|
||||
Assert.Equal("DuplicateWoNumber", ex.Code);
|
||||
}
|
||||
|
|
@ -89,7 +90,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
SiteCode = "BK5",
|
||||
AssignTo = "dispatcher-1",
|
||||
ScheduledDate = new DateTime(2026, 6, 25)
|
||||
}, "actor-1");
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
|
||||
Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate);
|
||||
|
|
@ -106,7 +107,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
SiteCode = "BK5",
|
||||
ScheduleWeekOnly = true,
|
||||
TargetWeek = new DateOnly(2026, 6, 22)
|
||||
}, "actor-1");
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
Assert.True(result.ScheduleWeekOnly);
|
||||
Assert.Equal(new DateOnly(2026, 6, 22), result.TargetWeek);
|
||||
|
|
@ -123,7 +124,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
ScheduleWeekOnly = true
|
||||
}, "actor-1"));
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -139,7 +140,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
SiteCode = "BK5",
|
||||
VendorId = 5,
|
||||
ApptDate = new DateTime(2026, 6, 26)
|
||||
}, "actor-1");
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
Assert.Equal(5, result.VendorId);
|
||||
Assert.NotNull(result.PrimaryDispatchId);
|
||||
|
|
@ -155,7 +156,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
Description = "Test WO"
|
||||
}, "actor-1");
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
var locks = await context.WorkOrderFieldLocks.ToListAsync();
|
||||
Assert.Contains(locks, l => l.FieldName == "SiteCode");
|
||||
|
|
@ -173,7 +174,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5"
|
||||
}, "actor-1");
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
var logs = await context.WorkOrderAuditLogs.ToListAsync();
|
||||
Assert.Contains(logs, l => l.Action == "Create");
|
||||
|
|
@ -194,7 +195,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
PrimaryService = "HVAC PM",
|
||||
ExtraServices = new List<string> { "Filter change", "Coil clean", "Filter change" },
|
||||
ServiceNotes = "Unit on roof"
|
||||
}, "actor-1");
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
Assert.Equal("HVAC PM", result.Pm);
|
||||
Assert.Equal("Unit on roof", result.ServiceNotes);
|
||||
|
|
@ -220,7 +221,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
SiteCode = "CHI",
|
||||
Trade = "Legacy Trade",
|
||||
PrimaryService = "Plumbing"
|
||||
}, "actor-1");
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
Assert.Equal("Plumbing", result.Pm);
|
||||
}
|
||||
|
|
@ -236,7 +237,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
ExtraServices = new List<string> { "Filter change" }
|
||||
}, "actor-1"));
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -251,7 +252,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
PocName = "Jane Site Lead",
|
||||
PocPhone = "+1 555-0100",
|
||||
PocNotes = "Call 30 min before"
|
||||
}, "actor-1");
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
Assert.Equal("Jane Site Lead", result.PocName);
|
||||
Assert.Equal("+1 555-0100", result.PocPhone);
|
||||
|
|
@ -273,7 +274,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
SiteCode = "BK5",
|
||||
TechPhone = "+1 555-0199",
|
||||
VendorNotes = "Gate code 4421"
|
||||
}, "actor-1");
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
Assert.Equal("+1 555-0199", result.TechPhone);
|
||||
|
||||
|
|
@ -298,7 +299,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
ApptDate = new DateTime(2026, 7, 18),
|
||||
TechPhone = "+1 555-0199",
|
||||
VendorNotes = "Park behind dock"
|
||||
}, "actor-1");
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
Assert.Equal("+1 555-0199", result.TechPhone);
|
||||
Assert.Equal(55, result.VendorId);
|
||||
|
|
@ -322,7 +323,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
SiteCode = "BK5",
|
||||
Trade = "HVAC",
|
||||
Description = "pmNote\nPOC: Jane · +1 555\nVendor notes: gate"
|
||||
}, "actor-1");
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
Assert.Equal("HVAC", result.Pm);
|
||||
Assert.Null(result.PocName);
|
||||
|
|
@ -346,7 +347,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
VendorId = 999
|
||||
}, "actor-1"));
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
|
||||
|
||||
Assert.Equal("VendorNotFound", ex.Code);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ public class WorkOrderBoardCreateSyncLockTests
|
|||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
||||
var boardService = new WorkOrderBoardService(boardData, resolver);
|
||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||
var createService = new WorkOrderBoardCreateService(
|
||||
|
|
@ -29,14 +30,15 @@ public class WorkOrderBoardCreateSyncLockTests
|
|||
mutationData,
|
||||
boardService,
|
||||
audit,
|
||||
new WorkOrderBoardCreateValidation());
|
||||
new WorkOrderBoardCreateValidation(),
|
||||
resolver);
|
||||
var policy = new SyncFieldMergePolicy(fieldLocks);
|
||||
|
||||
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5"
|
||||
}, "dispatcher-1");
|
||||
}, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1");
|
||||
|
||||
var wo = await context.workOrders.SingleAsync();
|
||||
var syncContext = new WorkOrderSyncContext
|
||||
|
|
@ -66,7 +68,8 @@ public class WorkOrderBoardCreateSyncLockTests
|
|||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
||||
var boardService = new WorkOrderBoardService(boardData, resolver);
|
||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||
var createService = new WorkOrderBoardCreateService(
|
||||
|
|
@ -74,14 +77,15 @@ public class WorkOrderBoardCreateSyncLockTests
|
|||
mutationData,
|
||||
boardService,
|
||||
audit,
|
||||
new WorkOrderBoardCreateValidation());
|
||||
new WorkOrderBoardCreateValidation(),
|
||||
resolver);
|
||||
var policy = new SyncFieldMergePolicy(fieldLocks);
|
||||
|
||||
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5"
|
||||
}, "dispatcher-1");
|
||||
}, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1");
|
||||
|
||||
var wo = await context.workOrders.SingleAsync();
|
||||
wo.Customer = "Original";
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
|
|
@ -222,7 +222,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
Sites = new List<string> { "BK5" },
|
||||
Page = 0,
|
||||
PageSize = 50
|
||||
}, null);
|
||||
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||
|
||||
Assert.Equal(1, result.TotalCount);
|
||||
Assert.Single(result.Items);
|
||||
|
|
@ -234,13 +234,13 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
{
|
||||
await using var context = CreateContext();
|
||||
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek,
|
||||
PageSize = 500
|
||||
}, null);
|
||||
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||
|
||||
Assert.Equal(100, result.PageSize);
|
||||
}
|
||||
|
|
@ -272,7 +272,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
|
|
@ -281,7 +281,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
DateTo = weekStart.AddDays(4),
|
||||
Page = 0,
|
||||
PageSize = 1
|
||||
}, null);
|
||||
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||
|
||||
Assert.Equal(2, result.TotalCount);
|
||||
Assert.Single(result.Items);
|
||||
|
|
@ -315,7 +315,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
|
|
@ -323,7 +323,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
|
||||
DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
|
||||
Types = new List<WorkOrderType> { WorkOrderType.Other }
|
||||
}, null);
|
||||
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||
|
||||
Assert.Equal(1, result.TotalCount);
|
||||
Assert.Equal(2, result.Items.First().Id);
|
||||
|
|
@ -366,7 +366,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
|
|
@ -374,7 +374,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
|
||||
DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
|
||||
Overdue = true
|
||||
}, null);
|
||||
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||
|
||||
Assert.Equal(1, result.TotalCount);
|
||||
Assert.Equal(1, result.Items.First().Id);
|
||||
|
|
@ -416,7 +416,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
|
|
@ -425,7 +425,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
|
||||
Types = new List<WorkOrderType> { WorkOrderType.Other },
|
||||
Overdue = true
|
||||
}, null);
|
||||
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||
|
||||
Assert.Equal(2, result.TotalCount);
|
||||
Assert.Contains(result.Items, i => i.Id == 1 && i.IsPastDue);
|
||||
|
|
@ -476,7 +476,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
|
|
@ -486,7 +486,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
Overdue = true,
|
||||
Page = 0,
|
||||
PageSize = 50
|
||||
}, null);
|
||||
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||
|
||||
var overdue = Assert.Single(result.Items);
|
||||
Assert.Equal(0, result.Page);
|
||||
|
|
@ -532,7 +532,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
|
|
@ -542,7 +542,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
Types = new List<WorkOrderType> { WorkOrderType.PM, WorkOrderType.Other },
|
||||
Page = 0,
|
||||
PageSize = 50
|
||||
}, null);
|
||||
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||
|
||||
Assert.Equal(2, result.TotalCount);
|
||||
Assert.Equal(0, result.Page);
|
||||
|
|
@ -576,14 +576,14 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
|
||||
DateFrom = weekStart,
|
||||
DateTo = weekStart.AddDays(4)
|
||||
}, null);
|
||||
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||
|
||||
Assert.Equal(2, result.TotalCount);
|
||||
Assert.Contains(result.Items, i => i.Id == 2);
|
||||
|
|
@ -594,14 +594,14 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
{
|
||||
await using var context = CreateContext();
|
||||
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<ArgumentException>(() => service.SearchAsync(
|
||||
new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek,
|
||||
SortBy = "invalid"
|
||||
}, null));
|
||||
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null));
|
||||
|
||||
Assert.Contains("sortBy", ex.Message);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,12 +185,12 @@ public class WorkOrderBoardServiceTests
|
|||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(dataService);
|
||||
var boardService = new WorkOrderBoardService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto
|
||||
{
|
||||
WeekStart = weekStart
|
||||
}, null);
|
||||
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||
|
||||
Assert.Equal(1, response.Counts.Total);
|
||||
Assert.Single(response.Scheduled);
|
||||
|
|
@ -202,12 +202,12 @@ public class WorkOrderBoardServiceTests
|
|||
public async Task GetBoardAsync_InvalidWeekWindow_Throws()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(context));
|
||||
var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(context), WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => boardService.GetBoardAsync(new WorkOrderBoardQueryDto
|
||||
{
|
||||
WeekStart = new DateOnly(2026, 6, 22),
|
||||
WeekEnd = new DateOnly(2026, 6, 15)
|
||||
}, null));
|
||||
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,11 +93,12 @@ public class WorkOrderDetailServiceTests
|
|||
.Options;
|
||||
var context = new ApplicationDbContext(options);
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
||||
var boardService = new WorkOrderBoardService(boardData, resolver);
|
||||
var detailData = new WorkOrderDetailDataService(context);
|
||||
var templateData = new CompletionDocTemplateDataService(context);
|
||||
var userData = new UserDataService(context);
|
||||
var service = new WorkOrderDetailService(boardService, detailData, templateData, userData);
|
||||
var service = new WorkOrderDetailService(boardService, detailData, templateData, userData, resolver);
|
||||
return (context, service);
|
||||
}
|
||||
|
||||
|
|
@ -152,7 +153,7 @@ public class WorkOrderDetailServiceTests
|
|||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var detail = await service.GetDetailAsync(1);
|
||||
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
|
||||
|
||||
Assert.NotNull(detail);
|
||||
Assert.True(detail!.Info.IsPastDue);
|
||||
|
|
@ -222,7 +223,7 @@ public class WorkOrderDetailServiceTests
|
|||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var audit = await service.GetAuditAsync(1);
|
||||
var audit = await service.GetAuditAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
|
||||
|
||||
Assert.NotNull(audit);
|
||||
Assert.Equal(2, audit!.Count);
|
||||
|
|
|
|||
|
|
@ -28,10 +28,11 @@ public class WorkOrderPhase7CoexistenceTests
|
|||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "Phase7 Customer");
|
||||
|
||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||
var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit);
|
||||
var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
var payload = new WorkOrderIngestPayloadDto
|
||||
{
|
||||
|
|
@ -39,7 +40,8 @@ public class WorkOrderPhase7CoexistenceTests
|
|||
Description = "Initial",
|
||||
WoStatus = "new",
|
||||
Severity = "2",
|
||||
SiteCode = "BK1"
|
||||
SiteCode = "BK1",
|
||||
Customer = "Phase7 Customer"
|
||||
};
|
||||
|
||||
var create = await ingest.UpsertBatchAsync(new[] { payload });
|
||||
|
|
@ -66,19 +68,20 @@ public class WorkOrderPhase7CoexistenceTests
|
|||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
||||
var boardService = new WorkOrderBoardService(boardData, resolver);
|
||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||
var createService = new WorkOrderBoardCreateService(
|
||||
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation());
|
||||
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
|
||||
var policy = new SyncFieldMergePolicy(fieldLocks);
|
||||
var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), policy, fieldLocks, audit);
|
||||
var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), policy, fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5"
|
||||
}, "dispatcher-1");
|
||||
}, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1");
|
||||
|
||||
var wo = await context.workOrders.SingleAsync();
|
||||
wo.ExternalWorkOrderId = "ext-shoc-1";
|
||||
|
|
@ -126,7 +129,7 @@ public class WorkOrderPhase7CoexistenceTests
|
|||
|
||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||
var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit);
|
||||
var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
await ingest.UpsertBatchAsync(new[]
|
||||
{
|
||||
|
|
@ -151,15 +154,21 @@ public class WorkOrderPhase7CoexistenceTests
|
|||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "Phase7 Customer");
|
||||
|
||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||
var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit);
|
||||
var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
var result = await ingest.UpsertBatchAsync(new[]
|
||||
{
|
||||
new WorkOrderIngestPayloadDto { ExternalWorkOrderId = " ", Description = "bad" },
|
||||
new WorkOrderIngestPayloadDto { ExternalWorkOrderId = "ext-ok", Description = "good" }
|
||||
new WorkOrderIngestPayloadDto
|
||||
{
|
||||
ExternalWorkOrderId = "ext-ok",
|
||||
Description = "good",
|
||||
Customer = "Phase7 Customer"
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Equal(1, result.Created);
|
||||
|
|
@ -233,7 +242,7 @@ public class WorkOrderPhase7CoexistenceTests
|
|||
await context.SaveChangesAsync();
|
||||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
var count = await context.workOrders.CountAsync();
|
||||
|
||||
Assert.Equal(1, count);
|
||||
|
|
|
|||
|
|
@ -291,8 +291,8 @@ public class WorkOrderWeekRolledTests
|
|||
await service.ProcessWeekRolledAsync(SourceWeekStart);
|
||||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var row = await boardService.GetBoardRowAsync(1);
|
||||
var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
var row = await boardService.GetBoardRowAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
|
||||
|
||||
Assert.NotNull(row);
|
||||
Assert.Equal(1, row!.CarriedOver);
|
||||
|
|
@ -314,8 +314,8 @@ public class WorkOrderWeekRolledTests
|
|||
await context.SaveChangesAsync();
|
||||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var row = await boardService.GetBoardRowAsync(1);
|
||||
var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
var row = await boardService.GetBoardRowAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
|
||||
|
||||
Assert.NotNull(row);
|
||||
Assert.True(row!.IsPastDue);
|
||||
|
|
@ -338,8 +338,8 @@ public class WorkOrderWeekRolledTests
|
|||
await context.SaveChangesAsync();
|
||||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var row = await boardService.GetBoardRowAsync(1);
|
||||
var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
var row = await boardService.GetBoardRowAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
|
||||
|
||||
Assert.NotNull(row);
|
||||
Assert.False(row!.IsPastDue);
|
||||
|
|
|
|||
|
|
@ -1,20 +1,18 @@
|
|||
# ADR 0001: Work-order media uses single-org scope (board-aligned)
|
||||
# ADR 0001: Work-order domain uses server-derived account scope
|
||||
|
||||
## Status
|
||||
|
||||
**Superseded** (2026-08-06) by the SH-221 media slice in PR #47, with
|
||||
**fail-closed** account scope (follow-up on the same PR):
|
||||
**Superseded** (historical Proposed single-org ADR). Current contract (PR #47 / SH-221):
|
||||
|
||||
- `WorkOrder.AccountId` / `ApplicationUser.AccountId` schema keys
|
||||
- `WorkOrder.AccountId` / `ApplicationUser.AccountId` schema keys (nullable; legacy null fail-closed)
|
||||
- JWT `account_id` when `ApplicationUser.AccountId` is set
|
||||
- JWT `org_scope=all` when Admin has no AccountId (explicit signed elevation)
|
||||
- Media loads: `ApplyBaseScope` + `ApplyAccountScope(int)` when account-scoped;
|
||||
org-wide path skips account filter
|
||||
- **Reads** (board, list, advanced search, detail, media): `ApplyBaseScope` +
|
||||
`ApplyAccountScope(int)` when account-scoped; org-wide path skips account filter
|
||||
- **Creates** (board, AddWorkorder, ingest, webhook/recon, sync): stamp `AccountId`
|
||||
from claim or unique `Accounts.Name` ↔ `Customer` match; unresolvable → reject/skip
|
||||
- Missing/malformed scope → **Forbidden** (absence of claim does not elevate)
|
||||
|
||||
Board, detail, and search outside media still use base scope only until the
|
||||
remainder of [SH-221](https://luby-us.atlassian.net/browse/SH-221) lands.
|
||||
|
||||
## Context (historical)
|
||||
|
||||
SH-116 requires that cross-tenant, unauthorized, and out-of-scope media access
|
||||
|
|
@ -22,34 +20,44 @@ be rejected without metadata disclosure. An interim Proposed ADR allowed
|
|||
org-wide staff access via absence of an account claim; that path was rejected
|
||||
in review (fail-open) and replaced by the contract below.
|
||||
|
||||
## Current media contract (superseding)
|
||||
## Current domain contract (superseding)
|
||||
|
||||
1. **Organization boundary** = `ApplyBaseScope` (non-deleted, non-template).
|
||||
2. **Account boundary** = claim `account_id` → `WorkOrder.AccountId == claim`.
|
||||
3. **Org-wide** = claim `org_scope=all` only (issued to Admin without
|
||||
AccountId). Not inferred from missing `account_id`.
|
||||
4. **Fail-closed** = no valid account or org-scope claim → Forbidden.
|
||||
5. **Authorization at service entry**: staff roles may read/mutate any resulting
|
||||
work order; role `User` only when `AssignTo == actorId`; delete staff-only.
|
||||
6. **User lifecycle** persists `AccountId` on create/edit so non-Admin principals
|
||||
can receive `account_id`.
|
||||
5. **Create stamp**:
|
||||
- Authenticated + `account_id` → stamp claim (ignore client AccountId).
|
||||
- Authenticated + `org_scope=all` → unique Customer→Accounts.Name; else
|
||||
`AccountUnresolved`.
|
||||
- Ingest / webhook / sync → same Customer resolution; unresolved create is
|
||||
rejected or skipped (no null AccountId on new rows).
|
||||
6. **Legacy rows** with `AccountId == null` are invisible to account-scoped
|
||||
callers; only `org_scope=all` may read them.
|
||||
7. **Authorization at service entry**: staff roles may read/mutate any resulting
|
||||
work order; role `User` only when `AssignTo == actorId` (media); delete staff-only.
|
||||
8. **User lifecycle** persists `AccountId` on Admin create/edit so non-Admin
|
||||
principals can receive `account_id`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Cross-account and missing-scope media tests are required.
|
||||
- Dispatcher/Manager/Supervisor/User without AccountId cannot access media until
|
||||
AccountId is assigned (or they are Admin with `org_scope=all`).
|
||||
- Board/search/detail without account filtering remain a SH-221 follow-up.
|
||||
- Cross-account and missing-scope tests are required for create and read paths.
|
||||
- Dispatcher/Manager/Supervisor/User without AccountId cannot access board,
|
||||
detail, search, list, or media until AccountId is assigned (or they are Admin
|
||||
with `org_scope=all`).
|
||||
- Locations do not carry AccountId in the EF model; Customer name match is the
|
||||
unauthenticated resolution path.
|
||||
|
||||
## Excepted rule
|
||||
|
||||
None for media. Hard rule **server-derived tenant scope**
|
||||
(`ARCHITECTURE_AND_CODE_QUALITY.md` §2) is enforced via claims.
|
||||
None. Hard rule **server-derived tenant scope**
|
||||
(`ARCHITECTURE_AND_CODE_QUALITY.md` §2) is enforced via claims + AccountId.
|
||||
|
||||
## Review / expiry
|
||||
|
||||
Re-review when SH-221 closes remaining board/search/detail account filters, or
|
||||
by **2027-02-04**.
|
||||
Re-review by **2027-02-04**, or when AccountId becomes non-nullable with a
|
||||
full backfill migration.
|
||||
|
||||
## References
|
||||
|
||||
|
|
@ -57,5 +65,5 @@ by **2027-02-04**.
|
|||
- SH-221 — Server-derived tenant/customer scope for Work Order domain
|
||||
- PR: Sea-Haven-Industries/shoc-backend#47
|
||||
- `WorkOrderBoardQueryFilters.ApplyBaseScope` / `ApplyAccountScope`
|
||||
- `WorkOrderMediaAuthorization` / `SeaHavenClaimTypes`
|
||||
- `IWorkOrderAccountResolver` / `WorkOrderMediaAuthorization` / `SeaHavenClaimTypes`
|
||||
- `ARCHITECTURE_AND_CODE_QUALITY.md` §2, §10
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue