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..da18278 --- /dev/null +++ b/.platform/nginx/conf.d/01_upload_body_size.conf @@ -0,0 +1,6 @@ +# 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/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/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs b/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs new file mode 100644 index 0000000..7a5d6a7 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs @@ -0,0 +1,206 @@ +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; + +// 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); + } + + [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")] + 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/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs index 78fe0ac..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_SumsGrantedAmountsAcrossAllPendingRequests() + public async Task List_PendingExposureTotal_SumsEachPendingRequestsIncreaseAcrossTheQueue() { using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); @@ -567,10 +568,10 @@ public sealed class UpliftQueueReadTests 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. + // 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-path rows (createdby set) store the requested increment: 90. + // 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)); @@ -584,6 +585,38 @@ public sealed class UpliftQueueReadTests result.PendingExposureTotal.Should().Be(740m); } + [Fact] + public async Task List_PendingExposureTotal_EqualsSumOfPendingRowAmounts_ForBothCreationPaths() + { + 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: 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 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(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + result.Items.Should().HaveCount(2); + 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)); + } + // --- Approved-on-WO exposure totals --- [Fact] diff --git a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs index 9b3f220..0c2f27d 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] @@ -388,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() { @@ -427,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() { @@ -650,6 +805,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/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/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 0d8f6e3..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()); @@ -363,6 +368,278 @@ 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"); + } + + [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, 0x2D })] + [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(); + } + + /// 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() + { + 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 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() + { + 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..f0ae1af 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,36 +40,293 @@ 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); + 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); 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 = 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, 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_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 = 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, 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 = 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, 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(); + } + + [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]; + // 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_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() + { + 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() { @@ -167,7 +425,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[] { @@ -184,4 +443,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/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs index b3cdbfc..2b52ed2 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,75 @@ 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 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..55d1372 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs @@ -0,0 +1,138 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Moq; +using SeaHaven.DataServices.Dto; +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() + { + // 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; + + 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(sharedDispatch); + 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()); + // SH-327: the requester must hold RequestUplifts before the create reaches the insert. + var permissionData = new Mock(); + permissionData.Setup(x => x.GetUserAsync("dispatcher-1", It.IsAny())) + .ReturnsAsync(new TeamPermissionUserData { UserId = "dispatcher-1", RoleName = "Dispatcher" }); + + var service = new WorkOrderUpliftService( + upliftData.Object, + dispatchData.Object, + detailData.Object, + accountResolver.Object, + userData.Object, + permissionData.Object, + new TeamPermissionPolicy(), + 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..41d024c 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -4,6 +4,8 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers @@ -217,6 +219,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" }); @@ -294,7 +300,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/WorkOrderDetailController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs index d975b32..edbafbc 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs @@ -164,13 +164,20 @@ 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") }); } 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) { diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index 17b3ba3..32122ca 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,14 +88,28 @@ namespace Api.SeaHavenIndustries.Controllers string? fileUrl = null; try { - if (file.Length > MaxUploadBytes) + // Type first, so an unsupported file always reports UnsupportedMediaType instead + // of being sized under a kind it does not have. + WorkOrderMediaFileRules.EnsureAllowed(file, category); + + // 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); + if (sizeMessage != null) { - throw new WorkOrderBoardValidationException( - "FileTooLarge", - "The uploaded file must not exceed 200 MB."); + throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage); } - 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/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/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs index 031cf01..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; } @@ -100,6 +103,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 +132,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/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/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 1d72cf5..09566f3 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -2,6 +2,8 @@ using System.Collections.Concurrent; 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 @@ -108,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, @@ -127,16 +130,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 @@ -152,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, @@ -192,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, @@ -248,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, @@ -336,6 +334,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) @@ -382,25 +399,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); @@ -418,17 +424,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(); } - // 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 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 @@ -436,7 +445,8 @@ 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; + .Select(UpliftAmount.ForQuery) + .SumAsync(cancellationToken); } public Task> GetPendingForWorkOrderAsync( @@ -525,7 +535,58 @@ 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 + && IsActiveDispatchIndexViolationMessage(databaseException.Message); + } + + 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; + } + + internal static bool IsActiveDispatchIndexViolationMessage(string message) + { + const string activeDispatchIndex = "'IX_DispatchUpliftRequests_DispatchId'"; + return message.Contains(activeDispatchIndex, StringComparison.OrdinalIgnoreCase); } public async Task ExecuteWorkOrderMutationAsync( 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/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 fd05a45..2d226e6 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs @@ -56,6 +56,36 @@ 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 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/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs index 26c968c..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); @@ -19,6 +18,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.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.DataServices/Interfaces/IVendorDocumentDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs index b56948c..46a4274 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. 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 (photo/video 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 57cbbf3..144a521 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs @@ -22,6 +22,19 @@ 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 (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). The 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.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/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/DTOs/VendorPortalServiceDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs index afdaf18..8ddf071 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; } @@ -134,6 +136,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; } + } + + /// + /// 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/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/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/Helpers/VideoDurationProbe.cs b/SeaHaven.Services/Helpers/VideoDurationProbe.cs new file mode 100644 index 0000000..1818c50 --- /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 (per the media contract: 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 new file mode 100644 index 0000000..46865bf --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -0,0 +1,149 @@ +namespace SeaHaven.Services.Helpers +{ + /// + /// 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 + /// 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 + { + 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 + } + + 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); + return KindByExtension.TryGetValue(extension, out var byExtension) + ? byExtension + : 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 (photos, videos) = CountKinds(attachmentUrls, vendorContentTypes); + + if (kind == UploadKind.Photo && photos >= MaxPhotosPerWorkOrder) + return "A work order can have at most 10 photos."; + 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. + /// + 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) + { + 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 => + "Files 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..b4db302 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) @@ -61,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) @@ -68,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) @@ -123,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."); } } @@ -139,6 +156,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 +228,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/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/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/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/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/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 5459c36..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; @@ -36,7 +37,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 { @@ -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; @@ -166,13 +172,13 @@ 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}", - NewValue = $"${req.RequestedNTE:F2}", + NewValue = $"${dispatch.NTEAmount:F2}", Action = "uplift_approved", CreatedAt = now }, cancellationToken); @@ -252,9 +258,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 +296,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 +313,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; @@ -335,8 +354,12 @@ namespace SeaHaven.Services.Implementation return UpliftEvidenceDownloadResultDTO.Ok(content, evidence.ContentType ?? "application/octet-stream", evidence.OriginalFileName ?? "evidence"); } + // 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) { diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 5fc5140..d0f14d1 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -4,6 +4,8 @@ using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation @@ -12,9 +14,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; @@ -106,6 +135,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, @@ -178,8 +208,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, @@ -651,7 +710,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). @@ -686,7 +752,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)) { @@ -728,7 +796,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) { @@ -820,15 +890,31 @@ namespace SeaHaven.Services.Implementation throw new InvalidOperationException("A completion document file is required."); } - if (file.Length > _documentOptions.MaxSizeBytes) + // 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) { - 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)) + // 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) { - 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,11 +937,21 @@ 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."); } + 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) { @@ -882,50 +978,80 @@ namespace SeaHaven.Services.Implementation "The completion document could not be replaced."); } } + 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); + // 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); @@ -984,35 +1110,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/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/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/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index 0b0e38c..d2c266d 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,23 +156,34 @@ namespace SeaHaven.Services.Implementation }; } - var attachment = new WorkOrderAttachments - { - WorkorderId = workOrderId, - Attachments = fileUrl, - Category = category.HasValue ? resolvedCategory : null, - CreatedDate = DateTime.UtcNow, - createdby = actorId - }; - - _mediaData.TrackAttachment(attachment); - await _auditService.StageFieldChangedAsync( + // 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 { @@ -348,6 +362,29 @@ namespace SeaHaven.Services.Implementation throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); } + /// + /// 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. + /// + 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 vendorTypes = await _mediaData.ListActiveVendorMediaContentTypesAsync(workOrderId, cancellationToken); + var countMessage = WorkOrderMediaContract.ValidateCount(kind, urls, vendorTypes); + if (countMessage != null) + throw new WorkOrderBoardValidationException("MediaCountExceeded", countMessage); + } + /// /// Account filter for data queries. Null means org-wide (skip ApplyAccountScope). /// Call only after EnsureCan* has verified scope is not Missing. @@ -432,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/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 149aeaa..0980e0d 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"); @@ -69,17 +78,49 @@ 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 EnsureCanRequestUpliftAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + var permissionUser = string.IsNullOrWhiteSpace(userId) + ? null + : await _permissionOverrideData.GetUserAsync(userId, cancellationToken); + + if (permissionUser is null) + { + throw new UpliftForbiddenException( + UpliftForbiddenException.RequestUpliftsDeniedMessage); + } + + if (!_permissionPolicy.IsAllowed( + permissionUser.RoleName, + TeamPermissionKeys.RequestUplifts, + permissionUser.Overrides)) + { + throw new UpliftForbiddenException( + UpliftForbiddenException.RequestUpliftsDeniedMessage); + } } private async Task CreateLockedAsync( @@ -104,24 +145,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/TeamMemberCreateTransactionTests.cs b/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs new file mode 100644 index 0000000..8c5e2bc --- /dev/null +++ b/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs @@ -0,0 +1,230 @@ +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.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; + +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 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(); + services.AddDbContext(builder => builder.UseSqlite(connection)); + services.Replace(ServiceDescriptor.Scoped(provider => + new SqliteTeamMemberTestDbContext( + provider.GetRequiredService>()))); + services.AddIdentity() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + services.AddDataServices(); + services.AddBusinessServices(configuration); + + if (injectFailure) + { + services.AddScoped(); + services.AddScoped(provider => + new ThrowingAfterPersistPermissionDataService( + provider.GetRequiredService(), + provider.GetRequiredService())); + } + + return services.BuildServiceProvider(); + } + + 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; + private readonly ApplicationDbContext _context; + + 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); + + 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); + 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"); + } + + public Task ClearOverridesAsync(string userId, CancellationToken cancellationToken) => + _inner.ClearOverridesAsync(userId, cancellationToken); + } + +} diff --git a/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs b/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs new file mode 100644 index 0000000..43220e1 --- /dev/null +++ b/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs @@ -0,0 +1,336 @@ +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 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); + } + + [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() + { + // 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) + { + } + } +} diff --git a/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs b/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs index f585766..2825fcd 100644 --- a/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs +++ b/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs @@ -9,6 +9,117 @@ 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() + { + 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() { diff --git a/SeaHavenIndustries.Tests/UpliftDecisionAuditRelationalTests.cs b/SeaHavenIndustries.Tests/UpliftDecisionAuditRelationalTests.cs new file mode 100644 index 0000000..fb07d09 --- /dev/null +++ b/SeaHavenIndustries.Tests/UpliftDecisionAuditRelationalTests.cs @@ -0,0 +1,332 @@ +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); + } + + [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? initialNotificationSentAt = null) + { + 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, + InitialNotificationSentAt = initialNotificationSentAt, + NotificationStatus = initialNotificationSentAt == null ? "Pending" : "Sent" + }; + 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(); + } + + // Records sends without delivering anything. + private sealed class NoEmailSender : IEmailSender + { + public int Calls; + + public Task SendEmailAsync(string emailTo, string subject, string body) + { + Calls++; + return 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; + } + } + } +} 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; + } +} 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() { diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index e797a68..46e86fc 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; @@ -203,6 +204,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 +267,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( @@ -300,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 { @@ -332,6 +344,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 +359,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( @@ -431,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(); @@ -442,6 +457,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/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) 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() { 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 cd24883..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; @@ -812,7 +814,8 @@ public class WorkOrderMediaServiceTests new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), audit, - fileStorage ?? new TestFileStoragePort()); + fileStorage ?? new TestFileStoragePort(), + new UpliftDataService(context)); return (context, service); } @@ -1309,6 +1312,410 @@ 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_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() + { + // 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_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() + { + 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() { @@ -1968,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/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs new file mode 100644 index 0000000..adba1a7 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs @@ -0,0 +1,352 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Identity; +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" }); + // 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; + } + + 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), + new TeamPermissionOverrideDataService(context), + new TeamPermissionPolicy(), + 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..390beaf 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -1,9 +1,11 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Identity; 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 +39,8 @@ public sealed class WorkOrderUpliftServiceTests new WorkOrderDetailDataService(context), WorkOrderAccountTestHelpers.Resolver(context), new UserDataService(context), + new TeamPermissionOverrideDataService(context), + new TeamPermissionPolicy(), TimeProvider.System, Options.Create(NewOptions())); } @@ -47,6 +51,7 @@ public sealed class WorkOrderUpliftServiceTests private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync( ApplicationDbContext context, WorkOrderType type = WorkOrderType.PM, + string role = "Admin", int? primaryDispatchId = 10) { await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); @@ -57,6 +62,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 { @@ -124,6 +136,22 @@ public sealed class WorkOrderUpliftServiceTests Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); } + [Fact] + public async Task CreateAsync_CurrentDatabaseAdmin_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 }, + WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, "Scheduler"), + CancellationToken.None); + + Assert.NotNull(created); + } + [Fact] public async Task CreateAsync_WithoutPrimaryDispatch_UsesWorkOrderDispatch() { @@ -174,6 +202,109 @@ public sealed class WorkOrderUpliftServiceTests Assert.Equal(workOrder.Id, audit.WorkOrderId); } + [Fact] + public async Task CreateAsync_StaleAdminClaim_DeniesWhenDatabaseRoleIsNotAdmin() + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: "Scheduler"); + 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.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); + + 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, role: role); + 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, role: role); + 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() { @@ -289,7 +420,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, 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..83fcc9e 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. +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 ' ')"