fix(work-orders): scope legacy list/detail GETs by account [SH-221]

Close the remaining SH-221 read gap so Getworkorders, filtered lists, and GetWorkorderById enforce the same server-derived account boundary as board/media.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Arthur Bassi 2026-08-11 11:57:37 -03:00
parent bca2e0d50d
commit 62a4828e2f
6 changed files with 217 additions and 38 deletions

View file

@ -193,10 +193,15 @@ namespace Api.SeaHavenIndustries.Controllers
{ {
try try
{ {
var accountId = _accountResolver.ResolveAccountFilter(User);
var result = await _workOrderService.GetFilteredWorkOrdersAsync( var result = await _workOrderService.GetFilteredWorkOrdersAsync(
assignto, location, priority, status, duedate, search, sort, sortby, page, pageSize); assignto, location, priority, status, duedate, search, sort, sortby, page, pageSize, accountId);
return Ok(result); return Ok(result);
} }
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
}
catch (Exception) catch (Exception)
{ {
return StatusCode(500, "Internal Server Error"); return StatusCode(500, "Internal Server Error");
@ -207,10 +212,18 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet("GetWorkorderById")] [HttpGet("GetWorkorderById")]
public async Task<IActionResult> GetWorkorderById(int id) public async Task<IActionResult> GetWorkorderById(int id)
{ {
var detail = await _workOrderService.GetWorkOrderDetailAsync(id); try
if (detail == null) {
return BadRequest(new Response { Status = "Error", Message = "ID not found!" }); var accountId = _accountResolver.ResolveAccountFilter(User);
return Ok(detail); var detail = await _workOrderService.GetWorkOrderDetailAsync(id, accountId);
if (detail == null)
return BadRequest(new Response { Status = "Error", Message = "ID not found!" });
return Ok(detail);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
}
} }
[HttpGet] [HttpGet]
@ -229,10 +242,15 @@ namespace Api.SeaHavenIndustries.Controllers
{ {
try try
{ {
var accountId = _accountResolver.ResolveAccountFilter(User);
var result = await _workOrderService.GetFilteredWorkOrders2Async( var result = await _workOrderService.GetFilteredWorkOrders2Async(
assignto, location, priority, status, duedate, search, sort, sortby, page, pageSize); assignto, location, priority, status, duedate, search, sort, sortby, page, pageSize, accountId);
return Ok(result); return Ok(result);
} }
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
}
catch (Exception) catch (Exception)
{ {
var totalCount = await _workOrderService.GetTotalWorkOrderCountAsync(); var totalCount = await _workOrderService.GetTotalWorkOrderCountAsync();
@ -352,16 +370,32 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("GetworkordersDD")] [Route("GetworkordersDD")]
public async Task<IActionResult> GetworkordersDD() public async Task<IActionResult> GetworkordersDD()
{ {
var data = await _workOrderService.GetWorkordersDDAsync(); try
return Ok(data); {
var accountId = _accountResolver.ResolveAccountFilter(User);
var data = await _workOrderService.GetWorkordersDDAsync(accountId);
return Ok(data);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
}
} }
[HttpGet] [HttpGet]
[Route("Getworkorders")] [Route("Getworkorders")]
public async Task<IActionResult> Getworkorders() public async Task<IActionResult> Getworkorders()
{ {
var data = await _workOrderService.GetWorkordersAsync(); try
return Ok(data); {
var accountId = _accountResolver.ResolveAccountFilter(User);
var data = await _workOrderService.GetWorkordersAsync(accountId);
return Ok(data);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
}
} }
} }
} }

View file

