diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index fef6d6f..9660877 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -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 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 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 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 }); + } } } } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs index cbd896b..d32b77b 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs @@ -373,10 +373,15 @@ namespace SeaHaven.DataServices.Implementation return await _context.workOrders.CountAsync(w => w.LocationId == locationId); } - public async Task> GetAllWithDetailsAsync() + public async Task> GetAllWithDetailsAsync(int? accountId = null) { - return await _context.workOrders - .Where(w => w.istemplate != true) + IQueryable 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 GetWorkOrderDetailAsync(int id) + public async Task 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> GetNonTemplateWorkOrdersWithLocationsAsync() + public async Task> GetNonTemplateWorkOrdersWithLocationsAsync(int? accountId = null) { - return await _context.workOrders - .Where(w => w.istemplate != true) + IQueryable 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(); } diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs index 744042d..d1fb67f 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs @@ -71,7 +71,7 @@ namespace SeaHaven.DataServices.Interfaces Task CountByStatusAsync(string status); Task CountByPriorityAsync(string priority); Task CountByLocationAsync(int locationId); - Task> GetAllWithDetailsAsync(); + Task> 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 GetWorkOrderDetailAsync(int id); - Task> GetNonTemplateWorkOrdersWithLocationsAsync(); + Task GetWorkOrderDetailAsync(int id, int? accountId = null); + Task> GetNonTemplateWorkOrdersWithLocationsAsync(int? accountId = null); } } diff --git a/SeaHaven.Services/Implementation/WorkOrderService.cs b/SeaHaven.Services/Implementation/WorkOrderService.cs index 39b3c63..5a8e452 100644 --- a/SeaHaven.Services/Implementation/WorkOrderService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderService.cs @@ -138,17 +138,17 @@ namespace SeaHaven.Services.Implementation page, pageSize, search, status, assignee, locationId, sortBy, sortDir, accountId); } - public async Task GetWorkOrderDetailAsync(int id) + public async Task GetWorkOrderDetailAsync(int id, int? accountId = null) { - return await _workOrderDataService.GetWorkOrderDetailAsync(id); + return await _workOrderDataService.GetWorkOrderDetailAsync(id, accountId); } public async Task 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 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(); priority ??= Array.Empty(); @@ -238,7 +238,7 @@ namespace SeaHaven.Services.Implementation duedate ??= Array.Empty(); 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> GetWorkordersDDAsync() + public async Task> 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> GetWorkordersAsync() + public async Task> GetWorkordersAsync(int? accountId = null) { - var workOrders = await _workOrderDataService.GetAllWithDetailsAsync(); + var workOrders = await _workOrderDataService.GetAllWithDetailsAsync(accountId); return workOrders.Select(s => new WorkorderFilterVM { Id = s.Id, diff --git a/SeaHaven.Services/Interfaces/IWorkOrderService.cs b/SeaHaven.Services/Interfaces/IWorkOrderService.cs index 1cf7ef9..4267730 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderService.cs @@ -23,13 +23,15 @@ namespace SeaHaven.Services.Interfaces Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( int page, int pageSize, string? search, string? status, string? assignee, int? locationId, string? sortBy, string? sortDir, int? accountId = null); - Task GetWorkOrderDetailAsync(int id); + Task GetWorkOrderDetailAsync(int id, int? accountId = null); Task 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 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 ChangeStatusAsync(int id, string status, string userId); Task ChangeAssignmentAsync(int id, string? assignTo, string userId); Task CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, ClaimsPrincipal user, string userId); @@ -39,7 +41,7 @@ namespace SeaHaven.Services.Interfaces Task AddCommentJsonAsync(AddCommentInput input, string userId); Task> GetCommentsAsync(); Task> GetCommentsByWorkorderIdAsync(int woid); - Task> GetWorkordersDDAsync(); - Task> GetWorkordersAsync(); + Task> GetWorkordersDDAsync(int? accountId = null); + Task> GetWorkordersAsync(int? accountId = null); } } diff --git a/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs index 51f461f..e13efa1 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs @@ -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(), 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(); + } }