fix(uplifts): count evidence photos and require request permission

Uplift evidence uploads now share the work-order photo limit and the
RequestUplifts check used when creating an uplift.

Co-authored-by: Arthur Bassi <bassi-arthurr@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-10-05 14:31:04 +00:00
parent 1daac954a3
commit 98bd45423d
No known key found for this signature in database
6 changed files with 193 additions and 36 deletions

View file

@ -18,12 +18,18 @@ public sealed class WorkOrderUpliftControllerTests
private static WorkOrderDetailController NewController(
Mock<IWorkOrderUpliftService> upliftService,
params string[] roles)
=> NewController(upliftService, new Mock<IWorkOrderUpliftEvidenceService>(), roles);
private static WorkOrderDetailController NewController(
Mock<IWorkOrderUpliftService> upliftService,
Mock<IWorkOrderUpliftEvidenceService> evidenceService,
params string[] roles)
{
var controller = new WorkOrderDetailController(
Mock.Of<IWorkOrderDetailService>(),
Mock.Of<IWorkOrderCommentService>(),
upliftService.Object,
Mock.Of<IWorkOrderUpliftEvidenceService>(),
evidenceService.Object,
Mock.Of<ILogger<WorkOrderDetailController>>());
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, "dispatcher-1") };
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
@ -103,6 +109,31 @@ public sealed class WorkOrderUpliftControllerTests
response.Message.Should().Be(UpliftForbiddenException.RequestUpliftsDeniedMessage);
}
[Fact]
public async Task UploadUpliftEvidence_RequestPermissionDenied_ReturnsExact403Message()
{
var evidence = new Mock<IWorkOrderUpliftEvidenceService>();
evidence.Setup(x => x.UploadAsync(
7,
It.IsAny<IFormFile>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new UpliftForbiddenException(UpliftForbiddenException.RequestUpliftsDeniedMessage));
var controller = NewController(new Mock<IWorkOrderUpliftService>(), evidence, "Scheduler");
var file = new FormFile(new MemoryStream(new byte[] { 0xFF, 0xD8, 0xFF }), 0, 3, "file", "quote.jpg")
{
Headers = new HeaderDictionary(),
ContentType = "image/jpeg",
};
var result = await controller.UploadUpliftEvidence(7, file, CancellationToken.None);
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
var response = forbidden.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().Be(UpliftForbiddenException.RequestUpliftsDeniedMessage);
}
[Fact]
public async Task CreateUplift_UnrelatedForbiddenException_ReturnsSanitized403()
{

View file

@ -206,6 +206,13 @@ namespace Api.SeaHavenIndustries.Controllers
{
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Resource not found") });
}
catch (UpliftForbiddenException ex)
{
var message = ex.Message == UpliftForbiddenException.RequestUpliftsDeniedMessage
? UpliftForbiddenException.RequestUpliftsDeniedMessage
: _logger.Sanitize(ex, "You are not authorized to perform this action");
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The evidence file could not be uploaded") });

View file

@ -0,0 +1,42 @@
using System.Security.Claims;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// Shared gate for creating an uplift and uploading its evidence. Both paths load the
/// caller's database role and overrides, then require <see cref="TeamPermissionKeys.RequestUplifts"/>.
/// </summary>
public static class WorkOrderUpliftRequestAuthorization
{
public static async Task EnsureCanRequestUpliftAsync(
ITeamPermissionOverrideDataService permissionUsers,
ITeamPermissionPolicy permissionPolicy,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
var permissionUser = string.IsNullOrWhiteSpace(userId)
? null
: await permissionUsers.GetUserAsync(userId, cancellationToken);
if (permissionUser is null)
{
throw new UpliftForbiddenException(
UpliftForbiddenException.RequestUpliftsDeniedMessage);
}
if (!permissionPolicy.IsAllowed(
permissionUser.RoleName,
TeamPermissionKeys.RequestUplifts,
permissionUser.Overrides))
{
throw new UpliftForbiddenException(
UpliftForbiddenException.RequestUpliftsDeniedMessage);
}
}
}
}

View file

@ -6,6 +6,7 @@ using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
@ -23,6 +24,8 @@ namespace SeaHaven.Services.Implementation
private readonly IVendorDocumentDataService _documentData;
private readonly IVendorDocumentStoragePort _documentStorage;
private readonly IWorkOrderAccountResolver _accountResolver;
private readonly ITeamPermissionOverrideDataService _permissionOverrideData;
private readonly ITeamPermissionPolicy _permissionPolicy;
private readonly VendorDocumentsOptions _documentOptions;
public WorkOrderUpliftEvidenceService(
@ -32,6 +35,8 @@ namespace SeaHaven.Services.Implementation
IVendorDocumentDataService documentData,
IVendorDocumentStoragePort documentStorage,
IWorkOrderAccountResolver accountResolver,
ITeamPermissionOverrideDataService permissionOverrideData,
ITeamPermissionPolicy permissionPolicy,
IOptions<VendorDocumentsOptions> documentOptions)
{
_detailData = detailData;
@ -40,6 +45,8 @@ namespace SeaHaven.Services.Implementation
_documentData = documentData;
_documentStorage = documentStorage;
_accountResolver = accountResolver;
_permissionOverrideData = permissionOverrideData;
_permissionPolicy = permissionPolicy;
_documentOptions = documentOptions.Value;
}
@ -49,6 +56,12 @@ namespace SeaHaven.Services.Implementation
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
await WorkOrderUpliftRequestAuthorization.EnsureCanRequestUpliftAsync(
_permissionOverrideData,
_permissionPolicy,
user,
cancellationToken);
var workOrder = await FindAccessibleWorkOrderAsync(workOrderId, user, cancellationToken);
if (workOrder == null)
return null;
@ -63,6 +76,7 @@ namespace SeaHaven.Services.Implementation
RejectTerminalTarget(workOrder, dispatch);
var (contentType, bytes) = await ReadAcceptedFileAsync(file, cancellationToken);
await EnsureWithinMediaCountsAsync(workOrderId, contentType, file.FileName, cancellationToken);
var latest = await _documentData.GetLatestForDispatchAsync(dispatch.Id, cancellationToken);
var version = (latest?.Version ?? 0) + 1;
var storedFileName = $"{dispatch.Id}_{version}_{Guid.NewGuid():N}{SafeExtension(file.FileName)}";
@ -190,6 +204,29 @@ namespace SeaHaven.Services.Implementation
throw new InvalidOperationException($"Cannot attach uplift evidence on a '{dispatch.Status}' dispatch");
}
private async Task EnsureWithinMediaCountsAsync(
int workOrderId,
string contentType,
string fileName,
CancellationToken cancellationToken)
{
// Same per-work-order photo/video count as the vendor uplift-evidence upload:
// dispatcher attachments plus every active vendor document, with no purpose filter.
var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync(
workOrderId,
null,
cancellationToken);
var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync(
workOrderId,
cancellationToken);
var countMessage = WorkOrderMediaContract.ValidateCount(
WorkOrderMediaContract.ResolveKind(contentType, fileName),
attachmentUrls,
vendorTypes);
if (countMessage != null)
throw new InvalidOperationException(countMessage);
}
private async Task<(string ContentType, byte[] Bytes)> ReadAcceptedFileAsync(
IFormFile file,
CancellationToken cancellationToken)

View file

@ -5,7 +5,6 @@ using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
@ -81,7 +80,11 @@ namespace SeaHaven.Services.Implementation
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
return null;
await EnsureCanRequestUpliftAsync(user, cancellationToken);
await WorkOrderUpliftRequestAuthorization.EnsureCanRequestUpliftAsync(
_permissionOverrideData,
_permissionPolicy,
user,
cancellationToken);
if (request.Amount <= 0)
throw new InvalidOperationException("Uplift amount must be greater than zero");
@ -114,31 +117,6 @@ namespace SeaHaven.Services.Implementation
}
}
private async Task EnsureCanRequestUpliftAsync(
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
var permissionUser = string.IsNullOrWhiteSpace(userId)
? null
: await _permissionOverrideData.GetUserAsync(userId, cancellationToken);
if (permissionUser is null)
{
throw new UpliftForbiddenException(
UpliftForbiddenException.RequestUpliftsDeniedMessage);
}
if (!_permissionPolicy.IsAllowed(
permissionUser.RoleName,
TeamPermissionKeys.RequestUplifts,
permissionUser.Overrides))
{
throw new UpliftForbiddenException(
UpliftForbiddenException.RequestUpliftsDeniedMessage);
}
}
private async Task<WorkOrderUpliftDto?> CreateLockedAsync(
int workOrderId,
decimal amount,

View file

@ -24,7 +24,9 @@ public sealed class WorkOrderUpliftEvidenceServiceTests
return new ApplicationDbContext(options);
}
private static async Task<WorkOrder> SeedWorkOrderAsync(ApplicationDbContext context)
private static async Task<WorkOrder> SeedWorkOrderAsync(
ApplicationDbContext context,
string role = "Admin")
{
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
context.Users.Add(new ApplicationUser
@ -34,6 +36,13 @@ public sealed class WorkOrderUpliftEvidenceServiceTests
FirstName = "Alex",
LastName = "Dispatcher",
});
var identityRole = new IdentityRole(role);
context.Roles.Add(identityRole);
context.UserRoles.Add(new IdentityUserRole<string>
{
UserId = "dispatcher-1",
RoleId = identityRole.Id,
});
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
@ -69,6 +78,8 @@ public sealed class WorkOrderUpliftEvidenceServiceTests
new VendorDocumentDataService(context),
storage,
WorkOrderAccountTestHelpers.Resolver(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
Options.Create(new VendorDocumentsOptions
{
PassWhenScannerUnavailable = passWhenScannerUnavailable,
@ -158,6 +169,64 @@ public sealed class WorkOrderUpliftEvidenceServiceTests
Assert.Empty(storage.Saved);
}
[Fact]
public async Task UploadAsync_EleventhPhoto_RejectsAndPersistsNothing()
{
await using var context = CreateContext();
var workOrder = await SeedWorkOrderAsync(context);
for (var i = 0; i < 10; i++)
{
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
{
VendorId = 1,
DispatchId = 10,
WorkOrderId = workOrder.Id,
OriginalFileName = $"photo-{i}.jpg",
StoredFileName = $"photo-{i}.jpg",
ContentType = "image/jpeg",
Purpose = "Completion",
ScanStatus = "Passed",
Version = 1,
});
}
await context.SaveChangesAsync();
var (service, storage) = NewService(context);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.UploadAsync(
workOrder.Id,
FormFile(new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }, "quote.jpg", "image/jpeg"),
Dispatcher(),
CancellationToken.None));
Assert.Equal("A work order can have at most 10 photos.", ex.Message);
Assert.Equal(10, context.VendorCompletionDocuments.Count());
Assert.DoesNotContain(context.VendorCompletionDocuments, document => document.Purpose == VendorDocumentPurpose.UpliftEvidence);
Assert.Empty(context.WorkOrderAuditLogs);
Assert.Empty(storage.Saved);
}
[Fact]
public async Task UploadAsync_RequestUpliftsDenied_PersistsNothing()
{
await using var context = CreateContext();
var workOrder = await SeedWorkOrderAsync(context, role: "Scheduler");
var (service, storage) = NewService(context);
var ex = await Assert.ThrowsAsync<UpliftForbiddenException>(() =>
service.UploadAsync(
workOrder.Id,
FormFile(new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }, "quote.jpg", "image/jpeg"),
WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, "Scheduler"),
CancellationToken.None));
Assert.Equal(UpliftForbiddenException.RequestUpliftsDeniedMessage, ex.Message);
Assert.Empty(context.VendorCompletionDocuments);
Assert.Empty(context.WorkOrderAuditLogs);
Assert.Empty(storage.Saved);
}
[Fact]
public async Task GetStatusAsync_ReturnsScanStatusOnlyForUpliftEvidenceOnTheDispatch()
{
@ -281,13 +350,6 @@ public sealed class WorkOrderUpliftEvidenceServiceTests
await using var context = CreateContext();
var workOrder = await SeedWorkOrderAsync(context);
workOrder.PrimaryDispatchId = null;
var role = new IdentityRole("Admin");
context.Roles.Add(role);
context.UserRoles.Add(new IdentityUserRole<string>
{
UserId = "dispatcher-1",
RoleId = role.Id,
});
await context.SaveChangesAsync();
var (evidence, _) = NewService(context);