@ -373,10 +373,15 @@ namespace SeaHaven.DataServices.Implementation
return await _context.workOrders.CountAsync(w => w.LocationId == locationId); return await _context.workOrders.CountAsync(w => w.LocationId == locationId);
} }
public async Task<IEnumerable<WorkOrder>> GetAllWithDetailsAsync() public async Task<IEnumerable<WorkOrder>> GetAllWithDetailsAsync(int? accountId = null)
{ {
return await _context.workOrders IQueryable<WorkOrder> query = _context.workOrders
.Where(w => w.istemplate != true) .Where(w => w.istemplate != true);
if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
return await query
.Include(w => w.Locations) .Include(w => w.Locations)
.Include(w => w.AssignToUser) .Include(w => w.AssignToUser)
.AsSplitQuery() .AsSplitQuery()
@ -408,11 +413,16 @@ namespace SeaHaven.DataServices.Implementation
return ordered; return ordered;
} }
public async Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id) public async Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id, int? accountId = null)
{ {
return await _context.workOrders var query = _context.workOrders
.AsNoTracking() .AsNoTracking()
.Where(w => w.Id == id) .Where(w => w.Id == id);
if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
return await query
.Select(s => new WorkOrderDetailReadModel .Select(s => new WorkOrderDetailReadModel
{ {
Id = s.Id, Id = s.Id,
@ -509,10 +519,15 @@ namespace SeaHaven.DataServices.Implementation
}).FirstOrDefaultAsync(); }).FirstOrDefaultAsync();
} }
public async Task<List<WorkOrder>> GetNonTemplateWorkOrdersWithLocationsAsync() public async Task<List<WorkOrder>> GetNonTemplateWorkOrdersWithLocationsAsync(int? accountId = null)
{ {
return await _context.workOrders IQueryable<WorkOrder> query = _context.workOrders
.Where(w => w.istemplate != true) .Where(w => w.istemplate != true);
if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
return await query
.Include(w => w.Locations) .Include(w => w.Locations)
.ToListAsync(); .ToListAsync();
} }

View file

@ -71,7 +71,7 @@ namespace SeaHaven.DataServices.Interfaces
Task<int> CountByStatusAsync(string status); Task<int> CountByStatusAsync(string status);
Task<int> CountByPriorityAsync(string priority); Task<int> CountByPriorityAsync(string priority);
Task<int> CountByLocationAsync(int locationId); Task<int> CountByLocationAsync(int locationId);
Task<IEnumerable<WorkOrder>> GetAllWithDetailsAsync(); Task<IEnumerable<WorkOrder>> GetAllWithDetailsAsync(int? accountId = null);
// Batched lookup: server-filters the distinct requested ids, excludes templates, // Batched lookup: server-filters the distinct requested ids, excludes templates,
// eagerly loads the location detail, deduplicates, and returns results in input order. // eagerly loads the location detail, deduplicates, and returns results in input order.
@ -80,7 +80,7 @@ namespace SeaHaven.DataServices.Interfaces
CancellationToken cancellationToken); CancellationToken cancellationToken);
// Complex read models // Complex read models
Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id); Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id, int? accountId = null);
Task<List<WorkOrder>> GetNonTemplateWorkOrdersWithLocationsAsync(); Task<List<WorkOrder>> GetNonTemplateWorkOrdersWithLocationsAsync(int? accountId = null);
} }
} }

View file

