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
{
var accountId = _accountResolver.ResolveAccountFilter(User);
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);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
}
catch (Exception)
{
return StatusCode(500, "Internal Server Error");
@ -207,10 +212,18 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet("GetWorkorderById")]
public async Task<IActionResult> GetWorkorderById(int id)
{
var detail = await _workOrderService.GetWorkOrderDetailAsync(id);
if (detail == null)
return BadRequest(new Response { Status = "Error", Message = "ID not found!" });
return Ok(detail);
try
{
var accountId = _accountResolver.ResolveAccountFilter(User);
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]
@ -229,10 +242,15 @@ namespace Api.SeaHavenIndustries.Controllers
{
try
{
var accountId = _accountResolver.ResolveAccountFilter(User);
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);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
}
catch (Exception)
{
var totalCount = await _workOrderService.GetTotalWorkOrderCountAsync();
@ -352,16 +370,32 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("GetworkordersDD")]
public async Task<IActionResult> GetworkordersDD()
{
var data = await _workOrderService.GetWorkordersDDAsync();
return Ok(data);
try
{
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]
[Route("Getworkorders")]
public async Task<IActionResult> Getworkorders()
{
var data = await _workOrderService.GetWorkordersAsync();
return Ok(data);
try
{
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);
}
public async Task<IEnumerable<WorkOrder>> GetAllWithDetailsAsync()
public async Task<IEnumerable<WorkOrder>> GetAllWithDetailsAsync(int? accountId = null)
{
return await _context.workOrders
.Where(w => w.istemplate != true)
IQueryable<WorkOrder> query = _context.workOrders
.Where(w => w.istemplate != true);
if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
return await query
.Include(w => w.Locations)
.Include(w => w.AssignToUser)
.AsSplitQuery()
@ -408,11 +413,16 @@ namespace SeaHaven.DataServices.Implementation
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()
.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
{
Id = s.Id,
@ -509,10 +519,15 @@ namespace SeaHaven.DataServices.Implementation
}).FirstOrDefaultAsync();
}
public async Task<List<WorkOrder>> GetNonTemplateWorkOrdersWithLocationsAsync()
public async Task<List<WorkOrder>> GetNonTemplateWorkOrdersWithLocationsAsync(int? accountId = null)
{
return await _context.workOrders
.Where(w => w.istemplate != true)
IQueryable<WorkOrder> query = _context.workOrders
.Where(w => w.istemplate != true);
if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
return await query
.Include(w => w.Locations)
.ToListAsync();
}

View file

@ -71,7 +71,7 @@ namespace SeaHaven.DataServices.Interfaces
Task<int> CountByStatusAsync(string status);
Task<int> CountByPriorityAsync(string priority);
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,
// eagerly loads the location detail, deduplicates, and returns results in input order.
@ -80,7 +80,7 @@ namespace SeaHaven.DataServices.Interfaces
CancellationToken cancellationToken);
// Complex read models
Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id);
Task<List<WorkOrder>> GetNonTemplateWorkOrdersWithLocationsAsync();
Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id, int? accountId = null);
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);
}
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(
string[] assignto, int[]? location, string[]? priority,
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();
@ -230,7 +230,7 @@ namespace SeaHaven.Services.Implementation
public async Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrders2Async(
string[] assignto, int[]? location, string[]? priority,
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>();
priority ??= Array.Empty<string>();
@ -238,7 +238,7 @@ namespace SeaHaven.Services.Implementation
duedate ??= Array.Empty<string>();
search ??= string.Empty;
var allWorkOrders = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync();
var allWorkOrders = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(accountId);
var totalCount = allWorkOrders.Count;
var currentDate = DateTime.UtcNow.Date;
@ -505,15 +505,15 @@ namespace SeaHaven.Services.Implementation
}).ToList();
}
public async Task<IEnumerable<WorkOrder>> GetWorkordersDDAsync()
public async Task<IEnumerable<WorkOrder>> GetWorkordersDDAsync(int? accountId = null)
{
var data = await _workOrderDataService.GetAllAsync();
return data.Where(w => w.istemplate != true).ToList();
var data = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(accountId);
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
{
Id = s.Id,

View file

@ -23,13 +23,15 @@ 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? accountId = null);
Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id);
Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id, int? accountId = null);
Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrdersAsync(
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(
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<ChangeAssignmentResult?> ChangeAssignmentAsync(int id, string? assignTo, 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<IEnumerable<CommentListItemReadModel>> GetCommentsAsync();
Task<IEnumerable<CommentListItemReadModel>> GetCommentsByWorkorderIdAsync(int woid);
Task<IEnumerable<WorkOrder>> GetWorkordersDDAsync();
Task<IEnumerable<WorkorderFilterVM>> GetWorkordersAsync();
Task<IEnumerable<WorkOrder>> GetWorkordersDDAsync(int? accountId = null);
Task<IEnumerable<WorkorderFilterVM>> GetWorkordersAsync(int? accountId = null);
}
}

View file

@ -301,4 +301,132 @@ public class WorkOrderAccountScopeTests
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();
}
}