From 9156107e725f961fb7408dea4499ac1119b31257 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 22 Sep 2026 23:09:47 -0300 Subject: [PATCH 01/35] fix(uplifts): enforce request permission at service boundary --- .../Implementation/WorkOrderUpliftService.cs | 31 +++++++ .../WorkOrderBoardCancelServiceTests.cs | 8 ++ .../WorkOrderUpliftServiceTests.cs | 83 +++++++++++++++++++ 3 files changed, 122 insertions(+) diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 6a2c388..261a8c5 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -4,6 +4,7 @@ using Data.SeaHavenIndustries.Enums; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; +using SeaHaven.Services.Constants; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; @@ -19,6 +20,8 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderDetailDataService _detailData; private readonly IWorkOrderAccountResolver _accountResolver; private readonly IUserDataService _userData; + private readonly ITeamPermissionOverrideDataService _permissionOverrideData; + private readonly ITeamPermissionPolicy _permissionPolicy; private readonly TimeProvider _timeProvider; private readonly ApprovalsOptions _approvalsOptions; @@ -28,6 +31,8 @@ namespace SeaHaven.Services.Implementation IWorkOrderDetailDataService detailData, IWorkOrderAccountResolver accountResolver, IUserDataService userData, + ITeamPermissionOverrideDataService permissionOverrideData, + ITeamPermissionPolicy permissionPolicy, TimeProvider timeProvider, IOptions approvalsOptions) { @@ -36,6 +41,8 @@ namespace SeaHaven.Services.Implementation _detailData = detailData; _accountResolver = accountResolver; _userData = userData; + _permissionOverrideData = permissionOverrideData; + _permissionPolicy = permissionPolicy; _timeProvider = timeProvider; _approvalsOptions = approvalsOptions.Value; } @@ -61,6 +68,8 @@ namespace SeaHaven.Services.Implementation if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken)) return null; + await EnsureCanRequestUpliftAsync(user, cancellationToken); + if (request.Amount <= 0) throw new InvalidOperationException("Uplift amount must be greater than zero"); @@ -82,6 +91,28 @@ namespace SeaHaven.Services.Implementation cancellationToken); } + 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); + var roleName = user.IsInRole("Admin") + ? "Admin" + : user.FindFirstValue(ClaimTypes.Role); + + if (!_permissionPolicy.IsAllowed( + roleName, + TeamPermissionKeys.RequestUplifts, + permissionUser?.Overrides)) + { + throw new UpliftForbiddenException( + "Your role can't request uplifts on this work order."); + } + } + private async Task CreateLockedAsync( int workOrderId, decimal amount, diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index e797a68..c910604 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -203,6 +203,8 @@ public class WorkOrderBoardCancelServiceTests new WorkOrderDetailDataService(context), WorkOrderAccountTestHelpers.Resolver(context), new UserDataService(context), + new TeamPermissionOverrideDataService(context), + new TeamPermissionPolicy(), TimeProvider.System, Options.Create(new ApprovalsOptions())); var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts, new PassThroughUpliftData()); @@ -264,6 +266,8 @@ public class WorkOrderBoardCancelServiceTests new WorkOrderDetailDataService(context), WorkOrderAccountTestHelpers.Resolver(context), new UserDataService(context), + new TeamPermissionOverrideDataService(context), + new TeamPermissionPolicy(), TimeProvider.System, Options.Create(new ApprovalsOptions())); var cancel = new WorkOrderBoardCancelService( @@ -332,6 +336,8 @@ public class WorkOrderBoardCancelServiceTests new WorkOrderDetailDataService(createContext), WorkOrderAccountTestHelpers.Resolver(createContext), new UserDataService(createContext), + new TeamPermissionOverrideDataService(createContext), + new TeamPermissionPolicy(), TimeProvider.System, Options.Create(new ApprovalsOptions())); var boardData = new WorkOrderBoardDataService(cancelContext); @@ -345,6 +351,8 @@ public class WorkOrderBoardCancelServiceTests new WorkOrderDetailDataService(cancelContext), WorkOrderAccountTestHelpers.Resolver(cancelContext), new UserDataService(cancelContext), + new TeamPermissionOverrideDataService(cancelContext), + new TeamPermissionPolicy(), TimeProvider.System, Options.Create(new ApprovalsOptions())); var cancel = new WorkOrderBoardCancelService( diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs index 7ec8856..bb63d73 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Implementation; using SeaHaven.Services.Configuration; +using SeaHaven.Services.Constants; using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; @@ -37,6 +38,8 @@ public sealed class WorkOrderUpliftServiceTests new WorkOrderDetailDataService(context), WorkOrderAccountTestHelpers.Resolver(context), new UserDataService(context), + new TeamPermissionOverrideDataService(context), + new TeamPermissionPolicy(), TimeProvider.System, Options.Create(NewOptions())); } @@ -123,6 +126,86 @@ public sealed class WorkOrderUpliftServiceTests Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); } + [Fact] + public async Task CreateAsync_Admin_RemainsUnrestricted() + { + await using var context = CreateContext(); + var (workOrder, _) = await SeedWorkOrderAsync(context); + var service = NewService(context); + + var created = await service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 400m }, + Dispatcher(), + CancellationToken.None); + + Assert.NotNull(created); + } + + [Theory] + [InlineData("Dispatcher", true)] + [InlineData("Scheduler", false)] + public async Task CreateAsync_UsesRoleDefaultForRequestPermission(string role, bool allowed) + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + var service = NewService(context); + + var act = () => service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 400m }, + WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, role), + CancellationToken.None); + + if (allowed) + { + Assert.NotNull(await act()); + return; + } + + var exception = await Assert.ThrowsAsync(act); + Assert.Equal("Your role can't request uplifts on this work order.", exception.Message); + Assert.Empty(context.DispatchUpliftRequests); + Assert.Equal(1000m, dispatch.NTEAmount); + } + + [Theory] + [InlineData("Scheduler", UserPermissionState.Allow, true)] + [InlineData("Dispatcher", UserPermissionState.Deny, false)] + public async Task CreateAsync_AppliesExplicitRequestPermissionOverride( + string role, + UserPermissionState overrideState, + bool allowed) + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + context.UserPermissionOverrides.Add(new UserPermissionOverride + { + UserId = "dispatcher-1", + PermissionKey = TeamPermissionKeys.RequestUplifts, + State = overrideState, + }); + await context.SaveChangesAsync(); + var service = NewService(context); + + var act = () => service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 400m }, + WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, role), + CancellationToken.None); + + if (allowed) + { + Assert.NotNull(await act()); + return; + } + + var exception = await Assert.ThrowsAsync(act); + Assert.Equal("Your role can't request uplifts on this work order.", exception.Message); + Assert.Empty(context.DispatchUpliftRequests); + Assert.Equal(1000m, dispatch.NTEAmount); + } + [Fact] public async Task CreateAsync_PmAmountAboveCap_CreatesPendingRequest() { From ff6a5945f1381151ad71da5d8f1a9e836d36af7f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 22 Sep 2026 23:24:17 -0300 Subject: [PATCH 02/35] fix(uplifts): honor current permissions and denial contract --- .../VendorPortalControllerTests.cs | 32 ++++++++++- .../Controllers/VendorPortalController.cs | 10 ++++ SeaHaven.Services/DTOs/UpliftDTOs.cs | 3 + .../Implementation/WorkOrderUpliftService.cs | 19 ++++--- .../WorkOrderBoardCancelServiceTests.cs | 8 +++ .../WorkOrderUpliftServiceTests.cs | 57 +++++++++++++++++-- 6 files changed, 115 insertions(+), 14 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs index dd9e40e..0e48da5 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs @@ -306,7 +306,37 @@ public class VendorPortalControllerTests var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody { RequestedNTE = 100m }); - result.Should().BeOfType(); + var badRequest = result.Should().BeOfType().Subject; + var response = badRequest.Value.Should().BeOfType().Subject; + response.Message.Should().Contain("reference"); + response.Message.Should().NotContain("Requested NTE must be greater than the current NTE"); + } + + [Fact] + public async Task RequestUplift_Forbidden_Returns403WithExactPermissionMessage() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.RequestUpliftAsync( + Session, + 10, + 500m, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new UpliftForbiddenException("internal permission detail")); + var controller = NewController(service); + + var result = await controller.RequestUplift( + 10, + new VendorPortalController.UpliftRequestBody { RequestedNTE = 500m }); + + var forbidden = result.Should().BeOfType().Subject; + forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + var response = forbidden.Value.Should().BeOfType().Subject; + response.Message.Should().Be("Your role can't request uplifts on this work order."); } [Fact] diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index 39dd90b..9fdeed4 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -74,6 +74,16 @@ namespace Api.SeaHavenIndustries.Controllers { return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); } + catch (UpliftForbiddenException) + { + return StatusCode( + StatusCodes.Status403Forbidden, + new Response + { + Status = "Error", + Message = UpliftForbiddenException.RequestUpliftsDeniedMessage + }); + } catch (InvalidOperationException ex) { return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") }); diff --git a/SeaHaven.Services/DTOs/UpliftDTOs.cs b/SeaHaven.Services/DTOs/UpliftDTOs.cs index 24cde20..d93dd63 100644 --- a/SeaHaven.Services/DTOs/UpliftDTOs.cs +++ b/SeaHaven.Services/DTOs/UpliftDTOs.cs @@ -104,6 +104,9 @@ namespace SeaHaven.Services.DTOs public class UpliftForbiddenException : Exception { + public const string RequestUpliftsDeniedMessage = + "Your role can't request uplifts on this work order."; + public UpliftForbiddenException(string message) : base(message) { } } diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 261a8c5..f5548ee 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -99,17 +99,20 @@ namespace SeaHaven.Services.Implementation var permissionUser = string.IsNullOrWhiteSpace(userId) ? null : await _permissionOverrideData.GetUserAsync(userId, cancellationToken); - var roleName = user.IsInRole("Admin") - ? "Admin" - : user.FindFirstValue(ClaimTypes.Role); - if (!_permissionPolicy.IsAllowed( - roleName, - TeamPermissionKeys.RequestUplifts, - permissionUser?.Overrides)) + if (permissionUser is null) { throw new UpliftForbiddenException( - "Your role can't request uplifts on this work order."); + UpliftForbiddenException.RequestUpliftsDeniedMessage); + } + + if (!_permissionPolicy.IsAllowed( + permissionUser.RoleName, + TeamPermissionKeys.RequestUplifts, + permissionUser.Overrides)) + { + throw new UpliftForbiddenException( + UpliftForbiddenException.RequestUpliftsDeniedMessage); } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index c910604..9f1e3f6 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -1,5 +1,6 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Implementation; @@ -304,6 +305,13 @@ public class WorkOrderBoardCancelServiceTests FirstName = "Alex", LastName = "Dispatcher", }); + var adminRole = new IdentityRole("Admin"); + seed.Roles.Add(adminRole); + seed.UserRoles.Add(new IdentityUserRole + { + UserId = "dispatcher-1", + RoleId = adminRole.Id, + }); seed.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); seed.Dispatches.Add(new Dispatch { diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs index bb63d73..86a7613 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -1,5 +1,6 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Implementation; @@ -49,7 +50,8 @@ public sealed class WorkOrderUpliftServiceTests private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync( ApplicationDbContext context, - WorkOrderType type = WorkOrderType.PM) + WorkOrderType type = WorkOrderType.PM, + string role = "Admin") { await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); context.Users.Add(new ApplicationUser @@ -59,6 +61,13 @@ public sealed class WorkOrderUpliftServiceTests FirstName = "Alex", LastName = "Dispatcher", }); + var identityRole = new IdentityRole(role); + context.Roles.Add(identityRole); + context.UserRoles.Add(new IdentityUserRole + { + UserId = "dispatcher-1", + RoleId = identityRole.Id, + }); context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); context.Dispatches.Add(new Dispatch { @@ -127,7 +136,7 @@ public sealed class WorkOrderUpliftServiceTests } [Fact] - public async Task CreateAsync_Admin_RemainsUnrestricted() + public async Task CreateAsync_CurrentDatabaseAdmin_RemainsUnrestricted() { await using var context = CreateContext(); var (workOrder, _) = await SeedWorkOrderAsync(context); @@ -136,19 +145,57 @@ public sealed class WorkOrderUpliftServiceTests var created = await service.CreateAsync( workOrder.Id, new CreateWorkOrderUpliftRequestDto { Amount = 400m }, - Dispatcher(), + WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, "Scheduler"), CancellationToken.None); Assert.NotNull(created); } + [Fact] + public async Task CreateAsync_StaleAdminClaim_DeniesWhenDatabaseRoleIsNotAdmin() + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: "Dispatcher"); + var service = NewService(context); + + var exception = await Assert.ThrowsAsync(() => service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 400m }, + Dispatcher(), + CancellationToken.None)); + + Assert.Equal(UpliftForbiddenException.RequestUpliftsDeniedMessage, exception.Message); + Assert.Empty(context.DispatchUpliftRequests); + Assert.Equal(1000m, dispatch.NTEAmount); + } + + [Fact] + public async Task CreateAsync_MissingDatabaseUser_DeniesEvenWithAdminClaim() + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + context.Users.Remove(context.Users.Single(user => user.Id == "dispatcher-1")); + await context.SaveChangesAsync(); + var service = NewService(context); + + var exception = await Assert.ThrowsAsync(() => service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 400m }, + Dispatcher("dispatcher-1"), + CancellationToken.None)); + + Assert.Equal(UpliftForbiddenException.RequestUpliftsDeniedMessage, exception.Message); + Assert.Empty(context.DispatchUpliftRequests); + Assert.Equal(1000m, dispatch.NTEAmount); + } + [Theory] [InlineData("Dispatcher", true)] [InlineData("Scheduler", false)] public async Task CreateAsync_UsesRoleDefaultForRequestPermission(string role, bool allowed) { await using var context = CreateContext(); - var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: role); var service = NewService(context); var act = () => service.CreateAsync( @@ -178,7 +225,7 @@ public sealed class WorkOrderUpliftServiceTests bool allowed) { await using var context = CreateContext(); - var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: role); context.UserPermissionOverrides.Add(new UserPermissionOverride { UserId = "dispatcher-1", From f4284badf2499514af66cc8d643986038047654c Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 22 Sep 2026 23:30:14 -0300 Subject: [PATCH 03/35] fix(uplifts): place forbidden handler on request endpoint --- .../Controllers/VendorPortalController.cs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index 9fdeed4..ec48a68 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -74,16 +74,6 @@ namespace Api.SeaHavenIndustries.Controllers { return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); } - catch (UpliftForbiddenException) - { - return StatusCode( - StatusCodes.Status403Forbidden, - new Response - { - Status = "Error", - Message = UpliftForbiddenException.RequestUpliftsDeniedMessage - }); - } catch (InvalidOperationException ex) { return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") }); @@ -231,6 +221,16 @@ namespace Api.SeaHavenIndustries.Controllers { return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); } + catch (UpliftForbiddenException) + { + return StatusCode( + StatusCodes.Status403Forbidden, + new Response + { + Status = "Error", + Message = UpliftForbiddenException.RequestUpliftsDeniedMessage + }); + } catch (InvalidOperationException ex) { return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") }); From e1632bc3bf9e2709aba656390a944a11ace93597 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 22 Sep 2026 23:35:07 -0300 Subject: [PATCH 04/35] fix(uplifts): expose only allowlisted denial copy --- .../VendorPortalControllerTests.cs | 32 +------------ .../WorkOrderUpliftControllerTests.cs | 47 +++++++++++++++++++ .../Controllers/VendorPortalController.cs | 10 ---- .../Controllers/WorkOrderDetailController.cs | 5 +- 4 files changed, 52 insertions(+), 42 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs index 0e48da5..dd9e40e 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs @@ -306,37 +306,7 @@ public class VendorPortalControllerTests var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody { RequestedNTE = 100m }); - var badRequest = result.Should().BeOfType().Subject; - var response = badRequest.Value.Should().BeOfType().Subject; - response.Message.Should().Contain("reference"); - response.Message.Should().NotContain("Requested NTE must be greater than the current NTE"); - } - - [Fact] - public async Task RequestUplift_Forbidden_Returns403WithExactPermissionMessage() - { - var service = new Mock(); - service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(Session); - service.Setup(x => x.RequestUpliftAsync( - Session, - 10, - 500m, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .ThrowsAsync(new UpliftForbiddenException("internal permission detail")); - var controller = NewController(service); - - var result = await controller.RequestUplift( - 10, - new VendorPortalController.UpliftRequestBody { RequestedNTE = 500m }); - - var forbidden = result.Should().BeOfType().Subject; - forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden); - var response = forbidden.Value.Should().BeOfType().Subject; - response.Message.Should().Be("Your role can't request uplifts on this work order."); + result.Should().BeOfType(); } [Fact] diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs index b3cdbfc..8dabbcd 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs @@ -78,6 +78,53 @@ public sealed class WorkOrderUpliftControllerTests Assert.Equal(12, created.Id); } + [Fact] + public async Task CreateUplift_RequestPermissionDenied_ReturnsExact403Message() + { + var service = new Mock(); + service.Setup(x => x.CreateAsync( + 7, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new UpliftForbiddenException(UpliftForbiddenException.RequestUpliftsDeniedMessage)); + + var controller = NewController(service, "Dispatcher"); + var result = await controller.CreateUplift( + 7, + new CreateWorkOrderUpliftRequestDto { Amount = 750m }, + CancellationToken.None); + + var forbidden = result.Should().BeOfType().Subject; + forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + var response = forbidden.Value.Should().BeOfType().Subject; + response.Message.Should().Be(UpliftForbiddenException.RequestUpliftsDeniedMessage); + } + + [Fact] + public async Task CreateUplift_UnrelatedForbiddenException_ReturnsSanitized403() + { + var service = new Mock(); + service.Setup(x => x.CreateAsync( + 7, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new UpliftForbiddenException("SECRET-internal-tier-detail")); + + var controller = NewController(service, "Dispatcher"); + var result = await controller.CreateUplift( + 7, + new CreateWorkOrderUpliftRequestDto { Amount = 750m }, + CancellationToken.None); + + var forbidden = result.Should().BeOfType().Subject; + forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + var response = forbidden.Value.Should().BeOfType().Subject; + response.Message.Should().NotContain("SECRET-internal-tier-detail"); + response.Message.Should().Contain("reference"); + } + [Fact] public async Task CancelUplift_NotFound_Returns404() { diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index ec48a68..39dd90b 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -221,16 +221,6 @@ namespace Api.SeaHavenIndustries.Controllers { return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); } - catch (UpliftForbiddenException) - { - return StatusCode( - StatusCodes.Status403Forbidden, - new Response - { - Status = "Error", - Message = UpliftForbiddenException.RequestUpliftsDeniedMessage - }); - } catch (InvalidOperationException ex) { return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") }); diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs index d975b32..be0a708 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs @@ -170,7 +170,10 @@ namespace Api.SeaHavenIndustries.Controllers } catch (UpliftForbiddenException ex) { - return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to perform this action") }); + 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) { From 50a2cbab5fd912287eaaab81aba6e6ef5e20c1cc Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 22 Sep 2026 23:39:51 -0300 Subject: [PATCH 05/35] test(uplifts): correct current-role denial fixtures --- SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs index 86a7613..1740334 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -155,7 +155,7 @@ public sealed class WorkOrderUpliftServiceTests public async Task CreateAsync_StaleAdminClaim_DeniesWhenDatabaseRoleIsNotAdmin() { await using var context = CreateContext(); - var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: "Dispatcher"); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: "Scheduler"); var service = NewService(context); var exception = await Assert.ThrowsAsync(() => service.CreateAsync( @@ -174,6 +174,7 @@ public sealed class WorkOrderUpliftServiceTests { await using var context = CreateContext(); var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + context.UserRoles.RemoveRange(context.UserRoles.Where(userRole => userRole.UserId == "dispatcher-1")); context.Users.Remove(context.Users.Single(user => user.Id == "dispatcher-1")); await context.SaveChangesAsync(); var service = NewService(context); From 8af9ad076fdf5a063b1a547a78bd1c7076f2085c Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 01:24:43 -0300 Subject: [PATCH 06/35] fix: map concurrent uplift inserts to conflict --- .../UpliftWorkflowTests.cs | 35 +++++- .../VendorPortalControllerTests.cs | 29 +++++ .../WorkOrderUpliftControllerTests.cs | 23 ++++ .../WorkOrderUpliftServiceConflictTests.cs | 113 ++++++++++++++++++ .../Controllers/VendorPortalController.cs | 5 + .../Controllers/WorkOrderDetailController.cs | 4 + .../UpliftDispatchConflictException.cs | 9 ++ .../Implementation/UpliftDataService.cs | 50 +++++++- .../Exceptions/UpliftConflictException.cs | 9 ++ .../Implementation/VendorPortalService.cs | 10 +- .../Implementation/WorkOrderUpliftService.cs | 27 +++-- .../UpliftDataServiceTransactionTests.cs | 101 ++++++++++++++++ 12 files changed, 401 insertions(+), 14 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs create mode 100644 SeaHaven.DataServices/Exceptions/UpliftDispatchConflictException.cs create mode 100644 SeaHaven.Services/Exceptions/UpliftConflictException.cs diff --git a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs index 9b3f220..b57d36f 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs @@ -139,7 +139,8 @@ public sealed class UpliftWorkflowTests private static VendorPortalService NewPortalService( ApplicationDbContext context, IEmailSender emailSender, - IVendorDocumentStoragePort? storage = null) + IVendorDocumentStoragePort? storage = null, + IUpliftDataService? upliftData = null) { var vendorData = new VendorDataService(context); var tokenService = new VendorPortalTokenService(vendorData, Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions())); @@ -150,7 +151,7 @@ public sealed class UpliftWorkflowTests return new VendorPortalService( tokenService, new DispatchDataService(context), - new UpliftDataService(context), + upliftData ?? new UpliftDataService(context), commentData.Object, Mock.Of(), emailSender, @@ -214,6 +215,36 @@ public sealed class UpliftWorkflowTests result.RequiredTier.Should().Be(2); } + [Fact] + public async Task RequestUplift_ConcurrentActiveRequest_MapsPersistenceConflict() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); + await context.SaveChangesAsync(); + + var upliftData = new Mock(); + upliftData.Setup(x => x.HasActiveAsync(dispatch.Id, It.IsAny())) + .ReturnsAsync(false); + upliftData.Setup(x => x.StageAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + upliftData.Setup(x => x.SaveChangesAsync(It.IsAny())) + .ThrowsAsync(new SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException()); + + var service = NewPortalService(context, new FakeEmailSender(deliver: true), upliftData: upliftData.Object); + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var act = () => service.RequestUpliftAsync( + session!, + dispatch.Id, + 1500m, + "reason", + null, + 1, + CancellationToken.None); + + await act.Should().ThrowAsync(); + } + // --- Evidence scan + cross-vendor/dispatch scoping --- [Fact] diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs index dd9e40e..bc097b5 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Moq; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; using Xunit; @@ -309,6 +310,34 @@ public class VendorPortalControllerTests result.Should().BeOfType(); } + [Fact] + public async Task RequestUplift_ConcurrentActiveRequest_ReturnsConflict() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.RequestUpliftAsync( + Session, + 10, + 1500m, + "Need more parts", + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new UpliftConflictException()); + var controller = NewController(service); + + var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody + { + RequestedNTE = 1500m, + Reason = "Need more parts", + }); + + var conflict = result.Should().BeOfType().Subject; + conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); + conflict.Value.Should().NotBeNull(); + } + [Fact] public async Task RequestUplift_Success_ReturnsResult() { diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs index b3cdbfc..3784558 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Moq; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; using System.Security.Claims; using Xunit; @@ -78,6 +79,28 @@ public sealed class WorkOrderUpliftControllerTests Assert.Equal(12, created.Id); } + [Fact] + public async Task CreateUplift_ConcurrentActiveRequest_ReturnsConflict() + { + var service = new Mock(); + service.Setup(x => x.CreateAsync( + 7, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new UpliftConflictException()); + + var controller = NewController(service); + var result = await controller.CreateUplift( + 7, + new CreateWorkOrderUpliftRequestDto { Amount = 750m, Notes = "Extra labor" }, + CancellationToken.None); + + var conflict = result.Should().BeOfType().Subject; + conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); + conflict.Value.Should().NotBeNull(); + } + [Fact] public async Task CancelUplift_NotFound_Returns404() { diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs new file mode 100644 index 0000000..129e360 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs @@ -0,0 +1,113 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Moq; +using SeaHaven.DataServices.Exceptions; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public sealed class WorkOrderUpliftServiceConflictTests +{ + [Fact] + public async Task CreateAsync_DifferentWorkOrdersSharingDispatch_MapsConcurrentInsertConflict() + { + var upliftData = new Mock(); + var bothAtInsert = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var savesArrived = 0; + var winner = 0; + + upliftData.Setup(x => x.ExecuteWorkOrderMutationAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Returns>, CancellationToken>( + (_, work, cancellationToken) => work(cancellationToken)); + upliftData.Setup(x => x.HasPendingForWorkOrderAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + upliftData.Setup(x => x.HasActiveAsync(10, It.IsAny())) + .ReturnsAsync(false); + upliftData.Setup(x => x.SumAutoApprovedAmountForWorkOrderAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(0m); + upliftData.Setup(x => x.StageAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + upliftData.Setup(x => x.SaveChangesAsync(It.IsAny())) + .Returns(async () => + { + if (Interlocked.Increment(ref savesArrived) == 2) + bothAtInsert.TrySetResult(); + + await bothAtInsert.Task.WaitAsync(TimeSpan.FromSeconds(5)); + if (Interlocked.Exchange(ref winner, 1) != 0) + throw new UpliftDispatchConflictException(); + }); + + var detailData = new Mock(); + detailData.Setup(x => x.ExistsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + detailData.Setup(x => x.GetWorkOrderForMediaAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int workOrderId, CancellationToken _, int? _) => new WorkOrder + { + Id = workOrderId, + PrimaryDispatchId = 10, + WorkOrderType = WorkOrderType.PM, + LifecycleStatus = LifecycleStatus.Scheduled, + }); + + var dispatchData = new Mock(); + dispatchData.Setup(x => x.GetByIdAsync(10)) + .ReturnsAsync(new Dispatch { Id = 10, Status = "Scheduled", NTEAmount = 1000m }); + dispatchData.Setup(x => x.StageAuditLogAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var accountResolver = new Mock(); + accountResolver.Setup(x => x.ResolveAccountFilter(It.IsAny())).Returns((int?)null); + var userData = new Mock(); + userData.Setup(x => x.GetDisplayNamesByIdsAsync(It.IsAny>())) + .ReturnsAsync(new Dictionary()); + + var service = new WorkOrderUpliftService( + upliftData.Object, + dispatchData.Object, + detailData.Object, + accountResolver.Object, + userData.Object, + TimeProvider.System, + Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions + { + UpliftTier1MaxUsd = 2500m, + Tier1Roles = new[] { "Approver" }, + Tier2Roles = new[] { "Manager" }, + })); + var user = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, "dispatcher-1") }, + "test")); + + static async Task<(WorkOrderUpliftDto? Result, Exception? Error)> Capture( + Func> create) + { + try + { + return (await create(), null); + } + catch (Exception exception) + { + return (null, exception); + } + } + + var outcomes = await Task.WhenAll( + Capture(() => service.CreateAsync(1, new CreateWorkOrderUpliftRequestDto { Amount = 500m }, user, CancellationToken.None)), + Capture(() => service.CreateAsync(2, new CreateWorkOrderUpliftRequestDto { Amount = 500m }, user, CancellationToken.None))); + + Assert.Single(outcomes, outcome => outcome.Result != null); + Assert.IsType(Assert.Single(outcomes, outcome => outcome.Error != null).Error); + upliftData.Verify(x => x.HasActiveAsync(10, It.IsAny()), Times.Exactly(2)); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index 39dd90b..636f963 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers @@ -217,6 +218,10 @@ namespace Api.SeaHavenIndustries.Controllers var result = await _portalService.RequestUpliftAsync(session, id, body?.RequestedNTE ?? 0m, body?.Reason, body?.RequestKey, body?.EvidenceDocumentId, cancellationToken); return Ok(new DataResponse { Status = "Success", Data = result }); } + catch (UpliftConflictException ex) + { + return Conflict(new Response { Status = "Error", Message = ex.Message }); + } catch (KeyNotFoundException) { return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs index d975b32..742eaaf 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs @@ -164,6 +164,10 @@ namespace Api.SeaHavenIndustries.Controllers return NotFound(new Response { Status = "Error", Message = "Work order not found." }); return Ok(new DataResponse { Status = "Success", Data = created }); } + catch (UpliftConflictException ex) + { + return Conflict(new Response { Status = "Error", Message = ex.Message }); + } catch (KeyNotFoundException ex) { return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Resource not found") }); diff --git a/SeaHaven.DataServices/Exceptions/UpliftDispatchConflictException.cs b/SeaHaven.DataServices/Exceptions/UpliftDispatchConflictException.cs new file mode 100644 index 0000000..0063ebd --- /dev/null +++ b/SeaHaven.DataServices/Exceptions/UpliftDispatchConflictException.cs @@ -0,0 +1,9 @@ +namespace SeaHaven.DataServices.Exceptions; + +public sealed class UpliftDispatchConflictException : Exception +{ + public UpliftDispatchConflictException() + : base("An active uplift request already exists for this dispatch.") + { + } +} diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 1d72cf5..a41d487 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Exceptions; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -525,7 +526,54 @@ namespace SeaHaven.DataServices.Implementation public async Task SaveChangesAsync(CancellationToken cancellationToken) { - await _context.SaveChangesAsync(cancellationToken); + try + { + await _context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException exception) when (IsActiveDispatchUniqueIndexViolation(exception)) + { + throw new UpliftDispatchConflictException(); + } + } + + private bool IsActiveDispatchUniqueIndexViolation(DbUpdateException exception) + { + var provider = _context.Database.ProviderName; + var databaseException = exception.GetBaseException(); + var exceptionType = databaseException.GetType(); + + if (provider?.Contains("SqlServer", StringComparison.OrdinalIgnoreCase) == true + && exceptionType.FullName == "Microsoft.Data.SqlClient.SqlException") + { + var number = exceptionType.GetProperty("Number")?.GetValue(databaseException) as int?; + return number is 2601 or 2627 + && databaseException.Message.Contains( + "IX_DispatchUpliftRequests_DispatchId", + StringComparison.OrdinalIgnoreCase); + } + + if (provider?.Contains("Sqlite", StringComparison.OrdinalIgnoreCase) == true + && exceptionType.FullName == "Microsoft.Data.Sqlite.SqliteException") + { + var extendedCode = exceptionType.GetProperty("SqliteExtendedErrorCode")?.GetValue(databaseException) as int?; + const string uniqueConstraintPrefix = "UNIQUE constraint failed: "; + var message = databaseException.Message; + var prefixIndex = message.IndexOf(uniqueConstraintPrefix, StringComparison.OrdinalIgnoreCase); + if (extendedCode != 2067 || prefixIndex < 0) + return false; + + var columnsStart = prefixIndex + uniqueConstraintPrefix.Length; + var columnsEnd = message.IndexOf('\'', columnsStart); + if (columnsEnd < 0) + columnsEnd = message.Length; + var columns = message[columnsStart..columnsEnd].Trim(); + return string.Equals( + columns, + "DispatchUpliftRequests.DispatchId", + StringComparison.OrdinalIgnoreCase); + } + + return false; } public async Task ExecuteWorkOrderMutationAsync( diff --git a/SeaHaven.Services/Exceptions/UpliftConflictException.cs b/SeaHaven.Services/Exceptions/UpliftConflictException.cs new file mode 100644 index 0000000..9e21b4e --- /dev/null +++ b/SeaHaven.Services/Exceptions/UpliftConflictException.cs @@ -0,0 +1,9 @@ +namespace SeaHaven.Services.Exceptions; + +public sealed class UpliftConflictException : Exception +{ + public UpliftConflictException() + : base("An active uplift request already exists for the associated dispatch. Refresh and retry.") + { + } +} diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 5fc5140..c595809 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation @@ -651,7 +652,14 @@ namespace SeaHaven.Services.Implementation CreatedAt = now }, cancellationToken); - await _upliftData.SaveChangesAsync(cancellationToken); + try + { + await _upliftData.SaveChangesAsync(cancellationToken); + } + catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException) + { + throw new UpliftConflictException(); + } // Notification is best-effort and persisted separately from workflow state: a failure // never destroys the actionable request (the lifecycle sweep retries by sentinel). diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 149aeaa..cb05911 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -69,17 +69,24 @@ namespace SeaHaven.Services.Implementation var notes = request.Notes?.Trim() ?? ""; var accountFilter = _accountResolver.ResolveAccountFilter(user); - return await _upliftData.ExecuteWorkOrderMutationAsync( - workOrderId, - ct => CreateLockedAsync( + try + { + return await _upliftData.ExecuteWorkOrderMutationAsync( workOrderId, - request.Amount, - notes, - userId, - requesterName, - accountFilter, - ct), - cancellationToken); + ct => CreateLockedAsync( + workOrderId, + request.Amount, + notes, + userId, + requesterName, + accountFilter, + ct), + cancellationToken); + } + catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException) + { + throw new UpliftConflictException(); + } } private async Task CreateLockedAsync( diff --git a/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs b/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs index f585766..1742f32 100644 --- a/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs +++ b/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs @@ -9,6 +9,107 @@ namespace SeaHavenIndustries.Tests; public sealed class UpliftDataServiceTransactionTests { + [Fact] + public async Task SaveChangesAsync_ActiveUpliftOnSameDispatch_MapsConcurrentInsertConflict() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new ApplicationDbContext(options); + await context.Database.EnsureCreatedAsync(); + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + context.Dispatches.Add(new Dispatch + { + Id = 10, + VendorId = 1, + DispatchNumber = "DIS-10", + Status = "Scheduled", + }); + await context.SaveChangesAsync(); + + var data = new UpliftDataService(context); + await data.StageAsync(new DispatchUpliftRequest + { + DispatchId = 10, + RequestedNTE = 1500m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending", + }, CancellationToken.None); + await data.SaveChangesAsync(CancellationToken.None); + + // The database index is the final guard when competing requests pass + // their dispatch-scoped active-request checks. + var exception = await Assert.ThrowsAsync( + () => data.ExecuteWorkOrderMutationAsync( + 2, + async cancellationToken => + { + await data.StageAsync(new DispatchUpliftRequest + { + DispatchId = 10, + RequestedNTE = 1700m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending", + }, cancellationToken); + await data.SaveChangesAsync(cancellationToken); + return true; + }, + CancellationToken.None)); + Assert.Equal("An active uplift request already exists for this dispatch.", exception.Message); + Assert.Equal(1, await context.DispatchUpliftRequests.CountAsync()); + } + + [Fact] + public async Task SaveChangesAsync_RequestKeyConflict_IsNotMappedToActiveDispatchConflict() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new ApplicationDbContext(options); + await context.Database.EnsureCreatedAsync(); + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + context.Dispatches.Add(new Dispatch + { + Id = 10, + VendorId = 1, + DispatchNumber = "DIS-10", + Status = "Scheduled", + }); + await context.SaveChangesAsync(); + + var data = new UpliftDataService(context); + await data.StageAsync(new DispatchUpliftRequest + { + DispatchId = 10, + RequestKey = "same-key", + RequestedNTE = 1500m, + Status = "Approved", + RequiredTier = 1, + NotificationStatus = "Sent", + }, CancellationToken.None); + await data.SaveChangesAsync(CancellationToken.None); + await data.StageAsync(new DispatchUpliftRequest + { + DispatchId = 10, + RequestKey = "same-key", + RequestedNTE = 1700m, + Status = "Approved", + RequiredTier = 1, + NotificationStatus = "Sent", + }, CancellationToken.None); + + await Assert.ThrowsAsync( + () => data.SaveChangesAsync(CancellationToken.None)); + } + [Fact] public async Task ExecuteWorkOrderMutationAsync_ReleasesGate_WhenTransactionInitializationFails() { From 0ca9b767edafe5eb6d92452e5a14d0de4dc89fa7 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 01:39:16 -0300 Subject: [PATCH 07/35] fix: distinguish uplift request key conflicts --- .../Implementation/UpliftDataService.cs | 10 +++++++--- SeaHaven.DataServices/Properties/AssemblyInfo.cs | 1 + .../UpliftDataServiceTransactionTests.cs | 10 ++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index a41d487..64df777 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -547,9 +547,7 @@ namespace SeaHaven.DataServices.Implementation { var number = exceptionType.GetProperty("Number")?.GetValue(databaseException) as int?; return number is 2601 or 2627 - && databaseException.Message.Contains( - "IX_DispatchUpliftRequests_DispatchId", - StringComparison.OrdinalIgnoreCase); + && IsActiveDispatchIndexViolationMessage(databaseException.Message); } if (provider?.Contains("Sqlite", StringComparison.OrdinalIgnoreCase) == true @@ -576,6 +574,12 @@ namespace SeaHaven.DataServices.Implementation return false; } + internal static bool IsActiveDispatchIndexViolationMessage(string message) + { + const string activeDispatchIndex = "'IX_DispatchUpliftRequests_DispatchId'"; + return message.Contains(activeDispatchIndex, StringComparison.OrdinalIgnoreCase); + } + public async Task ExecuteWorkOrderMutationAsync( int workOrderId, Func> work, diff --git a/SeaHaven.DataServices/Properties/AssemblyInfo.cs b/SeaHaven.DataServices/Properties/AssemblyInfo.cs index 75fc406..15ea248 100644 --- a/SeaHaven.DataServices/Properties/AssemblyInfo.cs +++ b/SeaHaven.DataServices/Properties/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Api.SeaHavenIndustries.Tests")] +[assembly: InternalsVisibleTo("SeaHavenIndustries.Tests")] diff --git a/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs b/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs index 1742f32..2825fcd 100644 --- a/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs +++ b/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs @@ -9,6 +9,16 @@ namespace SeaHavenIndustries.Tests; public sealed class UpliftDataServiceTransactionTests { + [Theory] + [InlineData("Cannot insert duplicate key row with unique index 'IX_DispatchUpliftRequests_DispatchId'.", true)] + [InlineData("Cannot insert duplicate key row with unique index 'IX_DispatchUpliftRequests_DispatchId_RequestKey'.", false)] + public void IsActiveDispatchIndexViolationMessage_MatchesOnlyTheActiveDispatchIndex( + string message, + bool expected) + { + Assert.Equal(expected, UpliftDataService.IsActiveDispatchIndexViolationMessage(message)); + } + [Fact] public async Task SaveChangesAsync_ActiveUpliftOnSameDispatch_MapsConcurrentInsertConflict() { From 3b626cca58e92aec5701a2f69803af5e0768c428 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 03:35:23 -0300 Subject: [PATCH 08/35] fix: make team member creation atomic --- .../TeamMemberServiceTests.cs | 54 +++++- .../Implementation/UserDataService.cs | 19 ++ .../Interfaces/IUserDataService.cs | 1 + .../Implementation/TeamMemberService.cs | 68 ++++---- .../TeamMemberCreateTransactionTests.cs | 162 ++++++++++++++++++ 5 files changed, 273 insertions(+), 31 deletions(-) create mode 100644 SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs diff --git a/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs index 5f5204b..cd2ced8 100644 --- a/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs @@ -319,7 +319,7 @@ public sealed class TeamMemberServiceTests [Fact] public async Task Create_ConcurrentDuplicateEmail_ReturnsAlreadyInUseInsteadOf500() { - var service = NewService(out var userManager, out var roleManager, out _, out _, out _, out _); + var service = NewService(out var userManager, out var roleManager, out _, out _, out var userData, out _); userManager .Setup(manager => manager.FindByEmailAsync(It.IsAny())) .ReturnsAsync((ApplicationUser?)null); @@ -332,10 +332,57 @@ public sealed class TeamMemberServiceTests result.Success.Should().BeFalse(); result.Error.Should().Be("Email is already in use."); + userData.Verify( + data => data.ExecuteTransactionalAsync(It.IsAny>(), It.IsAny()), + Times.Once); userManager.Verify(manager => manager.AddToRoleAsync(It.IsAny(), It.IsAny()), Times.Never); userManager.Verify(manager => manager.DeleteAsync(It.IsAny()), Times.Never); } + [Fact] + public async Task Create_MidSequencePersistenceFailure_PropagatesWithoutCompensation() + { + var service = NewService(out var userManager, out var roleManager, out var areas, out var overrides, out _, out _); + ApplicationUser? created = null; + userManager + .Setup(manager => manager.FindByEmailAsync(It.IsAny())) + .ReturnsAsync((ApplicationUser?)null); + userManager + .Setup(manager => manager.CreateAsync(It.IsAny())) + .Callback(user => + { + user.Id = "mid-fail-user"; + created = user; + }) + .ReturnsAsync(IdentityResult.Success); + userManager + .Setup(manager => manager.AddToRoleAsync(It.IsAny(), "Dispatcher")) + .ReturnsAsync(IdentityResult.Success); + roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true); + areas + .Setup(data => data.ReplaceAsync( + "mid-fail-user", + It.Is>(value => value.SequenceEqual(new[] { "East", "West" })), + It.IsAny())) + .Returns(Task.CompletedTask); + overrides + .Setup(data => data.SetOverridesAsync( + "mid-fail-user", + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("injected mid-sequence failure")); + + var failure = await Record.ExceptionAsync(() => service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None)); + + failure.Should().BeOfType() + .Which.Message.Should().Be("injected mid-sequence failure"); + created.Should().NotBeNull(); + areas.Verify( + data => data.ReplaceAsync("mid-fail-user", It.IsAny>(), It.IsAny()), + Times.Once); + userManager.Verify(manager => manager.DeleteAsync(It.IsAny()), Times.Never); + } + private static TeamMemberService NewService( out Mock> userManager, out Mock> roleManager, @@ -350,6 +397,11 @@ public sealed class TeamMemberServiceTests overrides = new Mock(); userData = new Mock(); permissions = new Mock(); + userData + .Setup(data => data.ExecuteTransactionalAsync( + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>((callback, token) => callback(token)); return new TeamMemberService( userManager.Object, roleManager.Object, diff --git a/SeaHaven.DataServices/Implementation/UserDataService.cs b/SeaHaven.DataServices/Implementation/UserDataService.cs index 374d34e..32ac851 100644 --- a/SeaHaven.DataServices/Implementation/UserDataService.cs +++ b/SeaHaven.DataServices/Implementation/UserDataService.cs @@ -166,6 +166,25 @@ namespace SeaHaven.DataServices.Implementation await transaction.CommitAsync(cancellationToken); } + public async Task ExecuteTransactionalAsync( + Func callback, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(callback); + + await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + try + { + await callback(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(CancellationToken.None); + throw; + } + } + public async Task GetEmailByIdAsync( string userId, CancellationToken cancellationToken) { diff --git a/SeaHaven.DataServices/Interfaces/IUserDataService.cs b/SeaHaven.DataServices/Interfaces/IUserDataService.cs index 0a60d5c..3c74dcf 100644 --- a/SeaHaven.DataServices/Interfaces/IUserDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUserDataService.cs @@ -18,6 +18,7 @@ namespace SeaHaven.DataServices.Interfaces Task GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken); Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken); Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken); + Task ExecuteTransactionalAsync(Func callback, CancellationToken cancellationToken); Task GetEmailByIdAsync(string userId, CancellationToken cancellationToken); Task> GetDisplayNamesByIdsAsync(IEnumerable ids); } diff --git a/SeaHaven.Services/Implementation/TeamMemberService.cs b/SeaHaven.Services/Implementation/TeamMemberService.cs index ba2cd2b..faf64f3 100644 --- a/SeaHaven.Services/Implementation/TeamMemberService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberService.cs @@ -52,10 +52,6 @@ public sealed class TeamMemberService : ITeamMemberService if (await _userManager.FindByEmailAsync(email!) is not null) return Failure("Email is already in use."); - var roleError = await EnsureRoleAsync(role!); - if (roleError is not null) - return Failure(roleError); - var now = DateTime.UtcNow; var user = new ApplicationUser { @@ -72,39 +68,44 @@ public sealed class TeamMemberService : ITeamMemberService PendingRegistrationCreatedDate = now }; - IdentityResult createResult; try { - createResult = await _userManager.CreateAsync(user); - } - catch (DbUpdateException) - { - // A concurrent create won the race on the unique user-name index - // between the FindByEmailAsync check above and this insert. Surface - // the same conflict message instead of letting the database - // exception bubble up as a 500. - return Failure("Email is already in use."); - } + await _userDataService.ExecuteTransactionalAsync( + async transactionCancellationToken => + { + var roleError = await EnsureRoleAsync(role!); + if (roleError is not null) + throw new TeamMemberCreateException(roleError); - if (!createResult.Succeeded) - return Failure(createResult.Errors.FirstOrDefault()?.Description ?? "Unable to create team member."); + IdentityResult createResult; + try + { + createResult = await _userManager.CreateAsync(user); + } + catch (DbUpdateException) + { + // A concurrent create won the race on the unique user-name index + // between the FindByEmailAsync check above and this insert. Surface + // the same conflict message instead of letting the database + // exception bubble up as a 500. + throw new TeamMemberCreateException("Email is already in use."); + } - var addRoleResult = await _userManager.AddToRoleAsync(user, role!); - if (!addRoleResult.Succeeded) - { - await _userManager.DeleteAsync(user); - return Failure(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to assign role."); - } + if (!createResult.Succeeded) + throw new TeamMemberCreateException(createResult.Errors.FirstOrDefault()?.Description ?? "Unable to create team member."); - try - { - await _areaDataService.ReplaceAsync(user.Id, areas!, cancellationToken); - await _permissionDataService.SetOverridesAsync(user.Id, overrides!, cancellationToken); + var addRoleResult = await _userManager.AddToRoleAsync(user, role!); + if (!addRoleResult.Succeeded) + throw new TeamMemberCreateException(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to assign role."); + + await _areaDataService.ReplaceAsync(user.Id, areas!, transactionCancellationToken); + await _permissionDataService.SetOverridesAsync(user.Id, overrides!, transactionCancellationToken); + }, + cancellationToken); } - catch + catch (TeamMemberCreateException exception) { - await _userManager.DeleteAsync(user); - throw; + return Failure(exception.Message); } return new CreateTeamMemberOutcomeDTO @@ -391,6 +392,13 @@ public sealed class TeamMemberService : ITeamMemberService private static CreateTeamMemberOutcomeDTO Failure(string error) => new() { Success = false, Error = error }; + private sealed class TeamMemberCreateException : Exception + { + public TeamMemberCreateException(string error) : base(error) + { + } + } + private static TeamMemberOperationOutcomeDTO OperationFailure(string error) => new() { Success = false, Error = error }; } diff --git a/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs b/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs new file mode 100644 index 0000000..43c2d40 --- /dev/null +++ b/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs @@ -0,0 +1,162 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.Logging.Abstractions; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +public sealed class TeamMemberCreateTransactionTests +{ + [Fact] + public async Task Create_MidSequencePersistenceFailure_RollsBackAllMemberRows() + { + await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using (var setup = new SqliteTeamMemberTestDbContext(options)) + await setup.Database.EnsureCreatedAsync(); + + await using (var createScope = new SqliteTeamMemberTestDbContext(options)) + { + var permissionData = new ThrowingAfterPersistPermissionDataService( + new TeamPermissionOverrideDataService(createScope)); + var service = new TeamMemberService( + CreateUserManager(createScope), + CreateRoleManager(createScope), + new UserServiceAreaDataService(createScope), + permissionData, + new UserDataService(createScope), + permissionService: null!); + + var failure = await Record.ExceptionAsync(() => service.CreateAsync( + ValidRequest(), + Admin(), + CancellationToken.None)); + + Assert.IsType(failure); + Assert.Equal("injected mid-sequence failure", failure!.Message); + } + + await using var verify = new SqliteTeamMemberTestDbContext(options); + Assert.Empty(await verify.Users.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.Roles.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.UserRoles.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.UserServiceAreas.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.UserPermissionOverrides.AsNoTracking().ToListAsync()); + } + + private static UserManager CreateUserManager(ApplicationDbContext context) => + new( + new UserStore(context), + Microsoft.Extensions.Options.Options.Create(new IdentityOptions()), + new PasswordHasher(), + Array.Empty>(), + Array.Empty>(), + new UpperInvariantLookupNormalizer(), + new IdentityErrorDescriber(), + new EmptyServiceProvider(), + NullLogger>.Instance); + + private static RoleManager CreateRoleManager(ApplicationDbContext context) => + new( + new RoleStore(context), + Array.Empty>(), + new UpperInvariantLookupNormalizer(), + new IdentityErrorDescriber(), + NullLogger>.Instance); + + private static CreateTeamMemberRequestDTO ValidRequest() => new() + { + Name = "Taylor Dispatcher", + Role = "dispatcher", + Color = "#F59E0B", + Email = "taylor@example.com", + Phone = "555-0100", + ServiceAreas = new[] { "east", "West" }, + PermissionOverrides = new Dictionary + { + ["deleteSites"] = UserPermissionState.Allow + } + }; + + private static ClaimsPrincipal Admin() => + new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "test")); + + private sealed class SqliteTeamMemberTestDbContext : ApplicationDbContext + { + public SqliteTeamMemberTestDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes())) + { + if (index.GetFilter() is not null) + index.SetFilter(null); + } + + foreach (var property in builder.Model.GetEntityTypes() + .SelectMany(entity => entity.GetProperties()) + .Where(property => property.Name == "RowVersion" && property.ClrType == typeof(byte[]))) + { + property.ValueGenerated = ValueGenerated.Never; + property.IsConcurrencyToken = false; + } + } + } + + private sealed class ThrowingAfterPersistPermissionDataService : ITeamPermissionOverrideDataService + { + private readonly ITeamPermissionOverrideDataService _inner; + + public ThrowingAfterPersistPermissionDataService(ITeamPermissionOverrideDataService inner) + { + _inner = inner; + } + + public Task GetUserAsync(string userId, CancellationToken cancellationToken) => + _inner.GetUserAsync(userId, cancellationToken); + + public Task SetOverrideAsync( + string userId, + string permissionKey, + UserPermissionState state, + CancellationToken cancellationToken) => + _inner.SetOverrideAsync(userId, permissionKey, state, cancellationToken); + + public async Task SetOverridesAsync( + string userId, + IReadOnlyDictionary overrides, + CancellationToken cancellationToken) + { + await _inner.SetOverridesAsync(userId, overrides, cancellationToken); + throw new InvalidOperationException("injected mid-sequence failure"); + } + + public Task ClearOverridesAsync(string userId, CancellationToken cancellationToken) => + _inner.ClearOverridesAsync(userId, cancellationToken); + } + + private sealed class EmptyServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } +} From 5aa3a6f820bc66814398ef9d7996bc21b8b245c8 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 03:44:44 -0300 Subject: [PATCH 09/35] test: exercise team member rollback through DI --- .../TeamMemberCreateTransactionTests.cs | 65 ++++++++----------- 1 file changed, 28 insertions(+), 37 deletions(-) diff --git a/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs b/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs index 43c2d40..bc7abe8 100644 --- a/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs +++ b/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs @@ -6,12 +6,17 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using SeaHaven.DataServices.Dto; using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.DependencyInjection; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; +using SeaHaven.Services.DependencyInjection; using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; namespace SeaHavenIndustries.Tests; @@ -30,18 +35,27 @@ public sealed class TeamMemberCreateTransactionTests await using (var setup = new SqliteTeamMemberTestDbContext(options)) await setup.Database.EnsureCreatedAsync(); - await using (var createScope = new SqliteTeamMemberTestDbContext(options)) - { - var permissionData = new ThrowingAfterPersistPermissionDataService( - new TeamPermissionOverrideDataService(createScope)); - var service = new TeamMemberService( - CreateUserManager(createScope), - CreateRoleManager(createScope), - new UserServiceAreaDataService(createScope), - permissionData, - new UserDataService(createScope), - permissionService: null!); + var configuration = new ConfigurationBuilder().Build(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(builder => builder.UseSqlite(connection)); + services.Replace(ServiceDescriptor.Scoped(provider => + new SqliteTeamMemberTestDbContext( + provider.GetRequiredService>()))); + services.AddIdentity() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + services.AddDataServices(); + services.AddBusinessServices(configuration); + services.AddScoped(); + services.AddScoped(provider => + new ThrowingAfterPersistPermissionDataService( + provider.GetRequiredService())); + await using var serviceProvider = services.BuildServiceProvider(); + await using (var createScope = serviceProvider.CreateAsyncScope()) + { + var service = createScope.ServiceProvider.GetRequiredService(); var failure = await Record.ExceptionAsync(() => service.CreateAsync( ValidRequest(), Admin(), @@ -51,7 +65,8 @@ public sealed class TeamMemberCreateTransactionTests Assert.Equal("injected mid-sequence failure", failure!.Message); } - await using var verify = new SqliteTeamMemberTestDbContext(options); + await using var verifyScope = serviceProvider.CreateAsyncScope(); + var verify = verifyScope.ServiceProvider.GetRequiredService(); Assert.Empty(await verify.Users.AsNoTracking().ToListAsync()); Assert.Empty(await verify.Roles.AsNoTracking().ToListAsync()); Assert.Empty(await verify.UserRoles.AsNoTracking().ToListAsync()); @@ -59,26 +74,6 @@ public sealed class TeamMemberCreateTransactionTests Assert.Empty(await verify.UserPermissionOverrides.AsNoTracking().ToListAsync()); } - private static UserManager CreateUserManager(ApplicationDbContext context) => - new( - new UserStore(context), - Microsoft.Extensions.Options.Options.Create(new IdentityOptions()), - new PasswordHasher(), - Array.Empty>(), - Array.Empty>(), - new UpperInvariantLookupNormalizer(), - new IdentityErrorDescriber(), - new EmptyServiceProvider(), - NullLogger>.Instance); - - private static RoleManager CreateRoleManager(ApplicationDbContext context) => - new( - new RoleStore(context), - Array.Empty>(), - new UpperInvariantLookupNormalizer(), - new IdentityErrorDescriber(), - NullLogger>.Instance); - private static CreateTeamMemberRequestDTO ValidRequest() => new() { Name = "Taylor Dispatcher", @@ -155,8 +150,4 @@ public sealed class TeamMemberCreateTransactionTests _inner.ClearOverridesAsync(userId, cancellationToken); } - private sealed class EmptyServiceProvider : IServiceProvider - { - public object? GetService(Type serviceType) => null; - } } From fddb7b790909774039b6ebf6c9909c65219b04c3 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 03:49:57 -0300 Subject: [PATCH 10/35] test: verify team member commit and rollback --- .../TeamMemberCreateTransactionTests.cs | 121 ++++++++++++++---- 1 file changed, 99 insertions(+), 22 deletions(-) diff --git a/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs b/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs index bc7abe8..8c5e2bc 100644 --- a/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs +++ b/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs @@ -35,6 +35,85 @@ public sealed class TeamMemberCreateTransactionTests await using (var setup = new SqliteTeamMemberTestDbContext(options)) await setup.Database.EnsureCreatedAsync(); + await using var serviceProvider = BuildServiceProvider(connection, injectFailure: true); + ThrowingAfterPersistPermissionDataService failingPermissionData; + await using (var createScope = serviceProvider.CreateAsyncScope()) + { + var service = createScope.ServiceProvider.GetRequiredService(); + failingPermissionData = (ThrowingAfterPersistPermissionDataService)createScope.ServiceProvider + .GetRequiredService(); + var failure = await Record.ExceptionAsync(() => service.CreateAsync( + ValidRequest(), + Admin(), + CancellationToken.None)); + + Assert.IsType(failure); + Assert.Equal("injected mid-sequence failure", failure!.Message); + } + + Assert.NotNull(failingPermissionData.RowsAtFailure); + var rowsAtFailure = failingPermissionData.RowsAtFailure!.Value; + Assert.Equal(1, rowsAtFailure.Users); + Assert.Equal(1, rowsAtFailure.Roles); + Assert.Equal(1, rowsAtFailure.UserRoles); + Assert.Equal(2, rowsAtFailure.ServiceAreas); + Assert.Equal(1, rowsAtFailure.PermissionOverrides); + + await using var verifyScope = serviceProvider.CreateAsyncScope(); + var verify = verifyScope.ServiceProvider.GetRequiredService(); + Assert.Empty(await verify.Users.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.Roles.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.UserRoles.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.UserServiceAreas.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.UserPermissionOverrides.AsNoTracking().ToListAsync()); + } + + [Fact] + public async Task Create_SuccessfulTransaction_CommitsPendingMemberAndAssociations() + { + await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using (var setup = new SqliteTeamMemberTestDbContext(options)) + await setup.Database.EnsureCreatedAsync(); + + await using var serviceProvider = BuildServiceProvider(connection); + await using (var createScope = serviceProvider.CreateAsyncScope()) + { + var service = createScope.ServiceProvider.GetRequiredService(); + var outcome = await service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None); + + Assert.True(outcome.Success); + Assert.True(outcome.Member!.PendingRegistration); + Assert.Equal("taylor@example.com", outcome.Member.Email); + } + + await using var verifyScope = serviceProvider.CreateAsyncScope(); + var verify = verifyScope.ServiceProvider.GetRequiredService(); + var user = await verify.Users.AsNoTracking().SingleAsync(user => user.Email == "taylor@example.com"); + Assert.True(user.PendingRegistration); + + var role = await verify.Roles.AsNoTracking().SingleAsync(role => role.Name == "Dispatcher"); + Assert.Contains(await verify.UserRoles.AsNoTracking().ToListAsync(), row => row.UserId == user.Id && row.RoleId == role.Id); + + var areas = await verify.UserServiceAreas.AsNoTracking() + .Where(area => area.UserId == user.Id) + .Select(area => area.Area) + .OrderBy(area => area) + .ToListAsync(); + Assert.Equal(new[] { "East", "West" }, areas); + + var permissionOverride = await verify.UserPermissionOverrides.AsNoTracking() + .SingleAsync(permission => permission.UserId == user.Id); + Assert.Equal("deleteSites", permissionOverride.PermissionKey); + } + + private static ServiceProvider BuildServiceProvider(SqliteConnection connection, bool injectFailure = false) + { var configuration = new ConfigurationBuilder().Build(); var services = new ServiceCollection(); services.AddLogging(); @@ -47,31 +126,17 @@ public sealed class TeamMemberCreateTransactionTests .AddDefaultTokenProviders(); services.AddDataServices(); services.AddBusinessServices(configuration); - services.AddScoped(); - services.AddScoped(provider => - new ThrowingAfterPersistPermissionDataService( - provider.GetRequiredService())); - await using var serviceProvider = services.BuildServiceProvider(); - await using (var createScope = serviceProvider.CreateAsyncScope()) + if (injectFailure) { - var service = createScope.ServiceProvider.GetRequiredService(); - var failure = await Record.ExceptionAsync(() => service.CreateAsync( - ValidRequest(), - Admin(), - CancellationToken.None)); - - Assert.IsType(failure); - Assert.Equal("injected mid-sequence failure", failure!.Message); + services.AddScoped(); + services.AddScoped(provider => + new ThrowingAfterPersistPermissionDataService( + provider.GetRequiredService(), + provider.GetRequiredService())); } - await using var verifyScope = serviceProvider.CreateAsyncScope(); - var verify = verifyScope.ServiceProvider.GetRequiredService(); - Assert.Empty(await verify.Users.AsNoTracking().ToListAsync()); - Assert.Empty(await verify.Roles.AsNoTracking().ToListAsync()); - Assert.Empty(await verify.UserRoles.AsNoTracking().ToListAsync()); - Assert.Empty(await verify.UserServiceAreas.AsNoTracking().ToListAsync()); - Assert.Empty(await verify.UserPermissionOverrides.AsNoTracking().ToListAsync()); + return services.BuildServiceProvider(); } private static CreateTeamMemberRequestDTO ValidRequest() => new() @@ -121,12 +186,18 @@ public sealed class TeamMemberCreateTransactionTests private sealed class ThrowingAfterPersistPermissionDataService : ITeamPermissionOverrideDataService { private readonly ITeamPermissionOverrideDataService _inner; + private readonly ApplicationDbContext _context; - public ThrowingAfterPersistPermissionDataService(ITeamPermissionOverrideDataService inner) + public ThrowingAfterPersistPermissionDataService( + ITeamPermissionOverrideDataService inner, + ApplicationDbContext context) { _inner = inner; + _context = context; } + public (int Users, int Roles, int UserRoles, int ServiceAreas, int PermissionOverrides)? RowsAtFailure { get; private set; } + public Task GetUserAsync(string userId, CancellationToken cancellationToken) => _inner.GetUserAsync(userId, cancellationToken); @@ -143,6 +214,12 @@ public sealed class TeamMemberCreateTransactionTests CancellationToken cancellationToken) { await _inner.SetOverridesAsync(userId, overrides, cancellationToken); + RowsAtFailure = ( + await _context.Users.CountAsync(cancellationToken), + await _context.Roles.CountAsync(cancellationToken), + await _context.UserRoles.CountAsync(cancellationToken), + await _context.UserServiceAreas.CountAsync(cancellationToken), + await _context.UserPermissionOverrides.CountAsync(cancellationToken)); throw new InvalidOperationException("injected mid-sequence failure"); } From dc251c42d42dff0a1d5da8cadb8d6b24c623de77 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 20:52:44 -0300 Subject: [PATCH 11/35] fix(media): apply SH-116 media contract and lift the 1 MB proxy body cap The Elastic Beanstalk nginx proxy kept its 1 MB default body limit, so every media upload over ~1 MB got an nginx 413 before reaching the API. Ship a .platform nginx override (120M) in the bundle and assert it in the bundle contract. Apply the client-confirmed contract: photos up to 10 MB (JPEG/PNG/HEIC), videos up to 100 MB (MP4/MOV), at most 10 photos and 3 videos per work order, with stable generic rejection messages. The request ceiling (110 MB) sits between the per-kind caps and the proxy so oversize files get the generic message. The vendor portal accepts the same photo/video types and caps. --- .../nginx/conf.d/01_upload_body_size.conf | 6 + .../VendorPortalDocumentTests.cs | 110 +++++++ .../WorkOrderMediaControllerTests.cs | 167 ++++++++++- .../Controllers/VendorPortalController.cs | 3 +- .../Controllers/WorkOrderMediaController.cs | 11 +- .../WorkOrderMediaDataService.cs | 12 + .../Interfaces/IWorkOrderMediaDataService.cs | 5 + .../Helpers/WorkOrderMediaContract.cs | 84 ++++++ .../Helpers/WorkOrderMediaFileRules.cs | 28 +- .../Implementation/VendorPortalService.cs | 86 +++--- .../Implementation/WorkOrderMediaService.cs | 38 +++ .../WorkOrderPhase6Tests.cs | 283 ++++++++++++++++++ scripts/check_app_terraform_isolation.py | 2 +- scripts/package-elastic-beanstalk.sh | 5 + scripts/test_check_app_terraform_isolation.py | 11 + scripts/validate-elastic-beanstalk-bundle.sh | 8 +- 16 files changed, 807 insertions(+), 52 deletions(-) create mode 100644 .platform/nginx/conf.d/01_upload_body_size.conf create mode 100644 SeaHaven.Services/Helpers/WorkOrderMediaContract.cs diff --git a/.platform/nginx/conf.d/01_upload_body_size.conf b/.platform/nginx/conf.d/01_upload_body_size.conf new file mode 100644 index 0000000..a7bd889 --- /dev/null +++ b/.platform/nginx/conf.d/01_upload_body_size.conf @@ -0,0 +1,6 @@ +# SH-383: the Elastic Beanstalk nginx proxy defaults client_max_body_size to 1m, +# which returned 413 for every media upload over ~1 MB before the request reached +# the API. The cap sits above the API's own request limit +# (WorkOrderMediaContract.MaxUploadRequestBytes = 110 MB) so oversize uploads get +# the API's generic per-kind message instead of an nginx error page. +client_max_body_size 120M; diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 0d8f6e3..83d9d1d 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -363,6 +363,116 @@ public sealed class VendorPortalDocumentTests : IDisposable context.VendorCompletionDocuments.Should().HaveCount(1); } + [Fact] + public void UploadCompletionDocument_AdvertisesContractRequestLimit() + { + var attribute = Assert.Single( + typeof(VendorPortalController) + .GetMethod(nameof(VendorPortalController.UploadCompletionDocument))! + .CustomAttributes, + candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute)); + var bytes = Assert.Single(attribute.ConstructorArguments); + + bytes.Value.Should().Be(110_000_000L); + } + + private static byte[] MediaBytes(int size, params byte[] header) + { + var bytes = new byte[size]; + header.AsSpan().CopyTo(bytes); + return bytes; + } + + private static byte[] FtypVideoBytes(int size) => MediaBytes( + size, 0x00, 0x00, 0x00, 0x20, (byte)'f', (byte)'t', (byte)'y', (byte)'p', + (byte)'i', (byte)'s', (byte)'o', (byte)'m'); + + [Fact] + public async Task UploadCompletionDocument_AcceptsSixtyMegabyteVideo() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(FtypVideoBytes(60_000_000), "site-clip.mp4", "video/mp4")); + + result.Should().BeOfType(); + var document = await context.VendorCompletionDocuments.SingleAsync(); + document.ContentType.Should().Be("video/mp4"); + document.SizeBytes.Should().Be(60_000_000L); + } + + [Fact] + public async Task UploadCompletionDocument_AcceptsMovWithEmptyContentType() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(FtypVideoBytes(2_048), "site-clip.MOV", "")); + + result.Should().BeOfType(); + (await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("video/quicktime"); + } + + [Fact] + public async Task UploadCompletionDocument_AcceptsIosTranscodedJpegLabelledHeic() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(MediaBytes(4_096, 0xFF, 0xD8, 0xFF, 0xE0), "IMG_2044.jpg", "image/heic")); + + result.Should().BeOfType(); + (await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("image/jpeg"); + } + + [Fact] + public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(FtypVideoBytes(100_000_001), "site-clip.mp4", "video/mp4")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + + [Fact] + public async Task UploadCompletionDocument_RejectsPhotoOverTenMegabytes() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(MediaBytes(10_000_001, 0xFF, 0xD8, 0xFF, 0xE0), "photo.jpg", "image/jpeg")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + + [Fact] + public async Task UploadCompletionDocument_RejectsUnsupportedVideoContainer() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile("not a video at all"u8.ToArray(), "clip.mp4", "video/mp4")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + [Fact] public async Task GetDispatchDetail_ReturnsUploadedDocumentWithQuarantineAndReplacementMetadata() { diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index a29b332..32c70df 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -1,5 +1,6 @@ using Api.SeaHavenIndustries.Controllers; using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Moq; @@ -31,7 +32,7 @@ public class WorkOrderMediaControllerTests } [Fact] - public void AddMedia_HasTwoHundredMegabyteRequestLimit() + public void AddMedia_HasContractRequestLimit() { var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia)); var attribute = Assert.Single( @@ -39,24 +40,26 @@ public class WorkOrderMediaControllerTests candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute)); var bytes = Assert.Single(attribute.ConstructorArguments); - Assert.Equal(200_000_000L, bytes.Value); + Assert.Equal(110_000_000L, bytes.Value); } [Fact] - public void AddMedia_HasTwoHundredMegabyteMultipartBodyLimit() + public void AddMedia_HasContractMultipartBodyLimit() { var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia)); var attribute = Assert.IsType(Assert.Single( method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: true))); - Assert.Equal(200_000_000L, attribute.MultipartBodyLengthLimit); + Assert.Equal(110_000_000L, attribute.MultipartBodyLengthLimit); } [Fact] - public async Task AddMedia_FileOverLimit_ReturnsStableUnprocessableEntity() + public async Task AddMedia_VideoOverHundredMegabytes_ReturnsStableUnprocessableEntity() { var file = new Mock(); - file.SetupGet(candidate => candidate.Length).Returns(200_000_001); + file.SetupGet(candidate => candidate.Length).Returns(100_000_001); + file.SetupGet(candidate => candidate.FileName).Returns("clip.mp4"); + file.SetupGet(candidate => candidate.ContentType).Returns("video/mp4"); var storage = new Mock(MockBehavior.Strict); var controller = CreateController(storage: storage); @@ -65,10 +68,160 @@ public class WorkOrderMediaControllerTests var response = Assert.IsType(result); var error = Assert.IsType(response.Value); Assert.Equal("FileTooLarge", error.Code); - Assert.Equal("The uploaded file must not exceed 200 MB.", error.Message); + Assert.Equal("Videos must be 100 MB or smaller.", error.Message); storage.VerifyNoOtherCalls(); } + [Fact] + public async Task AddMedia_PhotoOverTenMegabytes_ReturnsStableUnprocessableEntity() + { + var file = new Mock(); + file.SetupGet(candidate => candidate.Length).Returns(10_000_001); + file.SetupGet(candidate => candidate.FileName).Returns("photo.jpg"); + file.SetupGet(candidate => candidate.ContentType).Returns("image/jpeg"); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("FileTooLarge", error.Code); + Assert.Equal("Photos must be 10 MB or smaller.", error.Message); + storage.VerifyNoOtherCalls(); + } + + [Fact] + public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity() + { + var file = new Mock(); + file.SetupGet(candidate => candidate.Length).Returns(50_000_001); + file.SetupGet(candidate => candidate.FileName).Returns("report.pdf"); + file.SetupGet(candidate => candidate.ContentType).Returns("application/pdf"); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, WorkOrderMediaCategory.Extra, file.Object, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("FileTooLarge", error.Code); + Assert.Equal("Documents must be 50 MB or smaller.", error.Message); + storage.VerifyNoOtherCalls(); + } + + private static FormFile VideoFormFile(int size, string fileName, string contentType) + { + var bytes = new byte[size]; + // ISO BMFF ftyp box so the media type rules accept the payload as MP4/MOV. + bytes[4] = (byte)'f'; + bytes[5] = (byte)'t'; + bytes[6] = (byte)'y'; + bytes[7] = (byte)'p'; + bytes[8] = (byte)'i'; + bytes[9] = (byte)'s'; + bytes[10] = (byte)'o'; + bytes[11] = (byte)'m'; + return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } + + private static (Mock Service, Mock Storage) SetupSuccessfulAddMedia() + { + var service = new Mock(MockBehavior.Strict); + service + .Setup(candidate => candidate.EnsureCanMutateMediaAsync( + 1, It.IsAny(), It.IsAny(), It.IsAny(), null)) + .Returns(Task.CompletedTask); + service + .Setup(candidate => candidate.AddMediaAsync( + 1, null, "https://storage.test/stored", It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new WorkOrderMediaFileDto { Id = 5, Url = "https://storage.test/stored" }); + var storage = new Mock(MockBehavior.Strict); + storage + .Setup(candidate => candidate.SaveFileAsync(It.IsAny())) + .ReturnsAsync("https://storage.test/stored"); + return (service, storage); + } + + [Fact] + public async Task AddMedia_SixtyMegabyteMp4_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var file = VideoFormFile(60_000_000, "site-clip.mp4", "video/mp4"); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + var ok = Assert.IsType(result); + Assert.Equal(5, Assert.IsType(ok.Value).Id); + } + + [Fact] + public async Task AddMedia_SixtyMegabyteQuicktimeMov_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var file = VideoFormFile(60_000_000, "site-clip.mov", "video/quicktime"); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + Assert.IsType(result); + } + + [Fact] + public async Task AddMedia_SixtyMegabyteMovWithEmptyContentType_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var file = VideoFormFile(60_000_000, "site-clip.MOV", ""); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + Assert.IsType(result); + } + + [Fact] + public async Task AddMedia_SixtyMegabyteMp4WithOctetStreamContentType_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var file = VideoFormFile(60_000_000, "site-clip.mp4", "application/octet-stream"); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + Assert.IsType(result); + } + + [Fact] + public async Task AddMedia_HeicPhoto_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var bytes = new byte[512]; + bytes[4] = (byte)'f'; + bytes[5] = (byte)'t'; + bytes[6] = (byte)'y'; + bytes[7] = (byte)'p'; + bytes[8] = (byte)'h'; + bytes[9] = (byte)'e'; + bytes[10] = (byte)'i'; + bytes[11] = (byte)'c'; + var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "capture.heic") + { + Headers = new HeaderDictionary(), + ContentType = "image/heic" + }; + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + Assert.IsType(result); + } + [Fact] public async Task AddMedia_UnsupportedType_ReturnsUnprocessableEntity() { diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index 39dd90b..1e7e59c 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers @@ -294,7 +295,7 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost("dispatches/{id:int}/completion-documents")] [HttpPost("dispatches/{id:int}/documents")] - [RequestSizeLimit(10_000_000)] + [RequestSizeLimit(WorkOrderMediaContract.MaxUploadRequestBytes)] public async Task UploadCompletionDocument( int id, [FromForm] IFormFile file, diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index 17b3ba3..4f854ef 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -17,7 +17,7 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/workorders")] public class WorkOrderMediaController : Controller { - public const long MaxUploadBytes = 200_000_000; + public const long MaxUploadBytes = WorkOrderMediaContract.MaxUploadRequestBytes; private readonly IWorkOrderMediaService _workOrderMediaService; private readonly IFileStoragePort _fileStorage; @@ -88,11 +88,12 @@ namespace Api.SeaHavenIndustries.Controllers string? fileUrl = null; try { - if (file.Length > MaxUploadBytes) + // SH-116 contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB). + var sizeMessage = WorkOrderMediaContract.ValidateSize( + file.ContentType, file.FileName, file.Length); + if (sizeMessage != null) { - throw new WorkOrderBoardValidationException( - "FileTooLarge", - "The uploaded file must not exceed 200 MB."); + throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage); } WorkOrderMediaFileRules.EnsureAllowed(file, category); diff --git a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs index fd05a45..9ad9dbe 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs @@ -56,6 +56,18 @@ namespace SeaHaven.DataServices.Implementation public void TrackAttachment(WorkOrderAttachments attachment) => _context.workOrderAttachments.Add(attachment); + public async Task> ListActiveAttachmentUrlsAsync( + int workOrderId, + CancellationToken cancellationToken) + { + var urls = await _context.workOrderAttachments + .AsNoTracking() + .Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true && a.Attachments != null) + .Select(a => a.Attachments!) + .ToListAsync(cancellationToken); + return urls; + } + public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version) => _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version; diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs index 57cbbf3..30d0005 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs @@ -22,6 +22,11 @@ namespace SeaHaven.DataServices.Interfaces Task GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken); void TrackAttachment(WorkOrderAttachments attachment); + + /// Stored URLs of the work order's non-deleted attachments (SH-116 photo/video counts). + Task> ListActiveAttachmentUrlsAsync( + int workOrderId, + CancellationToken cancellationToken); void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version); void MarkWorkOrderModified(WorkOrder workOrder); Task SaveAsync(CancellationToken cancellationToken); diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs new file mode 100644 index 0000000..243dcc7 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -0,0 +1,84 @@ +namespace SeaHaven.Services.Helpers +{ + /// + /// SH-116 client-confirmed media contract (2026-09-22): photos up to 10 MB (JPEG/PNG/HEIC), + /// videos up to 100 MB and 90 seconds (MP4/MOV), at most 10 photos and 3 videos per work + /// order, across the dispatcher media modal, the completion-doc media tab and the vendor + /// portal upload. Documents (PDF/DOC/DOCX) keep the 50 MB document cap. Duration is only + /// checkable client-side (the server cannot probe it cheaply), so it is enforced in the + /// browser and documented here as part of the same contract. + /// + public static class WorkOrderMediaContract + { + public const long MaxPhotoBytes = 10_000_000; + public const long MaxVideoBytes = 100_000_000; + public const long MaxDocumentBytes = 50_000_000; + public const int MaxPhotosPerWorkOrder = 10; + public const int MaxVideosPerWorkOrder = 3; + public const int MaxVideoDurationSeconds = 90; + + /// + /// Request-level ceiling for media endpoints (RequestSizeLimit / multipart limit). It sits + /// above plus multipart overhead so an oversize file reaches the + /// per-kind check and gets its generic message instead of a framework 413. The EB nginx + /// proxy (.platform/nginx/conf.d/01_upload_body_size.conf) must stay above this value. + /// + public const long MaxUploadRequestBytes = 110_000_000; + + public enum UploadKind + { + Photo, + Video, + Document, + Unknown + } + + public static UploadKind ResolveKind(string? contentType, string? fileName) + { + var type = (contentType ?? string.Empty).Trim(); + var extension = Path.GetExtension(fileName ?? string.Empty); + + if (type.StartsWith("video/", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".mp4", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".mov", StringComparison.OrdinalIgnoreCase)) + return UploadKind.Video; + + if (type.StartsWith("image/", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".png", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".heic", StringComparison.OrdinalIgnoreCase)) + return UploadKind.Photo; + + if (type.Equals("application/pdf", StringComparison.OrdinalIgnoreCase) + || type.Equals("application/msword", StringComparison.OrdinalIgnoreCase) + || type.Equals( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + StringComparison.OrdinalIgnoreCase) + || extension.Equals(".pdf", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".doc", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".docx", StringComparison.OrdinalIgnoreCase)) + return UploadKind.Document; + + return UploadKind.Unknown; + } + + /// Stable, generic per-kind size message; never echoes file metadata. + public static string? ValidateSize(long length, UploadKind kind) + { + return kind switch + { + UploadKind.Photo when length > MaxPhotoBytes => "Photos must be 10 MB or smaller.", + UploadKind.Video when length > MaxVideoBytes => "Videos must be 100 MB or smaller.", + UploadKind.Document when length > MaxDocumentBytes => + "Documents must be 50 MB or smaller.", + UploadKind.Unknown when length > MaxVideoBytes => + "Videos must be 100 MB or smaller.", + _ => null + }; + } + + public static string? ValidateSize(string? contentType, string? fileName, long length) + => ValidateSize(length, ResolveKind(contentType, fileName)); + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs index aa6439a..ee02d9b 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs @@ -12,6 +12,7 @@ namespace SeaHaven.Services.Helpers "image/jpeg", "image/jpg", "image/png", + "image/heic", "video/mp4", "video/quicktime", "application/pdf", @@ -24,6 +25,7 @@ namespace SeaHaven.Services.Helpers { ["image/jpeg"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" }, ["image/png"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".png" }, + ["image/heic"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".heic" }, ["video/mp4"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mp4" }, ["video/quicktime"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mov" }, ["application/pdf"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".pdf" }, @@ -42,7 +44,11 @@ namespace SeaHaven.Services.Helpers // the accepted set of files. private static string? ResolveContentType(string declaredType, string extension) { - if (AllowedContentTypes.Contains(declaredType)) + // iOS transcodes a picked HEIC photo to JPEG (named .jpg) but can keep image/heic as + // its type, so a declared image/heic is only authoritative on a real .heic file. + var isTranscodedHeic = declaredType.Equals("image/heic", StringComparison.OrdinalIgnoreCase) + && !extension.Equals(".heic", StringComparison.OrdinalIgnoreCase); + if (AllowedContentTypes.Contains(declaredType) && !isTranscodedHeic) return declaredType; foreach (var (contentType, extensions) in ExtensionsByContentType) @@ -139,6 +145,11 @@ namespace SeaHaven.Services.Helpers && bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A; } + if (contentType.Equals("image/heic", StringComparison.OrdinalIgnoreCase)) + { + return IsHeifBrand(bytes); + } + if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase)) { return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; @@ -206,5 +217,20 @@ namespace SeaHaven.Services.Helpers && bytes[6] == (byte)'y' && bytes[7] == (byte)'p'; } + + private static bool IsHeifBrand(byte[] bytes) + { + if (!HasFtypBox(bytes)) + return false; + + // HEIC/HEIF containers identify by the ftyp major brand (bytes 8..11). + var brand = System.Text.Encoding.ASCII.GetString(bytes, 8, 4); + return brand switch + { + "heic" or "heix" or "hevc" or "hevx" or "heim" or "heis" or "hevm" or "hevs" + or "mif1" or "msf1" => true, + _ => false + }; + } } } diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 5fc5140..cbe8192 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -4,6 +4,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 @@ -12,9 +13,36 @@ namespace SeaHaven.Services.Implementation { private static readonly HashSet AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase) { - "application/pdf", "image/jpeg", "image/jpg", "image/png" + "application/pdf", "image/jpeg", "image/jpg", "image/png", "image/heic", + "video/mp4", "video/quicktime" }; + // The browser's File.type is unreliable on mobile (empty or application/octet-stream), + // so an extension pairing against the same allowlist resolves the real content type. + private static string? ResolveUploadContentType(IFormFile file) + { + var declaredType = (file.ContentType ?? string.Empty).Trim(); + var extension = Path.GetExtension(file.FileName ?? string.Empty); + // iOS transcodes a picked HEIC photo to JPEG (named .jpg) but can keep image/heic. + var isTranscodedHeic = declaredType.Equals("image/heic", StringComparison.OrdinalIgnoreCase) + && !extension.Equals(".heic", StringComparison.OrdinalIgnoreCase); + if (AllowedContentTypes.Contains(declaredType) && !isTranscodedHeic) + return declaredType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase) + ? "image/jpeg" + : declaredType; + + return extension.ToLowerInvariant() switch + { + ".pdf" => "application/pdf", + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".heic" => "image/heic", + ".mp4" => "video/mp4", + ".mov" => "video/quicktime", + _ => null + }; + } + private const int MaxRefusalReasonLength = 500; private readonly IVendorPortalTokenService _tokens; @@ -820,15 +848,30 @@ namespace SeaHaven.Services.Implementation throw new InvalidOperationException("A completion document file is required."); } - if (file.Length > _documentOptions.MaxSizeBytes) + // SH-116 contract: photos up to 10 MB (JPEG/PNG/HEIC), videos up to 100 MB + // (MP4/MOV). Documents keep the configured VendorDocuments cap. + var contentType = ResolveUploadContentType(file); + if (contentType == null) { - throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size."); + throw new InvalidOperationException("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are accepted."); } - var contentType = (file.ContentType ?? string.Empty).Trim(); - if (!AllowedContentTypes.Contains(contentType)) + var kind = WorkOrderMediaContract.ResolveKind(contentType, file.FileName); + if (kind == WorkOrderMediaContract.UploadKind.Photo + && file.Length > WorkOrderMediaContract.MaxPhotoBytes) { - throw new InvalidOperationException("Only PDF, JPG, and PNG completion documents are accepted."); + throw new InvalidOperationException("Photos must be 10 MB or smaller."); + } + if (kind == WorkOrderMediaContract.UploadKind.Video + && file.Length > WorkOrderMediaContract.MaxVideoBytes) + { + throw new InvalidOperationException("Videos must be 100 MB or smaller."); + } + if (kind != WorkOrderMediaContract.UploadKind.Photo + && kind != WorkOrderMediaContract.UploadKind.Video + && file.Length > _documentOptions.MaxSizeBytes) + { + throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size."); } var resolvedPurpose = string.IsNullOrWhiteSpace(purpose) @@ -851,7 +894,7 @@ namespace SeaHaven.Services.Implementation await file.CopyToAsync(buffer, cancellationToken); var bytes = buffer.ToArray(); - if (!MatchesSignature(contentType, bytes)) + if (!WorkOrderMediaFileRules.MatchesSignature(contentType, bytes)) { throw new InvalidOperationException("The uploaded file signature does not match its declared content type."); } @@ -984,35 +1027,6 @@ namespace SeaHaven.Services.Implementation }; } - private static bool MatchesSignature(string contentType, byte[] bytes) - { - if (bytes.Length == 0) - { - return false; - } - - if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)) - { - return bytes.Length >= 4 - && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46; // %PDF - } - - if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase)) - { - return bytes.Length >= 8 - && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 - && bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A; - } - - if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) - || contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase)) - { - return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; - } - - return false; - } - private static string GetSafeExtension(string fileName) { var extension = Path.GetExtension(fileName); diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index 0b0e38c..dccbffd 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -153,6 +153,8 @@ namespace SeaHaven.Services.Implementation }; } + await EnsureWithinMediaCountsAsync(workOrderId, fileUrl, cancellationToken); + var attachment = new WorkOrderAttachments { WorkorderId = workOrderId, @@ -348,6 +350,42 @@ namespace SeaHaven.Services.Implementation throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); } + /// + /// SH-116 contract: at most 10 photos and 3 videos per work order, counted over the + /// stored attachment rows. Legacy Before/After column slots replace in place and are + /// not counted. Documents have no per-work-order count limit. + /// + private async Task EnsureWithinMediaCountsAsync( + int workOrderId, + string fileUrl, + CancellationToken cancellationToken) + { + var kind = WorkOrderMediaContract.ResolveKind(null, fileUrl); + if (kind != WorkOrderMediaContract.UploadKind.Photo + && kind != WorkOrderMediaContract.UploadKind.Video) + return; + + var urls = await _mediaData.ListActiveAttachmentUrlsAsync(workOrderId, cancellationToken); + var sameKindCount = urls.Count(url => + WorkOrderMediaContract.ResolveKind(null, url) == kind); + + if (kind == WorkOrderMediaContract.UploadKind.Photo + && sameKindCount >= WorkOrderMediaContract.MaxPhotosPerWorkOrder) + { + throw new WorkOrderBoardValidationException( + "MediaCountExceeded", + "A work order can have at most 10 photos."); + } + + if (kind == WorkOrderMediaContract.UploadKind.Video + && sameKindCount >= WorkOrderMediaContract.MaxVideosPerWorkOrder) + { + throw new WorkOrderBoardValidationException( + "MediaCountExceeded", + "A work order can have at most 3 videos."); + } + } + /// /// Account filter for data queries. Null means org-wide (skip ApplyAccountScope). /// Call only after EnsureCan* has verified scope is not Missing. diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index cd24883..d5771a9 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1309,6 +1309,289 @@ public class WorkOrderMediaServiceTests Assert.Equal(WorkOrderMediaCategory.Extra, media.Category); } + [Fact] + public async Task AddMedia_EleventhPhoto_ThrowsMediaCountExceeded() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + for (var i = 0; i < 10; i++) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = $"https://example.com/photo-{i}.jpg", + Category = WorkOrderMediaCategory.Extra + }); + } + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync( + 1, + null, + "https://example.com/photo-10.jpg", + AuthenticatedUser(), + "actor-1")); + + Assert.Equal("MediaCountExceeded", ex.Code); + Assert.Equal("A work order can have at most 10 photos.", ex.Message); + Assert.Equal(10, context.workOrderAttachments.Count(a => a.IsDeleted != true)); + } + + [Fact] + public async Task AddMedia_FourthVideo_CountsStoredPhoneFileUrls() + { + // Same URL shape FileStorageAdapter.SaveFileAsync returns for an iPhone upload. + static string StoredUrl(string name) => + $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}"; + + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.mov", "clip.MP4" }) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = StoredUrl(name), + Category = WorkOrderMediaCategory.Extra + }); + } + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync( + 1, + null, + StoredUrl("IMG_0004.MOV"), + AuthenticatedUser(), + "actor-1")); + + Assert.Equal("MediaCountExceeded", ex.Code); + Assert.Equal("A work order can have at most 3 videos.", ex.Message); + } + + [Fact] + public async Task AddMedia_CrossAccount_FullWorkOrder_ThrowsNotFoundNotCount() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + AccountId = 20, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + for (var i = 0; i < 10; i++) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = $"https://example.com/photo-{i}.jpg", + Category = WorkOrderMediaCategory.Extra + }); + } + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync( + 1, + null, + "https://example.com/photo-10.jpg", + AuthenticatedUser(accountId: 10), + "actor-1")); + + // Another account must not learn the work order exists or how full it is. + Assert.Equal("NotFound", ex.Code); + Assert.Equal(10, context.workOrderAttachments.Count(a => a.IsDeleted != true)); + } + + [Fact] + public async Task AddMedia_TenthPhoto_Succeeds() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + for (var i = 0; i < 9; i++) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = $"https://example.com/photo-{i}.jpg", + Category = WorkOrderMediaCategory.Extra + }); + } + await context.SaveChangesAsync(); + + var media = await service.AddMediaAsync( + 1, + null, + "https://example.com/photo-9.jpg", + AuthenticatedUser(), + "actor-1"); + + Assert.True(media.Id > 0); + } + + [Fact] + public async Task AddMedia_FourthVideo_ThrowsMediaCountExceeded() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/clip-a.mp4", + Category = WorkOrderMediaCategory.Extra + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/clip-b.mov", + Category = WorkOrderMediaCategory.Extra + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/clip-c.mp4", + Category = WorkOrderMediaCategory.Extra + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync( + 1, + null, + "https://example.com/clip-d.mov", + AuthenticatedUser(), + "actor-1")); + + Assert.Equal("MediaCountExceeded", ex.Code); + Assert.Equal("A work order can have at most 3 videos.", ex.Message); + } + + [Fact] + public async Task AddMedia_ThirdVideo_Succeeds() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/clip-a.mp4", + Category = WorkOrderMediaCategory.Extra + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/clip-b.mov", + Category = WorkOrderMediaCategory.Extra + }); + await context.SaveChangesAsync(); + + var media = await service.AddMediaAsync( + 1, + null, + "https://example.com/clip-c.mp4", + AuthenticatedUser(), + "actor-1"); + + Assert.True(media.Id > 0); + } + + [Fact] + public async Task AddMedia_DeletedPhoto_DoesNotConsumePhotoCount() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + for (var i = 0; i < 10; i++) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = $"https://example.com/photo-{i}.jpg", + Category = WorkOrderMediaCategory.Extra, + IsDeleted = i == 0 + }); + } + await context.SaveChangesAsync(); + + var media = await service.AddMediaAsync( + 1, + null, + "https://example.com/photo-new.jpg", + AuthenticatedUser(), + "actor-1"); + + Assert.True(media.Id > 0); + } + + [Fact] + public async Task AddMedia_DocumentsDoNotConsumePhotoOrVideoCounts() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + for (var i = 0; i < 5; i++) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = $"https://example.com/report-{i}.pdf", + Category = WorkOrderMediaCategory.Extra + }); + } + await context.SaveChangesAsync(); + + var photo = await service.AddMediaAsync( + 1, + null, + "https://example.com/photo.jpg", + AuthenticatedUser(), + "actor-1"); + var video = await service.AddMediaAsync( + 1, + null, + "https://example.com/clip.mp4", + AuthenticatedUser(), + "actor-1"); + + Assert.True(photo.Id > 0); + Assert.True(video.Id > 0); + } + [Fact] public async Task DeleteMedia_Technician_ThrowsForbidden() { diff --git a/scripts/check_app_terraform_isolation.py b/scripts/check_app_terraform_isolation.py index 9f87212..11db9a8 100644 --- a/scripts/check_app_terraform_isolation.py +++ b/scripts/check_app_terraform_isolation.py @@ -26,7 +26,7 @@ def is_app_path(path: str) -> bool: normalized = path.replace("\\", "/") if normalized in APP_SCRIPT_NAMES: return True - if normalized.startswith(".ebextensions/"): + if normalized.startswith((".ebextensions/", ".platform/")): return True return normalized.endswith(APP_SUFFIXES) diff --git a/scripts/package-elastic-beanstalk.sh b/scripts/package-elastic-beanstalk.sh index bef20cf..5d42d50 100755 --- a/scripts/package-elastic-beanstalk.sh +++ b/scripts/package-elastic-beanstalk.sh @@ -8,6 +8,7 @@ # ./ published Api.SeaHavenIndustries (self-contained, linux-x64) # ./efbundle self-contained EF Core 8.0.8 migrations bundle (linux-x64, +x) # ./.ebextensions/* leader-only migration container command +# ./.platform/nginx/conf.d/* nginx proxy overrides (upload body size) # # The bundle reads ConnectionStrings__DefaultConnection from the runtime # environment (Elastic Beanstalk property). No connection string or secret is @@ -137,6 +138,10 @@ log "copy .ebextensions into bundle root" mkdir -p "$STAGING_DIR/.ebextensions" cp -R .ebextensions/. "$STAGING_DIR/.ebextensions/" +log "copy .platform (nginx proxy overrides) into bundle root" +mkdir -p "$STAGING_DIR/.platform" +cp -R .platform/. "$STAGING_DIR/.platform/" + log "verify no committed secret placeholders survived publish" if grep -rIEl -- 'Server=.*;.*Password=|AccountKey=|aws_secret|AKIA[0-9A-Z]{16}' "$STAGING_DIR" 2>/dev/null; then die "potential secret detected in publish output; refusing to package." diff --git a/scripts/test_check_app_terraform_isolation.py b/scripts/test_check_app_terraform_isolation.py index 94899a6..b13e0e7 100644 --- a/scripts/test_check_app_terraform_isolation.py +++ b/scripts/test_check_app_terraform_isolation.py @@ -54,6 +54,17 @@ class IsolationTests(unittest.TestCase): self.assertEqual(terraform_files, ["terraform/live/dev/main.tf"]) self.assertTrue(any(path.endswith(".cs") for path in app_files)) + def test_platform_proxy_config_is_app_side(self) -> None: + violation = isolation_violation( + [ + "terraform/live/dev/main.tf", + ".platform/nginx/conf.d/01_upload_body_size.conf", + ] + ) + self.assertIsNotNone(violation) + _, app_files = violation or ([], []) + self.assertEqual(app_files, [".platform/nginx/conf.d/01_upload_body_size.conf"]) + if __name__ == "__main__": unittest.main() diff --git a/scripts/validate-elastic-beanstalk-bundle.sh b/scripts/validate-elastic-beanstalk-bundle.sh index 139eb6b..1d88367 100755 --- a/scripts/validate-elastic-beanstalk-bundle.sh +++ b/scripts/validate-elastic-beanstalk-bundle.sh @@ -15,13 +15,15 @@ die() { contents_file="$(mktemp)" webhook_file="$(mktemp)" -trap 'rm -f "$contents_file" "$webhook_file"' EXIT +nginx_file="$(mktemp)" +trap 'rm -f "$contents_file" "$webhook_file" "$nginx_file"' EXIT unzip -tq "$BUNDLE" unzip -Z1 "$BUNDLE" > "$contents_file" grep -Fxq "efbundle" "$contents_file" grep -Fxq ".ebextensions/01_migrations.config" "$contents_file" grep -Fxq ".ebextensions/02_webhook_config.config" "$contents_file" +grep -Fxq ".platform/nginx/conf.d/01_upload_body_size.conf" "$contents_file" unzip -p "$BUNDLE" .ebextensions/02_webhook_config.config > "$webhook_file" grep -Fxq ' WorkOrderWebhook__Enabled: "true"' "$webhook_file" @@ -30,5 +32,9 @@ grep -Fxq \ ' WorkOrderWebhook__SecretId: arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB' \ "$webhook_file" +# Without this override the platform nginx caps request bodies at 1 MB (SH-383). +unzip -p "$BUNDLE" .platform/nginx/conf.d/01_upload_body_size.conf > "$nginx_file" +grep -Fxq 'client_max_body_size 120M;' "$nginx_file" + printf 'PASS: Elastic Beanstalk bundle contract (%s bytes)\n' \ "$(wc -c < "$BUNDLE" | tr -d ' ')" From 07bc81cc5270a963edc313bf31e92cdbffd8d91c Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:18:00 -0300 Subject: [PATCH 12/35] fix(media): size uploads by the validated content type A misleading file name could move a file into a larger size class: a real PDF or JPEG named .mp4/.mov got the 100 MB video cap on the vendor portal, and a .jpg declared with a foreign video type got it on the media endpoint. Classify by the same resolved type the signature check validates; the extension only decides when no allowlisted type is known. --- .../VendorPortalDocumentTests.cs | 22 +++++++ .../WorkOrderMediaControllerTests.cs | 21 ++++++ .../Controllers/WorkOrderMediaController.cs | 3 +- .../Helpers/WorkOrderMediaContract.cs | 65 ++++++++++++------- .../Helpers/WorkOrderMediaFileRules.cs | 19 ++++-- .../Implementation/VendorPortalService.cs | 3 +- 6 files changed, 103 insertions(+), 30 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 83d9d1d..4a6ac0d 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -431,6 +431,28 @@ public sealed class VendorPortalDocumentTests : IDisposable (await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("image/jpeg"); } + [Theory] + // Genuine PDF/JPEG bytes and declared type, but a video file name: the size class must + // follow the validated type, not the name, or the document/photo caps are bypassed. + [InlineData("report.mp4", "application/pdf", 12_000_000, new byte[] { 0x25, 0x50, 0x44, 0x46 })] + [InlineData("site.mov", "image/jpeg", 11_000_000, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 })] + public async Task UploadCompletionDocument_VideoExtensionDoesNotWidenSizeCap( + string fileName, + string contentType, + int size, + byte[] header) + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(MediaBytes(size, header), fileName, contentType)); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + [Fact] public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes() { diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index 32c70df..c990b2e 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -91,6 +91,27 @@ public class WorkOrderMediaControllerTests storage.VerifyNoOtherCalls(); } + [Fact] + public async Task AddMedia_PhotoDeclaredAsForeignVideoType_IsSizedAsAPhoto() + { + // A .jpg with a foreign video type resolves to image/jpeg for validation, so it must + // also be sized as a photo, not given the 100 MB video allowance. + var file = new Mock(); + file.SetupGet(candidate => candidate.Length).Returns(60_000_000); + file.SetupGet(candidate => candidate.FileName).Returns("photo.jpg"); + file.SetupGet(candidate => candidate.ContentType).Returns("video/3gpp"); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("FileTooLarge", error.Code); + Assert.Equal("Photos must be 10 MB or smaller.", error.Message); + storage.VerifyNoOtherCalls(); + } + [Fact] public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity() { diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index 4f854ef..b19efb9 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -89,8 +89,9 @@ namespace Api.SeaHavenIndustries.Controllers try { // SH-116 contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB). + // Classify by the same resolved type EnsureAllowed validates against. var sizeMessage = WorkOrderMediaContract.ValidateSize( - file.ContentType, file.FileName, file.Length); + WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName, file.Length); if (sizeMessage != null) { throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage); diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs index 243dcc7..f36fcf9 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -33,34 +33,51 @@ namespace SeaHaven.Services.Helpers Unknown } + private static readonly Dictionary KindByContentType = + new(StringComparer.OrdinalIgnoreCase) + { + ["image/jpeg"] = UploadKind.Photo, + ["image/jpg"] = UploadKind.Photo, + ["image/png"] = UploadKind.Photo, + ["image/heic"] = UploadKind.Photo, + ["video/mp4"] = UploadKind.Video, + ["video/quicktime"] = UploadKind.Video, + ["application/pdf"] = UploadKind.Document, + ["application/msword"] = UploadKind.Document, + ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"] = + UploadKind.Document, + }; + + private static readonly Dictionary KindByExtension = + new(StringComparer.OrdinalIgnoreCase) + { + [".jpg"] = UploadKind.Photo, + [".jpeg"] = UploadKind.Photo, + [".png"] = UploadKind.Photo, + [".heic"] = UploadKind.Photo, + [".mp4"] = UploadKind.Video, + [".mov"] = UploadKind.Video, + [".pdf"] = UploadKind.Document, + [".doc"] = UploadKind.Document, + [".docx"] = UploadKind.Document, + }; + + /// + /// Classifies an upload. Pass the validated (resolved) content type: it decides whenever + /// it is an allowlisted type, so a misleading file name cannot move a file into a larger + /// size class. The extension only decides when no allowlisted type is known (for example + /// counting stored attachment URLs). + /// public static UploadKind ResolveKind(string? contentType, string? fileName) { var type = (contentType ?? string.Empty).Trim(); + if (KindByContentType.TryGetValue(type, out var byType)) + return byType; + var extension = Path.GetExtension(fileName ?? string.Empty); - - if (type.StartsWith("video/", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".mp4", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".mov", StringComparison.OrdinalIgnoreCase)) - return UploadKind.Video; - - if (type.StartsWith("image/", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".png", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".heic", StringComparison.OrdinalIgnoreCase)) - return UploadKind.Photo; - - if (type.Equals("application/pdf", StringComparison.OrdinalIgnoreCase) - || type.Equals("application/msword", StringComparison.OrdinalIgnoreCase) - || type.Equals( - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - StringComparison.OrdinalIgnoreCase) - || extension.Equals(".pdf", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".doc", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".docx", StringComparison.OrdinalIgnoreCase)) - return UploadKind.Document; - - return UploadKind.Unknown; + return KindByExtension.TryGetValue(extension, out var byExtension) + ? byExtension + : UploadKind.Unknown; } /// Stable, generic per-kind size message; never echoes file metadata. diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs index ee02d9b..c438759 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs @@ -67,6 +67,19 @@ namespace SeaHaven.Services.Helpers return contentType; } + /// + /// The canonical content type validates the file as, or null when + /// no allowlisted type applies. Size classification must use this same type so a declared + /// type or a misleading extension cannot move a file into a larger size class. + /// + public static string? ResolveUploadContentType(IFormFile file) + { + var declaredType = (file.ContentType ?? string.Empty).Trim(); + var extension = Path.GetExtension(file.FileName ?? string.Empty); + var resolvedType = ResolveContentType(declaredType, extension); + return resolvedType == null ? null : CanonicalContentType(resolvedType); + } + public static bool IsAllowed( IFormFile file, WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra) @@ -74,13 +87,11 @@ namespace SeaHaven.Services.Helpers if (file == null || file.Length <= 0) return false; - var declaredType = (file.ContentType ?? string.Empty).Trim(); var extension = Path.GetExtension(file.FileName ?? string.Empty); - var resolvedType = ResolveContentType(declaredType, extension); - if (resolvedType == null) + var contentType = ResolveUploadContentType(file); + if (contentType == null) return false; - var contentType = CanonicalContentType(resolvedType); var resolvedCategory = category ?? WorkOrderMediaCategory.Extra; if (IsDocument(contentType) && resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta) diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index cbe8192..0893fbb 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -856,7 +856,8 @@ namespace SeaHaven.Services.Implementation throw new InvalidOperationException("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are accepted."); } - var kind = WorkOrderMediaContract.ResolveKind(contentType, file.FileName); + // Classify by the resolved type the signature check validates, never by the file name. + var kind = WorkOrderMediaContract.ResolveKind(contentType, fileName: null); if (kind == WorkOrderMediaContract.UploadKind.Photo && file.Length > WorkOrderMediaContract.MaxPhotoBytes) { From 16845a55f3ff7ddc04aab4ab3ba23d06a743f184 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:18:49 -0300 Subject: [PATCH 13/35] test(vendor-portal): use a valid PDF signature in the size-class regression --- Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 4a6ac0d..132f538 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -434,7 +434,7 @@ public sealed class VendorPortalDocumentTests : IDisposable [Theory] // Genuine PDF/JPEG bytes and declared type, but a video file name: the size class must // follow the validated type, not the name, or the document/photo caps are bypassed. - [InlineData("report.mp4", "application/pdf", 12_000_000, new byte[] { 0x25, 0x50, 0x44, 0x46 })] + [InlineData("report.mp4", "application/pdf", 12_000_000, new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D })] [InlineData("site.mov", "image/jpeg", 11_000_000, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 })] public async Task UploadCompletionDocument_VideoExtensionDoesNotWidenSizeCap( string fileName, From 89376e99e492f403caacaee60001a6bbb1774384 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:22:13 -0300 Subject: [PATCH 14/35] fix(uplifts): resolve work-order uplifts through owned dispatches (SH-393) Board create left the new primary dispatch with no WorkOrderId, so uplifts created on those work orders were written to a dispatch the work order's uplift reads never resolve: not listed, allowance never consumed, queue WO number blank. The same orphan made ApptDate/vendor patches fail with "A primary dispatch is required". - Board create backfills Dispatch.WorkOrderId after the first save. - Uplift create resolves its dispatch through the read-side scope (non-deleted, owned or linked); otherwise the stable "no primary dispatch" error. - Data-only migration assigns existing orphaned primaries to the single work order naming them primary; idempotent. Co-Authored-By: Claude Opus 5.5 --- ...lBoardCreatedDispatchWorkOrder.Designer.cs | 4213 +++++++++++++++++ ...3_BackfillBoardCreatedDispatchWorkOrder.cs | 43 + .../Implementation/UpliftDataService.cs | 19 + .../Interfaces/IUpliftDataService.cs | 4 + .../WorkOrderBoardCreateService.cs | 10 +- .../Implementation/WorkOrderUpliftService.cs | 28 +- .../WorkOrderBoardCancelServiceTests.cs | 1 + .../WorkOrderUpliftDispatchOwnershipTests.cs | 344 ++ .../WorkOrderUpliftServiceTests.cs | 16 +- 9 files changed, 4656 insertions(+), 22 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder.Designer.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs diff --git a/Data.SeaHavenIndustries/Migrations/20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder.Designer.cs new file mode 100644 index 0000000..12e9c2d --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder.Designer.cs @@ -0,0 +1,4213 @@ +// +using System; +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder")] + partial class SH393_BackfillBoardCreatedDispatchWorkOrder + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.HasSequence("WorkOrderInternalNumberSequence"); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrencyCode") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Importance") + .HasColumnType("int"); + + b.Property("IndustryId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerEmployeeId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceInstructions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("TerritoryId") + .HasColumnType("int"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("AddressType") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("nvarchar(max)"); + + b.Property("County") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ContactId"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Color") + .HasColumnType("nvarchar(max)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Contact") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeletedDate") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("Initials") + .HasColumnType("nvarchar(max)"); + + b.Property("IsAccountOwner") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("Locations") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PendingRegistration") + .HasColumnType("bit"); + + b.Property("PendingRegistrationCreatedDate") + .HasColumnType("datetime2"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UniqueName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserImage") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("IsAccountOwner") + .IsUnique() + .HasDatabaseName("IX_AspNetUsers_IsAccountOwner") + .HasFilter("IsAccountOwner = 1"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AssetCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenter") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenttext") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("Documents") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalCommentId") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalSourceEmailS3Key") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RecordType") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkerOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkerOrderId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TemplateUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceKey", "IsActive"); + + b.ToTable("CompletionDocTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Territory") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("ContactDetails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactType") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteContactOrder") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime2"); + + b.Property("CommercialStatusUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchedAt") + .HasColumnType("datetime2"); + + b.Property("EmailSent") + .HasColumnType("bit"); + + b.Property("EstimatedArrivalAt") + .HasColumnType("datetime2"); + + b.Property("EtaManualOverride") + .HasColumnType("bit"); + + b.Property("InvoiceNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NTEAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("VerifiedAt") + .HasColumnType("datetime2"); + + b.Property("VerifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchChecklistItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Signature") + .HasColumnType("nvarchar(max)"); + + b.Property("SignatureMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignoffType") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.ToTable("DispatchSignoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrentNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("DecidedAt") + .HasColumnType("datetime2"); + + b.Property("DecidedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DecisionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EscalatedAt") + .HasColumnType("datetime2"); + + b.Property("EvidenceDocumentId") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("InitialNotificationSentAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NotificationError") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RequestKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VendorReason") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId") + .IsUnique() + .HasFilter("[Status] IN ('Pending', 'ChangesRequested')"); + + b.HasIndex("EvidenceDocumentId"); + + b.HasIndex("DispatchId", "RequestKey") + .IsUnique() + .HasFilter("[RequestKey] IS NOT NULL"); + + b.ToTable("DispatchUpliftRequests"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchWorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ParentValue") + .HasColumnType("nvarchar(max)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DropdownOptions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CanChangeProviderNumber") + .HasColumnType("bit"); + + b.Property("ConfirmUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTitleId") + .HasColumnType("int"); + + b.Property("LastLogin") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("PermissionGroupId") + .HasColumnType("int"); + + b.Property("RegionId") + .HasColumnType("int"); + + b.Property("SMSAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobTitleId"); + + b.HasIndex("RegionId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CountryCode") + .HasColumnType("nvarchar(max)"); + + b.Property("CountyId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("StateCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.Property("StreetLineTwo") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId") + .IsUnique(); + + b.ToTable("EmployeeAddresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailTypeId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeeEmails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.Property("Number") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeePhones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Events", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllDay") + .HasColumnType("bit"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EndTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("EventColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Location") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("RecordID") + .HasColumnType("int"); + + b.Property("RecordName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecordTargetType") + .HasColumnType("nvarchar(max)"); + + b.Property("ReminderMinutes") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleDate") + .HasColumnType("datetime2"); + + b.Property("ScheduleStartTime") + .HasColumnType("time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LocationId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ForgetPasswordCodes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("JobTitles"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalLocationId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssetId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("LocationId"); + + b.ToTable("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("QuotId") + .HasColumnType("nvarchar(max)"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteId") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Lineitem") + .HasColumnType("decimal(18,2)"); + + b.Property("LineitemNumber") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotesLines"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletionDocTemplateId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequiresCompletionDocument") + .HasColumnType("bit"); + + b.Property("Trade") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompletionDocTemplateId"); + + b.HasIndex("IsActive"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.HasIndex("Trade"); + + b.ToTable("Services"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("ServiceId") + .HasColumnType("int"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "WorkOrderType") + .IsUnique(); + + b.ToTable("ServiceWorkOrderTypes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Trade") + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("LocationId", "VendorId") + .IsUnique() + .HasFilter("[Trade] IS NULL"); + + b.HasIndex("LocationId", "VendorId", "Trade") + .IsUnique() + .HasFilter("[Trade] IS NOT NULL"); + + b.ToTable("SitePreferredVendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TaskListTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TaskListTemplateId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TaskListTemplateId"); + + b.ToTable("TaskListTemplateItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ContactId"); + + b.HasIndex("LocationId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Trade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Trades"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("PermissionKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("State") + .HasColumnType("int"); + + b.HasKey("UserId", "PermissionKey"); + + b.HasIndex("UserId", "PermissionKey") + .IsUnique() + .HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey"); + + b.ToTable("UserPermissionOverrides"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserServiceArea", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Area") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("UserId", "Area"); + + b.HasIndex("UserId", "Area") + .IsUnique() + .HasDatabaseName("IX_UserServiceAreas_UserId_Area"); + + b.ToTable("UserServiceAreas"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PreferredContact") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TradeSpecialties") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAccessTokens"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Actor") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("VendorCompanies"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("RejectionReason") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplacesDocumentId") + .HasColumnType("int"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime2"); + + b.Property("ScanStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScannedAt") + .HasColumnType("datetime2"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("Version") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("VendorCompletionDocuments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AdditionalContacts") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignDate") + .HasColumnType("date"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("AvetaRequired") + .HasColumnType("bit"); + + b.Property("AvettaTask") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("Building") + .HasColumnType("nvarchar(max)"); + + b.Property("CarriedOver") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Customer") + .HasColumnType("nvarchar(max)"); + + b.Property("DateReported") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DocStatus") + .HasColumnType("int"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalAssignedTo") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalLastOccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalRecordType") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalWorkOrderId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExtraServices") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FlagColor") + .HasMaxLength(7) + .HasColumnType("nvarchar(7)"); + + b.Property("FrozenCompany") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenPoc") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenSite") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("IsAddOn") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LegacyStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("OperationalFlags") + .HasColumnType("int"); + + b.Property("OriginalDate") + .HasColumnType("date"); + + b.Property("OriginalWeek") + .HasColumnType("date"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("PocName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PocNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PocPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("PrimaryDispatchId") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("Problem") + .HasColumnType("nvarchar(max)"); + + b.Property("RescheduleCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduleWeekOnly") + .HasColumnType("bit"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("ScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ServiceId") + .HasColumnType("int"); + + b.Property("ServiceNameSnapshot") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServiceNotes") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteCode") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Source") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceEmailS3Key") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("SubTrade") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("TargetWeek") + .HasColumnType("date"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.Property("istemplate") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ExternalWorkOrderId") + .IsUnique() + .HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''"); + + b.HasIndex("InternalWONumber") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("ServiceId"); + + b.HasIndex("SiteCode") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("WorkOrderType"); + + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("LifecycleStatus", "ScheduledDate") + .HasFilter("[istemplate] = 0"); + + b.ToTable("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderAttachments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("nvarchar(max)"); + + b.Property("ActorType") + .HasColumnType("nvarchar(max)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderExternalReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VersionHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Source", "Kind", "ExternalId") + .IsUnique() + .HasDatabaseName("IX_WorkOrderExternalReceipts_Source_Kind_ExternalId"); + + b.ToTable("WorkOrderExternalReceipts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("LockedAt") + .HasColumnType("datetime2"); + + b.Property("LockedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "FieldName") + .IsUnique(); + + b.ToTable("WorkOrderFieldLocks"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderReconciliationJob", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CommentsProcessed") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ErrorCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FenceToken") + .HasColumnType("uniqueidentifier"); + + b.Property("LeaseExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("Reason") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequestedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("RunId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("WorkOrdersProcessed") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("State"); + + b.ToTable("WorkOrderReconciliationJobs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWebhookDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BodySha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeliveryId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("DeliveryId") + .IsUnique(); + + b.ToTable("WorkOrderWebhookDeliveries"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("SourceWeekStart") + .HasColumnType("date"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "SourceWeekStart") + .IsUnique(); + + b.ToTable("WorkOrderWeekRolledLedgers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Addresses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Comments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Comments") + .HasForeignKey("WorkerOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApplicationUser"); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Contacts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany("Contacts") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("Dispatches") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Dispatches") + .HasForeignKey("WorkOrderId"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("ChecklistItems") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Signoffs") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany() + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.VendorCompletionDocument", "EvidenceDocument") + .WithMany() + .HasForeignKey("EvidenceDocumentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("EvidenceDocument"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("DispatchWorkOrders") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.HasOne("Data.SeaHavenIndustries.Department", "Department") + .WithMany("Employees") + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.JobTitle", "JobTitle") + .WithMany("Employees") + .HasForeignKey("JobTitleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Region", "Region") + .WithMany("Employees") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Department"); + + b.Navigation("JobTitle"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithOne("Address") + .HasForeignKey("Data.SeaHavenIndustries.EmployeeAddress", "EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Emails") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Phones") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Employee"); + + b.Navigation("Location"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.HasOne("Data.SeaHavenIndustries.Assets", "Asset") + .WithMany("PMSchedules") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Asset"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contacts") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Quotes") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Contacts"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.HasOne("Data.SeaHavenIndustries.Quotes", "Quotes") + .WithMany("LineItems") + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate") + .WithMany() + .HasForeignKey("CompletionDocTemplateId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CompletionDocTemplate"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.HasOne("Data.SeaHavenIndustries.Service", "Service") + .WithMany("SupportedWorkOrderTypes") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("SitePreferences") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") + .WithMany("Items") + .HasForeignKey("TaskListTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("Templates") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("Templates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("Templates") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Category"); + + b.Navigation("Locations"); + + b.Navigation("POC"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserServiceArea", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany("ServiceAreas") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company") + .WithMany("Vendors") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("AuditLogs") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.HasOne("Data.SeaHavenIndustries.Area", "Area") + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Area"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("CompletionDocuments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("WorkOrders") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("workOrders") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Dispatch", "PrimaryDispatch") + .WithMany() + .HasForeignKey("PrimaryDispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Service", "ServiceDefinition") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + + b.Navigation("ServiceDefinition"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("workOrderAttachments") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("WorkOrderCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderCategories") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Category"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany("WorkOrderContacts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderContacts") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("POC"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("FieldLocks") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Navigation("ServiceAreas"); + + b.Navigation("Templates"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Navigation("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Navigation("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Navigation("ChecklistItems"); + + b.Navigation("Comments"); + + b.Navigation("CompletionDocuments"); + + b.Navigation("DispatchWorkOrders"); + + b.Navigation("Signoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Navigation("Address"); + + b.Navigation("Emails"); + + b.Navigation("Phones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Navigation("Contacts"); + + b.Navigation("Templates"); + + b.Navigation("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Navigation("LineItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.Navigation("SupportedWorkOrderTypes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Navigation("AuditLogs"); + + b.Navigation("Dispatches"); + + b.Navigation("SitePreferences"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Navigation("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Navigation("Comments"); + + b.Navigation("Dispatches"); + + b.Navigation("FieldLocks"); + + b.Navigation("Quotes"); + + b.Navigation("WorkOrderCategories"); + + b.Navigation("WorkOrderContacts"); + + b.Navigation("workOrderAttachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder.cs b/Data.SeaHavenIndustries/Migrations/20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder.cs new file mode 100644 index 0000000..c0f0242 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + /// SH-393: board-created work orders with a vendor stored their primary dispatch with + /// no WorkOrderId and no DispatchWorkOrders link, so every owned-or-linked read (uplift + /// list, allowance, approval queue WO number, dispatch patches) missed it. Data-only + /// heal: assign the dispatch to the single work order that names it as primary. Rows + /// already owned or linked, and dispatches named primary by more than one work order, + /// are left untouched, so re-running is a no-op. + /// + public partial class SH393_BackfillBoardCreatedDispatchWorkOrder : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + UPDATE d + SET d.[WorkOrderId] = w.[Id] + FROM [Dispatches] AS d + INNER JOIN [workOrders] AS w ON w.[PrimaryDispatchId] = d.[Id] + WHERE d.[WorkOrderId] IS NULL + AND NOT EXISTS ( + SELECT 1 FROM [DispatchWorkOrders] AS l + WHERE l.[DispatchId] = d.[Id]) + AND ( + SELECT COUNT(*) FROM [workOrders] AS owner + WHERE owner.[PrimaryDispatchId] = d.[Id]) = 1; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // The heal is not reversible: healed rows are indistinguishable from dispatches + // that were created with their work order set. + } + } +} diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 1d72cf5..42c338f 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -336,6 +336,25 @@ namespace SeaHaven.DataServices.Implementation .AnyAsync(u => u.Status == "Pending" || u.Status == "ChangesRequested", cancellationToken); } + public async Task GetUpliftDispatchForWorkOrderAsync( + int workOrderId, + int? primaryDispatchId, + CancellationToken cancellationToken) + { + var candidates = await _context.Dispatches + .Where(d => + (d.IsDeleted == null || d.IsDeleted == false) + && ( + d.WorkOrderId == workOrderId + || d.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId))) + .OrderByDescending(d => d.DispatchedAt ?? d.CreatedDate) + .ThenByDescending(d => d.Id) + .ToListAsync(cancellationToken); + + return candidates.FirstOrDefault(d => d.Id == primaryDispatchId) + ?? candidates.FirstOrDefault(); + } + public Task SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) { return ForWorkOrder(workOrderId) diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs index 26c968c..ed89f5b 100644 --- a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -19,6 +19,10 @@ namespace SeaHaven.DataServices.Interfaces Task GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken); Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken); Task HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); + // SH-393: the tracked dispatch a work-order uplift is written to, resolved through the + // same scope the work-order uplift reads use (non-deleted, owned or linked): the + // work order's primary dispatch when it qualifies, otherwise its latest qualifying one. + Task GetUpliftDispatchForWorkOrderAsync(int workOrderId, int? primaryDispatchId, CancellationToken cancellationToken); Task SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); // Approval queue read contract: set-based per-work-order exposure totals covering // auto-approved and admin-approved uplift amounts (pending, rejected, diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index dd2d06f..149a47b 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -249,10 +249,14 @@ namespace SeaHaven.Services.Implementation _mutationData.TrackNewWorkOrder(workOrder); var firstOutcome = await _mutationData.SaveAsync(ct); - if (dispatch != null && workOrder.PrimaryDispatchId == null) + // SH-393: the first save already fixes up PrimaryDispatchId from the + // PrimaryDispatch navigation, so the dispatch's own WorkOrderId must be + // backfilled independently. Without it the dispatch belongs to no work order + // and every owned-or-linked read (uplifts, dispatch patches) misses it. + if (dispatch != null) { - workOrder.PrimaryDispatchId = dispatch.Id; - dispatch.WorkOrderId = workOrder.Id; + workOrder.PrimaryDispatchId ??= dispatch.Id; + dispatch.WorkOrderId ??= workOrder.Id; } diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 149aeaa..82511d8 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -104,24 +104,16 @@ namespace SeaHaven.Services.Implementation $"Cannot create an uplift on a '{workOrder.LifecycleStatus}' work order"); } - Dispatch? dispatch; - if (workOrder.PrimaryDispatchId is int primaryDispatchId) - { - dispatch = await _dispatchData.GetByIdAsync(primaryDispatchId); - if (dispatch == null) - throw new KeyNotFoundException("Dispatch not found"); - } - else - { - dispatch = (await _dispatchData.GetByWorkOrderIdAsync(workOrderId, cancellationToken)) - .Where(candidate => candidate.IsDeleted != true) - .OrderByDescending(candidate => candidate.DispatchedAt ?? candidate.CreatedDate) - .ThenByDescending(candidate => candidate.Id) - .FirstOrDefault(); - - if (dispatch == null) - throw new InvalidOperationException("Work order has no primary dispatch for uplift requests"); - } + // SH-393: write to a dispatch the work order's uplift reads resolve back to it. + // Loading the primary by id alone accepted soft-deleted, unlinked, or other work + // orders' dispatches, so the request never showed on this work order and never + // consumed its allowance. + var dispatch = await _upliftData.GetUpliftDispatchForWorkOrderAsync( + workOrderId, + workOrder.PrimaryDispatchId, + cancellationToken); + if (dispatch == null) + throw new InvalidOperationException("Work order has no primary dispatch for uplift requests"); if (IsTerminalForUplift(dispatch.Status)) throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch"); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index e797a68..8e4fb7b 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -442,6 +442,7 @@ public class WorkOrderBoardCancelServiceTests public Task GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetUpliftDispatchForWorkOrderAsync(int workOrderId, int? primaryDispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException(); diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs new file mode 100644 index 0000000..840fd28 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs @@ -0,0 +1,344 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; +using Xunit; + +namespace SeaHavenIndustries.Tests; + +/// +/// SH-393: an uplift created from a work order must land on a dispatch the work order's +/// uplift reads resolve back to it (non-deleted, owned or linked), so the request is +/// listed on that work order, consumes its allowance and carries its WO number in the +/// approval queue. +/// +public sealed class WorkOrderUpliftDispatchOwnershipTests +{ + private const string NoPrimaryDispatchMessage = "Work order has no primary dispatch for uplift requests"; + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + WorkOrderAccountTestHelpers.SeedBoardCreateScope(context); + context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Acme HVAC" }); + context.SaveChanges(); + return context; + } + + private static WorkOrderBoardCreateService NewBoardCreateService(ApplicationDbContext context) + { + var boardData = new WorkOrderBoardDataService(context); + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + return new WorkOrderBoardCreateService( + boardData, + new WorkOrderBoardMutationDataService(context), + new WorkOrderBoardService(boardData, resolver), + new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks), + new WorkOrderBoardCreateValidation(), + resolver, + new WorkOrderPocDataService(context)); + } + + private static WorkOrderBoardUpdateService NewBoardUpdateService(ApplicationDbContext context) + { + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + return new WorkOrderBoardUpdateService( + new WorkOrderBoardDataService(context), + new WorkOrderBoardMutationDataService(context), + new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks)); + } + + private static WorkOrderUpliftService NewUpliftService(ApplicationDbContext context) + { + return new WorkOrderUpliftService( + new UpliftDataService(context), + new DispatchDataService(context), + new WorkOrderDetailDataService(context), + WorkOrderAccountTestHelpers.Resolver(context), + new UserDataService(context), + TimeProvider.System, + Options.Create(new ApprovalsOptions + { + UpliftTier1MaxUsd = 2500m, + Tier1Roles = new[] { "Approver" }, + Tier2Roles = new[] { "Manager" }, + })); + } + + private static Task BoardCreateWithVendorAsync( + ApplicationDbContext context, + bool? isAddOn = null) + { + return NewBoardCreateService(context).CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + LocationId = 1, + VendorId = 5, + IsAddOn = isAddOn, + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + } + + private static Task CreateUpliftAsync( + ApplicationDbContext context, + int workOrderId, + decimal amount, + int accountId = 1) + { + return NewUpliftService(context).CreateAsync( + workOrderId, + new CreateWorkOrderUpliftRequestDto { Amount = amount, Notes = "Extra coil work" }, + WorkOrderAccountTestHelpers.AccountUser(accountId: accountId), + CancellationToken.None); + } + + private static async Task<(int WorkOrderId, int PrimaryDispatchId)> SeedWorkOrderAsync( + ApplicationDbContext context, + Dispatch primary, + params Dispatch[] others) + { + context.Dispatches.Add(primary); + context.Dispatches.AddRange(others); + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "SH00001", + AccountId = 1, + WorkOrderType = WorkOrderType.PM, + PrimaryDispatchId = primary.Id, + }); + await context.SaveChangesAsync(); + return (1, primary.Id); + } + + [Fact] + public async Task BoardCreate_WithVendor_PrimaryDispatchBelongsToWorkOrder() + { + await using var context = CreateContext(); + + var row = await BoardCreateWithVendorAsync(context); + + var dispatch = await context.Dispatches.SingleAsync(d => d.Id == row.PrimaryDispatchId); + Assert.Equal(row.Id, dispatch.WorkOrderId); + } + + [Fact] + public async Task BoardCreatedWorkOrder_PatchApptDate_KeepsPrimaryDispatch() + { + await using var context = CreateContext(); + var row = await BoardCreateWithVendorAsync(context); + var workOrder = await context.workOrders.SingleAsync(w => w.Id == row.Id); + var dispatch = await context.Dispatches.SingleAsync(d => d.Id == row.PrimaryDispatchId); + workOrder.RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }; + dispatch.RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }; + await context.SaveChangesAsync(); + + var patched = await NewBoardUpdateService(context).PatchFieldAsync(row.Id, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ApptDate, + Value = "2026-10-05", + WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion), + DispatchVersion = Convert.ToBase64String(dispatch.RowVersion), + }, "actor-1"); + + Assert.Equal(row.PrimaryDispatchId, patched.PrimaryDispatchId); + Assert.Equal(1, await context.Dispatches.CountAsync()); + } + + [Fact] + public async Task BoardCreatedWorkOrder_AutoApprovedUplift_IsListedAndConsumesAllowance() + { + await using var context = CreateContext(); + var row = await BoardCreateWithVendorAsync(context); + + var created = await CreateUpliftAsync(context, row.Id, 400m); + + Assert.Equal("auto_approved", created!.Status); + var listed = await NewUpliftService(context).ListAsync( + row.Id, + WorkOrderAccountTestHelpers.AccountUser(), + CancellationToken.None); + var uplift = Assert.Single(listed!); + Assert.Equal(created.Id, uplift.Id); + Assert.Equal(400m, await new UpliftDataService(context) + .SumAutoApprovedAmountForWorkOrderAsync(row.Id, CancellationToken.None)); + } + + [Fact] + public async Task BoardCreatedWorkOrder_RequestBeyondRemainingAllowance_GoesToPendingWithWorkOrderNumber() + { + await using var context = CreateContext(); + var row = await BoardCreateWithVendorAsync(context); + await CreateUpliftAsync(context, row.Id, 400m); + + var second = await CreateUpliftAsync(context, row.Id, 200m); + + Assert.Equal("pending", second!.Status); + var (_, queue) = await new UpliftDataService(context) + .GetPagedAsync("Pending", null, 1, 25, CancellationToken.None); + var queued = Assert.Single(queue); + Assert.Equal(second.Id, queued.Id); + Assert.Equal(row.Id, queued.WorkOrderId); + Assert.Equal(row.WoNumber, queued.WorkOrderNumber); + } + + [Fact] + public async Task BoardCreatedWorkOrder_OwnerRevokesAutoApprovedUplift() + { + await using var context = CreateContext(); + var row = await BoardCreateWithVendorAsync(context); + var created = await CreateUpliftAsync(context, row.Id, 1m); + + var revoked = await NewUpliftService(context).RevokeAsync( + row.Id, + created!.Id, + new RevokeWorkOrderUpliftRequestDto(), + WorkOrderAccountTestHelpers.AccountUser(), + CancellationToken.None); + + Assert.Equal("revoked", revoked!.Status); + Assert.Equal(0m, await new UpliftDataService(context) + .SumAutoApprovedAmountForWorkOrderAsync(row.Id, CancellationToken.None)); + } + + [Fact] + public async Task ExAddOnWorkOrder_AfterScheduleClearsAddOn_UpliftIsListedOnWorkOrder() + { + await using var context = CreateContext(); + var row = await BoardCreateWithVendorAsync(context, isAddOn: true); + Assert.True(row.IsAddOn); + var workOrder = await context.workOrders.SingleAsync(w => w.Id == row.Id); + workOrder.RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }; + await context.SaveChangesAsync(); + var rescheduled = await NewBoardUpdateService(context).PatchFieldAsync(row.Id, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ScheduledDate, + Value = DateTime.UtcNow.Date.AddDays(60).ToString("yyyy-MM-dd"), + WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion), + }, "actor-1"); + Assert.False(rescheduled.IsAddOn); + + var created = await CreateUpliftAsync(context, row.Id, 1m); + + var listed = await NewUpliftService(context).ListAsync( + row.Id, + WorkOrderAccountTestHelpers.AccountUser(), + CancellationToken.None); + Assert.Equal(created!.Id, Assert.Single(listed!).Id); + } + + [Fact] + public async Task Create_SoftDeletedPrimaryDispatch_UsesOwnedLiveDispatch() + { + await using var context = CreateContext(); + var (workOrderId, _) = await SeedWorkOrderAsync( + context, + new Dispatch { Id = 10, VendorId = 5, WorkOrderId = 1, NTEAmount = 1000m, IsDeleted = true }, + new Dispatch { Id = 11, VendorId = 5, WorkOrderId = 1, NTEAmount = 800m }); + + var created = await CreateUpliftAsync(context, workOrderId, 100m); + + var request = await context.DispatchUpliftRequests.SingleAsync(u => u.Id == created!.Id); + Assert.Equal(11, request.DispatchId); + Assert.Equal(1000m, (await context.Dispatches.SingleAsync(d => d.Id == 10)).NTEAmount); + Assert.Equal(900m, (await context.Dispatches.SingleAsync(d => d.Id == 11)).NTEAmount); + } + + [Fact] + public async Task Create_OnlySoftDeletedPrimaryDispatch_IsRejectedWithoutPersisting() + { + await using var context = CreateContext(); + var (workOrderId, _) = await SeedWorkOrderAsync( + context, + new Dispatch { Id = 10, VendorId = 5, WorkOrderId = 1, NTEAmount = 1000m, IsDeleted = true }); + + var error = await Assert.ThrowsAsync( + () => CreateUpliftAsync(context, workOrderId, 100m)); + + Assert.Equal(NoPrimaryDispatchMessage, error.Message); + Assert.Empty(context.DispatchUpliftRequests); + Assert.Empty(context.WorkOrderAuditLogs); + } + + [Fact] + public async Task Create_PrimaryDispatchOwnedByAnotherWorkOrder_IsNotUsed() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder { Id = 2, InternalWONumber = "SH00002", AccountId = 1 }); + var (workOrderId, _) = await SeedWorkOrderAsync( + context, + new Dispatch { Id = 10, VendorId = 5, WorkOrderId = 2, NTEAmount = 1000m }); + + var error = await Assert.ThrowsAsync( + () => CreateUpliftAsync(context, workOrderId, 100m)); + + Assert.Equal(NoPrimaryDispatchMessage, error.Message); + Assert.Empty(context.DispatchUpliftRequests); + Assert.Equal(1000m, (await context.Dispatches.SingleAsync(d => d.Id == 10)).NTEAmount); + } + + [Fact] + public async Task Create_UnlinkedPrimaryDispatch_IsRejectedInsteadOfHidingTheUplift() + { + await using var context = CreateContext(); + var (workOrderId, _) = await SeedWorkOrderAsync( + context, + new Dispatch { Id = 10, VendorId = 5, WorkOrderId = null, NTEAmount = 1000m }); + + var error = await Assert.ThrowsAsync( + () => CreateUpliftAsync(context, workOrderId, 100m)); + + Assert.Equal(NoPrimaryDispatchMessage, error.Message); + Assert.Empty(context.DispatchUpliftRequests); + } + + [Fact] + public async Task Create_PrimaryDispatchLinkedThroughDispatchWorkOrders_IsUsed() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder { Id = 2, InternalWONumber = "SH00002", AccountId = 1 }); + var (workOrderId, _) = await SeedWorkOrderAsync( + context, + new Dispatch { Id = 10, VendorId = 5, WorkOrderId = 2, NTEAmount = 1000m }); + context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = 10, WorkOrderId = workOrderId }); + await context.SaveChangesAsync(); + + var created = await CreateUpliftAsync(context, workOrderId, 100m); + + var listed = await NewUpliftService(context).ListAsync( + workOrderId, + WorkOrderAccountTestHelpers.AccountUser(), + CancellationToken.None); + Assert.Equal(created!.Id, Assert.Single(listed!).Id); + } + + [Fact] + public async Task CrossTenant_CreateAndList_AreRejected() + { + await using var context = CreateContext(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, id: 2, name: "Other Corp"); + var row = await BoardCreateWithVendorAsync(context); + await CreateUpliftAsync(context, row.Id, 1m); + + var created = await CreateUpliftAsync(context, row.Id, 1m, accountId: 2); + var listed = await NewUpliftService(context).ListAsync( + row.Id, + WorkOrderAccountTestHelpers.AccountUser(accountId: 2), + CancellationToken.None); + + Assert.Null(created); + Assert.Null(listed); + Assert.Equal(1, await context.DispatchUpliftRequests.CountAsync()); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs index 13e5c22..3ebb7c1 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -289,7 +289,21 @@ public sealed class WorkOrderUpliftServiceTests { await using var context = CreateContext(); var (workOrder, dispatch) = await SeedWorkOrderAsync(context); - dispatch.WorkOrderId = null; + // SH-393: an unlinked primary is no longer an uplift target, so the shared-dispatch + // case (owned by another work order, linked to this one) carries the duplicate check. + context.workOrders.Add(new WorkOrder + { + Id = 2, + InternalWONumber = "10000000002", + AccountId = 1, + WorkOrderType = WorkOrderType.PM, + }); + dispatch.WorkOrderId = 2; + context.DispatchWorkOrders.Add(new DispatchWorkOrder + { + DispatchId = dispatch.Id, + WorkOrderId = workOrder.Id, + }); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { Id = 100, From e15de6e90bde6d1e02dd87fbace6c0376f7d43ac Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:27:01 -0300 Subject: [PATCH 15/35] fix(media): enforce the per-work-order photo/video limit across both surfaces The 10-photo / 3-video limit only counted dispatcher attachments, so vendor portal uploads could push a work order past it (and vice versa). Count the work order's current vendor documents alongside its attachments on both the dispatcher media endpoint and the vendor portal. A new completion version does not count the version it replaces. --- .../VendorPortalDocumentTests.cs | 56 +++++++++++++++++++ .../VendorDocumentDataService.cs | 33 +++++++++++ .../WorkOrderMediaDataService.cs | 18 ++++++ .../Interfaces/IVendorDocumentDataService.cs | 13 +++++ .../Interfaces/IWorkOrderMediaDataService.cs | 8 +++ .../Helpers/WorkOrderMediaContract.cs | 23 ++++++++ .../Implementation/VendorPortalService.cs | 20 +++++++ .../Implementation/WorkOrderMediaService.cs | 27 +++------ .../WorkOrderPhase6Tests.cs | 51 +++++++++++++++++ 9 files changed, 229 insertions(+), 20 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 132f538..2ef7eca 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -453,6 +453,62 @@ public sealed class VendorPortalDocumentTests : IDisposable context.VendorCompletionDocuments.Should().BeEmpty(); } + [Fact] + public async Task UploadCompletionDocument_RejectsFourthVideoAcrossDispatcherAndVendorUploads() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV", "clip.mp4" }) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = dispatch.WorkOrderId!.Value, + Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}", + }); + } + await context.SaveChangesAsync(); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(FtypVideoBytes(2_048), "site-clip.MOV", "video/quicktime")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + + [Fact] + public async Task UploadCompletionDocument_NewVersionDoesNotCountTheVideoItReplaces() + { + using var context = NewContext(); + var (vendor, dispatch) = await SeedDispatch(context); + foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV" }) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = dispatch.WorkOrderId!.Value, + Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}", + }); + } + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = dispatch.WorkOrderId!.Value, + ContentType = "video/mp4", + StoredFileName = "v1.mp4", + Version = 1, + Purpose = "Completion" + }); + await context.SaveChangesAsync(); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(FtypVideoBytes(2_048), "site-clip-v2.mp4", "video/mp4")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().HaveCount(2); + } + [Fact] public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes() { diff --git a/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs b/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs index a952f45..d2a6890 100644 --- a/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs @@ -17,6 +17,39 @@ namespace SeaHaven.DataServices.Implementation // supporting evidence never participates in completion versioning or replacement. private const string CompletionPurpose = "Completion"; + public async Task> ListActiveContentTypesForWorkOrderAsync( + int workOrderId, + int? excludingDocumentId, + CancellationToken cancellationToken) + { + // Uplift evidence also records the latest completion id in ReplacesDocumentId, so only + // a newer completion version supersedes a document. + return await _context.VendorCompletionDocuments + .AsNoTracking() + .Where(document => document.WorkOrderId == workOrderId + && (document.IsDeleted == null || document.IsDeleted == false) + && (excludingDocumentId == null || document.Id != excludingDocumentId) + && !_context.VendorCompletionDocuments.Any(newer => + newer.ReplacesDocumentId == document.Id + && newer.Purpose == CompletionPurpose + && (newer.IsDeleted == null || newer.IsDeleted == false))) + .Select(document => document.ContentType) + .ToListAsync(cancellationToken); + } + + public async Task> ListActiveWorkOrderAttachmentUrlsAsync( + int workOrderId, + CancellationToken cancellationToken) + { + return await _context.workOrderAttachments + .AsNoTracking() + .Where(attachment => attachment.WorkorderId == workOrderId + && attachment.IsDeleted != true + && attachment.Attachments != null) + .Select(attachment => attachment.Attachments!) + .ToListAsync(cancellationToken); + } + public Task GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken) { return _context.VendorCompletionDocuments diff --git a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs index 9ad9dbe..2d226e6 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs @@ -68,6 +68,24 @@ namespace SeaHaven.DataServices.Implementation return urls; } + public async Task> ListActiveVendorMediaContentTypesAsync( + int workOrderId, + CancellationToken cancellationToken) + { + // Uplift evidence also records the latest completion id in ReplacesDocumentId, so only + // a newer completion version supersedes a document. + return await _context.VendorCompletionDocuments + .AsNoTracking() + .Where(document => document.WorkOrderId == workOrderId + && (document.IsDeleted == null || document.IsDeleted == false) + && !_context.VendorCompletionDocuments.Any(newer => + newer.ReplacesDocumentId == document.Id + && newer.Purpose == "Completion" + && (newer.IsDeleted == null || newer.IsDeleted == false))) + .Select(document => document.ContentType) + .ToListAsync(cancellationToken); + } + public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version) => _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version; diff --git a/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs index b56948c..6986ef2 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs @@ -9,6 +9,19 @@ namespace SeaHaven.DataServices.Interfaces Task GetMetadataForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken); Task> ListForVendorDispatchAsync(int dispatchId, int vendorId, CancellationToken cancellationToken); Task GetUpliftEvidenceAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken); + /// + /// Content types of the work order's current vendor documents (not deleted, not replaced by a + /// newer completion version), optionally excluding one being replaced. SH-116 photo/video counts. + /// + Task> ListActiveContentTypesForWorkOrderAsync( + int workOrderId, + int? excludingDocumentId, + CancellationToken cancellationToken); + + /// Stored URLs of the work order's non-deleted dispatcher attachments (SH-116 counts). + Task> ListActiveWorkOrderAttachmentUrlsAsync( + int workOrderId, + CancellationToken cancellationToken); Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken); Task SaveChangesAsync(CancellationToken cancellationToken); } diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs index 30d0005..caf6071 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs @@ -27,6 +27,14 @@ namespace SeaHaven.DataServices.Interfaces Task> ListActiveAttachmentUrlsAsync( int workOrderId, CancellationToken cancellationToken); + + /// + /// Content types of the work order's current vendor-portal documents (not deleted, not + /// replaced by a newer completion version). SH-116 counts span both upload surfaces. + /// + Task> ListActiveVendorMediaContentTypesAsync( + int workOrderId, + CancellationToken cancellationToken); void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version); void MarkWorkOrderModified(WorkOrder workOrder); Task SaveAsync(CancellationToken cancellationToken); diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs index f36fcf9..5ba60e3 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -80,6 +80,29 @@ namespace SeaHaven.Services.Helpers : UploadKind.Unknown; } + /// + /// Per-work-order count check across both upload surfaces: dispatcher attachments + /// (classified by stored URL) and vendor-portal documents (classified by validated + /// content type). Returns the stable rejection message, or null when there is room. + /// + public static string? ValidateCount( + UploadKind kind, + IEnumerable attachmentUrls, + IEnumerable vendorContentTypes) + { + if (kind != UploadKind.Photo && kind != UploadKind.Video) + return null; + + var count = attachmentUrls.Count(url => ResolveKind(null, url) == kind) + + vendorContentTypes.Count(type => ResolveKind(type, null) == kind); + + if (kind == UploadKind.Photo && count >= MaxPhotosPerWorkOrder) + return "A work order can have at most 10 photos."; + if (kind == UploadKind.Video && count >= MaxVideosPerWorkOrder) + return "A work order can have at most 3 videos."; + return null; + } + /// Stable, generic per-kind size message; never echoes file metadata. public static string? ValidateSize(long length, UploadKind kind) { diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 0893fbb..0aa4353 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -926,6 +926,26 @@ namespace SeaHaven.Services.Implementation "The completion document could not be replaced."); } } + + // SH-116: the 10-photo / 3-video limit is per work order across the dispatcher and + // vendor surfaces. A new completion version replaces its predecessor, so that one + // does not count against the upload that supersedes it. + if (dispatch.WorkOrderId is int workOrderId) + { + var excluded = resolvedPurpose == VendorDocumentPurpose.Completion + ? replacedDocument?.Id + : null; + var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync( + workOrderId, excluded, cancellationToken); + var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync( + workOrderId, cancellationToken); + var countMessage = WorkOrderMediaContract.ValidateCount(kind, attachmentUrls, vendorTypes); + if (countMessage != null) + { + throw new InvalidOperationException(countMessage); + } + } + var version = (latest?.Version ?? 0) + 1; var storedFileName = $"{dispatchId}_{version}_{Guid.NewGuid():N}{GetSafeExtension(file.FileName)}"; diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index dccbffd..10ad8ff 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -352,8 +352,9 @@ namespace SeaHaven.Services.Implementation /// /// SH-116 contract: at most 10 photos and 3 videos per work order, counted over the - /// stored attachment rows. Legacy Before/After column slots replace in place and are - /// not counted. Documents have no per-work-order count limit. + /// stored attachment rows plus the work order's current vendor-portal documents. Legacy + /// Before/After column slots replace in place and are not counted. Documents have no + /// per-work-order count limit. /// private async Task EnsureWithinMediaCountsAsync( int workOrderId, @@ -366,24 +367,10 @@ namespace SeaHaven.Services.Implementation return; var urls = await _mediaData.ListActiveAttachmentUrlsAsync(workOrderId, cancellationToken); - var sameKindCount = urls.Count(url => - WorkOrderMediaContract.ResolveKind(null, url) == kind); - - if (kind == WorkOrderMediaContract.UploadKind.Photo - && sameKindCount >= WorkOrderMediaContract.MaxPhotosPerWorkOrder) - { - throw new WorkOrderBoardValidationException( - "MediaCountExceeded", - "A work order can have at most 10 photos."); - } - - if (kind == WorkOrderMediaContract.UploadKind.Video - && sameKindCount >= WorkOrderMediaContract.MaxVideosPerWorkOrder) - { - throw new WorkOrderBoardValidationException( - "MediaCountExceeded", - "A work order can have at most 3 videos."); - } + var vendorTypes = await _mediaData.ListActiveVendorMediaContentTypesAsync(workOrderId, cancellationToken); + var countMessage = WorkOrderMediaContract.ValidateCount(kind, urls, vendorTypes); + if (countMessage != null) + throw new WorkOrderBoardValidationException("MediaCountExceeded", countMessage); } /// diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index d5771a9..b1fe06f 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1380,6 +1380,57 @@ public class WorkOrderMediaServiceTests Assert.Equal("A work order can have at most 3 videos.", ex.Message); } + [Fact] + public async Task AddMedia_FourthVideo_CountsVendorPortalVideos() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV" }) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}", + Category = WorkOrderMediaCategory.Extra + }); + } + // Vendor v1 video was replaced by v2 (also a video): only v2 is current. + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + Id = 50, WorkOrderId = 1, DispatchId = 7, VendorId = 3, Version = 1, + ContentType = "video/mp4", Purpose = "Completion" + }); + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + Id = 51, WorkOrderId = 1, DispatchId = 7, VendorId = 3, Version = 2, + ContentType = "video/quicktime", Purpose = "Completion", ReplacesDocumentId = 50 + }); + // Another work order's vendor video never counts here. + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + Id = 60, WorkOrderId = 2, DispatchId = 8, VendorId = 3, Version = 1, + ContentType = "video/mp4", Purpose = "Completion" + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync( + 1, + null, + "https://api.example.com/Assets/Documents/x_IMG_0004.MOV", + AuthenticatedUser(), + "actor-1")); + + Assert.Equal("MediaCountExceeded", ex.Code); + Assert.Equal("A work order can have at most 3 videos.", ex.Message); + Assert.Equal(2, context.workOrderAttachments.Count()); + } + [Fact] public async Task AddMedia_CrossAccount_FullWorkOrder_ThrowsNotFoundNotCount() { From a8414cd4fa258fe608691ce35cacc96001275ccc Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:29:51 -0300 Subject: [PATCH 16/35] style(tests): format vendor quota test initializers --- .../WorkOrderPhase6Tests.cs | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index b1fe06f..3c299ba 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1402,19 +1402,35 @@ public class WorkOrderMediaServiceTests // Vendor v1 video was replaced by v2 (also a video): only v2 is current. context.VendorCompletionDocuments.Add(new VendorCompletionDocument { - Id = 50, WorkOrderId = 1, DispatchId = 7, VendorId = 3, Version = 1, - ContentType = "video/mp4", Purpose = "Completion" + Id = 50, + WorkOrderId = 1, + DispatchId = 7, + VendorId = 3, + Version = 1, + ContentType = "video/mp4", + Purpose = "Completion" }); context.VendorCompletionDocuments.Add(new VendorCompletionDocument { - Id = 51, WorkOrderId = 1, DispatchId = 7, VendorId = 3, Version = 2, - ContentType = "video/quicktime", Purpose = "Completion", ReplacesDocumentId = 50 + Id = 51, + WorkOrderId = 1, + DispatchId = 7, + VendorId = 3, + Version = 2, + ContentType = "video/quicktime", + Purpose = "Completion", + ReplacesDocumentId = 50 }); // Another work order's vendor video never counts here. context.VendorCompletionDocuments.Add(new VendorCompletionDocument { - Id = 60, WorkOrderId = 2, DispatchId = 8, VendorId = 3, Version = 1, - ContentType = "video/mp4", Purpose = "Completion" + Id = 60, + WorkOrderId = 2, + DispatchId = 8, + VendorId = 3, + Version = 1, + ContentType = "video/mp4", + Purpose = "Completion" }); await context.SaveChangesAsync(); From fd59a3da1353988f52e1376486abea89db01de7d Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:38:00 -0300 Subject: [PATCH 17/35] fix(media): enforce the 90-second video limit on the server Read the duration from the MP4/MOV movie header (moov/mvhd) on both the dispatcher media endpoint and the vendor portal completion upload, so a direct request cannot bypass the browser check. Unreadable metadata still never blocks an upload. --- .../VendorPortalDocumentTests.cs | 44 ++++++ .../WorkOrderMediaControllerTests.cs | 54 ++++++++ .../Controllers/WorkOrderMediaController.cs | 9 ++ .../Helpers/VideoDurationProbe.cs | 129 ++++++++++++++++++ .../Helpers/WorkOrderMediaContract.cs | 18 ++- .../Implementation/VendorPortalService.cs | 10 ++ .../VideoDurationProbeTests.cs | 112 +++++++++++++++ 7 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 SeaHaven.Services/Helpers/VideoDurationProbe.cs create mode 100644 SeaHavenIndustries.Tests/VideoDurationProbeTests.cs diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 2ef7eca..1258703 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -453,6 +453,50 @@ public sealed class VendorPortalDocumentTests : IDisposable context.VendorCompletionDocuments.Should().BeEmpty(); } + /// ftyp + mdat + moov/mvhd (moov last, as phones write it). + private static byte[] PhoneVideoBytes(uint durationSeconds) + { + static byte[] Box(string type, byte[] body) + { + var box = new byte[8 + body.Length]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length); + System.Text.Encoding.ASCII.GetBytes(type).CopyTo(box, 4); + body.CopyTo(box, 8); + return box; + } + + var mvhd = new byte[20]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(12), 600); + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(16), durationSeconds * 600); + return Box("ftyp", System.Text.Encoding.ASCII.GetBytes("qt \0\0\0\0")) + .Concat(Box("mdat", new byte[4096])) + .Concat(Box("moov", Box("mvhd", mvhd))) + .ToArray(); + } + + [Theory] + [InlineData(95u, false)] + [InlineData(89u, true)] + public async Task UploadCompletionDocument_EnforcesNinetySecondVideoLimit(uint seconds, bool accepted) + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(PhoneVideoBytes(seconds), "IMG_0042.MOV", "video/quicktime")); + + if (accepted) + { + result.Should().BeOfType(); + } + else + { + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + } + [Fact] public async Task UploadCompletionDocument_RejectsFourthVideoAcrossDispatcherAndVendorUploads() { diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index c990b2e..8234c92 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -182,6 +182,60 @@ public class WorkOrderMediaControllerTests Assert.Equal(5, Assert.IsType(ok.Value).Id); } + [Fact] + public async Task AddMedia_VideoLongerThanNinetySeconds_ReturnsStableUnprocessableEntity() + { + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + var file = PhoneVideo(durationSeconds: 95, "IMG_0042.MOV", "video/quicktime"); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("VideoTooLong", error.Code); + Assert.Equal("Videos must be 90 seconds or shorter.", error.Message); + storage.VerifyNoOtherCalls(); + } + + [Fact] + public async Task AddMedia_VideoWithinNinetySeconds_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var file = PhoneVideo(durationSeconds: 60, "IMG_0043.MOV", ""); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + Assert.IsType(result); + } + + /// ftyp + mdat + moov/mvhd (moov last, as phones write it). + private static FormFile PhoneVideo(uint durationSeconds, string fileName, string contentType) + { + static byte[] Box(string type, byte[] body) + { + var box = new byte[8 + body.Length]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length); + System.Text.Encoding.ASCII.GetBytes(type).CopyTo(box, 4); + body.CopyTo(box, 8); + return box; + } + + var mvhd = new byte[20]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(12), 1000); + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(16), durationSeconds * 1000); + var bytes = Box("ftyp", System.Text.Encoding.ASCII.GetBytes("qt \0\0\0\0")) + .Concat(Box("mdat", new byte[4096])) + .Concat(Box("moov", Box("mvhd", mvhd))) + .ToArray(); + return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } + [Fact] public async Task AddMedia_SixtyMegabyteQuicktimeMov_IsAccepted() { diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index b19efb9..e795f3c 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -98,6 +98,15 @@ namespace Api.SeaHavenIndustries.Controllers } WorkOrderMediaFileRules.EnsureAllowed(file, category); + if (WorkOrderMediaContract.ResolveKind( + WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName) + == WorkOrderMediaContract.UploadKind.Video) + { + using var content = file.OpenReadStream(); + var durationMessage = WorkOrderMediaContract.ValidateVideoDuration(content); + if (durationMessage != null) + throw new WorkOrderBoardValidationException("VideoTooLong", durationMessage); + } var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); await _workOrderMediaService.EnsureCanMutateMediaAsync(id, User, actorId, cancellationToken, category); diff --git a/SeaHaven.Services/Helpers/VideoDurationProbe.cs b/SeaHaven.Services/Helpers/VideoDurationProbe.cs new file mode 100644 index 0000000..8355c46 --- /dev/null +++ b/SeaHaven.Services/Helpers/VideoDurationProbe.cs @@ -0,0 +1,129 @@ +using System.Buffers.Binary; +using System.Text; + +namespace SeaHaven.Services.Helpers +{ + /// + /// Reads a video's duration from the ISO base media (MP4 / QuickTime) movie header: + /// top-level moov box → mvhd timescale and duration. It seeks over box + /// headers only (the moov box may sit after a large mdat), never decodes + /// media and never allocates more than a few bytes. Returns null whenever the structure + /// cannot be read, so callers can let unreadable files through (SH-116: unreadable + /// metadata never blocks an upload). + /// + public static class VideoDurationProbe + { + private const int MaxBoxesPerLevel = 1024; + + public static double? TryReadDurationSeconds(Stream? stream) + { + if (stream == null || !stream.CanSeek || !stream.CanRead) + return null; + + try + { + var moov = FindBox(stream, 0, stream.Length, "moov"); + if (moov == null) + return null; + + var mvhd = FindBox(stream, moov.Value.BodyStart, moov.Value.End, "mvhd"); + return mvhd == null ? null : ReadMovieHeaderSeconds(stream, mvhd.Value); + } + catch (IOException) + { + return null; + } + } + + private readonly record struct Box(long BodyStart, long End); + + private static Box? FindBox(Stream stream, long start, long end, string type) + { + var header = new byte[8]; + var position = start; + for (var i = 0; i < MaxBoxesPerLevel && position + 8 <= end; i++) + { + stream.Position = position; + if (!ReadExactly(stream, header, 8)) + return null; + + long size = BinaryPrimitives.ReadUInt32BigEndian(header); + var boxType = Encoding.ASCII.GetString(header, 4, 4); + var headerLength = 8; + if (size == 1) + { + // 64-bit "largesize" follows the type. + var large = new byte[8]; + if (!ReadExactly(stream, large, 8)) + return null; + var largeSize = BinaryPrimitives.ReadUInt64BigEndian(large); + if (largeSize > long.MaxValue) + return null; + size = (long)largeSize; + headerLength = 16; + } + else if (size == 0) + { + size = end - position; + } + + if (size < headerLength || position + size > end) + return null; + + if (boxType == type) + return new Box(position + headerLength, position + size); + + position += size; + } + + return null; + } + + private static double? ReadMovieHeaderSeconds(Stream stream, Box mvhd) + { + // version(1) flags(3), then v0: creation(4) modification(4) timescale(4) duration(4) + // v1: creation(8) modification(8) timescale(4) duration(8) + var body = new byte[32]; + stream.Position = mvhd.BodyStart; + var available = (int)Math.Min(body.Length, mvhd.End - mvhd.BodyStart); + if (available < 20 || !ReadExactly(stream, body, available)) + return null; + + uint timescale; + ulong duration; + if (body[0] == 0) + { + timescale = BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(12, 4)); + duration = BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(16, 4)); + } + else if (body[0] == 1 && available >= 32) + { + timescale = BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(20, 4)); + duration = BinaryPrimitives.ReadUInt64BigEndian(body.AsSpan(24, 8)); + } + else + { + return null; + } + + // All-ones duration means "unknown" in the spec. + if (timescale == 0 || duration == uint.MaxValue || duration == ulong.MaxValue) + return null; + + return (double)duration / timescale; + } + + private static bool ReadExactly(Stream stream, byte[] buffer, int count) + { + var read = 0; + while (read < count) + { + var n = stream.Read(buffer, read, count - read); + if (n <= 0) + return false; + read += n; + } + return true; + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs index 5ba60e3..a0bfa56 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -4,9 +4,9 @@ namespace SeaHaven.Services.Helpers /// SH-116 client-confirmed media contract (2026-09-22): photos up to 10 MB (JPEG/PNG/HEIC), /// videos up to 100 MB and 90 seconds (MP4/MOV), at most 10 photos and 3 videos per work /// order, across the dispatcher media modal, the completion-doc media tab and the vendor - /// portal upload. Documents (PDF/DOC/DOCX) keep the 50 MB document cap. Duration is only - /// checkable client-side (the server cannot probe it cheaply), so it is enforced in the - /// browser and documented here as part of the same contract. + /// portal upload. Documents (PDF/DOC/DOCX) keep the 50 MB document cap. Duration is read + /// from the MP4/MOV movie header (); the browser pre-checks + /// it and the server enforces it, and unreadable metadata never blocks an upload. /// public static class WorkOrderMediaContract { @@ -103,6 +103,18 @@ namespace SeaHaven.Services.Helpers return null; } + /// + /// 90-second video limit, read from the MP4/QuickTime movie header. A video whose + /// duration cannot be read is not blocked. Returns the stable message or null. + /// + public static string? ValidateVideoDuration(Stream? content) + { + var seconds = VideoDurationProbe.TryReadDurationSeconds(content); + return seconds > MaxVideoDurationSeconds + ? $"Videos must be {MaxVideoDurationSeconds} seconds or shorter." + : null; + } + /// Stable, generic per-kind size message; never echoes file metadata. public static string? ValidateSize(long length, UploadKind kind) { diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 0aa4353..c1307dc 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -900,6 +900,16 @@ namespace SeaHaven.Services.Implementation throw new InvalidOperationException("The uploaded file signature does not match its declared content type."); } + if (kind == WorkOrderMediaContract.UploadKind.Video) + { + using var content = new MemoryStream(bytes, writable: false); + var durationMessage = WorkOrderMediaContract.ValidateVideoDuration(content); + if (durationMessage != null) + { + throw new InvalidOperationException(durationMessage); + } + } + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken); if (dispatch == null) { diff --git a/SeaHavenIndustries.Tests/VideoDurationProbeTests.cs b/SeaHavenIndustries.Tests/VideoDurationProbeTests.cs new file mode 100644 index 0000000..df5913d --- /dev/null +++ b/SeaHavenIndustries.Tests/VideoDurationProbeTests.cs @@ -0,0 +1,112 @@ +using System.Buffers.Binary; +using System.Text; +using SeaHaven.Services.Helpers; + +namespace SeaHavenIndustries.Tests; + +public class VideoDurationProbeTests +{ + internal static byte[] Box(string type, params byte[][] children) + { + var body = children.SelectMany(child => child).ToArray(); + var box = new byte[8 + body.Length]; + BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length); + Encoding.ASCII.GetBytes(type).CopyTo(box, 4); + body.CopyTo(box, 8); + return box; + } + + internal static byte[] MovieHeader(uint timescale, ulong duration, bool version1 = false) + { + var body = new byte[version1 ? 32 : 20]; + body[0] = version1 ? (byte)1 : (byte)0; + if (version1) + { + BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(20), timescale); + BinaryPrimitives.WriteUInt64BigEndian(body.AsSpan(24), duration); + } + else + { + BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(12), timescale); + BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(16), (uint)duration); + } + return Box("mvhd", body); + } + + /// A phone-style file: ftyp, a large mdat, then moov at the end (not fast-start). + internal static byte[] Mp4(uint timescale, ulong duration, bool version1 = false, int mediaBytes = 4096) + { + var ftyp = Box("ftyp", Encoding.ASCII.GetBytes("isom"), new byte[4]); + var mdat = Box("mdat", new byte[mediaBytes]); + var moov = Box("moov", MovieHeader(timescale, duration, version1)); + return ftyp.Concat(mdat).Concat(moov).ToArray(); + } + + [Fact] + public void ReadsDurationFromMoovAfterMediaData() + { + Assert.Equal(95.0, VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(Mp4(600, 57_000)))); + } + + [Fact] + public void ReadsVersionOneMovieHeader() + { + Assert.Equal(30.5, VideoDurationProbe.TryReadDurationSeconds( + new MemoryStream(Mp4(1000, 30_500, version1: true)))); + } + + [Fact] + public void FollowsSixtyFourBitBoxSizes() + { + var ftyp = Box("ftyp", Encoding.ASCII.GetBytes("qt "), new byte[4]); + var mdatBody = new byte[512]; + var mdat = new byte[16 + mdatBody.Length]; + BinaryPrimitives.WriteUInt32BigEndian(mdat, 1); + Encoding.ASCII.GetBytes("mdat").CopyTo(mdat, 4); + BinaryPrimitives.WriteUInt64BigEndian(mdat.AsSpan(8), (ulong)mdat.Length); + var moov = Box("moov", MovieHeader(90_000, 90_000UL * 120)); + var file = ftyp.Concat(mdat).Concat(moov).ToArray(); + + Assert.Equal(120.0, VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(file))); + } + + [Theory] + [InlineData("no-moov")] + [InlineData("truncated")] + [InlineData("zero-timescale")] + [InlineData("oversized-box")] + public void UnreadableStructureReturnsNull(string shape) + { + var bytes = shape switch + { + "no-moov" => Box("ftyp", Encoding.ASCII.GetBytes("isom"), new byte[4]).Concat(Box("mdat", new byte[64])).ToArray(), + "truncated" => Mp4(600, 57_000)[..^10], + "zero-timescale" => Mp4(0, 57_000), + _ => Box("ftyp", new byte[8]).Concat(new byte[] { 0x7F, 0xFF, 0xFF, 0xFF, (byte)'m', (byte)'o', (byte)'o', (byte)'v' }).ToArray(), + }; + + Assert.Null(VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(bytes))); + } + + [Fact] + public void NonSeekableStreamReturnsNull() + { + Assert.Null(VideoDurationProbe.TryReadDurationSeconds(new NonSeekableStream(Mp4(600, 57_000)))); + } + + [Theory] + [InlineData(90.0, true)] + [InlineData(90.5, false)] + public void ContractAllowsUpToNinetySeconds(double seconds, bool allowed) + { + var message = WorkOrderMediaContract.ValidateVideoDuration( + new MemoryStream(Mp4(1000, (ulong)(seconds * 1000)))); + + Assert.Equal(allowed ? null : "Videos must be 90 seconds or shorter.", message); + } + + private sealed class NonSeekableStream(byte[] bytes) : MemoryStream(bytes) + { + public override bool CanSeek => false; + } +} From ce3b241991c9acd33c8fc1632c4cfb6d0f3b22d2 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:45:00 -0300 Subject: [PATCH 18/35] test(uplifts): prove board-create dispatch ownership persists relationally (SH-393) Co-Authored-By: Claude Opus 5.5 --- .../WorkOrderBoardCreateRelationalTests.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs index 3269309..d91794e 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs @@ -134,6 +134,72 @@ public class WorkOrderBoardCreateRelationalTests Assert.Empty(await verify.Dispatches.AsNoTracking().ToListAsync()); } + [Fact] + public async Task Create_WithVendor_PersistsPrimaryDispatchOwnershipForUpliftResolution() + { + await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using (var context = new SqliteBoardTestDbContext(options)) + { + await context.Database.EnsureCreatedAsync(); + context.Users.Add(new ApplicationUser + { + Id = "actor-1", + UserName = "actor-1", + NormalizedUserName = "ACTOR-1", + Email = "actor@test.local", + NormalizedEmail = "ACTOR@TEST.LOCAL" + }); + await context.SaveChangesAsync(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); + await WorkOrderAccountTestHelpers.EnsureLocationAsync(context); + context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Acme HVAC" }); + await context.SaveChangesAsync(); + } + + WorkOrderBoardRowDto result; + await using (var context = new SqliteBoardTestDbContext(options)) + { + var boardData = new WorkOrderBoardDataService(context); + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var service = new WorkOrderBoardCreateService( + boardData, + new WorkOrderBoardMutationDataService(context), + new WorkOrderBoardService(boardData, resolver), + new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks), + new WorkOrderBoardCreateValidation(), + resolver, + new WorkOrderPocDataService(context)); + + result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + LocationId = 1, + VendorId = 5 + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + } + + // SH-393: fresh context, committed state only. The primary dispatch must be owned + // by the work order so the uplift create/read scope resolves it. + await using var verify = new SqliteBoardTestDbContext(options); + var dispatch = await verify.Dispatches.AsNoTracking().SingleAsync(); + Assert.Equal(result.PrimaryDispatchId, dispatch.Id); + Assert.Equal(result.Id, dispatch.WorkOrderId); + + var upliftDispatch = await new UpliftDataService(verify).GetUpliftDispatchForWorkOrderAsync( + result.Id, + result.PrimaryDispatchId, + CancellationToken.None); + Assert.Equal(dispatch.Id, upliftDispatch?.Id); + } + private sealed class SqliteBoardTestDbContext : ApplicationDbContext { public SqliteBoardTestDbContext(DbContextOptions options) From eecc2a61bd95284ccc6afd3f77176e0905639e6c Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 22:24:07 -0300 Subject: [PATCH 19/35] feat(vendor-portal): report work-order media counts on the dispatch detail Adds MediaCounts (limits, current photos/videos, and the counts a new completion version would see) so the portal can refuse an 11th photo or 4th video before uploading it. Uses the same count and replacement rule the upload check enforces. --- .../VendorPortalDocumentTests.cs | 40 +++++++++++++++++++ .../DTOs/VendorPortalServiceDTOs.cs | 20 ++++++++++ .../Helpers/WorkOrderMediaContract.cs | 21 ++++++++-- .../Implementation/VendorPortalService.cs | 29 ++++++++++++++ 4 files changed, 106 insertions(+), 4 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 1258703..2eefb5c 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -553,6 +553,46 @@ public sealed class VendorPortalDocumentTests : IDisposable context.VendorCompletionDocuments.Should().HaveCount(2); } + [Fact] + public async Task GetDispatchDetail_ReportsWorkOrderMediaCountsForThePortalPreCheck() + { + using var context = NewContext(); + var (vendor, dispatch) = await SeedDispatch(context); + foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV", "IMG_0003.jpg" }) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = dispatch.WorkOrderId!.Value, + Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}", + }); + } + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = dispatch.WorkOrderId!.Value, + ContentType = "video/mp4", + StoredFileName = "v1.mp4", + Version = 1, + Purpose = "Completion" + }); + await context.SaveChangesAsync(); + + var service = NewService(context); + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); + + detail!.MediaCounts.Should().BeEquivalentTo(new SeaHaven.Services.DTOs.PortalMediaCountsDTO + { + MaxPhotos = 10, + MaxVideos = 3, + Photos = 1, + Videos = 3, + CompletionPhotos = 1, + CompletionVideos = 2, + }); + } + [Fact] public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes() { diff --git a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs index afdaf18..ace32c8 100644 --- a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs @@ -134,6 +134,26 @@ namespace SeaHaven.Services.DTOs public IEnumerable Comments { get; set; } = Enumerable.Empty(); public IEnumerable UpliftRequests { get; set; } = Enumerable.Empty(); public IEnumerable Documents { get; set; } = Enumerable.Empty(); + public PortalMediaCountsDTO? MediaCounts { get; set; } + } + + /// + /// SH-116 per-work-order photo/video usage, so the portal can pre-check an upload + /// before sending it. The server still enforces the limit on upload. + /// + public class PortalMediaCountsDTO + { + public int MaxPhotos { get; set; } + public int MaxVideos { get; set; } + /// Photos/videos on the work order, counted for evidence uploads. + public int Photos { get; set; } + public int Videos { get; set; } + /// + /// Photos/videos counted for a new completion version, which replaces the dispatch's + /// latest document and so does not count it. + /// + public int CompletionPhotos { get; set; } + public int CompletionVideos { get; set; } } public class VendorPortalDocumentDTO diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs index a0bfa56..34f8862 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -93,16 +93,29 @@ namespace SeaHaven.Services.Helpers if (kind != UploadKind.Photo && kind != UploadKind.Video) return null; - var count = attachmentUrls.Count(url => ResolveKind(null, url) == kind) - + vendorContentTypes.Count(type => ResolveKind(type, null) == kind); + var (photos, videos) = CountKinds(attachmentUrls, vendorContentTypes); - if (kind == UploadKind.Photo && count >= MaxPhotosPerWorkOrder) + if (kind == UploadKind.Photo && photos >= MaxPhotosPerWorkOrder) return "A work order can have at most 10 photos."; - if (kind == UploadKind.Video && count >= MaxVideosPerWorkOrder) + if (kind == UploadKind.Video && videos >= MaxVideosPerWorkOrder) return "A work order can have at most 3 videos."; return null; } + /// + /// Photos and videos on a work order: dispatcher attachments (kind from the stored URL) + /// plus current vendor-portal documents (kind from the validated content type). + /// + public static (int Photos, int Videos) CountKinds( + IEnumerable attachmentUrls, + IEnumerable vendorContentTypes) + { + var kinds = attachmentUrls.Select(url => ResolveKind(null, url)) + .Concat(vendorContentTypes.Select(type => ResolveKind(type, null))) + .ToList(); + return (kinds.Count(k => k == UploadKind.Photo), kinds.Count(k => k == UploadKind.Video)); + } + /// /// 90-second video limit, read from the MP4/QuickTime movie header. A video whose /// duration cannot be read is not blocked. Returns the stable message or null. diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index c1307dc..6ddcd95 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -206,8 +206,37 @@ namespace SeaHaven.Services.Implementation } } + PortalMediaCountsDTO? mediaCounts = null; + if (dispatch.WorkOrderId is int workOrderId) + { + // Same basis as the upload check: a new completion version replaces the + // dispatch's latest document, so that one is not counted for it. + var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync( + workOrderId, cancellationToken); + var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync( + workOrderId, null, cancellationToken); + var latest = await _documentData.GetLatestForDispatchAsync(id, cancellationToken); + var completionTypes = latest == null + ? vendorTypes + : await _documentData.ListActiveContentTypesForWorkOrderAsync( + workOrderId, latest.Id, cancellationToken); + var (photos, videos) = WorkOrderMediaContract.CountKinds(attachmentUrls, vendorTypes); + var (completionPhotos, completionVideos) = + WorkOrderMediaContract.CountKinds(attachmentUrls, completionTypes); + mediaCounts = new PortalMediaCountsDTO + { + MaxPhotos = WorkOrderMediaContract.MaxPhotosPerWorkOrder, + MaxVideos = WorkOrderMediaContract.MaxVideosPerWorkOrder, + Photos = photos, + Videos = videos, + CompletionPhotos = completionPhotos, + CompletionVideos = completionVideos + }; + } + return new VendorDispatchDetailDTO { + MediaCounts = mediaCounts, Id = dispatch.Id, DispatchNumber = dispatch.DispatchNumber, PONumber = dispatch.PONumber, From 2be74b261f8ea56e0d7ec9cb03450f7b6cd623d5 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 22:28:04 -0300 Subject: [PATCH 20/35] test(uplifts): share conflict fixture dispatch through owned and linked work orders After SH-393 the uplift dispatch resolves only through dispatches the work order owns or links through DispatchWorkOrders. Model the shared dispatch that way so the concurrent-insert conflict test exercises the conflict mapping again; assertions are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../WorkOrderUpliftServiceConflictTests.cs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs index 129e360..5366d8c 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs @@ -18,7 +18,25 @@ public sealed class WorkOrderUpliftServiceConflictTests [Fact] public async Task CreateAsync_DifferentWorkOrdersSharingDispatch_MapsConcurrentInsertConflict() { + // Dispatch 10 is legitimately shared: work order 1 owns it and work order 2 links to + // it through DispatchWorkOrders, so the uplift dispatch resolves to it for both. + var sharedDispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + Status = "Scheduled", + NTEAmount = 1000m, + DispatchWorkOrders = new List + { + new() { DispatchId = 10, WorkOrderId = 2 }, + }, + }; var upliftData = new Mock(); + upliftData.Setup(x => x.GetUpliftDispatchForWorkOrderAsync( + It.IsIn(1, 2), + 10, + It.IsAny())) + .ReturnsAsync(sharedDispatch); var bothAtInsert = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var savesArrived = 0; var winner = 0; @@ -62,7 +80,7 @@ public sealed class WorkOrderUpliftServiceConflictTests var dispatchData = new Mock(); dispatchData.Setup(x => x.GetByIdAsync(10)) - .ReturnsAsync(new Dispatch { Id = 10, Status = "Scheduled", NTEAmount = 1000m }); + .ReturnsAsync(sharedDispatch); dispatchData.Setup(x => x.StageAuditLogAsync(It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); From b116e71d168eb08abf41485ffd65f5f0ecbef3ee Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 22:31:07 -0300 Subject: [PATCH 21/35] test(uplifts): grant the SH-393 ownership fixture actor RequestUplifts Uplift creation now checks the actor's RequestUplifts permission, so the ownership tests construct the service with the permission services and seed their actor as an Admin. --- .../WorkOrderUpliftDispatchOwnershipTests.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs index 840fd28..adba1a7 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs @@ -1,5 +1,6 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Implementation; @@ -31,6 +32,11 @@ public sealed class WorkOrderUpliftDispatchOwnershipTests var context = new ApplicationDbContext(options); WorkOrderAccountTestHelpers.SeedBoardCreateScope(context); context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Acme HVAC" }); + // SH-327: uplift creation checks the actor's RequestUplifts permission. + var adminRole = new IdentityRole("Admin"); + context.Roles.Add(adminRole); + context.Users.Add(new ApplicationUser { Id = "actor-1", UserName = "actor-1" }); + context.UserRoles.Add(new IdentityUserRole { UserId = "actor-1", RoleId = adminRole.Id }); context.SaveChanges(); return context; } @@ -67,6 +73,8 @@ public sealed class WorkOrderUpliftDispatchOwnershipTests new WorkOrderDetailDataService(context), WorkOrderAccountTestHelpers.Resolver(context), new UserDataService(context), + new TeamPermissionOverrideDataService(context), + new TeamPermissionPolicy(), TimeProvider.System, Options.Create(new ApprovalsOptions { From cfb05a906f2a8014c87288d899b1286034a3f7dd Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 22:34:32 -0300 Subject: [PATCH 22/35] fix(uplifts): Admin passes uplift approval checks regardless of tier config (SH-327) UserCanApprove only accepted roles listed in Approvals:Tier1Roles/Tier2Roles, so an environment whose config omits Admin denied every approval surface to admins: can-approve, per-row CanDecide, approve/reject/request-changes and evidence download. Admin now short-circuits the check; tier-role config still governs every other role. --- .../UpliftAdminApprovalTests.cs | 165 ++++++++++++++++++ .../Implementation/UpliftService.cs | 4 + 2 files changed, 169 insertions(+) create mode 100644 Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs diff --git a/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs b/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs new file mode 100644 index 0000000..cbfc971 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs @@ -0,0 +1,165 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +// SH-327: Admin passes every permission check regardless of stored configuration. +// Uplift approval authority is otherwise driven by Approvals:Tier1Roles/Tier2Roles, +// which may be empty in a deployed environment; Admin must still be able to decide. +public sealed class UpliftAdminApprovalTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static ClaimsPrincipal UserWithRoles(params string[] roles) + { + var claims = new List { new(ClaimTypes.NameIdentifier, "user-42") }; + claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r))); + return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); + } + + private static UpliftService NewService(ApplicationDbContext context, ApprovalsOptions? options = null) => + new(new UpliftDataService(context), + new DispatchDataService(context), + new NoopDocumentStorage(), + TimeProvider.System, + Microsoft.Extensions.Options.Options.Create(options ?? new ApprovalsOptions())); + + private static async Task SeedPendingAsync(ApplicationDbContext context, int requiredTier) + { + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = workOrder.Id, + Status = "Completed", + NTEAmount = 1000m, + DispatchNumber = "DIS-1" + }; + context.Dispatches.Add(dispatch); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 1000m, + RequestedNTE = 1800m, + VendorReason = "reason", + Status = UpliftStatus.Pending, + RequiredTier = requiredTier, + CreatedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + return dispatch; + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + public void CanApprove_Admin_WithEmptyTierConfig_IsTrue(int tier) + { + using var context = NewContext(); + var service = NewService(context); + + service.CanApprove(UserWithRoles("Admin"), tier).Should().BeTrue(); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + public async Task List_Admin_WithEmptyTierConfig_CanDecidePendingRows(int tier) + { + using var context = NewContext(); + var dispatch = await SeedPendingAsync(context, tier); + var service = NewService(context); + + var queue = await service.ListAsync(UserWithRoles("Admin"), UpliftStatus.Pending, null, 1, 25, CancellationToken.None); + var forDispatch = await service.ListForDispatchAsync(UserWithRoles("Admin"), dispatch.Id, CancellationToken.None); + + queue.Items.Should().ContainSingle().Which.CanDecide.Should().BeTrue(); + forDispatch.Should().ContainSingle().Which.CanDecide.Should().BeTrue(); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + public async Task Approve_Admin_WithEmptyTierConfig_Succeeds(int tier) + { + using var context = NewContext(); + await SeedPendingAsync(context, tier); + var service = NewService(context); + + var result = await service.ApproveAsync(UserWithRoles("Admin"), 1, "ok", CancellationToken.None); + + result.Status.Should().Be(UpliftStatus.Approved); + context.Dispatches.Single().NTEAmount.Should().Be(1800m); + } + + [Fact] + public async Task Reject_Admin_WithEmptyTierConfig_Succeeds() + { + using var context = NewContext(); + await SeedPendingAsync(context, 2); + var service = NewService(context); + + var result = await service.RejectAsync(UserWithRoles("Admin"), 1, "no", CancellationToken.None); + + result.Status.Should().Be(UpliftStatus.Rejected); + context.Dispatches.Single().NTEAmount.Should().Be(1000m); + } + + [Theory] + [InlineData("Dispatcher")] + [InlineData("Scheduler")] + public async Task NonAdminRole_WithEmptyTierConfig_IsStillDenied(string role) + { + using var context = NewContext(); + await SeedPendingAsync(context, 1); + var service = NewService(context); + var user = UserWithRoles(role); + + service.CanApprove(user, 1).Should().BeFalse(); + service.CanApprove(user, 2).Should().BeFalse(); + var queue = await service.ListAsync(user, UpliftStatus.Pending, null, 1, 25, CancellationToken.None); + queue.Items.Should().ContainSingle().Which.CanDecide.Should().BeFalse(); + + var act = () => service.ApproveAsync(user, 1, "ok", CancellationToken.None); + + await act.Should().ThrowAsync(); + context.Dispatches.Single().NTEAmount.Should().Be(1000m); + context.DispatchUpliftRequests.Single().Status.Should().Be(UpliftStatus.Pending); + } + + [Fact] + public void Tier1OnlyRole_ApprovesTier1_ButNotTier2() + { + using var context = NewContext(); + var service = NewService(context, new ApprovalsOptions { Tier1Roles = new[] { "Approver" } }); + var user = UserWithRoles("Approver"); + + service.CanApprove(user, 1).Should().BeTrue(); + service.CanApprove(user, 2).Should().BeFalse(); + } + + private sealed class NoopDocumentStorage : IVendorDocumentStoragePort + { + public Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken) => Task.CompletedTask; + public Stream OpenRead(int vendorId, int dispatchId, string storedFileName) => new MemoryStream(); + public void Delete(int vendorId, int dispatchId, string storedFileName) { } + } +} diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs index 5459c36..801139e 100644 --- a/SeaHaven.Services/Implementation/UpliftService.cs +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -335,8 +335,12 @@ namespace SeaHaven.Services.Implementation return UpliftEvidenceDownloadResultDTO.Ok(content, evidence.ContentType ?? "application/octet-stream", evidence.OriginalFileName ?? "evidence"); } + // SH-327: Admin passes every permission check regardless of stored configuration, + // so the tier-role lists only govern non-Admin approvers. private bool UserCanApprove(ClaimsPrincipal user, int requiredTier) { + if (user.IsInRole("Admin")) return true; + var roles = RolesForTier(requiredTier); foreach (var r in roles) { From 26b4aaf1b2e06767615c34f51019f291920f4e2e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 22:51:57 -0300 Subject: [PATCH 23/35] test(uplifts): cover Admin request-changes and evidence download with empty tier config (SH-327) --- .../UpliftAdminApprovalTests.cs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs b/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs index cbfc971..e3fa547 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs @@ -123,6 +123,47 @@ public sealed class UpliftAdminApprovalTests context.Dispatches.Single().NTEAmount.Should().Be(1000m); } + [Fact] + public async Task RequestChanges_Admin_WithEmptyTierConfig_Succeeds() + { + using var context = NewContext(); + await SeedPendingAsync(context, 2); + var service = NewService(context); + + var result = await service.RequestChangesAsync(UserWithRoles("Admin"), 1, "more detail", CancellationToken.None); + + result.Status.Should().Be(UpliftStatus.ChangesRequested); + } + + [Fact] + public async Task EvidenceDownload_Admin_WithEmptyTierConfig_ReturnsFile() + { + using var context = NewContext(); + var dispatch = await SeedPendingAsync(context, 2); + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + VendorId = dispatch.VendorId, + DispatchId = dispatch.Id, + WorkOrderId = dispatch.WorkOrderId!.Value, + OriginalFileName = "invoice.pdf", + StoredFileName = "evidence.bin", + ContentType = "application/pdf", + SizeBytes = 4, + ScanStatus = "Passed", + ReviewStatus = "Approved", + Purpose = "UpliftEvidence", + Version = 1 + }); + context.DispatchUpliftRequests.Single().EvidenceDocumentId = 1; + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Admin"), 1, CancellationToken.None); + + result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok); + result.FileName.Should().Be("invoice.pdf"); + } + [Theory] [InlineData("Dispatcher")] [InlineData("Scheduler")] From 3e3f0f383d0b05ff3b2b1a6188c353721baf2bdb Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 22:56:21 -0300 Subject: [PATCH 24/35] fix(media): serialize SH-116 media counts under the work-order lock and serve HEIC as image/heic The 10-photo / 3-video cap was a check-then-insert with no lock on both upload surfaces, so two overlapping uploads could both take the last slot. The board media upload and the vendor portal upload now run count, insert and save inside ExecuteWorkOrderMutationAsync. GetMediaContent maps .heic to image/heic. --- .../VendorPortalDocumentTests.cs | 5 + .../WorkOrderMediaControllerTests.cs | 36 ++++- .../Implementation/VendorPortalService.cs | 126 ++++++++++-------- .../Implementation/WorkOrderMediaService.cs | 51 ++++--- .../WorkOrderCompletedSelectiveLockTests.cs | 3 +- ...orkOrderMediaConcurrencyRelationalTests.cs | 3 +- .../WorkOrderPhase6Tests.cs | 57 +++++++- 7 files changed, 200 insertions(+), 81 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 2eefb5c..e454b5f 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -54,6 +54,11 @@ public sealed class VendorPortalDocumentTests : IDisposable var upliftData = new Mock(); upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); + upliftData.Setup(u => u.ExecuteWorkOrderMutationAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Returns((int _, Func> work, CancellationToken ct) => work(ct)); var commentData = new Mock(); commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index 8234c92..3aab33e 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -395,7 +395,8 @@ public class WorkOrderMediaServiceCancellationTests mediaData.Object, new Mock(MockBehavior.Strict).Object, new Mock(MockBehavior.Strict).Object, - storage.Object); + storage.Object, + new Mock(MockBehavior.Strict).Object); var user = new ClaimsPrincipal(new ClaimsIdentity( new[] { @@ -412,4 +413,37 @@ public class WorkOrderMediaServiceCancellationTests mediaData.Verify(candidate => candidate.GetAttachmentForReadAsync(10, 1, token), Times.Once); mediaData.VerifyNoOtherCalls(); } + + [Fact] + public async Task GetMediaContent_ServesHeicAsImageHeic() + { + const string url = "https://example.test/Assets/Images/photo.heic"; + var mediaData = new Mock(MockBehavior.Strict); + mediaData.Setup(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, It.IsAny())) + .ReturnsAsync(new WorkOrder { Id = 1 }); + mediaData.Setup(candidate => candidate.GetAttachmentForReadAsync(10, 1, It.IsAny())) + .ReturnsAsync(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = url }); + var storage = new Mock(MockBehavior.Strict); + storage.Setup(candidate => candidate.OpenRead(url)).Returns(new MemoryStream([1, 2, 3])); + var service = new WorkOrderMediaService( + mediaData.Object, + new Mock(MockBehavior.Strict).Object, + new Mock(MockBehavior.Strict).Object, + storage.Object, + new Mock(MockBehavior.Strict).Object); + var user = new ClaimsPrincipal(new ClaimsIdentity( + new[] + { + new Claim(ClaimTypes.NameIdentifier, "actor-1"), + new Claim(ClaimTypes.Role, "Admin"), + new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll) + }, + "Test")); + + var result = await service.GetMediaContentAsync(1, 10, user, "actor-1"); + result.Content.Dispose(); + + Assert.Equal("image/heic", result.ContentType); + Assert.Equal("photo.heic", result.FileName); + } } diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 6ddcd95..b6c8b3c 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -966,69 +966,79 @@ namespace SeaHaven.Services.Implementation } } - // SH-116: the 10-photo / 3-video limit is per work order across the dispatcher and - // vendor surfaces. A new completion version replaces its predecessor, so that one - // does not count against the upload that supersedes it. - if (dispatch.WorkOrderId is int workOrderId) - { - var excluded = resolvedPurpose == VendorDocumentPurpose.Completion - ? replacedDocument?.Id - : null; - var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync( - workOrderId, excluded, cancellationToken); - var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync( - workOrderId, cancellationToken); - var countMessage = WorkOrderMediaContract.ValidateCount(kind, attachmentUrls, vendorTypes); - if (countMessage != null) - { - throw new InvalidOperationException(countMessage); - } - } - var version = (latest?.Version ?? 0) + 1; var storedFileName = $"{dispatchId}_{version}_{Guid.NewGuid():N}{GetSafeExtension(file.FileName)}"; - - var now = DateTime.UtcNow; - var document = new VendorCompletionDocument - { - VendorId = session.Id, - DispatchId = dispatchId, - WorkOrderId = dispatch.WorkOrderId ?? 0, - OriginalFileName = Path.GetFileName(file.FileName), - StoredFileName = storedFileName, - ContentType = contentType, - SizeBytes = bytes.Length, - ScanStatus = "Pending", - ReviewStatus = "Processing", - Version = version, - ReplacesDocumentId = replacedDocument?.Id, - Purpose = resolvedPurpose, - CreatedDate = now - }; - - await _documentData.AddAsync(document, cancellationToken); - var isUpliftEvidence = resolvedPurpose == VendorDocumentPurpose.UpliftEvidence; - var fieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document"; - await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + async Task PersistAsync(CancellationToken ct) { - WorkOrderId = dispatch.WorkOrderId ?? 0, - DispatchId = dispatchId, - FieldName = fieldName, - OldValue = isUpliftEvidence || replacedDocument == null - ? null - : $"v{replacedDocument.Version}", - NewValue = isUpliftEvidence - ? $"evidence {document.OriginalFileName}" - : $"v{version}", - Action = isUpliftEvidence - ? "vendor_uplift_evidence_uploaded" - : "vendor_completion_document_uploaded", - ActorType = "vendor", - CreatedAt = now - }, cancellationToken); - await _documentData.SaveChangesAsync(cancellationToken); + // SH-116: the 10-photo / 3-video limit is per work order across the dispatcher and + // vendor surfaces. A new completion version replaces its predecessor, so that one + // does not count against the upload that supersedes it. + if (dispatch.WorkOrderId is int workOrderId) + { + var excluded = resolvedPurpose == VendorDocumentPurpose.Completion + ? replacedDocument?.Id + : null; + var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync( + workOrderId, excluded, ct); + var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync( + workOrderId, ct); + var countMessage = WorkOrderMediaContract.ValidateCount(kind, attachmentUrls, vendorTypes); + if (countMessage != null) + { + throw new InvalidOperationException(countMessage); + } + } + + var now = DateTime.UtcNow; + var created = new VendorCompletionDocument + { + VendorId = session.Id, + DispatchId = dispatchId, + WorkOrderId = dispatch.WorkOrderId ?? 0, + OriginalFileName = Path.GetFileName(file.FileName), + StoredFileName = storedFileName, + ContentType = contentType, + SizeBytes = bytes.Length, + ScanStatus = "Pending", + ReviewStatus = "Processing", + Version = version, + ReplacesDocumentId = replacedDocument?.Id, + Purpose = resolvedPurpose, + CreatedDate = now + }; + + await _documentData.AddAsync(created, ct); + + var fieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document"; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + DispatchId = dispatchId, + FieldName = fieldName, + OldValue = isUpliftEvidence || replacedDocument == null + ? null + : $"v{replacedDocument.Version}", + NewValue = isUpliftEvidence + ? $"evidence {created.OriginalFileName}" + : $"v{version}", + Action = isUpliftEvidence + ? "vendor_uplift_evidence_uploaded" + : "vendor_completion_document_uploaded", + ActorType = "vendor", + CreatedAt = now + }, ct); + await _documentData.SaveChangesAsync(ct); + return created; + } + + // The count and the insert run under the per-work-order mutation lock the dispatcher + // media upload also takes, so concurrent uploads cannot both claim the last slot. + var document = dispatch.WorkOrderId is int lockedWorkOrderId + ? await _upliftData.ExecuteWorkOrderMutationAsync(lockedWorkOrderId, PersistAsync, cancellationToken) + : await PersistAsync(cancellationToken); using var stored = new MemoryStream(bytes); await _documentStorage.SaveAsync(session.Id, dispatchId, storedFileName, stored, cancellationToken); diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index 10ad8ff..0a0d1d5 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -16,17 +16,20 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderDetailDataService _detailData; private readonly IWorkOrderAuditService _auditService; private readonly IFileStoragePort _fileStorage; + private readonly IUpliftDataService _upliftData; public WorkOrderMediaService( IWorkOrderMediaDataService mediaData, IWorkOrderDetailDataService detailData, IWorkOrderAuditService auditService, - IFileStoragePort fileStorage) + IFileStoragePort fileStorage, + IUpliftDataService upliftData) { _mediaData = mediaData; _detailData = detailData; _auditService = auditService; _fileStorage = fileStorage; + _upliftData = upliftData; } public async Task?> GetMediaAsync( @@ -153,25 +156,34 @@ namespace SeaHaven.Services.Implementation }; } - await EnsureWithinMediaCountsAsync(workOrderId, fileUrl, cancellationToken); - - var attachment = new WorkOrderAttachments - { - WorkorderId = workOrderId, - Attachments = fileUrl, - Category = category.HasValue ? resolvedCategory : null, - CreatedDate = DateTime.UtcNow, - createdby = actorId - }; - - _mediaData.TrackAttachment(attachment); - await _auditService.StageFieldChangedAsync( + // SH-116 photo/video caps are a work-order aggregate shared with the vendor portal + // upload, so the count and the insert run under the per-work-order mutation lock. + var attachment = await _upliftData.ExecuteWorkOrderMutationAsync( workOrderId, - "MediaCategory", - null, - FormatMediaAuditValue(null, (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString()), - actorId); - await SaveMediaAsync(cancellationToken); + async ct => + { + await EnsureWithinMediaCountsAsync(workOrderId, fileUrl, ct); + + var created = new WorkOrderAttachments + { + WorkorderId = workOrderId, + Attachments = fileUrl, + Category = category.HasValue ? resolvedCategory : null, + CreatedDate = DateTime.UtcNow, + createdby = actorId + }; + + _mediaData.TrackAttachment(created); + await _auditService.StageFieldChangedAsync( + workOrderId, + "MediaCategory", + null, + FormatMediaAuditValue(null, (created.Category ?? WorkOrderMediaCategory.Extra).ToString()), + actorId); + await SaveMediaAsync(ct); + return created; + }, + cancellationToken); return new WorkOrderMediaFileDto { @@ -457,6 +469,7 @@ namespace SeaHaven.Services.Implementation { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", + ".heic" => "image/heic", ".mp4" => "video/mp4", ".mov" => "video/quicktime", ".pdf" => "application/pdf", diff --git a/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs b/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs index a51311c..cc765ed 100644 --- a/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs @@ -41,7 +41,8 @@ public class WorkOrderCompletedSelectiveLockTests new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), audit, - new TestFileStoragePort()); + new TestFileStoragePort(), + new UpliftDataService(context)); return (context, service); } diff --git a/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs index 1f2eea6..191dcca 100644 --- a/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs @@ -176,7 +176,8 @@ public class WorkOrderMediaConcurrencyRelationalTests new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), audit, - new TestFileStoragePort()); + new TestFileStoragePort(), + new UpliftDataService(context)); } private static async Task LoadVersionAsync(ApplicationDbContext context, int workOrderId) diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 3c299ba..224d73b 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -812,7 +812,8 @@ public class WorkOrderMediaServiceTests new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), audit, - fileStorage ?? new TestFileStoragePort()); + fileStorage ?? new TestFileStoragePort(), + new UpliftDataService(context)); return (context, service); } @@ -1343,6 +1344,60 @@ public class WorkOrderMediaServiceTests Assert.Equal(10, context.workOrderAttachments.Count(a => a.IsDeleted != true)); } + [Fact] + public async Task AddMedia_CountsAndInsertsUnderTheWorkOrderMutationLock() + { + // Distinct id: the mutation gate is process-wide per work order. + const int workOrderId = 173_001; + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = workOrderId, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + for (var i = 0; i < 9; i++) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = workOrderId, + Attachments = $"https://example.com/photo-{i}.jpg", + Category = WorkOrderMediaCategory.Extra + }); + } + await context.SaveChangesAsync(); + + var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var holder = new UpliftDataService(context).ExecuteWorkOrderMutationAsync( + workOrderId, + async _ => + { + held.SetResult(); + await release.Task; + return 0; + }, + CancellationToken.None); + await held.Task; + + var upload = service.AddMediaAsync( + workOrderId, + null, + "https://example.com/photo-9.jpg", + AuthenticatedUser(), + "actor-1"); + await Task.Delay(200); + + Assert.False(upload.IsCompleted); + Assert.Equal(9, context.workOrderAttachments.Count(a => a.WorkorderId == workOrderId && a.IsDeleted != true)); + + release.SetResult(); + await holder; + await upload; + + Assert.Equal(10, context.workOrderAttachments.Count(a => a.WorkorderId == workOrderId && a.IsDeleted != true)); + } + [Fact] public async Task AddMedia_FourthVideo_CountsStoredPhoneFileUrls() { From 1e2f39a5fa288fc68d2b4ed3ff9ef237570c2696 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 22:57:09 -0300 Subject: [PATCH 25/35] chore(uplifts): drop ticket key from source comments --- Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs | 2 +- SeaHaven.Services/Implementation/UpliftService.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs b/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs index e3fa547..7a5d6a7 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs @@ -11,7 +11,7 @@ using Xunit; namespace Api.SeaHavenIndustries.Tests; -// SH-327: Admin passes every permission check regardless of stored configuration. +// Admin passes every permission check regardless of stored configuration. // Uplift approval authority is otherwise driven by Approvals:Tier1Roles/Tier2Roles, // which may be empty in a deployed environment; Admin must still be able to decide. public sealed class UpliftAdminApprovalTests diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs index 801139e..3cfed76 100644 --- a/SeaHaven.Services/Implementation/UpliftService.cs +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -335,7 +335,7 @@ namespace SeaHaven.Services.Implementation return UpliftEvidenceDownloadResultDTO.Ok(content, evidence.ContentType ?? "application/octet-stream", evidence.OriginalFileName ?? "evidence"); } - // SH-327: Admin passes every permission check regardless of stored configuration, + // Admin passes every permission check regardless of stored configuration, // so the tier-role lists only govern non-Admin approvers. private bool UserCanApprove(ClaimsPrincipal user, int requiredTier) { From c5d82a996a89c29c08d52b67517e8894377ad51d Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 23:30:16 -0300 Subject: [PATCH 26/35] fix(uplifts): audit uplift decisions on the dispatch's resolved work order Approve, reject, request-changes, expiry and escalation staged their work order audit with WorkOrderId = dispatch.WorkOrderId ?? 0. WorkOrderAuditLogs requires a real work order, so any uplift on a dispatch without an owning work order failed to save: the decision returned a 500 and the request stayed Pending, and the expiry sweep failed on it every run. The audit now goes to the work order the uplift resolves to through the existing owner-or-linked read that revoke already uses. When none resolves, the status change is saved on the request and no audit row is written. --- .../UpliftWorkflowTests.cs | 2 + .../Implementation/UpliftLifecycleService.cs | 22 +- .../Implementation/UpliftService.cs | 25 +- .../UpliftDecisionAuditRelationalTests.cs | 273 ++++++++++++++++++ 4 files changed, 312 insertions(+), 10 deletions(-) create mode 100644 SeaHavenIndustries.Tests/UpliftDecisionAuditRelationalTests.cs diff --git a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs index b57d36f..4518910 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs @@ -681,6 +681,8 @@ public sealed class UpliftWorkflowTests var upliftData = new Mock(); upliftData.Setup(u => u.GetDueForExpiryAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { orphanReq, validReq }); + upliftData.Setup(u => u.GetWorkOrderIdForUpliftAsync(202, It.IsAny())) + .ReturnsAsync(99); upliftData.Setup(u => u.SaveChangesAsync(It.IsAny())) .Returns(Task.CompletedTask); diff --git a/SeaHaven.Services/Implementation/UpliftLifecycleService.cs b/SeaHaven.Services/Implementation/UpliftLifecycleService.cs index 9f1cfbb..2f94678 100644 --- a/SeaHaven.Services/Implementation/UpliftLifecycleService.cs +++ b/SeaHaven.Services/Implementation/UpliftLifecycleService.cs @@ -61,9 +61,9 @@ namespace SeaHaven.Services.Implementation req.DecidedAt = now; req.LastModificationTime = now; - await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + await StageAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog { - WorkOrderId = dispatch?.WorkOrderId ?? 0, + WorkOrderId = workOrderId, FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift", OldValue = previous, NewValue = UpliftStatus.Expired, @@ -136,9 +136,9 @@ namespace SeaHaven.Services.Implementation req.EscalatedAt = now; req.LastModificationTime = now; - await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + await StageAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog { - WorkOrderId = dispatch.WorkOrderId ?? 0, + WorkOrderId = workOrderId, FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", OldValue = "Pending", NewValue = "Escalated", @@ -247,5 +247,19 @@ namespace SeaHaven.Services.Implementation req.LastModificationTime = now; await _upliftData.SaveChangesAsync(cancellationToken); } + + // The audit goes to the work order the uplift's dispatch belongs to (owner, else the + // one it is linked to). With neither, the status change is recorded on the request + // alone; a work-order id of 0 would fail the required audit foreign key and abort + // the sweep on every run. + private async Task StageAuditAsync( + int upliftId, + Func buildEntry, + CancellationToken cancellationToken) + { + var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(upliftId, cancellationToken); + if (workOrderId is int resolved) + await _dispatchData.StageAuditLogAsync(buildEntry(resolved), cancellationToken); + } } } diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs index 3cfed76..134ec66 100644 --- a/SeaHaven.Services/Implementation/UpliftService.cs +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -166,9 +166,9 @@ namespace SeaHaven.Services.Implementation req.DecisionNote = string.IsNullOrWhiteSpace(note) ? null : note!.Trim(); req.LastModificationTime = now; - await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + await StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog { - WorkOrderId = dispatch.WorkOrderId ?? 0, + WorkOrderId = workOrderId, UserId = userId, FieldName = $"Dispatch {dispatch.DispatchNumber} NTE", OldValue = $"${oldNTE:F2}", @@ -252,9 +252,9 @@ namespace SeaHaven.Services.Implementation req.DecisionNote = note!.Trim(); req.LastModificationTime = now; - await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + await StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog { - WorkOrderId = dispatch?.WorkOrderId ?? 0, + WorkOrderId = workOrderId, UserId = userId, FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift", OldValue = previous, @@ -290,9 +290,9 @@ namespace SeaHaven.Services.Implementation req.DecisionNote = note.Trim(); req.LastModificationTime = now; - await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + await StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog { - WorkOrderId = dispatch?.WorkOrderId ?? 0, + WorkOrderId = workOrderId, UserId = userId, FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift", OldValue = previous, @@ -307,6 +307,19 @@ namespace SeaHaven.Services.Implementation public bool CanApprove(ClaimsPrincipal user, int tier) => UserCanApprove(user, tier); + // The decision audit goes to the work order the uplift's dispatch belongs to (owner, + // else the one it is linked to). A dispatch with neither has no work order to audit + // against, so the decision is recorded on the request alone. + private async Task StageDecisionAuditAsync( + int upliftId, + Func buildEntry, + CancellationToken cancellationToken) + { + var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(upliftId, cancellationToken); + if (workOrderId is int resolved) + await _dispatchData.StageAuditLogAsync(buildEntry(resolved), cancellationToken); + } + // SH-101: authorized internal download of a Passed UpliftEvidence file. The data // service resolves the document by the request's own linkage (no client-supplied // document id or vendor/public path). Only UpliftEvidence documents are exposed; diff --git a/SeaHavenIndustries.Tests/UpliftDecisionAuditRelationalTests.cs b/SeaHavenIndustries.Tests/UpliftDecisionAuditRelationalTests.cs new file mode 100644 index 0000000..b95ffbd --- /dev/null +++ b/SeaHavenIndustries.Tests/UpliftDecisionAuditRelationalTests.cs @@ -0,0 +1,273 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; + +namespace SeaHavenIndustries.Tests; + +// Relational (SQLite, foreign keys on) coverage for the work-order audit entry written by +// uplift decisions. WorkOrderAuditLogs.WorkOrderId is a required FK, so the in-memory +// provider cannot prove a decision on a dispatch without an owning work order commits. +// The audit goes to the work order the dispatch belongs to (owner, else the one it is +// linked to through DispatchWorkOrders); when none resolves, the decision is still saved +// and no audit row is written. +public sealed class UpliftDecisionAuditRelationalTests +{ + private const string AdminId = "admin-1"; + + public enum Ownership + { + Owned, + Linked, + Unresolvable + } + + public enum Decision + { + Approve, + Reject, + RequestChanges + } + + [Theory] + [InlineData(Decision.Approve, Ownership.Owned)] + [InlineData(Decision.Approve, Ownership.Linked)] + [InlineData(Decision.Approve, Ownership.Unresolvable)] + [InlineData(Decision.Reject, Ownership.Owned)] + [InlineData(Decision.Reject, Ownership.Linked)] + [InlineData(Decision.Reject, Ownership.Unresolvable)] + [InlineData(Decision.RequestChanges, Ownership.Owned)] + [InlineData(Decision.RequestChanges, Ownership.Linked)] + [InlineData(Decision.RequestChanges, Ownership.Unresolvable)] + public async Task Decision_CommitsAndAuditsResolvedWorkOrder(Decision decision, Ownership ownership) + { + await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + + int upliftId; + int? expectedWorkOrderId; + await using (var seed = new SqliteUpliftDecisionTestDbContext(options)) + { + await seed.Database.EnsureCreatedAsync(); + (upliftId, expectedWorkOrderId) = await SeedAsync(seed, ownership, expiresAt: null); + } + + await using (var context = new SqliteUpliftDecisionTestDbContext(options)) + { + var service = new UpliftService( + new UpliftDataService(context), + new DispatchDataService(context), + new NoDocumentStorage(), + TimeProvider.System, + Options.Create(new ApprovalsOptions())); + var admin = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, AdminId), + new Claim(ClaimTypes.Role, "Admin") + }, "Test")); + + switch (decision) + { + case Decision.Approve: + await service.ApproveAsync(admin, upliftId, null, CancellationToken.None); + break; + case Decision.Reject: + await service.RejectAsync(admin, upliftId, "too high", CancellationToken.None); + break; + case Decision.RequestChanges: + await service.RequestChangesAsync(admin, upliftId, "send a quote", CancellationToken.None); + break; + } + } + + await using var verify = new SqliteUpliftDecisionTestDbContext(options); + var saved = await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId); + var expectedStatus = decision switch + { + Decision.Approve => UpliftStatus.Approved, + Decision.Reject => UpliftStatus.Rejected, + _ => UpliftStatus.ChangesRequested + }; + Assert.Equal(expectedStatus, saved.Status); + Assert.Equal(AdminId, saved.DecidedByUserId); + if (decision == Decision.Approve) + { + var dispatch = await verify.Dispatches.AsNoTracking().SingleAsync(d => d.Id == saved.DispatchId); + Assert.Equal(5_000m, dispatch.NTEAmount); + } + + var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync(); + Assert.DoesNotContain(audits, a => a.WorkOrderId == 0); + if (expectedWorkOrderId is int workOrderId) + { + var audit = Assert.Single(audits); + Assert.Equal(workOrderId, audit.WorkOrderId); + Assert.StartsWith("uplift_", audit.Action); + } + else + { + Assert.Empty(audits); + } + } + + [Theory] + [InlineData(Ownership.Linked)] + [InlineData(Ownership.Unresolvable)] + public async Task Expiry_CommitsAndAuditsResolvedWorkOrder(Ownership ownership) + { + await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + + int upliftId; + int? expectedWorkOrderId; + await using (var seed = new SqliteUpliftDecisionTestDbContext(options)) + { + await seed.Database.EnsureCreatedAsync(); + (upliftId, expectedWorkOrderId) = await SeedAsync(seed, ownership, expiresAt: DateTime.UtcNow.AddHours(-1)); + } + + await using (var context = new SqliteUpliftDecisionTestDbContext(options)) + { + var lifecycle = new UpliftLifecycleService( + new UpliftDataService(context), + new DispatchDataService(context), + null!, + new NoEmailSender(), + Options.Create(new FrontendOptions()), + Options.Create(new ApprovalsOptions()), + TimeProvider.System, + NullLogger.Instance); + + Assert.Equal(1, await lifecycle.ExpireDueAsync(CancellationToken.None)); + } + + await using var verify = new SqliteUpliftDecisionTestDbContext(options); + var saved = await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId); + Assert.Equal(UpliftStatus.Expired, saved.Status); + + var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync(); + Assert.DoesNotContain(audits, a => a.WorkOrderId == 0); + if (expectedWorkOrderId is int workOrderId) + Assert.Equal(workOrderId, Assert.Single(audits).WorkOrderId); + else + Assert.Empty(audits); + } + + private static async Task<(int UpliftId, int? ExpectedWorkOrderId)> SeedAsync( + ApplicationDbContext context, + Ownership ownership, + DateTime? expiresAt) + { + context.Users.Add(new ApplicationUser + { + Id = AdminId, + UserName = AdminId, + NormalizedUserName = AdminId.ToUpperInvariant(), + Email = "admin@test.local", + NormalizedEmail = "ADMIN@TEST.LOCAL" + }); + var vendor = new Vendor { CompanyName = "Vinewood LLC", IsActive = true }; + // A second work order keeps ids distinct from the first, so an audit landing on + // the wrong work order cannot pass by coincidence. + var unrelated = new WorkOrder { InternalWONumber = "WO-OTHER", WorkerOrderTitle = "Other" }; + var workOrder = new WorkOrder + { + InternalWONumber = "WO-TARGET", + WorkerOrderTitle = "Repair", + LifecycleStatus = LifecycleStatus.Scheduled + }; + context.AddRange(vendor, unrelated, workOrder); + await context.SaveChangesAsync(); + + var dispatch = new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = ownership == Ownership.Owned ? workOrder.Id : null, + DispatchNumber = "DIS-UPLIFT", + Status = "Scheduled", + NTEAmount = 1_000m + }; + context.Dispatches.Add(dispatch); + await context.SaveChangesAsync(); + + if (ownership == Ownership.Linked) + { + context.DispatchWorkOrders.Add(new DispatchWorkOrder + { + DispatchId = dispatch.Id, + WorkOrderId = workOrder.Id + }); + } + + var request = new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 1_000m, + RequestedNTE = 5_000m, + VendorReason = "Scope grew", + Status = UpliftStatus.Pending, + RequiredTier = 2, + CreatedDate = DateTime.UtcNow.AddDays(-4), + ExpiresAt = expiresAt, + NotificationStatus = "Pending" + }; + context.DispatchUpliftRequests.Add(request); + await context.SaveChangesAsync(); + + return (request.Id, ownership == Ownership.Unresolvable ? null : workOrder.Id); + } + + private sealed class NoDocumentStorage : IVendorDocumentStoragePort + { + public Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Stream OpenRead(int vendorId, int dispatchId, string storedFileName) + => throw new NotSupportedException(); + + public void Delete(int vendorId, int dispatchId, string storedFileName) + => throw new NotSupportedException(); + } + + private sealed class NoEmailSender : IEmailSender + { + public Task SendEmailAsync(string emailTo, string subject, string body) => Task.FromResult(true); + } + + private sealed class SqliteUpliftDecisionTestDbContext : ApplicationDbContext + { + public SqliteUpliftDecisionTestDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + // SQL Server filtered index syntax is invalid on SQLite. + foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes())) + { + if (index.GetFilter() != null) + index.SetFilter(null); + } + + // SQLite has no rowversion type; treat as plain nullable blobs. + foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) }) + { + var property = builder.Entity(entityType).Property("RowVersion").Metadata; + property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never; + property.IsConcurrencyToken = false; + } + } + } +} From b2b9fc3588ef6c29b72bffea266b055e9bb3d7d3 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 23:37:58 -0300 Subject: [PATCH 27/35] test(uplifts): cover escalation audit on a dispatch without an owning work order --- .../UpliftDecisionAuditRelationalTests.cs | 65 ++++++++++++++++++- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/SeaHavenIndustries.Tests/UpliftDecisionAuditRelationalTests.cs b/SeaHavenIndustries.Tests/UpliftDecisionAuditRelationalTests.cs index b95ffbd..fb07d09 100644 --- a/SeaHavenIndustries.Tests/UpliftDecisionAuditRelationalTests.cs +++ b/SeaHavenIndustries.Tests/UpliftDecisionAuditRelationalTests.cs @@ -162,10 +162,61 @@ public sealed class UpliftDecisionAuditRelationalTests Assert.Empty(audits); } + [Theory] + [InlineData(Ownership.Linked)] + [InlineData(Ownership.Unresolvable)] + public async Task Escalation_CommitsAndAuditsResolvedWorkOrder(Ownership ownership) + { + await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + + int upliftId; + int? expectedWorkOrderId; + await using (var seed = new SqliteUpliftDecisionTestDbContext(options)) + { + await seed.Database.EnsureCreatedAsync(); + (upliftId, expectedWorkOrderId) = await SeedAsync( + seed, + ownership, + expiresAt: null, + initialNotificationSentAt: DateTime.UtcNow.AddDays(-3)); + } + + var emailSender = new NoEmailSender(); + await using (var context = new SqliteUpliftDecisionTestDbContext(options)) + { + var lifecycle = new UpliftLifecycleService( + new UpliftDataService(context), + new DispatchDataService(context), + null!, + emailSender, + Options.Create(new FrontendOptions()), + Options.Create(new ApprovalsOptions { EscalationRecipients = new[] { "escalate@test.local" } }), + TimeProvider.System, + NullLogger.Instance); + + Assert.Equal(1, await lifecycle.EscalateDueAsync(CancellationToken.None)); + } + + await using var verify = new SqliteUpliftDecisionTestDbContext(options); + var saved = await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId); + Assert.NotNull(saved.EscalatedAt); + Assert.Equal(1, emailSender.Calls); + + var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync(); + Assert.DoesNotContain(audits, a => a.WorkOrderId == 0); + if (expectedWorkOrderId is int workOrderId) + Assert.Equal(workOrderId, Assert.Single(audits).WorkOrderId); + else + Assert.Empty(audits); + } + private static async Task<(int UpliftId, int? ExpectedWorkOrderId)> SeedAsync( ApplicationDbContext context, Ownership ownership, - DateTime? expiresAt) + DateTime? expiresAt, + DateTime? initialNotificationSentAt = null) { context.Users.Add(new ApplicationUser { @@ -218,7 +269,8 @@ public sealed class UpliftDecisionAuditRelationalTests RequiredTier = 2, CreatedDate = DateTime.UtcNow.AddDays(-4), ExpiresAt = expiresAt, - NotificationStatus = "Pending" + InitialNotificationSentAt = initialNotificationSentAt, + NotificationStatus = initialNotificationSentAt == null ? "Pending" : "Sent" }; context.DispatchUpliftRequests.Add(request); await context.SaveChangesAsync(); @@ -238,9 +290,16 @@ public sealed class UpliftDecisionAuditRelationalTests => throw new NotSupportedException(); } + // Records sends without delivering anything. private sealed class NoEmailSender : IEmailSender { - public Task SendEmailAsync(string emailTo, string subject, string body) => Task.FromResult(true); + public int Calls; + + public Task SendEmailAsync(string emailTo, string subject, string body) + { + Calls++; + return Task.FromResult(true); + } } private sealed class SqliteUpliftDecisionTestDbContext : ApplicationDbContext From 0d8f32d1489b7a3a2d1675d13757a73c1351b23f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 23:47:18 -0300 Subject: [PATCH 28/35] fix(media): check the file type before the size cap on media upload AddMedia sized a file before validating its type, and ValidateSize's Unknown arm returned the video message, so a 150 MB .exe sent as application/octet-stream was rejected as FileTooLarge with "Videos must be 100 MB or smaller." EnsureAllowed now runs first, so an unsupported file always reports UnsupportedMediaType, and the Unknown arm uses a type-neutral message. The oversize controller tests now use real file headers so they pass the type check before reaching the size cap. --- .../WorkOrderMediaControllerTests.cs | 70 +++++++++++++------ .../Controllers/WorkOrderMediaController.cs | 5 +- .../Helpers/WorkOrderMediaContract.cs | 2 +- 3 files changed, 55 insertions(+), 22 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index 3aab33e..f0ae1af 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -56,14 +56,11 @@ public class WorkOrderMediaControllerTests [Fact] public async Task AddMedia_VideoOverHundredMegabytes_ReturnsStableUnprocessableEntity() { - var file = new Mock(); - file.SetupGet(candidate => candidate.Length).Returns(100_000_001); - file.SetupGet(candidate => candidate.FileName).Returns("clip.mp4"); - file.SetupGet(candidate => candidate.ContentType).Returns("video/mp4"); + var file = OversizeFile(100_000_001, "clip.mp4", "video/mp4", FtypHeader); var storage = new Mock(MockBehavior.Strict); var controller = CreateController(storage: storage); - var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None); + var result = await controller.AddMedia(1, null, file, CancellationToken.None); var response = Assert.IsType(result); var error = Assert.IsType(response.Value); @@ -75,14 +72,11 @@ public class WorkOrderMediaControllerTests [Fact] public async Task AddMedia_PhotoOverTenMegabytes_ReturnsStableUnprocessableEntity() { - var file = new Mock(); - file.SetupGet(candidate => candidate.Length).Returns(10_000_001); - file.SetupGet(candidate => candidate.FileName).Returns("photo.jpg"); - file.SetupGet(candidate => candidate.ContentType).Returns("image/jpeg"); + var file = OversizeFile(10_000_001, "photo.jpg", "image/jpeg", JpegHeader); var storage = new Mock(MockBehavior.Strict); var controller = CreateController(storage: storage); - var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None); + var result = await controller.AddMedia(1, null, file, CancellationToken.None); var response = Assert.IsType(result); var error = Assert.IsType(response.Value); @@ -96,14 +90,11 @@ public class WorkOrderMediaControllerTests { // A .jpg with a foreign video type resolves to image/jpeg for validation, so it must // also be sized as a photo, not given the 100 MB video allowance. - var file = new Mock(); - file.SetupGet(candidate => candidate.Length).Returns(60_000_000); - file.SetupGet(candidate => candidate.FileName).Returns("photo.jpg"); - file.SetupGet(candidate => candidate.ContentType).Returns("video/3gpp"); + var file = OversizeFile(60_000_000, "photo.jpg", "video/3gpp", JpegHeader); var storage = new Mock(MockBehavior.Strict); var controller = CreateController(storage: storage); - var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None); + var result = await controller.AddMedia(1, null, file, CancellationToken.None); var response = Assert.IsType(result); var error = Assert.IsType(response.Value); @@ -115,14 +106,11 @@ public class WorkOrderMediaControllerTests [Fact] public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity() { - var file = new Mock(); - file.SetupGet(candidate => candidate.Length).Returns(50_000_001); - file.SetupGet(candidate => candidate.FileName).Returns("report.pdf"); - file.SetupGet(candidate => candidate.ContentType).Returns("application/pdf"); + var file = OversizeFile(50_000_001, "report.pdf", "application/pdf", PdfHeader); var storage = new Mock(MockBehavior.Strict); var controller = CreateController(storage: storage); - var result = await controller.AddMedia(1, WorkOrderMediaCategory.Extra, file.Object, CancellationToken.None); + var result = await controller.AddMedia(1, WorkOrderMediaCategory.Extra, file, CancellationToken.None); var response = Assert.IsType(result); var error = Assert.IsType(response.Value); @@ -131,6 +119,48 @@ public class WorkOrderMediaControllerTests storage.VerifyNoOtherCalls(); } + [Fact] + public async Task AddMedia_OversizeUnsupportedType_ReportsUnsupportedTypeNotOversizeVideo() + { + var file = OversizeFile(150_000_000, "payload.exe", "application/octet-stream", [0x4D, 0x5A]); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("UnsupportedMediaType", error.Code); + storage.VerifyNoOtherCalls(); + } + + [Fact] + public void ValidateSize_OversizeUnknownKind_UsesTypeNeutralMessage() + { + Assert.Equal( + "Files must be 100 MB or smaller.", + WorkOrderMediaContract.ValidateSize(150_000_000, WorkOrderMediaContract.UploadKind.Unknown)); + } + + private static readonly byte[] FtypHeader = [0, 0, 0, 0x18, (byte)'f', (byte)'t', (byte)'y', (byte)'p', (byte)'i', (byte)'s', (byte)'o', (byte)'m']; + private static readonly byte[] JpegHeader = [0xFF, 0xD8, 0xFF, 0xE0]; + private static readonly byte[] PdfHeader = [0x25, 0x50, 0x44, 0x46, 0x2D]; + + /// + /// A file that reports bytes but only backs the 512-byte header the + /// type rules read, so oversize cases run through real signature validation cheaply. + /// + private static FormFile OversizeFile(long length, string fileName, string contentType, byte[] header) + { + var bytes = new byte[512]; + header.CopyTo(bytes, 0); + return new FormFile(new MemoryStream(bytes), 0, length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } + private static FormFile VideoFormFile(int size, string fileName, string contentType) { var bytes = new byte[size]; diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index e795f3c..fd7dac1 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -88,6 +88,10 @@ namespace Api.SeaHavenIndustries.Controllers string? fileUrl = null; try { + // Type first, so an unsupported file always reports UnsupportedMediaType instead + // of being sized under a kind it does not have. + WorkOrderMediaFileRules.EnsureAllowed(file, category); + // SH-116 contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB). // Classify by the same resolved type EnsureAllowed validates against. var sizeMessage = WorkOrderMediaContract.ValidateSize( @@ -97,7 +101,6 @@ namespace Api.SeaHavenIndustries.Controllers throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage); } - WorkOrderMediaFileRules.EnsureAllowed(file, category); if (WorkOrderMediaContract.ResolveKind( WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName) == WorkOrderMediaContract.UploadKind.Video) diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs index 34f8862..e064b02 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -138,7 +138,7 @@ namespace SeaHaven.Services.Helpers UploadKind.Document when length > MaxDocumentBytes => "Documents must be 50 MB or smaller.", UploadKind.Unknown when length > MaxVideoBytes => - "Videos must be 100 MB or smaller.", + "Files must be 100 MB or smaller.", _ => null }; } From 67cd9c589b4b38ba8bf03dd6d635dc65b202d338 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 01:41:49 -0300 Subject: [PATCH 29/35] fix(board-search): keep undated rows for clients that omit includeDateless (SH-391) The strict date range made undated open work orders disappear for every client that predates the includeDateless flag. The frontend on main sends the all-weeks window (2000-01-01..2099-12-31) for "no range", the 1970-01-01..2099-12-31 window for the pinned Unassigned queue, and no date input at all for the WO# duplicate lookup. None of those send the flag, so deploying this backend before the frontend would have dropped undated WOs from all three flows. includeDateless is now optional. An explicit value still wins. When it is omitted, a request with no date input or with an all-weeks Custom window keeps its open undated rows, as before SH-391. Any other range stays strict. The backend and frontend can therefore deploy in either order. --- .../DTOs/WorkOrderBoardRequestDTOs.cs | 6 +- .../WorkOrderSearchDateRangeResolver.cs | 30 ++++++ .../WorkOrderAdvancedSearchService.cs | 6 +- .../WorkOrderAdvancedSearchDateRangeTests.cs | 93 ++++++++++++++++++- 4 files changed, 131 insertions(+), 4 deletions(-) diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index e7c3dc6..38b4c24 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -90,9 +90,11 @@ namespace SeaHaven.Services.DTOs /// /// When true, rows with no date (no Schedule On) are returned alongside /// the in-range rows. Sent for searches with no date range selected; an - /// explicit range leaves it false so the range strictly narrows (SH-391). + /// explicit range leaves it unset or false so the range strictly narrows + /// (SH-391). When it is omitted, clients that predate the flag keep their + /// undated rows: see . /// - public bool IncludeDateless { get; set; } + public bool? IncludeDateless { get; set; } public List? Sites { get; set; } public List? Regions { get; set; } public List? Types { get; set; } diff --git a/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs b/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs index 48a8f48..7cf378e 100644 --- a/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs +++ b/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs @@ -15,6 +15,36 @@ namespace SeaHaven.Services.Helpers public static class WorkOrderSearchDateRangeResolver { + // Widest windows the frontend sends for "every week": the no-range search + // (ADVANCED_SEARCH_ALL_WEEKS_FROM/TO, 2000-01-01..2099-12-31) and the pinned + // Unassigned queue (UNASSIGNED_QUEUE_DATE_FROM/TO, 1970-01-01..2099-12-31). + private static readonly DateOnly AllWeeksFrom = new(2000, 1, 1); + private static readonly DateOnly AllWeeksTo = new(2099, 12, 31); + + /// + /// Whether a board search adds open undated rows to its date range (SH-391). + /// An explicit wins. When it is omitted, + /// as by clients that predate the flag, a search with no date input or with + /// an all-weeks Custom window still returns undated rows, as before SH-391: + /// those requests mean "no range selected". Any other range is strict. + /// + public static bool ResolveIncludeDateless( + bool? includeDateless, + WorkOrderAdvancedSearchDatePreset? preset, + DateOnly? dateFrom, + DateOnly? dateTo) + { + if (includeDateless.HasValue) + return includeDateless.Value; + + if (!preset.HasValue && !dateFrom.HasValue && !dateTo.HasValue) + return true; + + return preset == WorkOrderAdvancedSearchDatePreset.Custom + && dateFrom <= AllWeeksFrom + && dateTo >= AllWeeksTo; + } + public static WorkOrderSearchDateRange Resolve( WorkOrderAdvancedSearchDatePreset? preset, DateOnly? dateFrom, diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs index c428da7..39f63dc 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -73,7 +73,11 @@ namespace SeaHaven.Services.Implementation sortBy, query.SortDir ?? "asc", accountId, - query.IncludeDateless); + WorkOrderSearchDateRangeResolver.ResolveIncludeDateless( + query.IncludeDateless, + query.DatePreset, + query.DateFrom, + query.DateTo)); var result = await _searchDataService.SearchAsync(dataQuery); var utcNow = DateTime.UtcNow; diff --git a/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs b/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs index 634c12f..f6d61b2 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; @@ -55,7 +56,7 @@ public class WorkOrderAdvancedSearchDateRangeTests return wo; } - private static WorkOrderAdvancedSearchQueryDto UnassignedInRange(bool includeDateless = false) => new() + private static WorkOrderAdvancedSearchQueryDto UnassignedInRange(bool? includeDateless = null) => new() { DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, DateFrom = RangeFrom, @@ -149,6 +150,96 @@ public class WorkOrderAdvancedSearchDateRangeTests Assert.Equal(new[] { 1, 3 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray()); } + // Payloads the frontend sent before includeDateless existed. They must keep + // their undated rows so this backend can deploy ahead of the client. + [Theory] + [InlineData(2000)] // no range selected: ADVANCED_SEARCH_ALL_WEEKS_FROM + [InlineData(1970)] // pinned Unassigned queue: UNASSIGNED_QUEUE_DATE_FROM + public async Task SearchAsync_AllWeeksWindowWithoutFlag_KeepsOpenUndatedRows(int fromYear) + { + await using var context = CreateContext(); + context.workOrders.AddRange( + Wo(1, new DateTime(2026, 8, 4)), + Wo(2, null, status: LifecycleStatus.Incomplete), + Wo(3, null, status: LifecycleStatus.Completed)); + await context.SaveChangesAsync(); + + var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(fromYear, 1, 1), + DateTo = new DateOnly(2099, 12, 31), + Dispatchers = new List { "__unassigned__" }, + PageSize = 50 + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(new[] { 1, 2 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray()); + } + + [Fact] + public async Task SearchAsync_NoDateInputWithoutFlag_KeepsOpenUndatedRows() + { + // The WO# duplicate lookup sends only the search text and paging. + await using var context = CreateContext(); + var thisWeek = WorkOrderSearchDateRangeResolver.GetIsoWeekStart(DateOnly.FromDateTime(DateTime.UtcNow)); + context.workOrders.AddRange( + Wo(1, thisWeek.ToDateTime(TimeOnly.MinValue)), + Wo(2, null, status: LifecycleStatus.Incomplete), + Wo(3, thisWeek.AddDays(-7).ToDateTime(TimeOnly.MinValue))); + await context.SaveChangesAsync(); + + var result = await CreateService(context).SearchAsync( + new WorkOrderAdvancedSearchQueryDto { PageSize = 100 }, + WorkOrderAccountTestHelpers.OrgWideAdmin(), + null); + + Assert.Equal(new[] { 1, 2 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray()); + } + + [Fact] + public async Task SearchAsync_AllWeeksWindowWithFlagFalse_ExcludesUndatedRows() + { + await using var context = CreateContext(); + context.workOrders.AddRange( + Wo(1, new DateTime(2026, 8, 4)), + Wo(2, null, status: LifecycleStatus.Incomplete)); + await context.SaveChangesAsync(); + + var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(2000, 1, 1), + DateTo = new DateOnly(2099, 12, 31), + IncludeDateless = false, + PageSize = 50 + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray()); + } + + [Theory] + [InlineData(null, null, null, null, true)] + [InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2000-01-01", "2099-12-31", true)] + [InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "1970-01-01", "2099-12-31", true)] + [InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2000-01-02", "2099-12-31", false)] + [InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2026-09-21", "2026-09-25", false)] + [InlineData(null, WorkOrderAdvancedSearchDatePreset.ThisWeek, null, null, false)] + [InlineData(false, null, null, null, false)] + [InlineData(true, WorkOrderAdvancedSearchDatePreset.ThisWeek, null, null, true)] + public void ResolveIncludeDateless_InfersOnlyForNoRangeRequests( + bool? flag, + WorkOrderAdvancedSearchDatePreset? preset, + string? from, + string? to, + bool expected) + { + Assert.Equal(expected, WorkOrderSearchDateRangeResolver.ResolveIncludeDateless( + flag, + preset, + from is null ? null : DateOnly.Parse(from, CultureInfo.InvariantCulture), + to is null ? null : DateOnly.Parse(to, CultureInfo.InvariantCulture))); + } + [Fact] public async Task SearchAsync_RangeForAccountUser_NeverReturnsAnotherAccountsRows() { From f40ad7c1eff5ca2de9ac7f8b808208683cf18cb1 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 02:10:41 -0300 Subject: [PATCH 30/35] fix(uplifts): pending exposure header sums the row deltas The approvals header summed the whole RequestedNTE for work-order-path requests, while each Pending row shows RequestedNTE - CurrentNTE. When a work order already had an NTE the header overstated exposure by that NTE. The header now sums the same Delta the rows display, over the same rows the Pending tab lists (non-deleted request on a non-deleted dispatch). The unused duplicate aggregate is removed so one definition remains. Co-Authored-By: Claude Opus 5.5 --- .../UpliftQueueReadTests.cs | 39 ++++++++++++++++--- .../Implementation/UpliftDataService.cs | 19 +++------ .../Interfaces/IUpliftDataService.cs | 1 - .../Implementation/UpliftService.cs | 2 +- .../WorkOrderBoardCancelServiceTests.cs | 1 - 5 files changed, 40 insertions(+), 22 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs index 78fe0ac..5bd4a36 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -558,7 +558,7 @@ public sealed class UpliftQueueReadTests } [Fact] - public async Task List_PendingExposureTotal_SumsGrantedAmountsAcrossAllPendingRequests() + public async Task List_PendingExposureTotal_SumsRowDeltasAcrossAllPendingRequests() { using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); @@ -566,11 +566,10 @@ public sealed class UpliftQueueReadTests context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); await context.SaveChangesAsync(); context.DispatchUpliftRequests.AddRange( - // Vendor-portal rows (createdby null) store the requested new NTE total, - // so the pending exposure is the delta: 400 - 100 = 300 and 600 - 250 = 350. + // Each pending row contributes its displayed Delta (RequestedNTE - CurrentNTE), + // whichever path created it: 400 - 100 = 300, 600 - 250 = 350, 90 - 600 = -510. Request(dispatch, "Pending", new DateTime(2026, 3, 1), requested: 400m, currentNte: 100m), Request(dispatch, "Pending", new DateTime(2026, 3, 2), requested: 600m, currentNte: 250m), - // Work-order-path rows (createdby set) store the requested increment: 90. Request(dispatch, "Pending", new DateTime(2026, 3, 3), requested: 90m, currentNte: 600m, createdBy: "user-7"), // Non-pending rows never add pending exposure. Request(dispatch, "Approved", new DateTime(2026, 2, 1), decided: new DateTime(2026, 2, 2), requested: 500m, currentNte: 100m)); @@ -581,7 +580,37 @@ public sealed class UpliftQueueReadTests var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 1, CancellationToken.None); result.Items.Should().HaveCount(1); - result.PendingExposureTotal.Should().Be(740m); + result.PendingExposureTotal.Should().Be(140m); + } + + [Fact] + public async Task List_PendingExposureTotal_EqualsSumOfPendingRowDeltas_WhenWorkOrderAlreadyHasNte() + { + using var context = NewContext(); + var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); + var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "HVAC"); + var (vendorC, workOrderC) = await SeedWorkOrderAsync(context, "WO-C", "SITE-C", "HVAC"); + var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); + var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); + var deletedDispatch = await SeedDispatchAsync(context, vendorC, workOrderC, "DIS-C"); + deletedDispatch.IsDeleted = true; + context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.AddRange( + // Raised from the work order, which already carries a 3,000 NTE. + Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 5_000_000_000m, currentNte: 3_000m, createdBy: "user-7"), + // Vendor-portal request on a dispatch with no NTE recorded yet. + Request(dispatchB, "Pending", new DateTime(2026, 3, 2), requested: 333m), + // A pending request on a deleted dispatch is not a queue row, so it adds nothing. + Request(deletedDispatch, "Pending", new DateTime(2026, 3, 3), requested: 700m, currentNte: 100m)); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + result.Items.Should().HaveCount(2); + result.PendingExposureTotal.Should().Be(4_999_997_333m); + result.PendingExposureTotal.Should().Be(result.Items.Sum(i => i.Delta)); } // --- Approved-on-WO exposure totals --- diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 3c11978..3d2d68c 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -128,16 +128,6 @@ namespace SeaHaven.DataServices.Implementation return (total, items); } - public Task GetPendingExposureAsync(CancellationToken cancellationToken) - { - return _context.DispatchUpliftRequests - .Where(u => (u.IsDeleted == null || u.IsDeleted == false) - && u.Status == "Pending") - .SumAsync(u => u.createdby == null - ? u.RequestedNTE - (u.CurrentNTE ?? 0m) - : u.RequestedNTE, cancellationToken); - } - public async Task> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken) { return await (from u in _context.DispatchUpliftRequests @@ -446,9 +436,10 @@ namespace SeaHaven.DataServices.Implementation return totals.Values.ToList(); } - // SH-207: queue-wide pending exposure for the approvals header. Mirrors the - // approved-exposure convention (sum of RequestedNTE) over every non-deleted - // Pending request on a non-deleted dispatch, independent of page or filters. + // Queue-wide pending exposure for the approvals header: the sum of the Delta each + // Pending queue row displays (RequestedNTE - CurrentNTE), over the same rows the + // Pending tab lists (non-deleted request on a non-deleted dispatch), independent + // of page or filters. public async Task GetPendingExposureTotalAsync(CancellationToken cancellationToken) { return await _context.DispatchUpliftRequests @@ -456,7 +447,7 @@ namespace SeaHaven.DataServices.Implementation && u.Status == "Pending" && u.Dispatch != null && (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)) - .SumAsync(u => (decimal?)u.RequestedNTE, cancellationToken) ?? 0m; + .SumAsync(u => (decimal?)(u.RequestedNTE - (u.CurrentNTE ?? 0m)), cancellationToken) ?? 0m; } public Task> GetPendingForWorkOrderAsync( diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs index ed89f5b..4dfe871 100644 --- a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -6,7 +6,6 @@ namespace SeaHaven.DataServices.Interfaces { Task<(int TotalCount, IReadOnlyList Items)> GetPagedAsync( string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken); - Task GetPendingExposureAsync(CancellationToken cancellationToken); Task> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken); Task> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); Task GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken); diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs index 134ec66..c72ddc3 100644 --- a/SeaHaven.Services/Implementation/UpliftService.cs +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -36,7 +36,7 @@ namespace SeaHaven.Services.Implementation public async Task ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) { var (total, items) = await _upliftData.GetPagedAsync(status, tier, page, pageSize, cancellationToken); - var pendingExposureTotal = await _upliftData.GetPendingExposureAsync(cancellationToken); + var pendingExposureTotal = await _upliftData.GetPendingExposureTotalAsync(cancellationToken); var mapped = items.Select(r => new UpliftListItemDTO { diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index aad09c3..46e86fc 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -447,7 +447,6 @@ public class WorkOrderBoardCancelServiceTests public Task<(int TotalCount, IReadOnlyList Items)> GetPagedAsync( string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) => throw new NotSupportedException(); - public Task GetPendingExposureAsync(CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); From eb2b442775c26364afddcdbdc333968ff895b977 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 02:32:58 -0300 Subject: [PATCH 31/35] fix(uplifts): one per-path uplift amount for queue, approval and exposure Work-order requests store the requested increase in RequestedNTE; vendor portal requests store the requested NTE total. The queue Delta, the pending and approved exposure totals, the work-order uplift list, the board summary and the notification Delta now all read one definition (UpliftAmount) that honours both meanings and translates to SQL. Approving a work-order request now adds its increase to the dispatch NTE instead of replacing the NTE with the increase; vendor requests still end at their requested total. Co-Authored-By: Claude Opus 5.5 --- .../UpliftQueueReadTests.cs | 24 +- .../Models/VendorPortalReadModels.cs | 4 + SeaHaven.DataServices/Helpers/UpliftAmount.cs | 33 ++ .../Helpers/WorkOrderBoardProjection.cs | 10 +- .../Implementation/DispatchDataService.cs | 4 +- .../Implementation/UpliftDataService.cs | 44 ++- .../Helpers/WorkOrderUpliftContractMapper.cs | 5 +- .../UpliftNotificationMessage.cs | 3 +- .../Implementation/UpliftService.cs | 14 +- .../UpliftAmountPerCreationPathTests.cs | 298 ++++++++++++++++++ 10 files changed, 394 insertions(+), 45 deletions(-) create mode 100644 SeaHaven.DataServices/Helpers/UpliftAmount.cs create mode 100644 SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs index 5bd4a36..5c398cf 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -278,7 +278,8 @@ public sealed class UpliftQueueReadTests item.RequestedByName.Should().Be("Gateway"); item.RequestedAt.Should().Be(new DateTime(2026, 3, 2)); item.VendorReason.Should().Be("Scope grew"); - item.Delta.Should().Be(300m); + // A work-order request (createdby set) stores the increase itself in RequestedNTE. + item.Delta.Should().Be(400m); item.Status.Should().Be("Pending"); } @@ -558,7 +559,7 @@ public sealed class UpliftQueueReadTests } [Fact] - public async Task List_PendingExposureTotal_SumsRowDeltasAcrossAllPendingRequests() + public async Task List_PendingExposureTotal_SumsEachPendingRequestsIncreaseAcrossTheQueue() { using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); @@ -566,10 +567,11 @@ public sealed class UpliftQueueReadTests context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); await context.SaveChangesAsync(); context.DispatchUpliftRequests.AddRange( - // Each pending row contributes its displayed Delta (RequestedNTE - CurrentNTE), - // whichever path created it: 400 - 100 = 300, 600 - 250 = 350, 90 - 600 = -510. + // Vendor-portal rows (createdby null) store the requested new NTE total, + // so their increase is 400 - 100 = 300 and 600 - 250 = 350. Request(dispatch, "Pending", new DateTime(2026, 3, 1), requested: 400m, currentNte: 100m), Request(dispatch, "Pending", new DateTime(2026, 3, 2), requested: 600m, currentNte: 250m), + // Work-order rows (createdby set) store the increase itself: 90. Request(dispatch, "Pending", new DateTime(2026, 3, 3), requested: 90m, currentNte: 600m, createdBy: "user-7"), // Non-pending rows never add pending exposure. Request(dispatch, "Approved", new DateTime(2026, 2, 1), decided: new DateTime(2026, 2, 2), requested: 500m, currentNte: 100m)); @@ -580,11 +582,11 @@ public sealed class UpliftQueueReadTests var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 1, CancellationToken.None); result.Items.Should().HaveCount(1); - result.PendingExposureTotal.Should().Be(140m); + result.PendingExposureTotal.Should().Be(740m); } [Fact] - public async Task List_PendingExposureTotal_EqualsSumOfPendingRowDeltas_WhenWorkOrderAlreadyHasNte() + public async Task List_PendingExposureTotal_EqualsSumOfPendingRowAmounts_ForBothCreationPaths() { using var context = NewContext(); var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); @@ -597,10 +599,11 @@ public sealed class UpliftQueueReadTests context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); await context.SaveChangesAsync(); context.DispatchUpliftRequests.AddRange( - // Raised from the work order, which already carries a 3,000 NTE. + // Raised from the work order, which already carries a 3,000 NTE: RequestedNTE + // is the 5,000,000,000 increase itself. Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 5_000_000_000m, currentNte: 3_000m, createdBy: "user-7"), - // Vendor-portal request on a dispatch with no NTE recorded yet. - Request(dispatchB, "Pending", new DateTime(2026, 3, 2), requested: 333m), + // Vendor-portal request for a 3,333 total on a 3,000 NTE: a 333 increase. + Request(dispatchB, "Pending", new DateTime(2026, 3, 2), requested: 3_333m, currentNte: 3_000m), // A pending request on a deleted dispatch is not a queue row, so it adds nothing. Request(deletedDispatch, "Pending", new DateTime(2026, 3, 3), requested: 700m, currentNte: 100m)); await context.SaveChangesAsync(); @@ -609,7 +612,8 @@ public sealed class UpliftQueueReadTests var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); result.Items.Should().HaveCount(2); - result.PendingExposureTotal.Should().Be(4_999_997_333m); + result.Items.Select(i => i.Delta).Should().BeEquivalentTo(new[] { 5_000_000_000m, 333m }); + result.PendingExposureTotal.Should().Be(5_000_000_333m); result.PendingExposureTotal.Should().Be(result.Items.Sum(i => i.Delta)); } diff --git a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs index 031cf01..84a00e8 100644 --- a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs +++ b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs @@ -100,6 +100,8 @@ namespace Data.SeaHavenIndustries public string? DecidedByLastName { get; set; } public decimal? CurrentNTE { get; set; } public decimal RequestedNTE { get; set; } + /// The requested NTE increase; see UpliftAmount in SeaHaven.DataServices. + public decimal Amount { get; set; } public string? VendorReason { get; set; } public int RequiredTier { get; set; } public string? Status { get; set; } @@ -127,6 +129,8 @@ namespace Data.SeaHavenIndustries public int DispatchId { get; set; } public decimal? CurrentNTE { get; set; } public decimal RequestedNTE { get; set; } + /// The requested NTE increase; see UpliftAmount in SeaHaven.DataServices. + public decimal Amount { get; set; } public string? VendorReason { get; set; } public string? Status { get; set; } public int RequiredTier { get; set; } diff --git a/SeaHaven.DataServices/Helpers/UpliftAmount.cs b/SeaHaven.DataServices/Helpers/UpliftAmount.cs new file mode 100644 index 0000000..6f17961 --- /dev/null +++ b/SeaHaven.DataServices/Helpers/UpliftAmount.cs @@ -0,0 +1,33 @@ +using System.Linq.Expressions; +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Helpers +{ + /// + /// The single definition of an uplift's amount: the NTE increase being requested. + /// The two creation paths store different meanings in RequestedNTE. Vendor-portal + /// requests store the requested new NTE total, so the amount is RequestedNTE minus + /// the NTE at request time. Work-order requests store the increase itself. Vendor + /// sessions have no identity user, so createdby is null only on vendor-portal rows. + /// + public static class UpliftAmount + { + /// Translatable form for EF queries and aggregates. + public static readonly Expression> ForQuery = + u => u.createdby == null + ? u.RequestedNTE - (u.CurrentNTE ?? 0m) + : u.RequestedNTE; + + private static readonly Func Compiled = ForQuery.Compile(); + + public static decimal Of(DispatchUpliftRequest request) => Compiled(request); + + public static decimal Of(string? createdBy, decimal requestedNte, decimal? currentNte) => + Compiled(new DispatchUpliftRequest + { + createdby = createdBy, + RequestedNTE = requestedNte, + CurrentNTE = currentNte, + }); + } +} diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index d4557f0..a9f9861 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -255,6 +255,8 @@ namespace SeaHaven.DataServices.Helpers WorkOrderId = u.Dispatch!.WorkOrderId!.Value, u.Status, u.RequestedNTE, + u.CurrentNTE, + u.createdby, u.CreatedDate }); @@ -268,6 +270,8 @@ namespace SeaHaven.DataServices.Helpers WorkOrderId = link.WorkOrderId, u.Status, u.RequestedNTE, + u.CurrentNTE, + u.createdby, u.CreatedDate }); @@ -276,7 +280,7 @@ namespace SeaHaven.DataServices.Helpers l.UpliftId, l.WorkOrderId, l.Status, - l.RequestedNTE, + UpliftAmount.Of(l.createdby, l.RequestedNTE, l.CurrentNTE), l.CreatedDate))); } @@ -316,7 +320,7 @@ namespace SeaHaven.DataServices.Helpers pendingCount, HasUplift: true, primary?.Status, - primary?.RequestedNTE); + primary?.Amount); } return result; @@ -337,7 +341,7 @@ namespace SeaHaven.DataServices.Helpers int UpliftId, int WorkOrderId, string Status, - decimal RequestedNTE, + decimal Amount, DateTime? CreatedDate); private sealed record BoardUpliftAgg( diff --git a/SeaHaven.DataServices/Implementation/DispatchDataService.cs b/SeaHaven.DataServices/Implementation/DispatchDataService.cs index 6511ad6..3274713 100644 --- a/SeaHaven.DataServices/Implementation/DispatchDataService.cs +++ b/SeaHaven.DataServices/Implementation/DispatchDataService.cs @@ -1,6 +1,7 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; @@ -457,6 +458,7 @@ namespace SeaHaven.DataServices.Implementation u.Id, u.CurrentNTE, u.RequestedNTE, + u.createdby, u.VendorReason, u.Status, u.RequiredTier, @@ -474,7 +476,7 @@ namespace SeaHaven.DataServices.Implementation Id = u.Id, CurrentNTE = u.CurrentNTE, RequestedNTE = u.RequestedNTE, - Delta = u.RequestedNTE - (u.CurrentNTE ?? 0m), + Delta = UpliftAmount.Of(u.createdby, u.RequestedNTE, u.CurrentNTE), VendorReason = u.VendorReason, Status = u.Status, RequiredTier = u.RequiredTier, diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 3d2d68c..cbcf76d 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -3,6 +3,7 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Exceptions; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -109,6 +110,7 @@ namespace SeaHaven.DataServices.Implementation DecidedByLastName = x.decUser != null ? x.decUser.LastName : null, CurrentNTE = x.u.CurrentNTE, RequestedNTE = x.u.RequestedNTE, + Amount = UpliftAmount.Of(x.u.createdby, x.u.RequestedNTE, x.u.CurrentNTE), VendorReason = x.u.VendorReason, RequiredTier = x.u.RequiredTier, Status = x.u.Status, @@ -143,6 +145,7 @@ namespace SeaHaven.DataServices.Implementation DispatchId = u.DispatchId, CurrentNTE = u.CurrentNTE, RequestedNTE = u.RequestedNTE, + Amount = UpliftAmount.Of(u.createdby, u.RequestedNTE, u.CurrentNTE), VendorReason = u.VendorReason, Status = u.Status, RequiredTier = u.RequiredTier, @@ -183,6 +186,7 @@ namespace SeaHaven.DataServices.Implementation DispatchId = u.DispatchId, CurrentNTE = u.CurrentNTE, RequestedNTE = u.RequestedNTE, + Amount = UpliftAmount.Of(u.createdby, u.RequestedNTE, u.CurrentNTE), VendorReason = u.VendorReason, Status = u.Status, RequiredTier = u.RequiredTier, @@ -392,25 +396,14 @@ namespace SeaHaven.DataServices.Implementation .Where(u => (u.IsDeleted == null || u.IsDeleted == false) && dispatchIds.Contains(u.DispatchId) && (u.Status == "Approved" || u.Status == "NoApprovalRequired")) - .GroupBy(u => u.DispatchId) + // Summing each request's amount (the granted increase) keeps sequential + // approvals from double-counting whole NTE totals in both buckets. + .GroupBy(u => new { u.DispatchId, u.Status }, UpliftAmount.ForQuery) .Select(g => new { - DispatchId = g.Key, - // The two creation paths store different meanings in RequestedNTE. - // Vendor-portal rows store the requested new NTE total, so the - // granted amount is RequestedNTE - CurrentNTE; work-order-path rows - // store the granted increment directly. Vendor sessions have no - // identity user, so createdby is null only on vendor-portal rows. Summing - // granted amounts keeps sequential approvals from double-counting whole - // NTE totals. This applies to both buckets. - AutoApproved = g.Where(x => x.Status == "NoApprovalRequired") - .Sum(x => (decimal?)(x.createdby == null - ? x.RequestedNTE - (x.CurrentNTE ?? 0m) - : x.RequestedNTE)), - AdminApproved = g.Where(x => x.Status == "Approved") - .Sum(x => (decimal?)(x.createdby == null - ? x.RequestedNTE - (x.CurrentNTE ?? 0m) - : x.RequestedNTE)) + g.Key.DispatchId, + g.Key.Status, + Total = g.Sum() }) .ToListAsync(cancellationToken); @@ -428,18 +421,20 @@ namespace SeaHaven.DataServices.Implementation totals[workOrderId] = total; } - total.AutoApprovedTotal += sum.AutoApproved ?? 0m; - total.AdminApprovedTotal += sum.AdminApproved ?? 0m; + if (sum.Status == "NoApprovalRequired") + total.AutoApprovedTotal += sum.Total; + else + total.AdminApprovedTotal += sum.Total; } } return totals.Values.ToList(); } - // Queue-wide pending exposure for the approvals header: the sum of the Delta each - // Pending queue row displays (RequestedNTE - CurrentNTE), over the same rows the - // Pending tab lists (non-deleted request on a non-deleted dispatch), independent - // of page or filters. + // Queue-wide pending exposure for the approvals header: the sum of the amount each + // Pending queue row displays (UpliftAmount), over the same rows the Pending tab + // lists (non-deleted request on a non-deleted dispatch), independent of page or + // filters. public async Task GetPendingExposureTotalAsync(CancellationToken cancellationToken) { return await _context.DispatchUpliftRequests @@ -447,7 +442,8 @@ namespace SeaHaven.DataServices.Implementation && u.Status == "Pending" && u.Dispatch != null && (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)) - .SumAsync(u => (decimal?)(u.RequestedNTE - (u.CurrentNTE ?? 0m)), cancellationToken) ?? 0m; + .Select(UpliftAmount.ForQuery) + .SumAsync(cancellationToken); } public Task> GetPendingForWorkOrderAsync( diff --git a/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs b/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs index 7596b8d..667dc4b 100644 --- a/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs +++ b/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; @@ -42,7 +43,7 @@ namespace SeaHaven.Services.Helpers { Id = row.Id, Status = ToFrontendStatus(row.Status), - Amount = row.RequestedNTE, + Amount = row.Amount, Notes = row.VendorReason ?? "", RequestedAt = row.CreatedDate, RequestedById = row.CreatedByUserId, @@ -58,7 +59,7 @@ namespace SeaHaven.Services.Helpers { Id = request.Id, Status = ToFrontendStatus(request.Status), - Amount = request.RequestedNTE, + Amount = UpliftAmount.Of(request), Notes = request.VendorReason ?? "", RequestedAt = request.CreatedDate, RequestedById = request.createdby, diff --git a/SeaHaven.Services/Implementation/UpliftNotificationMessage.cs b/SeaHaven.Services/Implementation/UpliftNotificationMessage.cs index dd3c3f8..1a1331c 100644 --- a/SeaHaven.Services/Implementation/UpliftNotificationMessage.cs +++ b/SeaHaven.Services/Implementation/UpliftNotificationMessage.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Helpers; namespace SeaHaven.Services.Implementation { @@ -23,7 +24,7 @@ namespace SeaHaven.Services.Implementation - +
Current NTE${req.CurrentNTE ?? 0m:F2}
Requested NTE${req.RequestedNTE:F2}
Delta${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}
Delta${UpliftAmount.Of(req):F2}
Required Approval{tierText}