@ -138,17 +138,17 @@ namespace SeaHaven.Services.Implementation
page, pageSize, search, status, assignee, locationId, sortBy, sortDir, accountId); page, pageSize, search, status, assignee, locationId, sortBy, sortDir, accountId);
} }
public async Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id) public async Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id, int? accountId = null)
{ {
return await _workOrderDataService.GetWorkOrderDetailAsync(id); return await _workOrderDataService.GetWorkOrderDetailAsync(id, accountId);
} }
public async Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrdersAsync( public async Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrdersAsync(
string[] assignto, int[]? location, string[]? priority, string[] assignto, int[]? location, string[]? priority,
string[]? status, string[]? duedate, string search, string[]? status, string[]? duedate, string search,
string sort, string sortby, int page, int pageSize) string sort, string sortby, int page, int pageSize, int? accountId = null)
{ {
var allWorkOrders = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(); var allWorkOrders = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(accountId);
var data = allWorkOrders.AsEnumerable(); var data = allWorkOrders.AsEnumerable();
@ -230,7 +230,7 @@ namespace SeaHaven.Services.Implementation
public async Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrders2Async( public async Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrders2Async(
string[] assignto, int[]? location, string[]? priority, string[] assignto, int[]? location, string[]? priority,
string[]? status, string[]? duedate, string search, string[]? status, string[]? duedate, string search,
string sort, string sortby, int page, int pageSize) string sort, string sortby, int page, int pageSize, int? accountId = null)
{ {
location ??= Array.Empty<int>(); location ??= Array.Empty<int>();
priority ??= Array.Empty<string>(); priority ??= Array.Empty<string>();
@ -238,7 +238,7 @@ namespace SeaHaven.Services.Implementation
duedate ??= Array.Empty<string>(); duedate ??= Array.Empty<string>();
search ??= string.Empty; search ??= string.Empty;
var allWorkOrders = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(); var allWorkOrders = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(accountId);
var totalCount = allWorkOrders.Count; var totalCount = allWorkOrders.Count;
var currentDate = DateTime.UtcNow.Date; var currentDate = DateTime.UtcNow.Date;
@ -505,15 +505,15 @@ namespace SeaHaven.Services.Implementation
}).ToList(); }).ToList();
} }
public async Task<IEnumerable<WorkOrder>> GetWorkordersDDAsync() public async Task<IEnumerable<WorkOrder>> GetWorkordersDDAsync(int? accountId = null)
{ {
var data = await _workOrderDataService.GetAllAsync(); var data = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(accountId);
return data.Where(w => w.istemplate != true).ToList(); return data;
} }
public async Task<IEnumerable<WorkorderFilterVM>> GetWorkordersAsync() public async Task<IEnumerable<WorkorderFilterVM>> GetWorkordersAsync(int? accountId = null)
{ {
var workOrders = await _workOrderDataService.GetAllWithDetailsAsync(); var workOrders = await _workOrderDataService.GetAllWithDetailsAsync(accountId);
return workOrders.Select(s => new WorkorderFilterVM return workOrders.Select(s => new WorkorderFilterVM
{ {
Id = s.Id, Id = s.Id,

View file

@ -23,13 +23,15 @@ namespace SeaHaven.Services.Interfaces
Task<(IEnumerable<WorkOrderListItemReadModel> Items, int TotalCount)> GetWorkOrderListPagedAsync( Task<(IEnumerable<WorkOrderListItemReadModel> Items, int TotalCount)> GetWorkOrderListPagedAsync(
int page, int pageSize, string? search, string? status, string? assignee, int page, int pageSize, string? search, string? status, string? assignee,
int? locationId, string? sortBy, string? sortDir, int? accountId = null); int? locationId, string? sortBy, string? sortDir, int? accountId = null);
Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id); Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id, int? accountId = null);
Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrdersAsync( Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrdersAsync(
string[] assignto, int[]? location, string[]? priority, string[] assignto, int[]? location, string[]? priority,
string[]? status, string[]? duedate, string search, string sort, string sortby, int page, int pageSize); string[]? status, string[]? duedate, string search, string sort, string sortby, int page, int pageSize,
int? accountId = null);
Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrders2Async( Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrders2Async(
string[] assignto, int[]? location, string[]? priority, string[] assignto, int[]? location, string[]? priority,
string[]? status, string[]? duedate, string search, string sort, string sortby, int page, int pageSize); string[]? status, string[]? duedate, string search, string sort, string sortby, int page, int pageSize,
int? accountId = null);
Task<WorkOrderDTO?> ChangeStatusAsync(int id, string status, string userId); Task<WorkOrderDTO?> ChangeStatusAsync(int id, string status, string userId);
Task<ChangeAssignmentResult?> ChangeAssignmentAsync(int id, string? assignTo, string userId); Task<ChangeAssignmentResult?> ChangeAssignmentAsync(int id, string? assignTo, string userId);
Task<int> CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, ClaimsPrincipal user, string userId); Task<int> CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, ClaimsPrincipal user, string userId);
@ -39,7 +41,7 @@ namespace SeaHaven.Services.Interfaces
Task<CommentResult> AddCommentJsonAsync(AddCommentInput input, string userId); Task<CommentResult> AddCommentJsonAsync(AddCommentInput input, string userId);
Task<IEnumerable<CommentListItemReadModel>> GetCommentsAsync(); Task<IEnumerable<CommentListItemReadModel>> GetCommentsAsync();
Task<IEnumerable<CommentListItemReadModel>> GetCommentsByWorkorderIdAsync(int woid); Task<IEnumerable<CommentListItemReadModel>> GetCommentsByWorkorderIdAsync(int woid);
Task<IEnumerable<WorkOrder>> GetWorkordersDDAsync(); Task<IEnumerable<WorkOrder>> GetWorkordersDDAsync(int? accountId = null);
Task<IEnumerable<WorkorderFilterVM>> GetWorkordersAsync(); Task<IEnumerable<WorkorderFilterVM>> GetWorkordersAsync(int? accountId = null);
} }
} }

