diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 0a4fd3a..c7928de 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -370,8 +370,15 @@ namespace Api.SeaHavenIndustries.Controllers [Route("GetComments")] public async Task GetComments() { - var data = await _workOrderService.GetCommentsAsync(); - return Ok(data); + try + { + var data = await _workOrderService.GetCommentsAsync(User); + return Ok(data); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message }); + } } [HttpGet] diff --git a/SeaHaven.DataServices/Implementation/CommentDataService.cs b/SeaHaven.DataServices/Implementation/CommentDataService.cs index 705beaf..ed7e423 100644 --- a/SeaHaven.DataServices/Implementation/CommentDataService.cs +++ b/SeaHaven.DataServices/Implementation/CommentDataService.cs @@ -1,5 +1,6 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -25,6 +26,20 @@ namespace SeaHaven.DataServices.Implementation .ToListAsync(); } + public async Task> GetAllForAccountAsync(int? accountId) + { + var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); + if (accountId.HasValue) + workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, accountId.Value); + + return await ( + from c in _context.Comments.AsNoTracking().Include(c => c.ApplicationUser) + join w in workOrders on c.WorkerOrderId equals w.Id + orderby c.CreatedDate + select c + ).ToListAsync(); + } + public async Task> GetByWorkOrderIdAsync(int workOrderId) { return await _context.Comments diff --git a/SeaHaven.DataServices/Interfaces/ICommentDataService.cs b/SeaHaven.DataServices/Interfaces/ICommentDataService.cs index a4e87f7..f551f0c 100644 --- a/SeaHaven.DataServices/Interfaces/ICommentDataService.cs +++ b/SeaHaven.DataServices/Interfaces/ICommentDataService.cs @@ -6,6 +6,11 @@ namespace SeaHaven.DataServices.Interfaces { Task GetByIdAsync(int id); Task> GetAllAsync(); + /// + /// Comments linked to non-deleted, non-template work orders. + /// When is set, only that account's WOs; null = org-wide. + /// + Task> GetAllForAccountAsync(int? accountId); Task> GetByWorkOrderIdAsync(int workOrderId); Task> GetByDispatchIdAsync(int dispatchId); Task AddAsync(Comments comment); diff --git a/SeaHaven.Services/Implementation/WorkOrderService.cs b/SeaHaven.Services/Implementation/WorkOrderService.cs index 6d3b5ea..6cfa57f 100644 --- a/SeaHaven.Services/Implementation/WorkOrderService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderService.cs @@ -486,9 +486,10 @@ namespace SeaHaven.Services.Implementation }; } - public async Task> GetCommentsAsync() + public async Task> GetCommentsAsync(ClaimsPrincipal user) { - var comments = await _commentDataService.GetAllAsync(); + var accountId = _accountResolver.ResolveAccountFilter(user); + var comments = await _commentDataService.GetAllForAccountAsync(accountId); return comments.Select(s => new CommentListItemReadModel { Id = s.Id, diff --git a/SeaHaven.Services/Interfaces/IWorkOrderService.cs b/SeaHaven.Services/Interfaces/IWorkOrderService.cs index a7bf9aa..842e641 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderService.cs @@ -39,7 +39,7 @@ namespace SeaHaven.Services.Interfaces Task DeleteWorkOrderCascadeAsync(int id, string userId); Task AddCommentAsync(AddCommentInput input, ClaimsPrincipal user, string userId); Task AddCommentJsonAsync(AddCommentInput input, ClaimsPrincipal user, string userId); - Task> GetCommentsAsync(); + Task> GetCommentsAsync(ClaimsPrincipal user); Task?> GetCommentsByWorkorderIdAsync(int woid, ClaimsPrincipal user); Task> GetWorkordersDDAsync(int? accountId = null); Task> GetWorkordersAsync(int? accountId = null); diff --git a/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs index eeefa36..4b75111 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs @@ -636,4 +636,50 @@ public class WorkOrderAccountScopeTests Assert.Equal(DocStatus.Yes, result.DocStatus); Assert.Equal("https://example.com/ok.pdf", result.SignOffAttachment); } + + [Fact] + public async Task LegacyGetComments_ScopedUser_HidesOtherAccountComments() + { + await using var context = CreateContext(); + await SeedThreeAccountRowsAsync(context); + + context.Comments.AddRange( + new Comments + { + WorkerOrderId = 1, + Commenttext = "Account A note", + Documents = "a.pdf", + CreatedDate = DateTime.UtcNow + }, + new Comments + { + WorkerOrderId = 2, + Commenttext = "Account B secret", + Documents = "b.pdf", + CreatedDate = DateTime.UtcNow + }, + new Comments + { + WorkerOrderId = 3, + Commenttext = "Null account note", + CreatedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + var service = CreateLegacyReadService(context); + var scoped = await service.GetCommentsAsync( + WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher")); + + Assert.Single(scoped); + Assert.Equal("Account A note", scoped.Single().Commenttext); + Assert.DoesNotContain(scoped, c => c.Commenttext == "Account B secret"); + Assert.DoesNotContain(scoped, c => c.Commenttext == "Null account note"); + + var orgWide = await service.GetCommentsAsync(WorkOrderAccountTestHelpers.OrgWideAdmin()); + Assert.Equal(3, orgWide.Count()); + + var missingEx = await Assert.ThrowsAsync(() => + service.GetCommentsAsync(WorkOrderAccountTestHelpers.MissingScope())); + Assert.Equal("Forbidden", missingEx.Code); + } } diff --git a/docs/adr/0001-work-order-single-org-scope.md b/docs/adr/0001-work-order-single-org-scope.md index 2e17637..79b1599 100644 --- a/docs/adr/0001-work-order-single-org-scope.md +++ b/docs/adr/0001-work-order-single-org-scope.md @@ -8,7 +8,7 @@ - JWT `account_id` when `ApplicationUser.AccountId` is set - JWT `org_scope=all` when Admin has no AccountId (explicit signed elevation) - **Reads** (board, list, advanced search, detail, media, board comments, - legacy comments-by-work-order-id): `ApplyBaseScope` + + legacy comments-by-work-order-id, legacy `GET GetComments`): `ApplyBaseScope` + `ApplyAccountScope(int)` when account-scoped; org-wide path skips account filter - **Writes** (board mutations, media, board/legacy comments, `POST …/completion-doc`): same account filter at service/data entry; authorize before storing blobs