Vendor Reason

diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs index c72ddc3..450b76b 100644 --- a/SeaHaven.Services/Implementation/UpliftService.cs +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -1,5 +1,6 @@ using Data.SeaHavenIndustries; using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; @@ -56,7 +57,7 @@ namespace SeaHaven.Services.Implementation DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName), CurrentNTE = r.CurrentNTE, RequestedNTE = r.RequestedNTE, - Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m), + Delta = r.Amount, VendorReason = r.VendorReason, RequiredTier = r.RequiredTier, Status = UpliftStatus.ToCanonical(r.Status), @@ -115,7 +116,7 @@ namespace SeaHaven.Services.Implementation DispatchId = r.DispatchId, CurrentNTE = r.CurrentNTE, RequestedNTE = r.RequestedNTE, - Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m), + Delta = r.Amount, VendorReason = r.VendorReason, Status = UpliftStatus.ToCanonical(r.Status), RequiredTier = r.RequiredTier, @@ -157,7 +158,12 @@ namespace SeaHaven.Services.Implementation var now = _timeProvider.GetUtcNow().UtcDateTime; var oldNTE = dispatch.NTEAmount ?? 0m; - dispatch.NTEAmount = req.RequestedNTE; + // Approval raises the NTE by the request's amount. A vendor-portal request + // stores its requested NTE total and ends exactly there; a work-order request + // stores the increase, so it is added to the dispatch's current NTE. + dispatch.NTEAmount = req.createdby == null + ? req.RequestedNTE + : oldNTE + UpliftAmount.Of(req); dispatch.LastModificationTime = now; req.Status = UpliftStatus.Approved; @@ -172,7 +178,7 @@ namespace SeaHaven.Services.Implementation UserId = userId, FieldName = $"Dispatch {dispatch.DispatchNumber} NTE", OldValue = $"${oldNTE:F2}", - NewValue = $"${req.RequestedNTE:F2}", + NewValue = $"${dispatch.NTEAmount:F2}", Action = "uplift_approved", CreatedAt = now }, cancellationToken); diff --git a/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs b/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs new file mode 100644 index 0000000..a378b60 --- /dev/null +++ b/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs @@ -0,0 +1,298 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using Xunit; + +namespace SeaHavenIndustries.Tests; + +// An uplift's amount is the NTE increase being requested. Work-order requests store +// that increase in RequestedNTE; vendor-portal requests store the requested NTE total. +// These tests walk each path through the approvals queue, approve and revoke, and the +// queue-wide and per-work-order exposure totals. +public sealed class UpliftAmountPerCreationPathTests +{ + private const string DispatcherId = "dispatcher-1"; + private const int WorkOrderId = 1; + private const int PrimaryDispatchId = 10; + private const int SecondDispatchId = 11; + + private static ApplicationDbContext CreateContext() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options); + + private static ApprovalsOptions NewOptions() => new() + { + UpliftTier1MaxUsd = 2500m, + Tier1Roles = new[] { "Approver" }, + Tier2Roles = new[] { "Manager" }, + }; + + private static WorkOrderUpliftService NewWorkOrderUpliftService(ApplicationDbContext context) => + new(new UpliftDataService(context), + new DispatchDataService(context), + new WorkOrderDetailDataService(context), + WorkOrderAccountTestHelpers.Resolver(context), + new UserDataService(context), + new TeamPermissionOverrideDataService(context), + new TeamPermissionPolicy(), + TimeProvider.System, + Options.Create(NewOptions())); + + private static UpliftService NewQueueService(ApplicationDbContext context) => + new(new UpliftDataService(context), + new DispatchDataService(context), + new NoDocumentStorage(), + TimeProvider.System, + Options.Create(NewOptions())); + + private static ClaimsPrincipal Admin(string userId = DispatcherId) => + WorkOrderAccountTestHelpers.OrgWideAdmin(userId); + + private static Dispatch DispatchOf(ApplicationDbContext context, int id) => + context.Dispatches.Single(d => d.Id == id); + + // A work order with a 600 NTE on its primary dispatch, and a second dispatch on the + // same work order, also at 600. + private static async Task SeedWorkOrderAsync(ApplicationDbContext context) + { + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); + context.Users.Add(new ApplicationUser + { + Id = DispatcherId, + UserName = DispatcherId, + FirstName = "Alex", + LastName = "Dispatcher", + }); + var role = new IdentityRole("Admin"); + context.Roles.Add(role); + context.UserRoles.Add(new IdentityUserRole { UserId = DispatcherId, RoleId = role.Id }); + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + context.Dispatches.AddRange( + new Dispatch + { + Id = PrimaryDispatchId, + VendorId = 1, + WorkOrderId = WorkOrderId, + NTEAmount = 600m, + DispatchNumber = "DIS-10", + Status = "Completed", + }, + new Dispatch + { + Id = SecondDispatchId, + VendorId = 1, + WorkOrderId = WorkOrderId, + NTEAmount = 600m, + DispatchNumber = "DIS-11", + Status = "Completed", + }); + context.workOrders.Add(new WorkOrder + { + Id = WorkOrderId, + InternalWONumber = "10000000001", + PrimaryDispatchId = PrimaryDispatchId, + AccountId = 1, + WorkOrderType = WorkOrderType.PM, + }); + await context.SaveChangesAsync(); + } + + // Uses up the work order's auto-approval allowance, so a new work-order request + // waits for approval instead of auto-approving. + private static async Task ConsumeAutoApprovalAllowanceAsync(ApplicationDbContext context) + { + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = SecondDispatchId, + CurrentNTE = 100m, + RequestedNTE = 500m, + Status = "NoApprovalRequired", + RequiredTier = 0, + NotificationStatus = "Sent", + createdby = DispatcherId, + CreatedDate = new DateTime(2026, 1, 2), + }); + await context.SaveChangesAsync(); + } + + private static async Task CreatePendingWorkOrderRequestAsync(ApplicationDbContext context, decimal amount) + { + await ConsumeAutoApprovalAllowanceAsync(context); + var created = await NewWorkOrderUpliftService(context).CreateAsync( + WorkOrderId, + new CreateWorkOrderUpliftRequestDto { Amount = amount, Notes = "Extra parts" }, + Admin(), + CancellationToken.None); + Assert.Equal("pending", created!.Status); + return created.Id; + } + + // Stored exactly as the vendor portal writes it: no identity user, and RequestedNTE + // is the requested new NTE total. + private static async Task SeedVendorRequestAsync( + ApplicationDbContext context, int dispatchId, decimal currentNte, decimal requestedTotal) + { + var request = new DispatchUpliftRequest + { + DispatchId = dispatchId, + CurrentNTE = currentNte, + RequestedNTE = requestedTotal, + Status = "Pending", + RequiredTier = 1, + RequestedByVendorName = "Acme HVAC", + NotificationStatus = "Sent", + CreatedDate = new DateTime(2026, 3, 1), + }; + context.DispatchUpliftRequests.Add(request); + await context.SaveChangesAsync(); + return request.Id; + } + + private static Task RevokeAsync(ApplicationDbContext context, int upliftId) => + NewWorkOrderUpliftService(context).RevokeAsync( + WorkOrderId, + upliftId, + new RevokeWorkOrderUpliftRequestDto { Reason = "Scope reduced" }, + Admin(), + CancellationToken.None); + + [Fact] + public async Task WorkOrderRequest_QueueShowsTheRequestedIncrease() + { + await using var context = CreateContext(); + await SeedWorkOrderAsync(context); + var upliftId = await CreatePendingWorkOrderRequestAsync(context, 90m); + + var pending = await NewQueueService(context) + .ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None); + + Assert.Equal(90m, Assert.Single(pending.Items, i => i.Id == upliftId).Delta); + } + + [Fact] + public async Task WorkOrderRequest_ApproveAddsIncreaseToNte_RevokeRestoresNte() + { + await using var context = CreateContext(); + await SeedWorkOrderAsync(context); + var upliftId = await CreatePendingWorkOrderRequestAsync(context, 90m); + var queue = NewQueueService(context); + + var approved = await queue.ApproveAsync(Admin(), upliftId, null, CancellationToken.None); + Assert.Equal(690m, approved.NTEAmount); + Assert.Equal(690m, DispatchOf(context, PrimaryDispatchId).NTEAmount); + + var revoked = await RevokeAsync(context, upliftId); + Assert.Equal("revoked", revoked!.Status); + Assert.Equal(600m, DispatchOf(context, PrimaryDispatchId).NTEAmount); + } + + [Fact] + public async Task VendorRequest_QueueShowsIncrease_ApproveEndsAtRequestedTotal_RevokeRestoresNte() + { + await using var context = CreateContext(); + await SeedWorkOrderAsync(context); + var upliftId = await SeedVendorRequestAsync(context, PrimaryDispatchId, currentNte: 600m, requestedTotal: 900m); + var queue = NewQueueService(context); + + var pending = await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None); + Assert.Equal(300m, Assert.Single(pending.Items).Delta); + + var approved = await queue.ApproveAsync(Admin(), upliftId, null, CancellationToken.None); + Assert.Equal(900m, approved.NTEAmount); + Assert.Equal(900m, DispatchOf(context, PrimaryDispatchId).NTEAmount); + + var revoked = await RevokeAsync(context, upliftId); + Assert.Equal("revoked", revoked!.Status); + Assert.Equal(600m, DispatchOf(context, PrimaryDispatchId).NTEAmount); + } + + [Fact] + public async Task PendingExposureAndApprovedOnWorkOrder_SumEachRequestsIncrease() + { + await using var context = CreateContext(); + await SeedWorkOrderAsync(context); + var workOrderRequestId = await CreatePendingWorkOrderRequestAsync(context, 90m); + var vendorRequestId = await SeedVendorRequestAsync(context, SecondDispatchId, currentNte: 600m, requestedTotal: 900m); + var queue = NewQueueService(context); + + var pending = await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None); + Assert.Equal(390m, pending.PendingExposureTotal); + Assert.Equal(pending.Items.Sum(i => i.Delta), pending.PendingExposureTotal); + + await queue.ApproveAsync(Admin(), workOrderRequestId, null, CancellationToken.None); + await queue.ApproveAsync(Admin(), vendorRequestId, null, CancellationToken.None); + + var decided = await queue.ListAsync(Admin(), "Approved", null, 1, 25, CancellationToken.None); + var row = Assert.Single(decided.Items, i => i.Id == workOrderRequestId); + Assert.Equal(390m, row.WorkOrderAdminApprovedTotal); + // The earlier auto-approved work-order request counts its stored increase. + Assert.Equal(500m, row.WorkOrderAutoApprovedTotal); + Assert.Equal(0m, (await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None)).PendingExposureTotal); + } + + [Fact] + public async Task AutoApprovedWorkOrderRequest_ConsumesAllowanceByItsRequestedIncrease() + { + await using var context = CreateContext(); + await SeedWorkOrderAsync(context); + + var created = await NewWorkOrderUpliftService(context).CreateAsync( + WorkOrderId, + new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" }, + Admin(), + CancellationToken.None); + + Assert.Equal("auto_approved", created!.Status); + var stored = context.DispatchUpliftRequests.Single(u => u.Id == created.Id); + Assert.Equal(DispatcherId, stored.createdby); + Assert.Equal(400m, stored.RequestedNTE); + Assert.Equal(1000m, DispatchOf(context, PrimaryDispatchId).NTEAmount); + Assert.Equal(400m, await new UpliftDataService(context) + .SumAutoApprovedAmountForWorkOrderAsync(WorkOrderId, CancellationToken.None)); + } + + [Fact] + public void AmountDefinition_TranslatesToSqlServer_ForRowAndGroupedSums() + { + // ToQueryString only builds SQL; it never opens the connection. + using var context = new ApplicationDbContext(new DbContextOptionsBuilder() + .UseSqlServer("Server=translation-only;Database=none;Trusted_Connection=True;") + .Options); + + var rowSql = context.DispatchUpliftRequests + .Select(UpliftAmount.ForQuery) + .ToQueryString(); + var groupedSql = context.DispatchUpliftRequests + .GroupBy(u => new { u.DispatchId, u.Status }, UpliftAmount.ForQuery) + .Select(g => new { g.Key.DispatchId, g.Key.Status, Total = g.Sum() }) + .ToQueryString(); + + Assert.Contains("CASE", rowSql); + Assert.Contains("[createdby] IS NULL", rowSql); + Assert.Contains("SUM(CASE", groupedSql); + Assert.Contains("GROUP BY", groupedSql); + } + + private sealed class NoDocumentStorage : IVendorDocumentStoragePort + { + public Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken) => + Task.CompletedTask; + + public Stream OpenRead(int vendorId, int dispatchId, string storedFileName) => + throw new NotSupportedException(); + + public void Delete(int vendorId, int dispatchId, string storedFileName) + { + } + } +} From d33ba34db91e6f5bb77978ad3a443ccd9ef54b01 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 02:35:06 -0300 Subject: [PATCH 32/35] fix(vendor-portal): vendors revise only uplift requests they raised A work-order request stores the requested increase, not a total. Letting the vendor revise one after changes were requested rewrote RequestedNTE as a total while it still read as a work-order request, corrupting its amount and the NTE it would be approved to. Revise now answers not-found for any request the vendor did not raise and leaves the row untouched. Co-Authored-By: Claude Opus 5.5 --- .../UpliftWorkflowTests.cs | 38 +++++++++++++++++++ .../Implementation/VendorPortalService.cs | 4 +- .../UpliftAmountPerCreationPathTests.cs | 15 ++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs index 4518910..87f07cf 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs @@ -419,6 +419,44 @@ public sealed class UpliftWorkflowTests context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_revised"); } + [Fact] + public async Task Revise_WorkOrderRequest_IsRefusedAsNotFound_AndLeavesTheRowUnchanged() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 600m); + context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); + // Raised from the work order by an internal user and sent back for changes: + // RequestedNTE holds the 90 increase, not a total. + var workOrderRequest = new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 600m, + RequestedNTE = 90m, + Status = UpliftStatus.ChangesRequested, + RequiredTier = 1, + VendorReason = "Extra parts", + RequestedByVendorName = "Alex Dispatcher", + NotificationStatus = "Sent", + createdby = "dispatcher-1", + CreatedDate = new DateTime(2026, 3, 1), + }; + context.DispatchUpliftRequests.Add(workOrderRequest); + await context.SaveChangesAsync(); + var service = NewPortalService(context, new FakeEmailSender(deliver: true)); + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + + var act = () => service.ReviseUpliftAsync(session!, dispatch.Id, workOrderRequest.Id, 900m, "vendor total", 1, CancellationToken.None); + + await act.Should().ThrowAsync(); + var after = context.DispatchUpliftRequests.AsNoTracking().Single(u => u.Id == workOrderRequest.Id); + after.Status.Should().Be(UpliftStatus.ChangesRequested); + after.RequestedNTE.Should().Be(90m); + after.CurrentNTE.Should().Be(600m); + after.VendorReason.Should().Be("Extra parts"); + after.createdby.Should().Be("dispatcher-1"); + context.WorkOrderAuditLogs.Should().NotContain(a => a.Action == "uplift_revised"); + } + [Fact] public async Task Withdraw_Pending_TransitionsToWithdrawn_SetsDecidedAt() { diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index c595809..047abd0 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -736,7 +736,9 @@ namespace SeaHaven.Services.Implementation } var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken); - if (req == null) throw new KeyNotFoundException("Uplift request not found"); + // A vendor revises only requests it raised. Work-order requests (createdby set) + // store the increase, not a total, so a vendor revise would corrupt their amount. + if (req == null || req.createdby != null) throw new KeyNotFoundException("Uplift request not found"); if (UpliftStatus.ToCanonical(req.Status) != UpliftStatus.ChangesRequested) { diff --git a/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs b/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs index a378b60..11502ef 100644 --- a/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs +++ b/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs @@ -261,6 +261,21 @@ public sealed class UpliftAmountPerCreationPathTests .SumAutoApprovedAmountForWorkOrderAsync(WorkOrderId, CancellationToken.None)); } + [Fact] + public async Task WorkOrderUpliftList_ShowsEachRequestsIncrease() + { + await using var context = CreateContext(); + await SeedWorkOrderAsync(context); + var workOrderRequestId = await CreatePendingWorkOrderRequestAsync(context, 90m); + var vendorRequestId = await SeedVendorRequestAsync(context, SecondDispatchId, currentNte: 600m, requestedTotal: 900m); + + var uplifts = await NewWorkOrderUpliftService(context) + .ListAsync(WorkOrderId, Admin(), CancellationToken.None); + + Assert.Equal(90m, Assert.Single(uplifts!, u => u.Id == workOrderRequestId).Amount); + Assert.Equal(300m, Assert.Single(uplifts!, u => u.Id == vendorRequestId).Amount); + } + [Fact] public void AmountDefinition_TranslatesToSqlServer_ForRowAndGroupedSums() { From 85f2e709c9ba498bc9cf66cf23f87ee90bb370dd Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 02:36:17 -0300 Subject: [PATCH 33/35] test(uplifts): board summary and notification delta use the per-path amount Co-Authored-By: Claude Opus 5.5 --- .../UpliftAmountPerCreationPathTests.cs | 23 +++++++++ .../WorkOrderBoardServiceTests.cs | 51 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs b/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs index 11502ef..43220e1 100644 --- a/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs +++ b/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs @@ -276,6 +276,29 @@ public sealed class UpliftAmountPerCreationPathTests Assert.Equal(300m, Assert.Single(uplifts!, u => u.Id == vendorRequestId).Amount); } + [Theory] + [InlineData(DispatcherId, 90, "$90.00")] + [InlineData(null, 900, "$300.00")] + public void NotificationDeltaRow_ShowsTheRequestedIncrease(string? createdBy, int requestedNte, string expectedDelta) + { + var dispatch = new Dispatch { Id = PrimaryDispatchId, DispatchNumber = "DIS-10", NTEAmount = 600m }; + var request = new DispatchUpliftRequest + { + DispatchId = PrimaryDispatchId, + CurrentNTE = 600m, + RequestedNTE = requestedNte, + RequiredTier = 1, + createdby = createdBy, + }; + + // Builds the message content only; nothing is sent. + var (_, body) = UpliftNotificationMessage.BuildInitial(dispatch, request, "Acme HVAC", "https://example.test"); + + Assert.Contains( + $"Delta{expectedDelta}", + body); + } + [Fact] public void AmountDefinition_TranslatesToSqlServer_ForRowAndGroupedSums() { diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs index 96acd1b..b579460 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -451,6 +451,57 @@ public class WorkOrderBoardPendingUpliftTests Assert.Equal(1500m, dto.UpliftSummary.Amount); } + [Fact] + public async Task GetBoardRows_VendorPortalUplift_SummaryAmountIsTheRequestedIncrease() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + context.Dispatches.Add(new Dispatch + { + Id = 10, + VendorId = 1, + WorkOrderId = 1, + NTEAmount = 600m, + ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0) + }); + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + SiteCode = "BK5" + }); + // A vendor-portal request (no identity user) stores the requested NTE total. + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 100, + DispatchId = 10, + CurrentNTE = 600m, + RequestedNTE = 900m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending" + }); + await context.SaveChangesAsync(); + + var result = await new WorkOrderBoardDataService(context).GetBoardRowsAsync(new WorkOrderBoardQuery( + weekStart, + weekStart.AddDays(4), + null, + false, + null, + false, + null, + null)); + + var dto = WorkOrderBoardService.MapRawRow(Assert.Single(result.ScheduledRows), DateTime.UtcNow); + Assert.Equal(300m, dto.UpliftSummary!.Amount); + } + [Fact] public async Task GetBoardRows_ApprovedUplift_DoesNotBlock() { From e02f9774dc7322f3b07ed4b2aa2ecf5eb9a87bd7 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 02:51:48 -0300 Subject: [PATCH 34/35] Keep work-order uplift requests read-only in the Vendor Portal A vendor could withdraw (or cancel) an uplift a dispatcher raised from the work order. Withdraw and its cancel alias now refuse requests with createdby set, using the portal's not-found response, and the portal read model reports RaisedByVendor so the portal can hide Revise and Withdraw on those requests. --- .../UpliftWorkflowTests.cs | 86 +++++++++++++++++++ .../Models/VendorPortalReadModels.cs | 3 + .../Implementation/UpliftDataService.cs | 3 + .../DTOs/VendorPortalServiceDTOs.cs | 2 + .../Implementation/VendorPortalService.cs | 5 +- 5 files changed, 98 insertions(+), 1 deletion(-) diff --git a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs index 87f07cf..0c2f27d 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs @@ -496,6 +496,92 @@ public sealed class UpliftWorkflowTests await act.Should().ThrowAsync(); } + // Raised from the work order by an internal user: createdby is set and RequestedNTE + // holds the increase. Vendor sessions have no identity user, so they never set it. + private static DispatchUpliftRequest WorkOrderRequest(int dispatchId, string status) => new() + { + DispatchId = dispatchId, + CurrentNTE = 600m, + RequestedNTE = 90m, + Status = status, + RequiredTier = 1, + VendorReason = "Extra parts", + RequestedByVendorName = "Alex Dispatcher", + NotificationStatus = "Sent", + createdby = "dispatcher-1", + CreatedDate = new DateTime(2026, 3, 1), + }; + + [Theory] + [InlineData("Pending", "withdraw")] + [InlineData("ChangesRequested", "withdraw")] + [InlineData("Pending", "cancel")] + [InlineData("ChangesRequested", "cancel")] + public async Task Withdraw_WorkOrderRequest_IsRefusedAsNotFound_AndLeavesTheRowUnchanged(string status, string route) + { + using var context = NewContext(); + var (_, _, dispatch) = await SeedAsync(context, nte: 600m); + var workOrderRequest = WorkOrderRequest(dispatch.Id, status); + context.DispatchUpliftRequests.Add(workOrderRequest); + await context.SaveChangesAsync(); + var service = NewPortalService(context, new FakeEmailSender(deliver: true)); + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + + Func act = route == "cancel" + ? () => service.CancelUpliftRequestAsync(session!, dispatch.Id, workOrderRequest.Id, CancellationToken.None) + : () => service.WithdrawUpliftAsync(session!, dispatch.Id, workOrderRequest.Id, CancellationToken.None); + + await act.Should().ThrowAsync(); + var after = context.DispatchUpliftRequests.AsNoTracking().Single(u => u.Id == workOrderRequest.Id); + after.Status.Should().Be(status); + after.DecidedAt.Should().BeNull(); + after.RequestedNTE.Should().Be(90m); + after.createdby.Should().Be("dispatcher-1"); + context.WorkOrderAuditLogs.Should().NotContain(a => a.Action == "uplift_withdraw" || a.Action == "uplift_cancel"); + } + + [Fact] + public async Task Cancel_VendorRaisedChangesRequested_StillWithdraws() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); + await context.SaveChangesAsync(); + var service = NewPortalService(context, new FakeEmailSender(deliver: true)); + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var created = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None); + context.DispatchUpliftRequests.Single().Status = UpliftStatus.ChangesRequested; + await context.SaveChangesAsync(); + + var result = await service.CancelUpliftRequestAsync(session!, dispatch.Id, created.Id, CancellationToken.None); + + result.Status.Should().Be(UpliftStatus.Withdrawn); + context.DispatchUpliftRequests.AsNoTracking().Single().Status.Should().Be(UpliftStatus.Withdrawn); + context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_cancel" && a.OldValue == UpliftStatus.ChangesRequested); + } + + [Fact] + public async Task DispatchDetail_ReportsRaisedByVendor_PerCreationPath() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); + await context.SaveChangesAsync(); + var service = NewPortalService(context, new FakeEmailSender(deliver: true)); + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var vendorRaised = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None); + await service.WithdrawUpliftAsync(session!, dispatch.Id, vendorRaised.Id, CancellationToken.None); + var workOrderRequest = WorkOrderRequest(dispatch.Id, UpliftStatus.Pending); + context.DispatchUpliftRequests.Add(workOrderRequest); + await context.SaveChangesAsync(); + + var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); + + detail!.UpliftRequests.Should().HaveCount(2); + detail.UpliftRequests.Single(u => u.Id == vendorRaised.Id).RaisedByVendor.Should().BeTrue(); + detail.UpliftRequests.Single(u => u.Id == workOrderRequest.Id).RaisedByVendor.Should().BeFalse(); + } + [Fact] public async Task RequestChanges_Pending_TransitionsToChangesRequested_AuditsOldValue() { diff --git a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs index 84a00e8..689f3f0 100644 --- a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs +++ b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs @@ -45,6 +45,9 @@ namespace Data.SeaHavenIndustries public string? Status { get; set; } public int RequiredTier { get; set; } public string? RequestedByVendorName { get; set; } + // True when the vendor raised the request in the portal; false when it was raised + // from the work order. Only vendor-raised requests are revisable or withdrawable. + public bool RaisedByVendor { get; set; } public DateTime? CreatedDate { get; set; } public DateTime? DecidedAt { get; set; } public string? DecisionNote { get; set; } diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index cbcf76d..09566f3 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -243,6 +243,9 @@ namespace SeaHaven.DataServices.Implementation Status = u.Status, RequiredTier = u.RequiredTier, RequestedByVendorName = u.RequestedByVendorName, + // Vendor sessions have no identity user, so createdby is null + // only on requests the vendor raised in the portal. + RaisedByVendor = u.createdby == null, CreatedDate = u.CreatedDate, DecidedAt = u.DecidedAt, DecisionNote = u.DecisionNote, diff --git a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs index afdaf18..8e4be7c 100644 --- a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs @@ -54,6 +54,8 @@ namespace SeaHaven.Services.DTOs public string? Status { get; set; } public int RequiredTier { get; set; } public string? RequestedByVendorName { get; set; } + // False for requests raised from the work order: the portal shows them read-only. + public bool RaisedByVendor { get; set; } public DateTime? RequestedAt { get; set; } public DateTime? DecidedAt { get; set; } public string? DecisionNote { get; set; } diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 047abd0..6bb22ab 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -107,6 +107,7 @@ namespace SeaHaven.Services.Implementation Status = UpliftStatus.ToCanonical(u.Status), RequiredTier = u.RequiredTier, RequestedByVendorName = u.RequestedByVendorName, + RaisedByVendor = u.RaisedByVendor, RequestedAt = u.CreatedDate, DecidedAt = u.DecidedAt, DecisionNote = u.DecisionNote, @@ -694,7 +695,9 @@ namespace SeaHaven.Services.Implementation if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken); - if (req == null) throw new KeyNotFoundException("Uplift request not found"); + // A vendor withdraws only requests it raised; requests raised from the work order + // (createdby set) are read-only in the portal. + if (req == null || req.createdby != null) throw new KeyNotFoundException("Uplift request not found"); if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Withdrawn)) { From 207bf59208ce3f97babd0139dbc50a7fc51d057b Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 02:58:15 -0300 Subject: [PATCH 35/35] fix(media): name HEIC in the unsupported-type message and keep ticket keys out of comments The rejection message now lists every type the media allowlist accepts, and a test fails if the message and the allowlist drift apart. --- .../nginx/conf.d/01_upload_body_size.conf | 2 +- .../Controllers/WorkOrderMediaController.cs | 2 +- .../Interfaces/IVendorDocumentDataService.cs | 4 +-- .../Interfaces/IWorkOrderMediaDataService.cs | 4 +-- .../DTOs/VendorPortalServiceDTOs.cs | 2 +- .../Helpers/VideoDurationProbe.cs | 2 +- .../Helpers/WorkOrderMediaContract.cs | 2 +- .../Helpers/WorkOrderMediaFileRules.cs | 2 +- .../Implementation/VendorPortalService.cs | 4 +-- .../Implementation/WorkOrderMediaService.cs | 4 +-- .../WorkOrderPhase6Tests.cs | 28 +++++++++++++++++++ scripts/validate-elastic-beanstalk-bundle.sh | 2 +- 12 files changed, 43 insertions(+), 15 deletions(-) diff --git a/.platform/nginx/conf.d/01_upload_body_size.conf b/.platform/nginx/conf.d/01_upload_body_size.conf index a7bd889..da18278 100644 --- a/.platform/nginx/conf.d/01_upload_body_size.conf +++ b/.platform/nginx/conf.d/01_upload_body_size.conf @@ -1,4 +1,4 @@ -# SH-383: the Elastic Beanstalk nginx proxy defaults client_max_body_size to 1m, +# The Elastic Beanstalk nginx proxy defaults client_max_body_size to 1m, # which returned 413 for every media upload over ~1 MB before the request reached # the API. The cap sits above the API's own request limit # (WorkOrderMediaContract.MaxUploadRequestBytes = 110 MB) so oversize uploads get diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index fd7dac1..32122ca 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -92,7 +92,7 @@ namespace Api.SeaHavenIndustries.Controllers // of being sized under a kind it does not have. WorkOrderMediaFileRules.EnsureAllowed(file, category); - // SH-116 contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB). + // Media contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB). // Classify by the same resolved type EnsureAllowed validates against. var sizeMessage = WorkOrderMediaContract.ValidateSize( WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName, file.Length); diff --git a/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs index 6986ef2..46a4274 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs @@ -11,14 +11,14 @@ namespace SeaHaven.DataServices.Interfaces Task GetUpliftEvidenceAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken); /// /// Content types of the work order's current vendor documents (not deleted, not replaced by a - /// newer completion version), optionally excluding one being replaced. SH-116 photo/video counts. + /// newer completion version), optionally excluding one being replaced. Feeds the per-work-order photo/video counts. /// Task> ListActiveContentTypesForWorkOrderAsync( int workOrderId, int? excludingDocumentId, CancellationToken cancellationToken); - /// Stored URLs of the work order's non-deleted dispatcher attachments (SH-116 counts). + /// Stored URLs of the work order's non-deleted dispatcher attachments (photo/video counts). Task> ListActiveWorkOrderAttachmentUrlsAsync( int workOrderId, CancellationToken cancellationToken); diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs index caf6071..144a521 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs @@ -23,14 +23,14 @@ namespace SeaHaven.DataServices.Interfaces Task GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken); void TrackAttachment(WorkOrderAttachments attachment); - /// Stored URLs of the work order's non-deleted attachments (SH-116 photo/video counts). + /// Stored URLs of the work order's non-deleted attachments (photo/video counts). Task> ListActiveAttachmentUrlsAsync( int workOrderId, CancellationToken cancellationToken); /// /// Content types of the work order's current vendor-portal documents (not deleted, not - /// replaced by a newer completion version). SH-116 counts span both upload surfaces. + /// replaced by a newer completion version). The counts span both upload surfaces. /// Task> ListActiveVendorMediaContentTypesAsync( int workOrderId, diff --git a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs index ace32c8..088c761 100644 --- a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs @@ -138,7 +138,7 @@ namespace SeaHaven.Services.DTOs } /// - /// SH-116 per-work-order photo/video usage, so the portal can pre-check an upload + /// Per-work-order photo/video usage, so the portal can pre-check an upload /// before sending it. The server still enforces the limit on upload. /// public class PortalMediaCountsDTO diff --git a/SeaHaven.Services/Helpers/VideoDurationProbe.cs b/SeaHaven.Services/Helpers/VideoDurationProbe.cs index 8355c46..1818c50 100644 --- a/SeaHaven.Services/Helpers/VideoDurationProbe.cs +++ b/SeaHaven.Services/Helpers/VideoDurationProbe.cs @@ -8,7 +8,7 @@ namespace SeaHaven.Services.Helpers /// top-level moov box → mvhd timescale and duration. It seeks over box /// headers only (the moov box may sit after a large mdat), never decodes /// media and never allocates more than a few bytes. Returns null whenever the structure - /// cannot be read, so callers can let unreadable files through (SH-116: unreadable + /// cannot be read, so callers can let unreadable files through (per the media contract: unreadable /// metadata never blocks an upload). ///
public static class VideoDurationProbe diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs index e064b02..46865bf 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -1,7 +1,7 @@ namespace SeaHaven.Services.Helpers { /// - /// SH-116 client-confirmed media contract (2026-09-22): photos up to 10 MB (JPEG/PNG/HEIC), + /// Client-confirmed media contract (2026-09-22): photos up to 10 MB (JPEG/PNG/HEIC), /// videos up to 100 MB and 90 seconds (MP4/MOV), at most 10 photos and 3 videos per work /// order, across the dispatcher media modal, the completion-doc media tab and the vendor /// portal upload. Documents (PDF/DOC/DOCX) keep the 50 MB document cap. Duration is read diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs index c438759..b4db302 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs @@ -140,7 +140,7 @@ namespace SeaHaven.Services.Helpers { throw new Exceptions.WorkOrderBoardValidationException( "UnsupportedMediaType", - "Supported file types are JPG, PNG, MP4, MOV, PDF, DOC, and DOCX for Extra Docs; photos accept media only."); + "Supported file types are JPG, PNG, HEIC, MP4, MOV, PDF, DOC, and DOCX for Extra Docs; photos accept media only."); } } diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 782f446..a98c57d 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -885,7 +885,7 @@ namespace SeaHaven.Services.Implementation throw new InvalidOperationException("A completion document file is required."); } - // SH-116 contract: photos up to 10 MB (JPEG/PNG/HEIC), videos up to 100 MB + // Media contract: photos up to 10 MB (JPEG/PNG/HEIC), videos up to 100 MB // (MP4/MOV). Documents keep the configured VendorDocuments cap. var contentType = ResolveUploadContentType(file); if (contentType == null) @@ -980,7 +980,7 @@ namespace SeaHaven.Services.Implementation async Task PersistAsync(CancellationToken ct) { - // SH-116: the 10-photo / 3-video limit is per work order across the dispatcher and + // The 10-photo / 3-video limit is per work order across the dispatcher and // vendor surfaces. A new completion version replaces its predecessor, so that one // does not count against the upload that supersedes it. if (dispatch.WorkOrderId is int workOrderId) diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index 0a0d1d5..d2c266d 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -156,7 +156,7 @@ namespace SeaHaven.Services.Implementation }; } - // SH-116 photo/video caps are a work-order aggregate shared with the vendor portal + // Photo/video caps are a work-order aggregate shared with the vendor portal // upload, so the count and the insert run under the per-work-order mutation lock. var attachment = await _upliftData.ExecuteWorkOrderMutationAsync( workOrderId, @@ -363,7 +363,7 @@ namespace SeaHaven.Services.Implementation } /// - /// SH-116 contract: at most 10 photos and 3 videos per work order, counted over the + /// Media contract: at most 10 photos and 3 videos per work order, counted over the /// stored attachment rows plus the work order's current vendor-portal documents. Legacy /// Before/After column slots replace in place and are not counted. Documents have no /// per-work-order count limit. diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 224d73b..ea77fb4 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1,6 +1,8 @@ using System.Security.Claims; using System.Text; using System.IO.Compression; +using System.Reflection; +using System.Text.RegularExpressions; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Http; @@ -2373,6 +2375,32 @@ public class WorkOrderMediaFileRulesTests return stream.ToArray(); } + [Fact] + public void EnsureAllowed_RejectionMessage_NamesEveryAllowedType() + { + const BindingFlags privateStatic = BindingFlags.NonPublic | BindingFlags.Static; + var allowedTypes = (HashSet?)typeof(WorkOrderMediaFileRules) + .GetField("AllowedContentTypes", privateStatic)?.GetValue(null); + var extensionsByType = (Dictionary>?)typeof(WorkOrderMediaFileRules) + .GetField("ExtensionsByContentType", privateStatic)?.GetValue(null); + Assert.NotNull(allowedTypes); + Assert.NotNull(extensionsByType); + Assert.NotEmpty(allowedTypes); + + var ex = Assert.Throws( + () => WorkOrderMediaFileRules.EnsureAllowed(FormFile(Encoding.UTF8.GetBytes("plain text"), "notes.txt", "text/plain"))); + + foreach (var contentType in allowedTypes) + { + var canonical = contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" : contentType; + Assert.True(extensionsByType.TryGetValue(canonical, out var extensions), $"No extensions mapped for {contentType}."); + var labels = extensions.Select(extension => extension.TrimStart('.').ToUpperInvariant()).ToList(); + Assert.True( + labels.Any(label => Regex.IsMatch(ex.Message, $@"\b{Regex.Escape(label)}\b")), + $"Rejection message does not name {contentType} ({string.Join("/", labels)}): {ex.Message}"); + } + } + [Fact] public void IsAllowed_ValidJpeg_ReturnsTrue() { diff --git a/scripts/validate-elastic-beanstalk-bundle.sh b/scripts/validate-elastic-beanstalk-bundle.sh index 1d88367..83fcc9e 100755 --- a/scripts/validate-elastic-beanstalk-bundle.sh +++ b/scripts/validate-elastic-beanstalk-bundle.sh @@ -32,7 +32,7 @@ grep -Fxq \ ' WorkOrderWebhook__SecretId: arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB' \ "$webhook_file" -# Without this override the platform nginx caps request bodies at 1 MB (SH-383). +# Without this override the platform nginx caps request bodies at 1 MB. unzip -p "$BUNDLE" .platform/nginx/conf.d/01_upload_body_size.conf > "$nginx_file" grep -Fxq 'client_max_body_size 120M;' "$nginx_file"