diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs index e253cbd..bf90361 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs @@ -106,10 +106,13 @@ namespace Api.SeaHavenIndustries.Controllers if (file == null || file.Length == 0) return BadRequest(new Response { Status = "Error", Message = "file is required." }); + string? fileUrl = null; try { var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var fileUrl = await _fileStorage.SaveFileAsync(file); + await _workOrderCompletionService.EnsureCanUploadCompletionDocAsync(id, User, actorId); + + fileUrl = await _fileStorage.SaveFileAsync(file); var result = await _workOrderCompletionService.UploadCompletionDocAsync( id, new WorkOrderCompletionDocUploadDto @@ -119,21 +122,37 @@ namespace Api.SeaHavenIndustries.Controllers WorkOrderVersion = workOrderVersion }, fileUrl, + User, actorId); return Ok(result); } catch (WorkOrderBoardValidationException ex) when (ex.Code is "NotFound") { + TryCompensateUpload(fileUrl); return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); } + catch (WorkOrderBoardValidationException ex) when (ex.Code is "Forbidden") + { + TryCompensateUpload(fileUrl); + return StatusCode(StatusCodes.Status403Forbidden, + new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } catch (WorkOrderBoardValidationException ex) when (ex.Code is "ConcurrencyConflict") { + TryCompensateUpload(fileUrl); return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); } catch (WorkOrderBoardValidationException ex) { + TryCompensateUpload(fileUrl); return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); } } + + private void TryCompensateUpload(string? fileUrl) + { + if (!string.IsNullOrWhiteSpace(fileUrl)) + _fileStorage.TryDelete(fileUrl); + } } } diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 9660877..0a4fd3a 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -318,9 +318,17 @@ namespace Api.SeaHavenIndustries.Controllers Document = model.Document }; - var saved = await _workOrderService.AddCommentAsync(input, userId!); + var saved = await _workOrderService.AddCommentAsync(input, User, userId!); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200", Data = saved }); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new Response { Status = "Error", Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message }); + } catch (Exception ex) { return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); @@ -341,9 +349,17 @@ namespace Api.SeaHavenIndustries.Controllers CommentType = model.CommentType }; - var result = await _workOrderService.AddCommentJsonAsync(input, userId!); + var result = await _workOrderService.AddCommentJsonAsync(input, User, userId!); return Ok(result); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new Response { Status = "Error", Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message }); + } catch (Exception ex) { return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); @@ -362,8 +378,17 @@ namespace Api.SeaHavenIndustries.Controllers [Route("GetCommentsByWorkorderId")] public async Task GetCommentsByWorkorderId(int woid) { - var data = await _workOrderService.GetCommentsByWorkorderIdAsync(woid); - return Ok(data); + try + { + var data = await _workOrderService.GetCommentsByWorkorderIdAsync(woid, User); + if (data == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + 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/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs index 489e106..8f0d654 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs @@ -61,10 +61,18 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("{id:int}/comments")] public async Task GetBoardComments(int id) { - var comments = await _workOrderCommentService.GetCommentsAsync(id); - if (comments == null) - return NotFound(new Response { Status = "Error", Message = "Work order not found." }); - return Ok(comments); + try + { + var comments = await _workOrderCommentService.GetCommentsAsync(id, User); + if (comments == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(comments); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, + new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } } [HttpPost("{id:int}/comments")] @@ -73,13 +81,18 @@ namespace Api.SeaHavenIndustries.Controllers try { var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var comment = await _workOrderCommentService.AddCommentAsync(id, request, actorId); + var comment = await _workOrderCommentService.AddCommentAsync(id, request, User, actorId); return Ok(comment); } catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") { return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, + new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } catch (WorkOrderBoardValidationException ex) { return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); @@ -96,7 +109,7 @@ namespace Api.SeaHavenIndustries.Controllers { var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); var comment = await _workOrderCommentService.UpdateCommentAsync( - id, commentId, request, actorId); + id, commentId, request, User, actorId); return Ok(comment); } catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") diff --git a/SeaHaven.DataServices/Implementation/WorkOrderCompletionDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderCompletionDataService.cs index 25369bd..0dae9d5 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderCompletionDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderCompletionDataService.cs @@ -1,5 +1,6 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -13,10 +14,29 @@ namespace SeaHaven.DataServices.Implementation _context = context; } - public Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) - => _context.workOrders.FirstOrDefaultAsync( - w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null), - cancellationToken); + public Task GetWorkOrderForCompletionAuthAsync( + int workOrderId, + int? accountId, + CancellationToken cancellationToken) + { + var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); + if (accountId.HasValue) + query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value); + + return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken); + } + + public Task GetTrackedWorkOrderAsync( + int workOrderId, + int? accountId, + CancellationToken cancellationToken) + { + var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders); + if (accountId.HasValue) + query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value); + + return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken); + } public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version) => _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version; diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderCompletionDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderCompletionDataService.cs index aa6174c..3116652 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderCompletionDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderCompletionDataService.cs @@ -4,7 +4,16 @@ namespace SeaHaven.DataServices.Interfaces { public interface IWorkOrderCompletionDataService { - Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); + Task GetWorkOrderForCompletionAuthAsync( + int workOrderId, + int? accountId, + CancellationToken cancellationToken); + + Task GetTrackedWorkOrderAsync( + int workOrderId, + int? accountId, + CancellationToken cancellationToken); + void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version); Task GetTrackedTemplateAsync(int id, CancellationToken cancellationToken); Task SaveAsync(CancellationToken cancellationToken); diff --git a/SeaHaven.Services/Implementation/WorkOrderCommentService.cs b/SeaHaven.Services/Implementation/WorkOrderCommentService.cs index e0ad136..8d65ef1 100644 --- a/SeaHaven.Services/Implementation/WorkOrderCommentService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderCommentService.cs @@ -1,4 +1,5 @@ -using Data.SeaHavenIndustries; +using System.Security.Claims; +using Data.SeaHavenIndustries; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; @@ -12,20 +13,26 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderDetailDataService _detailData; private readonly ICommentDataService _commentData; private readonly IUserDataService _userDataService; + private readonly IWorkOrderAccountResolver _accountResolver; public WorkOrderCommentService( IWorkOrderDetailDataService detailData, ICommentDataService commentData, - IUserDataService userDataService) + IUserDataService userDataService, + IWorkOrderAccountResolver accountResolver) { _detailData = detailData; _commentData = commentData; _userDataService = userDataService; + _accountResolver = accountResolver; } - public async Task?> GetCommentsAsync(int workOrderId) + public async Task?> GetCommentsAsync( + 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 comments = await _detailData.GetCommentsAsync(workOrderId); @@ -36,10 +43,12 @@ namespace SeaHaven.Services.Implementation public async Task AddCommentAsync( int workOrderId, WorkOrderCommentCreateDto request, + ClaimsPrincipal user, string? actorId, string? documentUrl = null) { - if (!await _detailData.ExistsAsync(workOrderId)) + var accountId = _accountResolver.ResolveAccountFilter(user); + if (!await _detailData.ExistsAsync(workOrderId, CancellationToken.None, accountId)) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); if (string.IsNullOrWhiteSpace(request.Text)) @@ -65,9 +74,12 @@ namespace SeaHaven.Services.Implementation int workOrderId, int commentId, WorkOrderCommentCreateDto request, + ClaimsPrincipal user, string? actorId) { - var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId); + var accountId = _accountResolver.ResolveAccountFilter(user); + var workOrder = await _detailData.GetWorkOrderForMediaAsync( + workOrderId, CancellationToken.None, accountId); if (workOrder == null) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); diff --git a/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs index 2ec8f5b..534cd6b 100644 --- a/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using SeaHaven.DataServices.Interfaces; @@ -14,17 +15,20 @@ namespace SeaHaven.Services.Implementation private readonly ICompletionDocTemplateDataService _templateData; private readonly IWorkOrderDetailDataService _detailData; private readonly IWorkOrderAuditService _auditService; + private readonly IWorkOrderAccountResolver _accountResolver; public WorkOrderCompletionService( IWorkOrderCompletionDataService completionData, ICompletionDocTemplateDataService templateData, IWorkOrderDetailDataService detailData, - IWorkOrderAuditService auditService) + IWorkOrderAuditService auditService, + IWorkOrderAccountResolver accountResolver) { _completionData = completionData; _templateData = templateData; _detailData = detailData; _auditService = auditService; + _accountResolver = accountResolver; } public async Task> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType) @@ -39,13 +43,34 @@ namespace SeaHaven.Services.Implementation return row == null ? null : WorkOrderDetailService.MapTemplate(row); } + public async Task EnsureCanUploadCompletionDocAsync( + int workOrderId, + ClaimsPrincipal user, + string? actorId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(actorId) || user?.Identity?.IsAuthenticated != true) + { + throw new WorkOrderBoardValidationException( + "Forbidden", + "You are not allowed to upload completion documents."); + } + + // AsNoTracking pre-check so UploadCompletionDocAsync load is not stale-cached. + await GetMutableWorkOrderForAuthAsync(workOrderId, user, cancellationToken); + } + public async Task UploadCompletionDocAsync( int workOrderId, WorkOrderCompletionDocUploadDto request, string fileUrl, - string? actorId) + ClaimsPrincipal user, + string? actorId, + CancellationToken cancellationToken = default) { - var workOrder = await _completionData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); + var accountId = _accountResolver.ResolveAccountFilter(user); + var workOrder = await _completionData.GetTrackedWorkOrderAsync( + workOrderId, accountId, cancellationToken); if (workOrder == null) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); @@ -77,7 +102,7 @@ namespace SeaHaven.Services.Implementation if (oldDocStatus != DocStatus.Yes.ToString()) await _auditService.StageFieldChangedAsync(workOrderId, "DocStatus", oldDocStatus, DocStatus.Yes.ToString(), actorId); - await _completionData.SaveAsync(CancellationToken.None); + await _completionData.SaveAsync(cancellationToken); var extended = await _detailData.GetExtendedFieldsAsync(workOrderId); var template = await _templateData.ResolveForWorkOrderAsync(workOrder.Trade, workOrder.WorkOrderType); @@ -143,6 +168,23 @@ namespace SeaHaven.Services.Implementation throw new WorkOrderBoardValidationException("NotFound", "Template not found."); } + private async Task GetMutableWorkOrderForAuthAsync( + int workOrderId, + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + var accountId = _accountResolver.ResolveAccountFilter(user); + var workOrder = await _completionData.GetWorkOrderForCompletionAuthAsync( + workOrderId, accountId, cancellationToken); + if (workOrder == null) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) + throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); + + return workOrder; + } + private static void ValidateTemplateRequest(CompletionDocTemplateCreateDto request) { if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.ServiceKey)) diff --git a/SeaHaven.Services/Implementation/WorkOrderService.cs b/SeaHaven.Services/Implementation/WorkOrderService.cs index 5a8e452..6d3b5ea 100644 --- a/SeaHaven.Services/Implementation/WorkOrderService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderService.cs @@ -5,6 +5,7 @@ using FluentValidation; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; using SeaHaven.Services.Validation; @@ -439,8 +440,10 @@ namespace SeaHaven.Services.Implementation return true; } - public async Task AddCommentAsync(AddCommentInput input, string userId) + public async Task AddCommentAsync(AddCommentInput input, ClaimsPrincipal user, string userId) { + await EnsureWorkOrderInAccountScopeAsync(input.WorkorderId, user); + var comment = new Comments { UserId = userId, @@ -458,8 +461,10 @@ namespace SeaHaven.Services.Implementation return await _commentDataService.AddAsync(comment); } - public async Task AddCommentJsonAsync(AddCommentInput input, string userId) + public async Task AddCommentJsonAsync(AddCommentInput input, ClaimsPrincipal user, string userId) { + await EnsureWorkOrderInAccountScopeAsync(input.WorkorderId, user); + var comment = new Comments { UserId = userId, @@ -469,7 +474,7 @@ namespace SeaHaven.Services.Implementation }; var saved = await _commentDataService.AddAsync(comment); - var user = await _userDataService.GetByIdAsync(userId); + var displayUser = await _userDataService.GetByIdAsync(userId); return new CommentResult { @@ -477,7 +482,7 @@ namespace SeaHaven.Services.Implementation CreatedDate = saved.CreatedDate, Commenttext = saved.Commenttext, CommentType = saved.CommentType, - UserName = user != null ? (user.FirstName + " " + user.LastName).Trim() : "" + UserName = displayUser != null ? (displayUser.FirstName + " " + displayUser.LastName).Trim() : "" }; } @@ -493,8 +498,15 @@ namespace SeaHaven.Services.Implementation }).ToList(); } - public async Task> GetCommentsByWorkorderIdAsync(int woid) + public async Task?> GetCommentsByWorkorderIdAsync( + int woid, + ClaimsPrincipal user) { + var accountId = _accountResolver.ResolveAccountFilter(user); + var workOrder = await _workOrderDataService.GetWorkOrderDetailAsync(woid, accountId); + if (workOrder == null) + return null; + var comments = await _commentDataService.GetByWorkOrderIdAsync(woid); return comments.Select(s => new CommentListItemReadModel { @@ -505,6 +517,14 @@ namespace SeaHaven.Services.Implementation }).ToList(); } + private async Task EnsureWorkOrderInAccountScopeAsync(int workOrderId, ClaimsPrincipal user) + { + var accountId = _accountResolver.ResolveAccountFilter(user); + var workOrder = await _workOrderDataService.GetWorkOrderDetailAsync(workOrderId, accountId); + if (workOrder == null) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + } + public async Task> GetWorkordersDDAsync(int? accountId = null) { var data = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(accountId); diff --git a/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs b/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs index 3c5d27c..71ca88a 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs @@ -1,16 +1,22 @@ -using Data.SeaHavenIndustries.Enums; +using System.Security.Claims; using SeaHaven.Services.DTOs; namespace SeaHaven.Services.Interfaces { public interface IWorkOrderCommentService { - Task?> GetCommentsAsync(int workOrderId); - Task AddCommentAsync(int workOrderId, WorkOrderCommentCreateDto request, string? actorId, string? documentUrl = null); + Task?> GetCommentsAsync(int workOrderId, ClaimsPrincipal user); + Task AddCommentAsync( + int workOrderId, + WorkOrderCommentCreateDto request, + ClaimsPrincipal user, + string? actorId, + string? documentUrl = null); Task UpdateCommentAsync( int workOrderId, int commentId, WorkOrderCommentCreateDto request, + ClaimsPrincipal user, string? actorId); } } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs b/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs index 333e906..12c2399 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using Data.SeaHavenIndustries.Enums; using SeaHaven.Services.DTOs; @@ -7,11 +8,18 @@ namespace SeaHaven.Services.Interfaces { Task> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType); Task GetTemplateByIdAsync(int id); + Task EnsureCanUploadCompletionDocAsync( + int workOrderId, + ClaimsPrincipal user, + string? actorId, + CancellationToken cancellationToken = default); Task UploadCompletionDocAsync( int workOrderId, WorkOrderCompletionDocUploadDto request, string fileUrl, - string? actorId); + ClaimsPrincipal user, + string? actorId, + CancellationToken cancellationToken = default); Task CreateTemplateAsync(CompletionDocTemplateCreateDto request); Task UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request); Task DeleteTemplateAsync(int id); diff --git a/SeaHaven.Services/Interfaces/IWorkOrderService.cs b/SeaHaven.Services/Interfaces/IWorkOrderService.cs index 4267730..a7bf9aa 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderService.cs @@ -37,10 +37,10 @@ namespace SeaHaven.Services.Interfaces Task CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, ClaimsPrincipal user, string userId); Task UpdateWorkOrderWithDetailsAsync(UpdateWorkOrderWithDetailsInput input, string userId); Task DeleteWorkOrderCascadeAsync(int id, string userId); - Task AddCommentAsync(AddCommentInput input, string userId); - Task AddCommentJsonAsync(AddCommentInput input, string userId); + Task AddCommentAsync(AddCommentInput input, ClaimsPrincipal user, string userId); + Task AddCommentJsonAsync(AddCommentInput input, ClaimsPrincipal user, string userId); Task> GetCommentsAsync(); - Task> GetCommentsByWorkorderIdAsync(int woid); + 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 e13efa1..eeefa36 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs @@ -429,4 +429,211 @@ public class WorkOrderAccountScopeTests }); await context.SaveChangesAsync(); } + + [Fact] + public async Task Comments_CrossAccount_GetAddUpdate_ReturnNotFound() + { + await using var context = CreateContext(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A"); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B"); + + context.Users.Add(new ApplicationUser + { + Id = "author-1", + UserName = "author", + FirstName = "Ann", + LastName = "Author" + }); + context.workOrders.Add(new WorkOrder + { + Id = 20, + InternalWONumber = "00000000020", + AccountId = 2, + LifecycleStatus = LifecycleStatus.Scheduled, + istemplate = false + }); + context.Comments.Add(new Comments + { + Id = 5, + WorkerOrderId = 20, + UserId = "author-1", + Commenttext = "Secret", + CommentType = "General", + RecordType = "WorkOrder", + CreatedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + var service = new WorkOrderCommentService( + new WorkOrderDetailDataService(context), + new CommentDataService(context), + new UserDataService(context), + WorkOrderAccountTestHelpers.Resolver(context)); + + var scoped = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"); + + Assert.Null(await service.GetCommentsAsync(20, scoped)); + + var addEx = await Assert.ThrowsAsync(() => + service.AddCommentAsync(20, new WorkOrderCommentCreateDto { Text = "Nope" }, scoped, "disp-1")); + Assert.Equal("NotFound", addEx.Code); + + var updateEx = await Assert.ThrowsAsync(() => + service.UpdateCommentAsync( + 20, 5, new WorkOrderCommentCreateDto { Text = "Nope" }, scoped, "author-1")); + Assert.Equal("NotFound", updateEx.Code); + } + + [Fact] + public async Task Comments_SameAccount_AddSucceeds() + { + await using var context = CreateContext(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A"); + + context.Users.Add(new ApplicationUser + { + Id = "disp-1", + UserName = "disp", + FirstName = "Dee", + LastName = "Spatch" + }); + context.workOrders.Add(new WorkOrder + { + Id = 21, + InternalWONumber = "00000000021", + AccountId = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + istemplate = false + }); + await context.SaveChangesAsync(); + + var service = new WorkOrderCommentService( + new WorkOrderDetailDataService(context), + new CommentDataService(context), + new UserDataService(context), + WorkOrderAccountTestHelpers.Resolver(context)); + + var scoped = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"); + var result = await service.AddCommentAsync( + 21, new WorkOrderCommentCreateDto { Text = "In scope" }, scoped, "disp-1"); + + Assert.Equal("In scope", result.Text); + Assert.Equal("disp-1", result.AuthorId); + } + + [Fact] + public async Task LegacyCommentsByWorkOrder_CrossAccount_ReturnsNull() + { + await using var context = CreateContext(); + await SeedThreeAccountRowsAsync(context); + context.Comments.Add(new Comments + { + WorkerOrderId = 2, + Commenttext = "Other account", + CreatedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + var service = CreateLegacyReadService(context); + var result = await service.GetCommentsByWorkorderIdAsync( + 2, WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher")); + + Assert.Null(result); + } + + [Fact] + public async Task CompletionDoc_CrossAccount_EnsureAndUpload_ThrowNotFound() + { + await using var context = CreateContext(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A"); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B"); + + context.workOrders.Add(new WorkOrder + { + Id = 30, + InternalWONumber = "00000000030", + AccountId = 2, + LifecycleStatus = LifecycleStatus.Scheduled, + DocStatus = DocStatus.No, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }, + istemplate = false + }); + await context.SaveChangesAsync(); + + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var service = new WorkOrderCompletionService( + new WorkOrderCompletionDataService(context), + new CompletionDocTemplateDataService(context), + new WorkOrderDetailDataService(context), + audit, + WorkOrderAccountTestHelpers.Resolver(context)); + + var scoped = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"); + + var ensureEx = await Assert.ThrowsAsync(() => + service.EnsureCanUploadCompletionDocAsync(30, scoped, "disp-1")); + Assert.Equal("NotFound", ensureEx.Code); + + var uploadEx = await Assert.ThrowsAsync(() => + service.UploadCompletionDocAsync( + 30, + new WorkOrderCompletionDocUploadDto + { + WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }) + }, + "https://example.com/should-not-stick.pdf", + scoped, + "disp-1")); + Assert.Equal("NotFound", uploadEx.Code); + + var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == 30); + Assert.Null(wo.SignOffAttachment); + Assert.Equal(DocStatus.No, wo.DocStatus); + } + + [Fact] + public async Task CompletionDoc_SameAccount_EnsureAllowsUpload() + { + await using var context = CreateContext(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A"); + + var rowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }; + context.workOrders.Add(new WorkOrder + { + Id = 31, + InternalWONumber = "00000000031", + AccountId = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + DocStatus = DocStatus.No, + RowVersion = rowVersion, + istemplate = false + }); + await context.SaveChangesAsync(); + + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var service = new WorkOrderCompletionService( + new WorkOrderCompletionDataService(context), + new CompletionDocTemplateDataService(context), + new WorkOrderDetailDataService(context), + audit, + WorkOrderAccountTestHelpers.Resolver(context)); + + var scoped = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"); + await service.EnsureCanUploadCompletionDocAsync(31, scoped, "disp-1"); + + var result = await service.UploadCompletionDocAsync( + 31, + new WorkOrderCompletionDocUploadDto + { + WorkOrderVersion = Convert.ToBase64String(rowVersion) + }, + "https://example.com/ok.pdf", + scoped, + "disp-1"); + + Assert.Equal(DocStatus.Yes, result.DocStatus); + Assert.Equal("https://example.com/ok.pdf", result.SignOffAttachment); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 1d8b6c9..8eeb94e 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -247,7 +247,12 @@ public class WorkOrderCompletionServiceTests var detailData = new WorkOrderDetailDataService(context); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); - var service = new WorkOrderCompletionService(completionData, templateData, detailData, audit); + var service = new WorkOrderCompletionService( + completionData, + templateData, + detailData, + audit, + WorkOrderAccountTestHelpers.Resolver(context)); return (context, service); } @@ -274,6 +279,7 @@ public class WorkOrderCompletionServiceTests WorkOrderVersion = Convert.ToBase64String(wo.RowVersion!) }, "https://example.com/signed.pdf", + WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"), "actor-1"); Assert.Equal(DocStatus.Yes, result.DocStatus); @@ -304,6 +310,7 @@ public class WorkOrderCompletionServiceTests 1, new WorkOrderCompletionDocUploadDto { WorkOrderVersion = version }, "https://example.com/first.pdf", + WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"), "actor-1"); // Simulate a new request scope: clear local tracker then reload @@ -319,6 +326,7 @@ public class WorkOrderCompletionServiceTests WorkOrderVersion = Convert.ToBase64String(reloaded.RowVersion!) }, "https://example.com/second.pdf", + WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"), "actor-1"); var locks = await context.WorkOrderFieldLocks.ToListAsync(); @@ -344,6 +352,7 @@ public class WorkOrderCompletionServiceTests 1, new WorkOrderCompletionDocUploadDto(), "https://example.com/signed.pdf", + WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"), "actor-1")); Assert.Equal("WorkOrderVersionRequired", ex.Code); @@ -368,6 +377,7 @@ public class WorkOrderCompletionServiceTests WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }) }, "https://example.com/signed.pdf", + WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"), "actor-1")); Assert.Equal("ReadOnly", ex.Code); @@ -396,9 +406,14 @@ public class WorkOrderCommentServiceTests var detailData = new WorkOrderDetailDataService(context); var commentData = new CommentDataService(context); var userData = new UserDataService(context); - var service = new WorkOrderCommentService(detailData, commentData, userData); + var service = new WorkOrderCommentService( + detailData, commentData, userData, WorkOrderAccountTestHelpers.Resolver(context)); - var result = await service.AddCommentAsync(1, new WorkOrderCommentCreateDto { Text = "Note" }, "user-abc"); + var result = await service.AddCommentAsync( + 1, + new WorkOrderCommentCreateDto { Text = "Note" }, + WorkOrderAccountTestHelpers.OrgWideAdmin("user-abc"), + "user-abc"); Assert.Equal("user-abc", result.AuthorId); Assert.Equal("Bob Tech", result.AuthorName); @@ -426,9 +441,10 @@ public class WorkOrderCommentServiceTests var detailData = new WorkOrderDetailDataService(context); var commentData = new CommentDataService(context); var userData = new UserDataService(context); - var service = new WorkOrderCommentService(detailData, commentData, userData); + var service = new WorkOrderCommentService( + detailData, commentData, userData, WorkOrderAccountTestHelpers.Resolver(context)); - var comments = await service.GetCommentsAsync(1); + var comments = await service.GetCommentsAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); Assert.NotNull(comments); Assert.Single(comments!); @@ -471,7 +487,8 @@ public class WorkOrderCommentServiceTests var service = new WorkOrderCommentService( new WorkOrderDetailDataService(context), new CommentDataService(context), - new UserDataService(context)); + new UserDataService(context), + WorkOrderAccountTestHelpers.Resolver(context)); return (context, service, comment); } @@ -484,6 +501,7 @@ public class WorkOrderCommentServiceTests 1, comment.Id, new WorkOrderCommentCreateDto { Text = " Updated note " }, + WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"), "user-author"); Assert.Equal("Updated note", result.Text); @@ -501,6 +519,7 @@ public class WorkOrderCommentServiceTests 1, comment.Id, new WorkOrderCommentCreateDto { Text = "Admin edit" }, + WorkOrderAccountTestHelpers.OrgWideAdmin("admin-user"), "admin-user")); Assert.Equal("Forbidden", ex.Code); @@ -516,6 +535,7 @@ public class WorkOrderCommentServiceTests 1, comment.Id, new WorkOrderCommentCreateDto { Text = "Nope" }, + WorkOrderAccountTestHelpers.OrgWideAdmin("other-user"), "other-user")); Assert.Equal("Forbidden", ex.Code); @@ -533,6 +553,7 @@ public class WorkOrderCommentServiceTests 1, comment.Id, new WorkOrderCommentCreateDto { Text = "Nope" }, + WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"), "user-author")); Assert.Equal("NotEditable", ex.Code); @@ -548,6 +569,7 @@ public class WorkOrderCommentServiceTests 1, comment.Id, new WorkOrderCommentCreateDto { Text = "Nope" }, + WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"), "user-author")); Assert.Equal("ReadOnly", ex.Code); @@ -565,6 +587,7 @@ public class WorkOrderCommentServiceTests 2, comment.Id, new WorkOrderCommentCreateDto { Text = "Nope" }, + WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"), "user-author")); Assert.Equal("NotFound", ex.Code); diff --git a/docs/adr/0001-work-order-single-org-scope.md b/docs/adr/0001-work-order-single-org-scope.md index 0788c61..2e17637 100644 --- a/docs/adr/0001-work-order-single-org-scope.md +++ b/docs/adr/0001-work-order-single-org-scope.md @@ -7,8 +7,11 @@ - `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) -- **Reads** (board, list, advanced search, detail, media): `ApplyBaseScope` + +- **Reads** (board, list, advanced search, detail, media, board comments, + legacy comments-by-work-order-id): `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 - **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) @@ -37,6 +40,8 @@ in review (fail-open) and replaced by the contract below. 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. + Board comments, legacy comments-by-WO-id, and completion-doc uploads apply the + same account filter before read/write (and before blob storage). 8. **User lifecycle** persists `AccountId` on Admin create/edit so non-Admin principals can receive `account_id`.