View file

@ -301,4 +301,132 @@ public class WorkOrderAccountScopeTests
Assert.Equal("Forbidden", ex.Code); Assert.Equal("Forbidden", ex.Code);
} }
[Fact]
public async Task LegacyList_ScopedUser_HidesOtherAccountAndNullAccountRows()
{
await using var context = CreateContext();
await SeedThreeAccountRowsAsync(context);
var data = new WorkOrderDataService(context);
var scoped = (await data.GetAllWithDetailsAsync(accountId: 1)).ToList();
var dd = await data.GetNonTemplateWorkOrdersWithLocationsAsync(accountId: 1);
Assert.Single(scoped);
Assert.Equal(1, scoped[0].Id);
Assert.Single(dd);
Assert.Equal(1, dd[0].Id);
}
[Fact]
public async Task LegacyList_OrgWide_IncludesNullAccountRows()
{
await using var context = CreateContext();
await SeedThreeAccountRowsAsync(context);
var data = new WorkOrderDataService(context);
var orgWide = (await data.GetAllWithDetailsAsync(accountId: null)).ToList();
Assert.Equal(3, orgWide.Count);
}
[Fact]
public async Task LegacyDetail_CrossAccount_ReturnsNull()
{
await using var context = CreateContext();
await SeedThreeAccountRowsAsync(context);
var data = new WorkOrderDataService(context);
var detail = await data.GetWorkOrderDetailAsync(2, accountId: 1);
Assert.Null(detail);
}
[Fact]
public async Task LegacyDetail_SameAccount_ReturnsRow()
{
await using var context = CreateContext();
await SeedThreeAccountRowsAsync(context);
var data = new WorkOrderDataService(context);
var detail = await data.GetWorkOrderDetailAsync(1, accountId: 1);
Assert.NotNull(detail);
Assert.Equal(1, detail!.Id);
}
[Fact]
public async Task LegacyDetail_OrgWide_CanReadNullAccountRow()
{
await using var context = CreateContext();
await SeedThreeAccountRowsAsync(context);
var data = new WorkOrderDataService(context);
var detail = await data.GetWorkOrderDetailAsync(3, accountId: null);
Assert.NotNull(detail);
Assert.Equal(3, detail!.Id);
}
[Fact]
public async Task LegacyFiltered_ScopedUser_HidesOtherAccountAndNullAccountRows()
{
await using var context = CreateContext();
await SeedThreeAccountRowsAsync(context);
var service = CreateLegacyReadService(context);
var page = await service.GetFilteredWorkOrdersAsync(
Array.Empty<string>(), null, null, null, null,
search: "", sort: "", sortby: "", page: 1, pageSize: 50, accountId: 1);
Assert.Equal(1, page.TotalCount);
Assert.Single(page.Data);
Assert.Equal(1, page.Data.First().Id);
}
private static WorkOrderService CreateLegacyReadService(ApplicationDbContext context)
{
return new WorkOrderService(
new WorkOrderDataService(context),
new CommentDataService(context),
new UserDataService(context),
new QuotesDataService(context),
fileStorage: null!,
new CreateWorkOrderValidation(),
new UpdateWorkOrderValidation(),
WorkOrderAccountTestHelpers.Resolver(context));
}
private static async Task SeedThreeAccountRowsAsync(ApplicationDbContext context)
{
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,
istemplate = false
},
new WorkOrder
{
Id = 2,
InternalWONumber = "00000000002",
AccountId = 2,
LifecycleStatus = LifecycleStatus.Scheduled,
istemplate = false
},
new WorkOrder
{
Id = 3,
InternalWONumber = "00000000003",
AccountId = null,
LifecycleStatus = LifecycleStatus.Scheduled,
istemplate = false
});
await context.SaveChangesAsync();
}
} }