mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
fix(work-orders): scope comments and completion-doc by account [SH-221]
Close the remaining SH-221 bypass: board/legacy comments and completion-doc now enforce server-derived account scope, authorize before blob storage, and cover cross-account regressions.
This commit is contained in:
parent
62a4828e2f
commit
3c090e2757
14 changed files with 454 additions and 45 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<IActionResult> 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]
|
||||
|
|
|
|||
|
|
@ -61,10 +61,18 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[HttpGet("{id:int}/comments")]
|
||||
public async Task<IActionResult> 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")
|
||||
|
|
|
|||
|
|
@ -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<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
|
||||
=> _context.workOrders.FirstOrDefaultAsync(
|
||||
w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null),
|
||||
cancellationToken);
|
||||
public Task<WorkOrder?> 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<WorkOrder?> 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;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,16 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
{
|
||||
public interface IWorkOrderCompletionDataService
|
||||
{
|
||||
Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||
Task<WorkOrder?> GetWorkOrderForCompletionAuthAsync(
|
||||
int workOrderId,
|
||||
int? accountId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<WorkOrder?> GetTrackedWorkOrderAsync(
|
||||
int workOrderId,
|
||||
int? accountId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
|
||||
Task<CompletionDocTemplate?> GetTrackedTemplateAsync(int id, CancellationToken cancellationToken);
|
||||
Task SaveAsync(CancellationToken cancellationToken);
|
||||
|
|
|
|||
|
|
@ -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<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId)
|
||||
public async Task<IReadOnlyList<WorkOrderCommentDto>?> 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<WorkOrderCommentDto> 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.");
|
||||
|
||||
|
|
|
|||
|
|
@ -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<IReadOnlyList<CompletionDocTemplateDto>> 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<WorkOrderCompletionDto> 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<WorkOrder> 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))
|
||||
|
|
|
|||
|
|
@ -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<Comments> AddCommentAsync(AddCommentInput input, string userId)
|
||||
public async Task<Comments> 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<CommentResult> AddCommentJsonAsync(AddCommentInput input, string userId)
|
||||
public async Task<CommentResult> 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<IEnumerable<CommentListItemReadModel>> GetCommentsByWorkorderIdAsync(int woid)
|
||||
public async Task<IEnumerable<CommentListItemReadModel>?> 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<IEnumerable<WorkOrder>> GetWorkordersDDAsync(int? accountId = null)
|
||||
{
|
||||
var data = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(accountId);
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using System.Security.Claims;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderCommentService
|
||||
{
|
||||
Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId);
|
||||
Task<WorkOrderCommentDto> AddCommentAsync(int workOrderId, WorkOrderCommentCreateDto request, string? actorId, string? documentUrl = null);
|
||||
Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId, ClaimsPrincipal user);
|
||||
Task<WorkOrderCommentDto> AddCommentAsync(
|
||||
int workOrderId,
|
||||
WorkOrderCommentCreateDto request,
|
||||
ClaimsPrincipal user,
|
||||
string? actorId,
|
||||
string? documentUrl = null);
|
||||
Task<WorkOrderCommentDto> UpdateCommentAsync(
|
||||
int workOrderId,
|
||||
int commentId,
|
||||
WorkOrderCommentCreateDto request,
|
||||
ClaimsPrincipal user,
|
||||
string? actorId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType);
|
||||
Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id);
|
||||
Task EnsureCanUploadCompletionDocAsync(
|
||||
int workOrderId,
|
||||
ClaimsPrincipal user,
|
||||
string? actorId,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<WorkOrderCompletionDto> UploadCompletionDocAsync(
|
||||
int workOrderId,
|
||||
WorkOrderCompletionDocUploadDto request,
|
||||
string fileUrl,
|
||||
string? actorId);
|
||||
ClaimsPrincipal user,
|
||||
string? actorId,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request);
|
||||
Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request);
|
||||
Task DeleteTemplateAsync(int id);
|
||||
|
|
|
|||
|
|
@ -37,10 +37,10 @@ namespace SeaHaven.Services.Interfaces
|
|||
Task<int> CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, ClaimsPrincipal user, string userId);
|
||||
Task<bool> UpdateWorkOrderWithDetailsAsync(UpdateWorkOrderWithDetailsInput input, string userId);
|
||||
Task<bool> DeleteWorkOrderCascadeAsync(int id, string userId);
|
||||
Task<Comments> AddCommentAsync(AddCommentInput input, string userId);
|
||||
Task<CommentResult> AddCommentJsonAsync(AddCommentInput input, string userId);
|
||||
Task<Comments> AddCommentAsync(AddCommentInput input, ClaimsPrincipal user, string userId);
|
||||
Task<CommentResult> AddCommentJsonAsync(AddCommentInput input, ClaimsPrincipal user, string userId);
|
||||
Task<IEnumerable<CommentListItemReadModel>> GetCommentsAsync();
|
||||
Task<IEnumerable<CommentListItemReadModel>> GetCommentsByWorkorderIdAsync(int woid);
|
||||
Task<IEnumerable<CommentListItemReadModel>?> GetCommentsByWorkorderIdAsync(int woid, ClaimsPrincipal user);
|
||||
Task<IEnumerable<WorkOrder>> GetWorkordersDDAsync(int? accountId = null);
|
||||
Task<IEnumerable<WorkorderFilterVM>> GetWorkordersAsync(int? accountId = null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<WorkOrderBoardValidationException>(() =>
|
||||
service.AddCommentAsync(20, new WorkOrderCommentCreateDto { Text = "Nope" }, scoped, "disp-1"));
|
||||
Assert.Equal("NotFound", addEx.Code);
|
||||
|
||||
var updateEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
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<WorkOrderBoardValidationException>(() =>
|
||||
service.EnsureCanUploadCompletionDocAsync(30, scoped, "disp-1"));
|
||||
Assert.Equal("NotFound", ensureEx.Code);
|
||||
|
||||
var uploadEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue