diff --git a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs index d5a031e..abad676 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs @@ -210,4 +210,79 @@ public class UpliftControllerTests var envelope = ok.Value.Should().BeOfType().Subject; envelope.Status.Should().Be("Success"); } + + [Fact] + public async Task DownloadEvidence_NotFound_Returns404() + { + var service = new Mock(); + service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny(), 99, It.IsAny())) + .ReturnsAsync(UpliftEvidenceDownloadResultDTO.NotFound()); + var controller = NewController(service); + + var result = await controller.DownloadEvidence(99); + + result.Should().BeOfType(); + } + + [Fact] + public async Task DownloadEvidence_Locked_Returns423() + { + var service = new Mock(); + service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny(), 5, It.IsAny())) + .ReturnsAsync(UpliftEvidenceDownloadResultDTO.Locked()); + var controller = NewController(service); + + var result = await controller.DownloadEvidence(5); + + var status = result.Should().BeOfType().Subject; + status.StatusCode.Should().Be(StatusCodes.Status423Locked); + var response = status.Value.Should().BeOfType().Subject; + response.Message.Should().NotContain("scan", "the locked message must not disclose internal state"); + } + + [Fact] + public async Task DownloadEvidence_Available_ReturnsFile() + { + var service = new Mock(); + service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny(), 5, It.IsAny())) + .ReturnsAsync(UpliftEvidenceDownloadResultDTO.Ok(new MemoryStream(new byte[] { 1, 2, 3 }), "application/pdf", "invoice.pdf")); + var controller = NewController(service); + + var result = await controller.DownloadEvidence(5); + + result.Should().BeOfType(); + } + + [Fact] + public async Task DownloadEvidence_Forbidden_ReturnsSanitized403() + { + var service = new Mock(); + service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny(), 5, It.IsAny())) + .ThrowsAsync(new UpliftForbiddenException("SECRET-role-policy")); + var controller = NewController(service); + + var result = await controller.DownloadEvidence(5); + + var forbidden = result.Should().BeOfType().Subject; + forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + var response = forbidden.Value.Should().BeOfType().Subject; + response.Message.Should().NotContain("SECRET"); + response.Message.Should().Contain("reference"); + } + + [Fact] + public async Task RequestChanges_InvalidOperation_ReturnsSanitized400() + { + var service = new Mock(); + service.Setup(x => x.RequestChangesAsync(It.IsAny(), 5, "note", It.IsAny())) + .ThrowsAsync(new InvalidOperationException("SECRET-internal-state")); + var controller = NewController(service); + + var result = await controller.RequestChanges(5, new UpliftController.DecisionRequest { Note = "note" }); + + var bad = result.Should().BeOfType().Subject; + var resp = bad.Value.Should().BeOfType().Subject; + resp.Message.Should().NotContain("SECRET"); + resp.Message.Should().Contain("reference"); + } } diff --git a/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs b/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs index 9af6136..9fa989c 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs @@ -6,6 +6,7 @@ using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; using Xunit; namespace Api.SeaHavenIndustries.Tests; @@ -38,6 +39,8 @@ public sealed class UpliftServiceRefusedTests var service = new UpliftService( upliftData.Object, dispatchData.Object, + Mock.Of(), + TimeProvider.System, Microsoft.Extensions.Options.Options.Create( new ApprovalsOptions { Tier1Roles = new[] { "UpliftApprover" } })); var user = new ClaimsPrincipal(new ClaimsIdentity( diff --git a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs new file mode 100644 index 0000000..9b3f220 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs @@ -0,0 +1,887 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; +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; + +// Repository-owned behavior tests for the SH-101 uplift approval workflow. These exercise +// the real service + data-service layers against an in-memory DbContext so that state +// transitions, audit correctness, idempotency, scoping, and the internal evidence +// download are validated without recreating infrastructure or depending on SQL Server. +public sealed class UpliftWorkflowTests +{ + private const string Token = "uplift-workflow-token"; + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static ApprovalsOptions NewOptions() => new() + { + UpliftTier1MaxUsd = 2500m, + Tier1Roles = new[] { "Approver" }, + Tier2Roles = new[] { "Manager" }, + Tier1NotificationRecipients = new[] { "tier1@example.com" }, + Tier2NotificationRecipients = new[] { "tier2@example.com" }, + EscalationRecipients = new[] { "escalate@example.com" } + }; + + 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 sealed class FakeDocumentStorage : IVendorDocumentStoragePort + { + private readonly Dictionary _files = new(StringComparer.Ordinal); + + public Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken) + { + using var ms = new MemoryStream(); + content.CopyTo(ms); + _files[Key(vendorId, dispatchId, storedFileName)] = ms.ToArray(); + return Task.CompletedTask; + } + + public Stream OpenRead(int vendorId, int dispatchId, string storedFileName) + { + var bytes = _files[Key(vendorId, dispatchId, storedFileName)]; + return new MemoryStream(bytes, writable: false); + } + + public void Delete(int vendorId, int dispatchId, string storedFileName) + => _files.Remove(Key(vendorId, dispatchId, storedFileName)); + + private static string Key(int vendorId, int dispatchId, string storedFileName) + => $"{vendorId}/{dispatchId}/{storedFileName}"; + } + + private sealed class FakeEmailSender : IEmailSender + { + private readonly bool _deliver; + public int Calls; + public FakeEmailSender(bool deliver) => _deliver = deliver; + public Task SendEmailAsync(string emailTo, string subject, string body) + { + Calls++; + return Task.FromResult(_deliver); + } + } + + private static async Task<(Vendor Vendor, WorkOrder WorkOrder, Dispatch Dispatch)> SeedAsync( + ApplicationDbContext context, string status = "Completed", decimal? nte = 1000m) + { + 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 = status, + NTEAmount = nte, + DispatchNumber = "DIS-1" + }; + context.Dispatches.Add(dispatch); + context.VendorAccessTokens.Add(new VendorAccessToken + { + VendorId = vendor.Id, + Token = Token, + IssuedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddDays(1) + }); + await context.SaveChangesAsync(); + return (vendor, workOrder, dispatch); + } + + private static VendorCompletionDocument EvidenceDocument( + int vendorId, int dispatchId, int workOrderId, string scanStatus = "Passed", string purpose = "UpliftEvidence") => new() + { + VendorId = vendorId, + DispatchId = dispatchId, + WorkOrderId = workOrderId, + OriginalFileName = "invoice.pdf", + StoredFileName = "evidence.bin", + ContentType = "application/pdf", + SizeBytes = 4, + ScanStatus = scanStatus, + ReviewStatus = scanStatus == "Passed" ? "Approved" : "Processing", + Purpose = purpose, + Version = 1 + }; + + private static UpliftService NewUpliftService( + ApplicationDbContext context, IVendorDocumentStoragePort storage) => + new(new UpliftDataService(context), + new DispatchDataService(context), + storage, + TimeProvider.System, + Microsoft.Extensions.Options.Options.Create(NewOptions())); + + private static VendorPortalService NewPortalService( + ApplicationDbContext context, + IEmailSender emailSender, + IVendorDocumentStoragePort? storage = null) + { + var vendorData = new VendorDataService(context); + var tokenService = new VendorPortalTokenService(vendorData, Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions())); + storage ??= new FakeDocumentStorage(); + var commentData = new Mock(); + commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + return new VendorPortalService( + tokenService, + new DispatchDataService(context), + new UpliftDataService(context), + commentData.Object, + Mock.Of(), + emailSender, + new VendorDocumentDataService(context), + storage, + Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), + Microsoft.Extensions.Options.Options.Create(NewOptions()), + Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions()), + TimeProvider.System); + } + + // --- NoApprovalRequired: no mutation, no request row, audit-only --- + + [Fact] + public async Task RequestUplift_NoApprovalRequired_WhenRequestedNteAtOrBelowCurrent_DoesNotMutate() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + var service = NewPortalService(context, new FakeEmailSender(deliver: true)); + + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var result = await service.RequestUpliftAsync(session!, dispatch.Id, 1000m, "ignored", null, null, CancellationToken.None); + + result.NoApprovalRequired.Should().BeTrue(); + result.Id.Should().Be(0); + result.Status.Should().Be(UpliftStatus.NoApprovalRequired); + context.DispatchUpliftRequests.Should().BeEmpty(); + context.Dispatches.Single().NTEAmount.Should().Be(1000m); + context.WorkOrderAuditLogs.Should().ContainSingle(a => a.Action == "uplift_no_approval_required"); + } + + // --- Tier edge --- + + [Fact] + public async Task RequestUplift_Tier1_WhenDeltaEqualsTier1Max() + { + 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 result = await service.RequestUpliftAsync(session!, dispatch.Id, 3500m, "reason", null, 1, CancellationToken.None); + + result.RequiredTier.Should().Be(1, "delta 2500 == tier1 max is still tier 1"); + } + + [Fact] + public async Task RequestUplift_Tier2_WhenDeltaExceedsTier1Max() + { + 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 result = await service.RequestUpliftAsync(session!, dispatch.Id, 3500.01m, "reason", null, 1, CancellationToken.None); + + result.RequiredTier.Should().Be(2); + } + + // --- Evidence scan + cross-vendor/dispatch scoping --- + + [Fact] + public async Task RequestUplift_EvidenceScanNotPassed_Throws() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id, scanStatus: "Pending")); + await context.SaveChangesAsync(); + var service = NewPortalService(context, new FakeEmailSender(deliver: true)); + + 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(); + context.DispatchUpliftRequests.Should().BeEmpty(); + } + + [Fact] + public async Task RequestUplift_EvidenceFromAnotherVendor_Throws() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + var otherVendor = new Vendor { CompanyName = "Other", IsActive = true }; + var otherWorkOrder = new WorkOrder { WorkerOrderTitle = "Other" }; + context.AddRange(otherVendor, otherWorkOrder); + await context.SaveChangesAsync(); + var otherDispatch = new Dispatch { VendorId = otherVendor.Id, WorkOrderId = otherWorkOrder.Id, Status = "Completed" }; + context.Dispatches.Add(otherDispatch); + context.VendorCompletionDocuments.Add(EvidenceDocument(otherVendor.Id, otherDispatch.Id, otherWorkOrder.Id)); + await context.SaveChangesAsync(); + var service = NewPortalService(context, new FakeEmailSender(deliver: true)); + + 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(); + } + + // --- RequestKey identical replay / mismatch (incl. EvidenceDocumentId) --- + + [Fact] + public async Task RequestUplift_RequestKey_IdenticalReplay_ReturnsSameRequest() + { + 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 first = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "need parts", "key-1", 1, CancellationToken.None); + var replay = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "need parts", "key-1", 1, CancellationToken.None); + + replay.Id.Should().Be(first.Id); + replay.IdempotentReplay.Should().BeTrue(); + context.DispatchUpliftRequests.Should().ContainSingle(); + } + + [Fact] + public async Task RequestUplift_RequestKey_MismatchedEvidence_Throws() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + context.VendorCompletionDocuments.AddRange( + EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id), + new VendorCompletionDocument + { + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = workOrder.Id, + OriginalFileName = "second.pdf", + StoredFileName = "second.bin", + ContentType = "application/pdf", + SizeBytes = 4, + ScanStatus = "Passed", + ReviewStatus = "Approved", + Purpose = "UpliftEvidence", + Version = 2 + }); + await context.SaveChangesAsync(); + var firstEvidenceId = 1; + var secondEvidenceId = 2; + var service = NewPortalService(context, new FakeEmailSender(deliver: true)); + + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "need parts", "key-1", firstEvidenceId, CancellationToken.None); + var act = () => service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "need parts", "key-1", secondEvidenceId, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RequestUplift_RequestKey_MismatchedAmount_Throws() + { + 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); + await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "need parts", "key-1", 1, CancellationToken.None); + var act = () => service.RequestUpliftAsync(session!, dispatch.Id, 1600m, "need parts", "key-1", 1, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + // --- Notification error is separate from workflow state --- + + [Fact] + public async Task RequestUplift_NotificationDeliveryFailure_LeavesRequestPendingButRecordsError() + { + 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: false)); + + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var result = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "need parts", null, 1, CancellationToken.None); + + var req = context.DispatchUpliftRequests.Single(); + req.Status.Should().Be(UpliftStatus.Pending); + req.NotificationStatus.Should().Be(UpliftNotificationStatus.Error); + result.Status.Should().Be(UpliftStatus.Pending); + result.Id.Should().NotBe(0); + } + + // --- SH-98 terminal dispatch guard (Refused) --- + + [Fact] + public async Task RequestUplift_RefusedDispatch_Throws() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, status: "Refused", nte: 1000m); + var service = NewPortalService(context, new FakeEmailSender(deliver: true)); + + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var act = () => service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, null, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + // --- revise / withdraw / request-changes state transitions --- + + [Fact] + public async Task Revise_OnChangesRequested_ReturnsToPending_AndResetsDecision() + { + 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); + + var req = context.DispatchUpliftRequests.Single(); + req.Status = UpliftStatus.ChangesRequested; + req.DecisionNote = "lower please"; + req.DecidedAt = DateTime.UtcNow; + await context.SaveChangesAsync(); + + var revised = await service.ReviseUpliftAsync(session!, dispatch.Id, created.Id, 1700m, "revised reason", 1, CancellationToken.None); + + revised.Status.Should().Be(UpliftStatus.Pending); + var after = context.DispatchUpliftRequests.Single(); + after.Status.Should().Be(UpliftStatus.Pending); + after.DecisionNote.Should().BeNull(); + after.DecidedAt.Should().BeNull(); + after.RequestedNTE.Should().Be(1700m); + context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_revised"); + } + + [Fact] + public async Task Withdraw_Pending_TransitionsToWithdrawn_SetsDecidedAt() + { + 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); + var beforeDecidedAt = context.DispatchUpliftRequests.Single().DecidedAt; + + var result = await service.WithdrawUpliftAsync(session!, dispatch.Id, created.Id, CancellationToken.None); + + result.Status.Should().Be(UpliftStatus.Withdrawn); + var after = context.DispatchUpliftRequests.Single(); + after.Status.Should().Be(UpliftStatus.Withdrawn); + after.DecidedAt.Should().NotBeNull(); + context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_withdraw" && a.OldValue == UpliftStatus.Pending); + } + + [Fact] + public async Task Withdraw_AlreadyApproved_Throws() + { + 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.Approved; + await context.SaveChangesAsync(); + + var act = () => service.WithdrawUpliftAsync(session!, dispatch.Id, created.Id, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RequestChanges_Pending_TransitionsToChangesRequested_AuditsOldValue() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 1000m, + RequestedNTE = 1500m, + VendorReason = "reason", + Status = UpliftStatus.Pending, + RequiredTier = 1, + CreatedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + var service = NewUpliftService(context, new FakeDocumentStorage()); + + var result = await service.RequestChangesAsync(UserWithRoles("Approver"), 1, "provide quote", CancellationToken.None); + + result.Status.Should().Be(UpliftStatus.ChangesRequested); + context.WorkOrderAuditLogs.Should().Contain(a => + a.Action == "uplift_changes_requested" && a.OldValue == UpliftStatus.Pending && a.NewValue == UpliftStatus.ChangesRequested); + } + + // --- Fix 1: reject/deny audit OldValue captures the pre-transition canonical status --- + + [Fact] + public async Task Reject_Pending_AuditOldValueIsPending_NotRejected() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 1000m, + RequestedNTE = 1500m, + VendorReason = "reason", + Status = UpliftStatus.Pending, + RequiredTier = 1, + CreatedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + var service = NewUpliftService(context, new FakeDocumentStorage()); + + await service.RejectAsync(UserWithRoles("Approver"), 1, "too high", CancellationToken.None); + + context.WorkOrderAuditLogs.Should().Contain(a => + a.Action == "uplift_reject" + && a.OldValue == UpliftStatus.Pending + && a.NewValue == UpliftStatus.Rejected); + } + + [Fact] + public async Task Deny_Pending_AuditOldValueIsPending_NotRejected() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 1000m, + RequestedNTE = 1500m, + VendorReason = "reason", + Status = UpliftStatus.Pending, + RequiredTier = 1, + CreatedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + var service = NewUpliftService(context, new FakeDocumentStorage()); + + await service.DenyAsync(UserWithRoles("Approver"), 1, "too high", CancellationToken.None); + + context.WorkOrderAuditLogs.Should().Contain(a => + a.Action == "uplift_deny" + && a.OldValue == UpliftStatus.Pending + && a.NewValue == UpliftStatus.Rejected); + } + + [Fact] + public async Task Approve_Pending_UpdatesNte_AndTransitionsToApproved() + { + using var context = NewContext(); + var (_, _, dispatch) = await SeedAsync(context, nte: 1000m); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 1000m, + RequestedNTE = 1800m, + VendorReason = "reason", + Status = UpliftStatus.Pending, + RequiredTier = 1, + CreatedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + var service = NewUpliftService(context, new FakeDocumentStorage()); + + var result = await service.ApproveAsync(UserWithRoles("Approver"), 1, "ok", CancellationToken.None); + + result.Status.Should().Be(UpliftStatus.Approved); + context.Dispatches.Single().NTEAmount.Should().Be(1800m); + context.DispatchUpliftRequests.Single().DecidedAt.Should().NotBeNull(); + } + + [Fact] + public async Task Approve_RefusedDispatch_Throws() + { + using var context = NewContext(); + var (_, _, dispatch) = await SeedAsync(context, status: "Refused", nte: 1000m); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 1000m, + RequestedNTE = 1800m, + VendorReason = "reason", + Status = UpliftStatus.Pending, + RequiredTier = 1, + CreatedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + var service = NewUpliftService(context, new FakeDocumentStorage()); + + var act = () => service.ApproveAsync(UserWithRoles("Approver"), 1, "ok", CancellationToken.None); + + await act.Should().ThrowAsync(); + context.Dispatches.Single().NTEAmount.Should().Be(1000m); + } + + // --- Fix 6: expiry sets DecidedAt and preserves auditable transition --- + + [Fact] + public async Task ExpireDue_SetsStatusExpired_AndDecidedAt_AndAuditsTransition() + { + using var context = NewContext(); + var (_, _, dispatch) = await SeedAsync(context, status: "In Progress", nte: 1000m); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 1000m, + RequestedNTE = 1500m, + VendorReason = "reason", + Status = UpliftStatus.Pending, + RequiredTier = 1, + ExpiresAt = DateTime.UtcNow.AddHours(-1), + CreatedDate = DateTime.UtcNow.AddHours(-2) + }); + await context.SaveChangesAsync(); + var lifecycle = new UpliftLifecycleService( + new UpliftDataService(context), + new DispatchDataService(context), + Mock.Of(), + new FakeEmailSender(deliver: true), + Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), + Microsoft.Extensions.Options.Options.Create(NewOptions()), + TimeProvider.System, + Mock.Of>()); + + var expired = await lifecycle.ExpireDueAsync(CancellationToken.None); + + expired.Should().Be(1); + var req = context.DispatchUpliftRequests.Single(); + req.Status.Should().Be(UpliftStatus.Expired); + req.DecidedAt.Should().NotBeNull(); + context.WorkOrderAuditLogs.Should().Contain(a => + a.Action == "uplift_expired" && a.OldValue == UpliftStatus.Pending && a.NewValue == UpliftStatus.Expired); + } + + [Fact] + public async Task ExpireDue_DoesNotTransitionTerminalRequests() + { + using var context = NewContext(); + var (_, _, dispatch) = await SeedAsync(context, status: "In Progress", nte: 1000m); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 1000m, + RequestedNTE = 1500m, + VendorReason = "reason", + Status = UpliftStatus.Approved, + RequiredTier = 1, + ExpiresAt = DateTime.UtcNow.AddHours(-1), + CreatedDate = DateTime.UtcNow.AddHours(-2) + }); + await context.SaveChangesAsync(); + var lifecycle = new UpliftLifecycleService( + new UpliftDataService(context), + new DispatchDataService(context), + Mock.Of(), + new FakeEmailSender(deliver: true), + Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), + Microsoft.Extensions.Options.Options.Create(NewOptions()), + TimeProvider.System, + Mock.Of>()); + + var expired = await lifecycle.ExpireDueAsync(CancellationToken.None); + + expired.Should().Be(0); + context.DispatchUpliftRequests.Single().Status.Should().Be(UpliftStatus.Approved); + } + + // --- SH-101: orphan dispatch (deleted/unresolvable) must be skipped, not mutated/audited/saved --- + + [Fact] + public async Task ExpireDue_OrphanDispatchIsSkipped_ValidRequestStillExpiresAndAuditsOnce() + { + var orphanReq = new DispatchUpliftRequest + { + Id = 101, + DispatchId = 404, + Status = UpliftStatus.Pending, + RequiredTier = 1 + }; + var validReq = new DispatchUpliftRequest + { + Id = 202, + DispatchId = 7, + Status = UpliftStatus.Pending, + RequiredTier = 1 + }; + + var upliftData = new Mock(); + upliftData.Setup(u => u.GetDueForExpiryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { orphanReq, validReq }); + upliftData.Setup(u => u.SaveChangesAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + var dispatch = new Dispatch { Id = 7, DispatchNumber = "DIS-7", WorkOrderId = 99 }; + var dispatchData = new Mock(); + dispatchData.Setup(d => d.GetByIdAsync(404)).ReturnsAsync((Dispatch?)null); + dispatchData.Setup(d => d.GetByIdAsync(7)).ReturnsAsync(dispatch); + dispatchData.Setup(d => d.StageAuditLogAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var lifecycle = new UpliftLifecycleService( + upliftData.Object, + dispatchData.Object, + Mock.Of(), + new FakeEmailSender(deliver: true), + Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), + Microsoft.Extensions.Options.Options.Create(NewOptions()), + TimeProvider.System, + Mock.Of>()); + + var expired = await lifecycle.ExpireDueAsync(CancellationToken.None); + + expired.Should().Be(1); + orphanReq.Status.Should().Be(UpliftStatus.Pending, "orphan must not be mutated"); + orphanReq.DecidedAt.Should().BeNull("orphan must not be mutated"); + validReq.Status.Should().Be(UpliftStatus.Expired, "valid request must expire"); + validReq.DecidedAt.Should().NotBeNull("valid request must be decided"); + dispatchData.Verify(d => d.StageAuditLogAsync(It.IsAny(), It.IsAny()), Times.Once, "only the valid request stages an audit"); + upliftData.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once, "only the valid request saves"); + } + + // --- Escalation is once-per-request (dedup) --- + + [Fact] + public async Task EscalateDue_IsOncePerRequest_SecondRunIsNoop() + { + using var context = NewContext(); + var (_, _, dispatch) = await SeedAsync(context, status: "In Progress", nte: 1000m); + var sentAt = DateTime.UtcNow.AddHours(-3); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 1000m, + RequestedNTE = 1500m, + VendorReason = "reason", + Status = UpliftStatus.Pending, + RequiredTier = 1, + InitialNotificationSentAt = sentAt, + CreatedDate = DateTime.UtcNow.AddHours(-4) + }); + await context.SaveChangesAsync(); + var lifecycle = new UpliftLifecycleService( + new UpliftDataService(context), + new DispatchDataService(context), + Mock.Of(), + new FakeEmailSender(deliver: true), + Microsoft.Extensions.Options.Options.Create(new FrontendOptions { FrontendBaseUrl = "https://shoc.test" }), + Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions + { + EscalationAfterHours = 1, + EscalationRecipients = new[] { "escalate@example.com" } + }), + TimeProvider.System, + Mock.Of>()); + + var first = await lifecycle.EscalateDueAsync(CancellationToken.None); + var second = await lifecycle.EscalateDueAsync(CancellationToken.None); + + first.Should().Be(1); + second.Should().Be(0); + context.DispatchUpliftRequests.Single().EscalatedAt.Should().NotBeNull(); + context.WorkOrderAuditLogs.Should().ContainSingle(a => a.Action == "uplift_escalated"); + } + + // --- Internal evidence download security --- + + [Fact] + public async Task GetEvidenceForDownload_ReturnsOk_WhenPassedUpliftEvidence() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + var storage = new FakeDocumentStorage(); + await storage.SaveAsync(vendor.Id, dispatch.Id, "evidence.bin", new MemoryStream("%PDF"u8.ToArray()), CancellationToken.None); + context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 1000m, + RequestedNTE = 1500m, + VendorReason = "reason", + Status = UpliftStatus.Pending, + RequiredTier = 1, + EvidenceDocumentId = 1, + CreatedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + var service = NewUpliftService(context, storage); + + var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None); + + result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok); + result.ContentType.Should().Be("application/pdf"); + result.FileName.Should().Be("invoice.pdf"); + } + + [Fact] + public async Task GetEvidenceForDownload_ThrowsForbidden_WhenUserLacksRequiredTier() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 1000m, + RequestedNTE = 4000m, + VendorReason = "reason", + Status = UpliftStatus.Pending, + RequiredTier = 2, + EvidenceDocumentId = 1, + CreatedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + var service = NewUpliftService(context, new FakeDocumentStorage()); + + var act = () => service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetEvidenceForDownload_ReturnsNotFound_WhenNoLinkedEvidence() + { + using var context = NewContext(); + var (_, _, dispatch) = await SeedAsync(context, nte: 1000m); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 1000m, + RequestedNTE = 1500m, + VendorReason = "reason", + Status = UpliftStatus.Pending, + RequiredTier = 1, + EvidenceDocumentId = null, + CreatedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + var service = NewUpliftService(context, new FakeDocumentStorage()); + + var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None); + + result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound); + } + + [Fact] + public async Task GetEvidenceForDownload_ReturnsLocked_WhenScanNotPassed() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id, scanStatus: "Pending")); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 1000m, + RequestedNTE = 1500m, + VendorReason = "reason", + Status = UpliftStatus.Pending, + RequiredTier = 1, + EvidenceDocumentId = 1, + CreatedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + var service = NewUpliftService(context, new FakeDocumentStorage()); + + var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None); + + result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Locked); + } + + [Fact] + public async Task GetEvidenceForDownload_ReturnsNotFound_WhenLinkedDocumentIsCompletionPurpose() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id, purpose: "Completion")); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 1000m, + RequestedNTE = 1500m, + VendorReason = "reason", + Status = UpliftStatus.Pending, + RequiredTier = 1, + EvidenceDocumentId = 1, + CreatedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + var service = NewUpliftService(context, new FakeDocumentStorage()); + + var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None); + + result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound); + } + + [Fact] + public async Task GetEvidenceForDownload_ReturnsNotFound_WhenDispatchLinkageBroken() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + var otherWorkOrder = new WorkOrder { WorkerOrderTitle = "Other" }; + context.Add(otherWorkOrder); + await context.SaveChangesAsync(); + var otherDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = otherWorkOrder.Id, Status = "Completed" }; + context.Dispatches.Add(otherDispatch); + // Evidence document belongs to a different dispatch than the uplift request. + context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, otherDispatch.Id, otherWorkOrder.Id)); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 1000m, + RequestedNTE = 1500m, + VendorReason = "reason", + Status = UpliftStatus.Pending, + RequiredTier = 1, + EvidenceDocumentId = 1, + CreatedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + var service = NewUpliftService(context, new FakeDocumentStorage()); + + var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None); + + result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs index 57d8332..dd9e40e 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs @@ -300,7 +300,7 @@ public class VendorPortalControllerTests var service = new Mock(); service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Session); - service.Setup(x => x.RequestUpliftAsync(Session, 10, 100m, null, It.IsAny())) + service.Setup(x => x.RequestUpliftAsync(Session, 10, 100m, null, It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("Requested NTE must be greater than the current NTE")); var controller = NewController(service); @@ -316,7 +316,7 @@ public class VendorPortalControllerTests service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Session); var upliftResult = new UpliftRequestResultDTO { Id = 77, Status = "Pending", RequiredTier = 1 }; - service.Setup(x => x.RequestUpliftAsync(Session, 10, 500m, "Need more parts", It.IsAny())) + service.Setup(x => x.RequestUpliftAsync(Session, 10, 500m, "Need more parts", It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(upliftResult); var controller = NewController(service); diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 9ca6a7e..0d8f6e3 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -13,6 +13,7 @@ using Moq; using SeaHaven.DataServices.Implementation; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using Xunit; @@ -68,7 +69,8 @@ public sealed class VendorPortalDocumentTests : IDisposable storage, Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()), - Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions())); + Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions()), + TimeProvider.System); } private static VendorPortalController NewController(VendorPortalService service) @@ -177,6 +179,60 @@ public sealed class VendorPortalDocumentTests : IDisposable locked.StatusCode.Should().Be(StatusCodes.Status423Locked); } + [Fact] + public async Task GetDocumentStatus_ReturnsOwnedUpliftEvidenceMetadata() + { + using var context = NewContext(); + var (vendor, dispatch) = await SeedDispatch(context); + var document = new VendorCompletionDocument + { + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = dispatch.WorkOrderId!.Value, + OriginalFileName = "estimate.pdf", + StoredFileName = "stored.pdf", + ContentType = "application/pdf", + Purpose = VendorDocumentPurpose.UpliftEvidence, + ScanStatus = "Passed", + ReviewStatus = "Accepted" + }; + context.VendorCompletionDocuments.Add(document); + await context.SaveChangesAsync(); + + var result = await NewService(context).GetDocumentStatusAsync( + new VendorPortalSession { Id = vendor.Id }, dispatch.Id, document.Id, CancellationToken.None); + + result.Should().NotBeNull(); + result!.Id.Should().Be(document.Id); + result.OriginalFileName.Should().Be("estimate.pdf"); + result.Purpose.Should().Be(VendorDocumentPurpose.UpliftEvidence); + result.ScanStatus.Should().Be("Passed"); + } + + [Fact] + public async Task GetDocumentStatus_DoesNotExposeAnotherVendorsDocument() + { + using var context = NewContext(); + var (vendor, dispatch) = await SeedDispatch(context); + var document = new VendorCompletionDocument + { + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = dispatch.WorkOrderId!.Value, + OriginalFileName = "estimate.pdf", + StoredFileName = "stored.pdf", + ContentType = "application/pdf", + Purpose = VendorDocumentPurpose.UpliftEvidence + }; + context.VendorCompletionDocuments.Add(document); + await context.SaveChangesAsync(); + + var result = await NewService(context).GetDocumentStatusAsync( + new VendorPortalSession { Id = vendor.Id + 1 }, dispatch.Id, document.Id, CancellationToken.None); + + result.Should().BeNull(); + } + [Fact] public async Task UploadCompletionDocument_AcceptsMixedCasePdfContentType() { diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalRefuseTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalRefuseTests.cs index fba8e58..793faf7 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalRefuseTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalRefuseTests.cs @@ -85,7 +85,8 @@ public sealed class VendorPortalRefuseTests Mock.Of(), Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()), - Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions())); + Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions()), + TimeProvider.System); } private static VendorPortalSession SessionFor(Vendor vendor) => new() @@ -303,7 +304,7 @@ public sealed class VendorPortalRefuseTests }; var act = () => service.UploadCompletionDocumentAsync( - SessionFor(vendor), dispatch.Id, file, null, CancellationToken.None); + SessionFor(vendor), dispatch.Id, file, null, null, CancellationToken.None); await act.Should().ThrowAsync().WithMessage("*locked*"); context.VendorCompletionDocuments.Should().BeEmpty(); diff --git a/Api.SeaHavenIndustries/Controllers/UpliftController.cs b/Api.SeaHavenIndustries/Controllers/UpliftController.cs index 7cccbb7..cc2d9ca 100644 --- a/Api.SeaHavenIndustries/Controllers/UpliftController.cs +++ b/Api.SeaHavenIndustries/Controllers/UpliftController.cs @@ -81,12 +81,81 @@ namespace Api.SeaHavenIndustries.Controllers } } + // SH-101: canonical reject route (result is Rejected); deny alias stays compatible. + [HttpPost("{id:int}/reject")] + public async Task Reject(int id, [FromBody] DecisionRequest? body, CancellationToken cancellationToken = default) + { + try + { + var result = await _upliftService.RejectAsync(User, id, body?.Note, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Uplift request not found") }); + } + catch (UpliftForbiddenException ex) + { + return StatusCode(403, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to perform this action on this uplift request") }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "This uplift request cannot be modified in its current state") }); + } + } + + // SH-101: internal request-changes route (note + tier authorization + audit). + [HttpPost("{id:int}/request-changes")] + public async Task RequestChanges(int id, [FromBody] DecisionRequest? body, CancellationToken cancellationToken = default) + { + try + { + var result = await _upliftService.RequestChangesAsync(User, id, body?.Note ?? string.Empty, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Uplift request not found") }); + } + catch (UpliftForbiddenException ex) + { + return StatusCode(403, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to perform this action on this uplift request") }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "This uplift request cannot be modified in its current state") }); + } + } + [HttpGet("can-approve")] public IActionResult CanApprove([FromQuery] int tier) { return Ok(new DataResponse { Status = "Success", Data = new { canApprove = _upliftService.CanApprove(User, tier) } }); } + // SH-101: authorized internal download of the Passed UpliftEvidence file linked to + // a specific uplift request. Server-side linkage only; no vendor/public path or + // document id is accepted from the client. 404 covers missing request/evidence and + // any non-UpliftEvidence document; 423 covers a scan that has not Passed. + [HttpGet("{id:int}/evidence")] + public async Task DownloadEvidence(int id, CancellationToken cancellationToken = default) + { + try + { + var result = await _upliftService.GetEvidenceForDownloadAsync(User, id, cancellationToken); + return result.Outcome switch + { + VendorDocumentDownloadOutcome.Ok => File(result.Content!, result.ContentType!, result.FileName!), + VendorDocumentDownloadOutcome.Locked => StatusCode(StatusCodes.Status423Locked, new Response { Status = "Locked", Message = "The uplift evidence is not available for download yet." }), + _ => NotFound(new Response { Status = "Error", Message = "Uplift evidence not found" }) + }; + } + catch (UpliftForbiddenException ex) + { + return StatusCode(403, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to view evidence for this uplift request") }); + } + } + public class DecisionRequest { public string? Note { get; set; } diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index 3642a48..39dd90b 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -214,7 +214,7 @@ namespace Api.SeaHavenIndustries.Controllers try { - var result = await _portalService.RequestUpliftAsync(session, id, body?.RequestedNTE ?? 0m, body?.Reason, cancellationToken); + 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 (KeyNotFoundException) @@ -248,6 +248,50 @@ namespace Api.SeaHavenIndustries.Controllers } } + // SH-101: vendor withdraw (canonical) endpoint. + [HttpPost("dispatches/{id:int}/uplift-request/{requestId:int}/withdraw")] + public async Task WithdrawUpliftRequest(int id, int requestId, CancellationToken cancellationToken = default) + { + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + + try + { + var result = await _portalService.WithdrawUpliftAsync(session, id, requestId, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); + } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") }); + } + } + + // SH-101: vendor revise endpoint on ChangesRequested. + [HttpPost("dispatches/{id:int}/uplift-request/{requestId:int}/revise")] + public async Task ReviseUpliftRequest(int id, int requestId, [FromBody] UpliftRequestBody? body, CancellationToken cancellationToken = default) + { + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + + try + { + var result = await _portalService.ReviseUpliftAsync(session, id, requestId, body?.RequestedNTE ?? 0m, body?.Reason, body?.EvidenceDocumentId, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); + } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") }); + } + } + [HttpPost("dispatches/{id:int}/completion-documents")] [HttpPost("dispatches/{id:int}/documents")] [RequestSizeLimit(10_000_000)] @@ -255,6 +299,7 @@ namespace Api.SeaHavenIndustries.Controllers int id, [FromForm] IFormFile file, [FromForm] int? replacesDocumentId = null, + [FromForm] string? purpose = null, CancellationToken cancellationToken = default) { var session = await ResolveSessionAsync(cancellationToken); @@ -267,6 +312,7 @@ namespace Api.SeaHavenIndustries.Controllers id, file, replacesDocumentId, + purpose, cancellationToken); return Ok(new DataResponse { Status = "Success", Data = result }); } @@ -296,6 +342,18 @@ namespace Api.SeaHavenIndustries.Controllers }; } + [HttpGet("dispatches/{id:int}/documents/{documentId:int}/status")] + public async Task GetDocumentStatus(int id, int documentId, CancellationToken cancellationToken = default) + { + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + + var result = await _portalService.GetDocumentStatusAsync(session, id, documentId, cancellationToken); + return result == null + ? NotFound(new Response { Status = "Error", Message = "Document not found" }) + : Ok(new DataResponse { Status = "Success", Data = result }); + } + private async Task ResolveSessionAsync(CancellationToken cancellationToken) { if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null; @@ -340,6 +398,9 @@ namespace Api.SeaHavenIndustries.Controllers { public decimal RequestedNTE { get; set; } public string? Reason { get; set; } + // SH-101: client idempotency key and supporting evidence. + public string? RequestKey { get; set; } + public int? EvidenceDocumentId { get; set; } } } } diff --git a/Api.SeaHavenIndustries/HostedServices/UpliftLifecycleHostedService.cs b/Api.SeaHavenIndustries/HostedServices/UpliftLifecycleHostedService.cs new file mode 100644 index 0000000..23258f1 --- /dev/null +++ b/Api.SeaHavenIndustries/HostedServices/UpliftLifecycleHostedService.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Options; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.HostedServices +{ + public class UpliftLifecycleHostedService : BackgroundService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly ApprovalsOptions _options; + + public UpliftLifecycleHostedService( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + _options = options.Value; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await SweepAsync(stoppingToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Uplift lifecycle sweep failed; will retry on next interval"); + } + + await Task.Delay(_options.EffectiveSweepInterval, stoppingToken); + } + } + + private async Task SweepAsync(CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var lifecycle = scope.ServiceProvider.GetRequiredService(); + + await lifecycle.ExpireDueAsync(cancellationToken); + await lifecycle.SendDueInitialNotificationsAsync(cancellationToken); + await lifecycle.EscalateDueAsync(cancellationToken); + } + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index d40fb01..16028f9 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -111,6 +111,7 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddOptions() .Configure, Microsoft.Extensions.Options.IOptions, diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index c8b2369..541c2d7 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -30,9 +30,18 @@ namespace Data.SeaHavenIndustries .IsUnique(); builder.Entity() - .HasIndex(u => new { u.DispatchId, u.Status }) + .HasIndex(u => u.DispatchId) .IsUnique() - .HasFilter("[Status] = 'Pending'"); + .HasFilter("[Status] IN ('Pending', 'ChangesRequested')"); + + builder.Entity() + .HasIndex(u => new { u.DispatchId, u.RequestKey }) + .IsUnique() + .HasFilter("[RequestKey] IS NOT NULL"); + + builder.Entity() + .Property(u => u.RowVersion) + .IsRowVersion(); builder.Entity() .HasIndex(l => new { l.WorkOrderId, l.FieldName }) @@ -252,6 +261,12 @@ namespace Data.SeaHavenIndustries if (entry.State == EntityState.Modified) entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion); } + + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State == EntityState.Modified) + entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion); + } } private static byte[] IncrementRowVersion(byte[]? current) diff --git a/Data.SeaHavenIndustries/Migrations/20260810183356_SH101_UpliftApprovalWorkflow.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260810183356_SH101_UpliftApprovalWorkflow.Designer.cs new file mode 100644 index 0000000..c9f0460 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260810183356_SH101_UpliftApprovalWorkflow.Designer.cs @@ -0,0 +1,3835 @@ +// +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("20260810183356_SH101_UpliftApprovalWorkflow")] + partial class SH101_UpliftApprovalWorkflow + { + /// + 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("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("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("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("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + 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("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("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.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.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.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("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("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("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("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("InternalWONumber") + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + 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("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("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("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.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() + .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.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.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.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.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.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.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + }); + + 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("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("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.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/20260810183356_SH101_UpliftApprovalWorkflow.cs b/Data.SeaHavenIndustries/Migrations/20260810183356_SH101_UpliftApprovalWorkflow.cs new file mode 100644 index 0000000..75a24ff --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260810183356_SH101_UpliftApprovalWorkflow.cs @@ -0,0 +1,170 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class SH101_UpliftApprovalWorkflow : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_DispatchUpliftRequests_DispatchId_Status", + table: "DispatchUpliftRequests"); + + migrationBuilder.AddColumn( + name: "Purpose", + table: "VendorCompletionDocuments", + type: "nvarchar(30)", + maxLength: 30, + nullable: false, + defaultValue: "Completion"); + + migrationBuilder.AddColumn( + name: "EscalatedAt", + table: "DispatchUpliftRequests", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "EvidenceDocumentId", + table: "DispatchUpliftRequests", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "ExpiresAt", + table: "DispatchUpliftRequests", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "InitialNotificationSentAt", + table: "DispatchUpliftRequests", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "NotificationError", + table: "DispatchUpliftRequests", + type: "nvarchar(500)", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "NotificationStatus", + table: "DispatchUpliftRequests", + type: "nvarchar(20)", + maxLength: 20, + nullable: false, + defaultValue: "Pending"); + + migrationBuilder.AddColumn( + name: "RequestKey", + table: "DispatchUpliftRequests", + type: "nvarchar(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.AddColumn( + name: "RowVersion", + table: "DispatchUpliftRequests", + type: "rowversion", + rowVersion: true, + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_DispatchUpliftRequests_DispatchId", + table: "DispatchUpliftRequests", + column: "DispatchId", + unique: true, + filter: "[Status] IN ('Pending', 'ChangesRequested')"); + + migrationBuilder.CreateIndex( + name: "IX_DispatchUpliftRequests_DispatchId_RequestKey", + table: "DispatchUpliftRequests", + columns: new[] { "DispatchId", "RequestKey" }, + unique: true, + filter: "[RequestKey] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_DispatchUpliftRequests_EvidenceDocumentId", + table: "DispatchUpliftRequests", + column: "EvidenceDocumentId"); + + migrationBuilder.AddForeignKey( + name: "FK_DispatchUpliftRequests_VendorCompletionDocuments_EvidenceDocumentId", + table: "DispatchUpliftRequests", + column: "EvidenceDocumentId", + principalTable: "VendorCompletionDocuments", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_DispatchUpliftRequests_VendorCompletionDocuments_EvidenceDocumentId", + table: "DispatchUpliftRequests"); + + migrationBuilder.DropIndex( + name: "IX_DispatchUpliftRequests_DispatchId", + table: "DispatchUpliftRequests"); + + migrationBuilder.DropIndex( + name: "IX_DispatchUpliftRequests_DispatchId_RequestKey", + table: "DispatchUpliftRequests"); + + migrationBuilder.DropIndex( + name: "IX_DispatchUpliftRequests_EvidenceDocumentId", + table: "DispatchUpliftRequests"); + + migrationBuilder.DropColumn( + name: "Purpose", + table: "VendorCompletionDocuments"); + + migrationBuilder.DropColumn( + name: "EscalatedAt", + table: "DispatchUpliftRequests"); + + migrationBuilder.DropColumn( + name: "EvidenceDocumentId", + table: "DispatchUpliftRequests"); + + migrationBuilder.DropColumn( + name: "ExpiresAt", + table: "DispatchUpliftRequests"); + + migrationBuilder.DropColumn( + name: "InitialNotificationSentAt", + table: "DispatchUpliftRequests"); + + migrationBuilder.DropColumn( + name: "NotificationError", + table: "DispatchUpliftRequests"); + + migrationBuilder.DropColumn( + name: "NotificationStatus", + table: "DispatchUpliftRequests"); + + migrationBuilder.DropColumn( + name: "RequestKey", + table: "DispatchUpliftRequests"); + + migrationBuilder.DropColumn( + name: "RowVersion", + table: "DispatchUpliftRequests"); + + migrationBuilder.CreateIndex( + name: "IX_DispatchUpliftRequests_DispatchId_Status", + table: "DispatchUpliftRequests", + columns: new[] { "DispatchId", "Status" }, + unique: true, + filter: "[Status] = 'Pending'"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 3ab48ca..4df0eec 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -952,6 +952,18 @@ namespace Data.SeaHavenIndustries.Migrations 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"); @@ -961,6 +973,19 @@ namespace Data.SeaHavenIndustries.Migrations 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)"); @@ -970,6 +995,11 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("RequiredTier") .HasColumnType("int"); + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + b.Property("Status") .IsRequired() .HasMaxLength(20) @@ -983,9 +1013,15 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.HasIndex("DispatchId", "Status") + b.HasIndex("DispatchId") .IsUnique() - .HasFilter("[Status] = 'Pending'"); + .HasFilter("[Status] IN ('Pending', 'ChangesRequested')"); + + b.HasIndex("EvidenceDocumentId"); + + b.HasIndex("DispatchId", "RequestKey") + .IsUnique() + .HasFilter("[RequestKey] IS NOT NULL"); b.ToTable("DispatchUpliftRequests"); }); @@ -2256,6 +2292,11 @@ namespace Data.SeaHavenIndustries.Migrations .IsRequired() .HasColumnType("nvarchar(max)"); + b.Property("Purpose") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + b.Property("RejectionReason") .HasColumnType("nvarchar(max)"); @@ -3230,7 +3271,14 @@ namespace Data.SeaHavenIndustries.Migrations .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 => diff --git a/Data.SeaHavenIndustries/Models/DispatchUpliftRequest.cs b/Data.SeaHavenIndustries/Models/DispatchUpliftRequest.cs index 3cdf1dd..f921730 100644 --- a/Data.SeaHavenIndustries/Models/DispatchUpliftRequest.cs +++ b/Data.SeaHavenIndustries/Models/DispatchUpliftRequest.cs @@ -28,5 +28,27 @@ namespace Data.SeaHavenIndustries public DateTime? DecidedAt { get; set; } public string? DecidedByUserId { get; set; } public string? DecisionNote { get; set; } + + // SH-101: supporting evidence, idempotency, lifecycle, concurrency. + public int? EvidenceDocumentId { get; set; } + [ForeignKey(nameof(EvidenceDocumentId))] + public virtual VendorCompletionDocument? EvidenceDocument { get; set; } + + [MaxLength(100)] + public string? RequestKey { get; set; } + + public DateTime? ExpiresAt { get; set; } + + public DateTime? InitialNotificationSentAt { get; set; } + public DateTime? EscalatedAt { get; set; } + + [Required] + [MaxLength(20)] + public string NotificationStatus { get; set; } = "Pending"; + [MaxLength(500)] + public string? NotificationError { get; set; } + + [Timestamp] + public byte[]? RowVersion { get; set; } } } diff --git a/Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs b/Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs index 2058e00..bee05e2 100644 --- a/Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs +++ b/Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace Data.SeaHavenIndustries @@ -27,5 +28,11 @@ namespace Data.SeaHavenIndustries public int? ReplacesDocumentId { get; set; } public DateTime? ScannedAt { get; set; } public DateTime? ReviewedAt { get; set; } + + // SH-101: document purpose. Default "Completion" preserves legacy behavior; + // "UpliftEvidence" is immutable supporting evidence for an uplift request. + [Required] + [MaxLength(30)] + public string Purpose { get; set; } = "Completion"; } } diff --git a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs index c2097f0..6e214c8 100644 --- a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs +++ b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs @@ -50,6 +50,15 @@ namespace Data.SeaHavenIndustries public string? DecisionNote { get; set; } public string? DecidedByFirstName { get; set; } public string? DecidedByLastName { get; set; } + // SH-101 additions + public int? EvidenceDocumentId { get; set; } + public string? EvidenceFileName { get; set; } + public string? EvidenceContentType { get; set; } + public long? EvidenceSizeBytes { get; set; } + public DateTime? ExpiresAt { get; set; } + public string? NotificationStatus { get; set; } + public string? NotificationError { get; set; } + public bool EvidenceScanPassed { get; set; } } public class PortalCommentData @@ -80,6 +89,14 @@ namespace Data.SeaHavenIndustries public DateTime? CreatedDate { get; set; } public DateTime? DecidedAt { get; set; } public string? DecisionNote { get; set; } + // SH-101 additions + public int? EvidenceDocumentId { get; set; } + public string? EvidenceFileName { get; set; } + public string? EvidenceContentType { get; set; } + public long? EvidenceSizeBytes { get; set; } + public DateTime? ExpiresAt { get; set; } + public string? NotificationStatus { get; set; } + public string? NotificationError { get; set; } } public class UpliftForDispatchData @@ -97,5 +114,30 @@ namespace Data.SeaHavenIndustries public string? DecisionNote { get; set; } public string? DecidedByFirstName { get; set; } public string? DecidedByLastName { get; set; } + // SH-101 additions + public int? EvidenceDocumentId { get; set; } + public DateTime? ExpiresAt { get; set; } + public string? NotificationStatus { get; set; } + public string? NotificationError { get; set; } + public string? EvidenceFileName { get; set; } + public string? EvidenceContentType { get; set; } + public long? EvidenceSizeBytes { get; set; } + public bool EvidenceScanPassed { get; set; } + } + + // SH-101: internal evidence-download projection. Server-side join enforces that the + // returned document is the one linked to this specific uplift request and dispatch. + public class UpliftEvidenceDownloadData + { + public int Id { get; set; } + public int DispatchId { get; set; } + public int VendorId { get; set; } + public int RequiredTier { get; set; } + public int? EvidenceDocumentId { get; set; } + public string? StoredFileName { get; set; } + public string? OriginalFileName { get; set; } + public string? ContentType { get; set; } + public string? Purpose { get; set; } + public string? ScanStatus { get; set; } } } diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index f7000c2..85beebe 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -20,8 +20,10 @@ namespace SeaHaven.DataServices.Implementation join d in _context.Dispatches on u.DispatchId equals d.Id join v in _context.Vendors on d.VendorId equals v.Id into vendors from v in vendors.DefaultIfEmpty() + join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id into evidences + from ev in evidences.DefaultIfEmpty() where (u.IsDeleted == null || u.IsDeleted == false) - select new { u, d, v }; + select new { u, d, v, ev }; if (!string.IsNullOrWhiteSpace(status)) query = query.Where(x => x.u.Status == status); @@ -49,7 +51,14 @@ namespace SeaHaven.DataServices.Implementation Status = x.u.Status, CreatedDate = x.u.CreatedDate, DecidedAt = x.u.DecidedAt, - DecisionNote = x.u.DecisionNote + DecisionNote = x.u.DecisionNote, + EvidenceDocumentId = x.u.EvidenceDocumentId, + EvidenceFileName = x.ev != null ? x.ev.OriginalFileName : null, + EvidenceContentType = x.ev != null ? x.ev.ContentType : null, + EvidenceSizeBytes = x.ev != null ? x.ev.SizeBytes : null, + ExpiresAt = x.u.ExpiresAt, + NotificationStatus = x.u.NotificationStatus, + NotificationError = x.u.NotificationError }) .ToListAsync(cancellationToken); @@ -62,6 +71,8 @@ namespace SeaHaven.DataServices.Implementation where u.DispatchId == dispatchId && (u.IsDeleted == null || u.IsDeleted == false) join dec in _context.Users on u.DecidedByUserId equals dec.Id into decs from dec in decs.DefaultIfEmpty() + join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id into evidences + from ev in evidences.DefaultIfEmpty() orderby u.CreatedDate descending select new UpliftForDispatchData { @@ -77,7 +88,15 @@ namespace SeaHaven.DataServices.Implementation DecidedAt = u.DecidedAt, DecisionNote = u.DecisionNote, DecidedByFirstName = dec != null ? dec.FirstName : null, - DecidedByLastName = dec != null ? dec.LastName : null + DecidedByLastName = dec != null ? dec.LastName : null, + EvidenceDocumentId = u.EvidenceDocumentId, + ExpiresAt = u.ExpiresAt, + NotificationStatus = u.NotificationStatus, + NotificationError = u.NotificationError, + EvidenceFileName = ev != null ? ev.OriginalFileName : null, + EvidenceContentType = ev != null ? ev.ContentType : null, + EvidenceSizeBytes = ev != null ? ev.SizeBytes : null, + EvidenceScanPassed = ev != null && ev.ScanStatus == "Passed" }).ToListAsync(cancellationToken); } @@ -87,6 +106,8 @@ namespace SeaHaven.DataServices.Implementation where u.DispatchId == dispatchId && (u.IsDeleted == null || u.IsDeleted == false) join dec in _context.Users on u.DecidedByUserId equals dec.Id into decs from dec in decs.DefaultIfEmpty() + join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id into evidences + from ev in evidences.DefaultIfEmpty() orderby u.CreatedDate descending select new PortalUpliftData { @@ -101,7 +122,15 @@ namespace SeaHaven.DataServices.Implementation DecidedAt = u.DecidedAt, DecisionNote = u.DecisionNote, DecidedByFirstName = dec != null ? dec.FirstName : null, - DecidedByLastName = dec != null ? dec.LastName : null + DecidedByLastName = dec != null ? dec.LastName : null, + EvidenceDocumentId = u.EvidenceDocumentId, + ExpiresAt = u.ExpiresAt, + NotificationStatus = u.NotificationStatus, + NotificationError = u.NotificationError, + EvidenceFileName = ev != null ? ev.OriginalFileName : null, + EvidenceContentType = ev != null ? ev.ContentType : null, + EvidenceSizeBytes = ev != null ? ev.SizeBytes : null, + EvidenceScanPassed = ev != null && ev.ScanStatus == "Passed" }).ToListAsync(cancellationToken); } @@ -117,6 +146,31 @@ namespace SeaHaven.DataServices.Implementation .FirstOrDefaultAsync(u => u.Id == requestId && u.DispatchId == dispatchId, cancellationToken); } + // SH-101: internal evidence download. The inner join on EvidenceDocumentId together + // with the DispatchId equality filter enforces server-side request/document linkage: + // a row is returned only when the document is the one linked to this exact request + // and dispatch. Soft-deleted requests/documents never resolve. + public async Task GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken) + { + return await (from u in _context.DispatchUpliftRequests + where u.Id == upliftRequestId && (u.IsDeleted == null || u.IsDeleted == false) + join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id + where ev.DispatchId == u.DispatchId && (ev.IsDeleted == null || ev.IsDeleted == false) + select new UpliftEvidenceDownloadData + { + Id = u.Id, + DispatchId = u.DispatchId, + VendorId = ev.VendorId, + RequiredTier = u.RequiredTier, + EvidenceDocumentId = u.EvidenceDocumentId, + StoredFileName = ev.StoredFileName, + OriginalFileName = ev.OriginalFileName, + ContentType = ev.ContentType, + Purpose = ev.Purpose, + ScanStatus = ev.ScanStatus + }).FirstOrDefaultAsync(cancellationToken); + } + public async Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken) { return await _context.DispatchUpliftRequests @@ -125,6 +179,64 @@ namespace SeaHaven.DataServices.Implementation && (u.IsDeleted == null || u.IsDeleted == false), cancellationToken); } + public async Task HasActiveAsync(int dispatchId, CancellationToken cancellationToken) + { + return await _context.DispatchUpliftRequests + .AnyAsync(u => u.DispatchId == dispatchId + && (u.Status == "Pending" || u.Status == "ChangesRequested") + && (u.IsDeleted == null || u.IsDeleted == false), cancellationToken); + } + + public async Task GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken) + { + return await _context.DispatchUpliftRequests + .FirstOrDefaultAsync(u => u.DispatchId == dispatchId + && (u.Status == "Pending" || u.Status == "ChangesRequested") + && (u.IsDeleted == null || u.IsDeleted == false), cancellationToken); + } + + public async Task GetByRequestKeyAsync(int dispatchId, string requestKey, CancellationToken cancellationToken) + { + return await _context.DispatchUpliftRequests + .FirstOrDefaultAsync(u => u.DispatchId == dispatchId + && u.RequestKey == requestKey + && (u.IsDeleted == null || u.IsDeleted == false), cancellationToken); + } + + public async Task> GetDueForExpiryAsync(DateTime utcNow, int count, CancellationToken cancellationToken) + { + return await _context.DispatchUpliftRequests + .Where(u => (u.Status == "Pending" || u.Status == "ChangesRequested") + && u.ExpiresAt != null && u.ExpiresAt <= utcNow + && (u.IsDeleted == null || u.IsDeleted == false)) + .OrderBy(u => u.ExpiresAt) + .Take(count) + .ToListAsync(cancellationToken); + } + + public async Task> GetDueForInitialNotificationAsync(int count, CancellationToken cancellationToken) + { + return await _context.DispatchUpliftRequests + .Where(u => (u.Status == "Pending" || u.Status == "ChangesRequested") + && u.InitialNotificationSentAt == null + && (u.IsDeleted == null || u.IsDeleted == false)) + .OrderBy(u => u.CreatedDate) + .Take(count) + .ToListAsync(cancellationToken); + } + + public async Task> GetDueForEscalationAsync(int count, CancellationToken cancellationToken) + { + return await _context.DispatchUpliftRequests + .Where(u => (u.Status == "Pending" || u.Status == "ChangesRequested") + && u.InitialNotificationSentAt != null + && u.EscalatedAt == null + && (u.IsDeleted == null || u.IsDeleted == false)) + .OrderBy(u => u.InitialNotificationSentAt) + .Take(count) + .ToListAsync(cancellationToken); + } + public async Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken) { await _context.DispatchUpliftRequests.AddAsync(request, cancellationToken); diff --git a/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs b/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs index 05aa4e9..a952f45 100644 --- a/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs @@ -13,10 +13,15 @@ namespace SeaHaven.DataServices.Implementation _context = context; } + // Completion-document queries/version semantics exclude uplift evidence so that + // supporting evidence never participates in completion versioning or replacement. + private const string CompletionPurpose = "Completion"; + public Task GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken) { return _context.VendorCompletionDocuments - .Where(document => document.DispatchId == dispatchId) + .Where(document => document.DispatchId == dispatchId + && document.Purpose == CompletionPurpose) .OrderByDescending(document => document.Version) .FirstOrDefaultAsync(cancellationToken); } @@ -24,6 +29,17 @@ namespace SeaHaven.DataServices.Implementation public Task GetForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken) { return _context.VendorCompletionDocuments + .FirstOrDefaultAsync(document => document.Id == documentId + && document.DispatchId == dispatchId + && document.VendorId == vendorId + && document.Purpose == CompletionPurpose + && (document.IsDeleted == null || document.IsDeleted == false), cancellationToken); + } + + public Task GetMetadataForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken) + { + return _context.VendorCompletionDocuments + .AsNoTracking() .FirstOrDefaultAsync(document => document.Id == documentId && document.DispatchId == dispatchId && document.VendorId == vendorId @@ -35,11 +51,25 @@ namespace SeaHaven.DataServices.Implementation return _context.VendorCompletionDocuments .Where(document => document.DispatchId == dispatchId && document.VendorId == vendorId + && document.Purpose == CompletionPurpose && (document.IsDeleted == null || document.IsDeleted == false)) .OrderByDescending(document => document.Version) .ToListAsync(cancellationToken); } + // SH-101: an uplift request > current requires a same-vendor, same-dispatch, + // nondeleted UpliftEvidence document whose scan passed. This lookup enforces all of those. + public Task GetUpliftEvidenceAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken) + { + return _context.VendorCompletionDocuments + .FirstOrDefaultAsync(document => document.Id == documentId + && document.DispatchId == dispatchId + && document.VendorId == vendorId + && document.Purpose == "UpliftEvidence" + && document.ScanStatus == "Passed" + && (document.IsDeleted == null || document.IsDeleted == false), cancellationToken); + } + public async Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken) { await _context.VendorCompletionDocuments.AddAsync(document, cancellationToken); diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs index ee7ead2..045f1fe 100644 --- a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -10,7 +10,17 @@ namespace SeaHaven.DataServices.Interfaces Task> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken); Task GetByIdAsync(int id, CancellationToken cancellationToken); Task GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken); + // SH-101: server-side join of an uplift request with its linked evidence document. + // Returns null when the request, the linked evidence, or the dispatch linkage is absent. + Task GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken); Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken); + // SH-101: active = Pending or ChangesRequested (the only states that block a new request). + Task HasActiveAsync(int dispatchId, CancellationToken cancellationToken); + Task GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken); + Task GetByRequestKeyAsync(int dispatchId, string requestKey, CancellationToken cancellationToken); + Task> GetDueForExpiryAsync(DateTime utcNow, int count, CancellationToken cancellationToken); + Task> GetDueForInitialNotificationAsync(int count, CancellationToken cancellationToken); + Task> GetDueForEscalationAsync(int count, CancellationToken cancellationToken); Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken); Task SaveChangesAsync(CancellationToken cancellationToken); } diff --git a/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs index d6c76a6..b56948c 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs @@ -6,7 +6,9 @@ namespace SeaHaven.DataServices.Interfaces { Task GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken); Task GetForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken); + 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); Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken); Task SaveChangesAsync(CancellationToken cancellationToken); } diff --git a/SeaHaven.Services/Configuration/ServiceOptions.cs b/SeaHaven.Services/Configuration/ServiceOptions.cs index 40999c1..901dab6 100644 --- a/SeaHaven.Services/Configuration/ServiceOptions.cs +++ b/SeaHaven.Services/Configuration/ServiceOptions.cs @@ -21,6 +21,18 @@ public sealed class ApprovalsOptions public decimal? UpliftTier1MaxUsd { get; set; } public string[] Tier1Roles { get; set; } = Array.Empty(); public string[] Tier2Roles { get; set; } = Array.Empty(); + + // SH-101 uplift lifecycle configuration. Defaults preserve a safe behavior. + public int? ExpirationHours { get; set; } = 72; + public int? EscalationAfterHours { get; set; } = 48; + public int SweepIntervalSeconds { get; set; } = 300; + public string[] Tier1NotificationRecipients { get; set; } = Array.Empty(); + public string[] Tier2NotificationRecipients { get; set; } = Array.Empty(); + public string[] EscalationRecipients { get; set; } = Array.Empty(); + + public TimeSpan EffectiveExpiration => TimeSpan.FromHours(ExpirationHours is > 0 ? ExpirationHours.Value : 72); + public TimeSpan EffectiveEscalation => TimeSpan.FromHours(EscalationAfterHours is > 0 ? EscalationAfterHours.Value : 48); + public TimeSpan EffectiveSweepInterval => TimeSpan.FromSeconds(SweepIntervalSeconds is > 0 ? SweepIntervalSeconds : 300); } public sealed class VendorPortalOptions diff --git a/SeaHaven.Services/DTOs/UpliftDTOs.cs b/SeaHaven.Services/DTOs/UpliftDTOs.cs index 0dcc483..c9893ee 100644 --- a/SeaHaven.Services/DTOs/UpliftDTOs.cs +++ b/SeaHaven.Services/DTOs/UpliftDTOs.cs @@ -18,6 +18,14 @@ namespace SeaHaven.Services.DTOs public DateTime? DecidedAt { get; set; } public string? DecisionNote { get; set; } public bool CanDecide { get; set; } + // SH-101 additions + public int? EvidenceDocumentId { get; set; } + public string? EvidenceFileName { get; set; } + public string? EvidenceContentType { get; set; } + public long? EvidenceSizeBytes { get; set; } + public DateTime? ExpiresAt { get; set; } + public string? NotificationStatus { get; set; } + public string? NotificationError { get; set; } } public class UpliftListResultDTO @@ -44,6 +52,15 @@ namespace SeaHaven.Services.DTOs public string? DecisionNote { get; set; } public string? DecidedByName { get; set; } public bool CanDecide { get; set; } + // SH-101 additions + public int? EvidenceDocumentId { get; set; } + public string? EvidenceFileName { get; set; } + public string? EvidenceContentType { get; set; } + public long? EvidenceSizeBytes { get; set; } + public bool EvidenceScanPassed { get; set; } + public DateTime? ExpiresAt { get; set; } + public string? NotificationStatus { get; set; } + public string? NotificationError { get; set; } } public class UpliftApproveResultDTO @@ -53,6 +70,13 @@ namespace SeaHaven.Services.DTOs public decimal? NTEAmount { get; set; } } + public class UpliftDecisionResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + } + + // SH-101: deny alias retained for route compatibility; canonical result is Rejected. public class UpliftDenyResultDTO { public int Id { get; set; } @@ -63,4 +87,26 @@ namespace SeaHaven.Services.DTOs { public UpliftForbiddenException(string message) : base(message) { } } + + // SH-101: internal (authorized) download of a Passed UpliftEvidence file linked to a + // specific uplift request. Outcome-driven so the controller returns 404/423 without + // exception disclosure. Completion documents are never exposed through this path. + public sealed class UpliftEvidenceDownloadResultDTO + { + public VendorDocumentDownloadOutcome Outcome { get; set; } + public Stream? Content { get; set; } + public string? ContentType { get; set; } + public string? FileName { get; set; } + + public static UpliftEvidenceDownloadResultDTO Ok(Stream content, string contentType, string fileName) => new() + { + Outcome = VendorDocumentDownloadOutcome.Ok, + Content = content, + ContentType = contentType, + FileName = fileName + }; + + public static UpliftEvidenceDownloadResultDTO Locked() => new() { Outcome = VendorDocumentDownloadOutcome.Locked }; + public static UpliftEvidenceDownloadResultDTO NotFound() => new() { Outcome = VendorDocumentDownloadOutcome.NotFound }; + } } diff --git a/SeaHaven.Services/DTOs/VendorDocumentDTOs.cs b/SeaHaven.Services/DTOs/VendorDocumentDTOs.cs index 9b98fc7..730a21d 100644 --- a/SeaHaven.Services/DTOs/VendorDocumentDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorDocumentDTOs.cs @@ -6,6 +6,17 @@ namespace SeaHaven.Services.DTOs public int Version { get; set; } public string ScanStatus { get; set; } = "Pending"; public string ReviewStatus { get; set; } = "Processing"; + // SH-101: echoes the validated document purpose. + public string Purpose { get; set; } = "Completion"; + } + + public sealed class VendorDocumentStatusDTO + { + public int Id { get; set; } + public string OriginalFileName { get; set; } = string.Empty; + public string ScanStatus { get; set; } = "Pending"; + public string ReviewStatus { get; set; } = "Processing"; + public string Purpose { get; set; } = "Completion"; } public enum VendorDocumentDownloadOutcome diff --git a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs index cbc82b8..afdaf18 100644 --- a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs @@ -58,6 +58,16 @@ namespace SeaHaven.Services.DTOs public DateTime? DecidedAt { get; set; } public string? DecisionNote { get; set; } public string? DecidedByName { get; set; } + // SH-101 additions + public int? EvidenceDocumentId { get; set; } + public string? EvidenceFileName { get; set; } + public string? EvidenceContentType { get; set; } + public long? EvidenceSizeBytes { get; set; } + public bool EvidenceScanPassed { get; set; } + public DateTime? ExpiresAt { get; set; } + public string? NotificationStatus { get; set; } + public string? NotificationError { get; set; } + public bool HasChangesRequested { get; set; } } public class PortalCommentDTO @@ -193,6 +203,12 @@ namespace SeaHaven.Services.DTOs public int RequiredTier { get; set; } public decimal? CurrentNTE { get; set; } public decimal RequestedNTE { get; set; } + // SH-101 additions + public bool NoApprovalRequired { get; set; } + public int? EvidenceDocumentId { get; set; } + public DateTime? ExpiresAt { get; set; } + public string? NotificationStatus { get; set; } + public bool IdempotentReplay { get; set; } } public class CancelUpliftResultDTO @@ -200,4 +216,23 @@ namespace SeaHaven.Services.DTOs public int Id { get; set; } public string? Status { get; set; } } + + // SH-101: vendor withdraw (canonical) and revise results. + public class WithdrawUpliftResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + } + + public class ReviseUpliftResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + public int RequiredTier { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public int? EvidenceDocumentId { get; set; } + public DateTime? ExpiresAt { get; set; } + public string? NotificationStatus { get; set; } + } } diff --git a/SeaHaven.Services/Implementation/UpliftLifecycleService.cs b/SeaHaven.Services/Implementation/UpliftLifecycleService.cs new file mode 100644 index 0000000..9f1cfbb --- /dev/null +++ b/SeaHaven.Services/Implementation/UpliftLifecycleService.cs @@ -0,0 +1,251 @@ +using Data.SeaHavenIndustries; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class UpliftLifecycleService : IUpliftLifecycleService + { + private readonly IUpliftDataService _upliftData; + private readonly IDispatchDataService _dispatchData; + private readonly IUserDataService _userData; + private readonly IEmailSender _emailSender; + private readonly FrontendOptions _frontendOptions; + private readonly ApprovalsOptions _approvalsOptions; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public UpliftLifecycleService( + IUpliftDataService upliftData, + IDispatchDataService dispatchData, + IUserDataService userData, + IEmailSender emailSender, + IOptions frontendOptions, + IOptions approvalsOptions, + TimeProvider timeProvider, + ILogger logger) + { + _upliftData = upliftData; + _dispatchData = dispatchData; + _userData = userData; + _emailSender = emailSender; + _frontendOptions = frontendOptions.Value; + _approvalsOptions = approvalsOptions.Value; + _timeProvider = timeProvider; + _logger = logger; + } + + public async Task ExpireDueAsync(CancellationToken cancellationToken) + { + var now = _timeProvider.GetUtcNow().UtcDateTime; + var due = await _upliftData.GetDueForExpiryAsync(now, 50, cancellationToken); + var expired = 0; + foreach (var req in due) + { + if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Expired)) + continue; + + var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId); + if (dispatch == null) + { + _logger.LogError("Uplift expiry skipped for orphan request {UpliftId}; dispatch {DispatchId} was not found.", req.Id, req.DispatchId); + continue; + } + + var previous = UpliftStatus.ToCanonical(req.Status); + + req.Status = UpliftStatus.Expired; + req.DecidedAt = now; + req.LastModificationTime = now; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch?.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift", + OldValue = previous, + NewValue = UpliftStatus.Expired, + Action = "uplift_expired", + CreatedAt = now + }, cancellationToken); + + await _upliftData.SaveChangesAsync(cancellationToken); + expired++; + } + + return expired; + } + + public async Task SendDueInitialNotificationsAsync(CancellationToken cancellationToken) + { + var due = await _upliftData.GetDueForInitialNotificationAsync(50, cancellationToken); + var sent = 0; + foreach (var req in due) + { + var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId); + if (dispatch == null) continue; + + var recipients = await ResolveInitialRecipientsAsync(dispatch, req, cancellationToken); + if (recipients.Count == 0) + { + await PersistNotificationOutcomeAsync(req, delivered: false, "No notification recipients are configured for this dispatch.", cancellationToken); + continue; + } + + var (subject, body) = UpliftNotificationMessage.BuildInitial(dispatch, req, req.RequestedByVendorName ?? "Vendor", _frontendOptions.FrontendBaseUrl ?? string.Empty); + var delivered = await SendToAllAsync(recipients, subject, body); + await PersistNotificationOutcomeAsync(req, delivered, delivered ? null : "The uplift notification could not be delivered.", cancellationToken); + if (delivered) sent++; + } + + return sent; + } + + public async Task EscalateDueAsync(CancellationToken cancellationToken) + { + var candidates = await _upliftData.GetDueForEscalationAsync(50, cancellationToken); + var escalationThreshold = _approvalsOptions.EffectiveEscalation; + var now = _timeProvider.GetUtcNow().UtcDateTime; + var escalated = 0; + + foreach (var req in candidates) + { + if (req.InitialNotificationSentAt == null || req.EscalatedAt != null) + continue; + if (now - req.InitialNotificationSentAt.Value < escalationThreshold) + continue; + + var recipients = await ResolveEscalationRecipientsAsync(req, cancellationToken); + if (recipients.Count == 0) + continue; + + var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId); + if (dispatch == null) continue; + + var (subject, body) = UpliftNotificationMessage.BuildEscalation(dispatch, req, req.RequestedByVendorName ?? "Vendor", _frontendOptions.FrontendBaseUrl ?? string.Empty); + var delivered = await SendToAllAsync(recipients, subject, body); + if (!delivered) + { + // Escalation send failure is logged only; it does not alter workflow status. + _logger.LogWarning("Uplift escalation delivery failed for request {UpliftId}", req.Id); + continue; + } + + req.EscalatedAt = now; + req.LastModificationTime = now; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", + OldValue = "Pending", + NewValue = "Escalated", + Action = "uplift_escalated", + CreatedAt = now + }, cancellationToken); + + await _upliftData.SaveChangesAsync(cancellationToken); + escalated++; + } + + return escalated; + } + + private async Task> ResolveInitialRecipientsAsync(Dispatch dispatch, DispatchUpliftRequest req, CancellationToken cancellationToken) + { + var recipients = new HashSet(StringComparer.OrdinalIgnoreCase); + + var dispatcherUserId = await _dispatchData.GetDispatchDispatcherUserIdAsync(dispatch.WorkOrderId ?? 0, cancellationToken); + if (!string.IsNullOrWhiteSpace(dispatcherUserId)) + { + var dispatcherEmail = await _userData.GetEmailByIdAsync(dispatcherUserId, cancellationToken); + if (!string.IsNullOrWhiteSpace(dispatcherEmail)) + recipients.Add(dispatcherEmail); + } + + var tierRecipients = req.RequiredTier == 2 + ? _approvalsOptions.Tier2NotificationRecipients + : _approvalsOptions.Tier1NotificationRecipients; + if (tierRecipients != null) + { + foreach (var recipient in tierRecipients) + { + if (!string.IsNullOrWhiteSpace(recipient)) + recipients.Add(recipient.Trim()); + } + } + + return recipients; + } + + private Task> ResolveEscalationRecipientsAsync(DispatchUpliftRequest req, CancellationToken cancellationToken) + { + var recipients = new HashSet(StringComparer.OrdinalIgnoreCase); + if (_approvalsOptions.EscalationRecipients != null) + { + foreach (var recipient in _approvalsOptions.EscalationRecipients) + { + if (!string.IsNullOrWhiteSpace(recipient)) + recipients.Add(recipient.Trim()); + } + } + + var tierRecipients = req.RequiredTier == 2 + ? _approvalsOptions.Tier2NotificationRecipients + : _approvalsOptions.Tier1NotificationRecipients; + if (tierRecipients != null) + { + foreach (var recipient in tierRecipients) + { + if (!string.IsNullOrWhiteSpace(recipient)) + recipients.Add(recipient.Trim()); + } + } + + return Task.FromResult(recipients); + } + + private async Task SendToAllAsync(HashSet recipients, string subject, string body) + { + try + { + foreach (var recipient in recipients) + { + var delivered = await _emailSender.SendEmailAsync(recipient, subject, body); + if (!delivered) + return false; + } + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + // Never leak addresses or exception details; the persisted signal is safe text. + return false; + } + } + + private async Task PersistNotificationOutcomeAsync(DispatchUpliftRequest req, bool delivered, string? errorMessage, CancellationToken cancellationToken) + { + var now = _timeProvider.GetUtcNow().UtcDateTime; + if (delivered) + { + req.InitialNotificationSentAt = now; + req.NotificationStatus = UpliftNotificationStatus.Sent; + req.NotificationError = null; + } + else + { + req.NotificationStatus = UpliftNotificationStatus.Error; + req.NotificationError = errorMessage; + } + req.LastModificationTime = now; + await _upliftData.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/SeaHaven.Services/Implementation/UpliftNotificationMessage.cs b/SeaHaven.Services/Implementation/UpliftNotificationMessage.cs new file mode 100644 index 0000000..dd3c3f8 --- /dev/null +++ b/SeaHaven.Services/Implementation/UpliftNotificationMessage.cs @@ -0,0 +1,64 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.Services.Implementation +{ + /// + /// Builds uplift notification email content. Shared by the synchronous request path + /// and the lifecycle sweep so content stays consistent without duplication. + /// + public static class UpliftNotificationMessage + { + public static (string Subject, string Body) BuildInitial(Dispatch dispatch, DispatchUpliftRequest req, string vendorName, string frontendBase) + { + var dispatchNum = System.Net.WebUtility.HtmlEncode(dispatch.DispatchNumber ?? ""); + var vendor = System.Net.WebUtility.HtmlEncode(vendorName); + var reason = System.Net.WebUtility.HtmlEncode(req.VendorReason ?? "(no reason provided)"); + var tierText = req.RequiredTier == 2 ? "Tier 2 (Manager approval required)" : "Tier 1"; + + var subject = $"[Uplift Request] {dispatch.DispatchNumber} — {vendorName} requests ${req.RequestedNTE:F2} (was ${req.CurrentNTE ?? 0m:F2})"; + var body = $@" +

Vendor Uplift Request

+

Dispatch: {dispatchNum}

+

Vendor: {vendor}

+ + + + + +
Current NTE${req.CurrentNTE ?? 0m:F2}
Requested NTE${req.RequestedNTE:F2}
Delta${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}
Required Approval{tierText}
+

Vendor Reason

+

{reason}

+ {ReviewLink(dispatch, frontendBase)}"; + return (subject, body); + } + + public static (string Subject, string Body) BuildEscalation(Dispatch dispatch, DispatchUpliftRequest req, string vendorName, string frontendBase) + { + var dispatchNum = System.Net.WebUtility.HtmlEncode(dispatch.DispatchNumber ?? ""); + var vendor = System.Net.WebUtility.HtmlEncode(vendorName); + var tierText = req.RequiredTier == 2 ? "Tier 2 (Manager approval required)" : "Tier 1"; + + var subject = $"[Escalation] Uplift request awaiting decision — {dispatch.DispatchNumber} ({vendorName})"; + var body = $@" +

Uplift Request Escalation

+

An uplift request is still awaiting a decision and has passed the escalation threshold.

+

Dispatch: {dispatchNum}

+

Vendor: {vendor}

+ + + + +
Current NTE${req.CurrentNTE ?? 0m:F2}
Requested NTE${req.RequestedNTE:F2}
Required Approval{tierText}
+ {ReviewLink(dispatch, frontendBase)}"; + return (subject, body); + } + + private static string ReviewLink(Dispatch dispatch, string frontendBase) + { + var link = dispatch.WorkOrderId.HasValue + ? $"{frontendBase.TrimEnd('/')}/workorders/{dispatch.WorkOrderId.Value}" + : frontendBase; + return $""; + } + } +} diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs index c6e7b8d..2cfe96f 100644 --- a/SeaHaven.Services/Implementation/UpliftService.cs +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -12,12 +12,21 @@ namespace SeaHaven.Services.Implementation { private readonly IUpliftDataService _upliftData; private readonly IDispatchDataService _dispatchData; + private readonly IVendorDocumentStoragePort _documentStorage; + private readonly TimeProvider _timeProvider; private readonly ApprovalsOptions _approvalsOptions; - public UpliftService(IUpliftDataService upliftData, IDispatchDataService dispatchData, IOptions approvalsOptions) + public UpliftService( + IUpliftDataService upliftData, + IDispatchDataService dispatchData, + IVendorDocumentStoragePort documentStorage, + TimeProvider timeProvider, + IOptions approvalsOptions) { _upliftData = upliftData; _dispatchData = dispatchData; + _documentStorage = documentStorage; + _timeProvider = timeProvider; _approvalsOptions = approvalsOptions.Value; } @@ -38,11 +47,18 @@ namespace SeaHaven.Services.Implementation Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m), VendorReason = r.VendorReason, RequiredTier = r.RequiredTier, - Status = r.Status, + Status = UpliftStatus.ToCanonical(r.Status), RequestedAt = r.CreatedDate, DecidedAt = r.DecidedAt, DecisionNote = r.DecisionNote, - CanDecide = UserCanApprove(user, r.RequiredTier) + CanDecide = UserCanApprove(user, r.RequiredTier), + EvidenceDocumentId = r.EvidenceDocumentId, + EvidenceFileName = r.EvidenceFileName, + EvidenceContentType = r.EvidenceContentType, + EvidenceSizeBytes = r.EvidenceSizeBytes, + ExpiresAt = r.ExpiresAt, + NotificationStatus = r.NotificationStatus, + NotificationError = r.NotificationError }).ToList(); return new UpliftListResultDTO @@ -66,7 +82,7 @@ namespace SeaHaven.Services.Implementation RequestedNTE = r.RequestedNTE, Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m), VendorReason = r.VendorReason, - Status = r.Status, + Status = UpliftStatus.ToCanonical(r.Status), RequiredTier = r.RequiredTier, RequestedByVendorName = r.RequestedByVendorName, RequestedAt = r.CreatedDate, @@ -74,7 +90,15 @@ namespace SeaHaven.Services.Implementation DecisionNote = r.DecisionNote, DecidedByName = string.Join(" ", new[] { r.DecidedByFirstName, r.DecidedByLastName } .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(), - CanDecide = UserCanApprove(user, r.RequiredTier) + CanDecide = UserCanApprove(user, r.RequiredTier), + EvidenceDocumentId = r.EvidenceDocumentId, + EvidenceFileName = r.EvidenceFileName, + EvidenceContentType = r.EvidenceContentType, + EvidenceSizeBytes = r.EvidenceSizeBytes, + EvidenceScanPassed = r.EvidenceScanPassed, + ExpiresAt = r.ExpiresAt, + NotificationStatus = r.NotificationStatus, + NotificationError = r.NotificationError }).ToList(); } @@ -82,23 +106,26 @@ namespace SeaHaven.Services.Implementation { var req = await _upliftData.GetByIdAsync(id, cancellationToken); if (req == null) throw new KeyNotFoundException("Uplift request not found"); - if (req.Status != "Pending") throw new InvalidOperationException($"Cannot approve a '{req.Status}' request"); + if (UpliftStatus.IsTerminal(req.Status)) + throw new InvalidOperationException($"Cannot approve a '{UpliftStatus.ToCanonical(req.Status)}' request"); + if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Approved)) + throw new InvalidOperationException($"Cannot approve a '{UpliftStatus.ToCanonical(req.Status)}' request"); if (!UserCanApprove(user, req.RequiredTier)) throw new UpliftForbiddenException($"Approval requires a Tier {req.RequiredTier} role"); var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); - if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled" || dispatch.Status == "Refused") + if (IsTerminalForUplift(dispatch.Status)) throw new InvalidOperationException($"Cannot approve uplift on a '{dispatch.Status}' dispatch"); var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; - var now = DateTime.UtcNow; + var now = _timeProvider.GetUtcNow().UtcDateTime; var oldNTE = dispatch.NTEAmount ?? 0m; dispatch.NTEAmount = req.RequestedNTE; dispatch.LastModificationTime = now; - req.Status = "Approved"; + req.Status = UpliftStatus.Approved; req.DecidedAt = now; req.DecidedByUserId = userId; req.DecisionNote = string.IsNullOrWhiteSpace(note) ? null : note!.Trim(); @@ -115,27 +142,41 @@ namespace SeaHaven.Services.Implementation CreatedAt = now }, cancellationToken); + // One EF unit-of-work commit for the dispatch RowVersion-protected NTE + request + audit. await _upliftData.SaveChangesAsync(cancellationToken); - return new UpliftApproveResultDTO { Id = req.Id, Status = req.Status, NTEAmount = dispatch.NTEAmount }; + return new UpliftApproveResultDTO { Id = req.Id, Status = UpliftStatus.Approved, NTEAmount = dispatch.NTEAmount }; } public async Task DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken) + { + var result = await RejectInternalAsync(user, id, note, "deny", cancellationToken); + return new UpliftDenyResultDTO { Id = result.Id, Status = result.Status }; + } + + public Task RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken) + => RejectInternalAsync(user, id, note, "reject", cancellationToken); + + private async Task RejectInternalAsync(ClaimsPrincipal user, int id, string? note, string actionSuffix, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(note)) - throw new InvalidOperationException("A note is required when denying"); + throw new InvalidOperationException("A note is required when rejecting"); var req = await _upliftData.GetByIdAsync(id, cancellationToken); if (req == null) throw new KeyNotFoundException("Uplift request not found"); - if (req.Status != "Pending") throw new InvalidOperationException($"Cannot deny a '{req.Status}' request"); + if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Rejected)) + throw new InvalidOperationException($"Cannot reject a '{UpliftStatus.ToCanonical(req.Status)}' request"); if (!UserCanApprove(user, req.RequiredTier)) throw new UpliftForbiddenException($"Decision requires a Tier {req.RequiredTier} role"); var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId); var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; - var now = DateTime.UtcNow; + var now = _timeProvider.GetUtcNow().UtcDateTime; - req.Status = "Denied"; + // Capture the pre-transition canonical status before mutating req.Status, + // otherwise the audit OldValue would record the already-applied Rejected value. + var previous = UpliftStatus.ToCanonical(req.Status); + req.Status = UpliftStatus.Rejected; req.DecidedAt = now; req.DecidedByUserId = userId; req.DecisionNote = note!.Trim(); @@ -146,18 +187,84 @@ namespace SeaHaven.Services.Implementation WorkOrderId = dispatch?.WorkOrderId ?? 0, UserId = userId, FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift", - OldValue = "Pending", - NewValue = "Denied", - Action = "uplift_denied", + OldValue = previous, + NewValue = UpliftStatus.Rejected, + Action = $"uplift_{actionSuffix}", CreatedAt = now }, cancellationToken); await _upliftData.SaveChangesAsync(cancellationToken); - return new UpliftDenyResultDTO { Id = req.Id, Status = req.Status }; + return new UpliftDecisionResultDTO { Id = req.Id, Status = UpliftStatus.Rejected }; + } + + public async Task RequestChangesAsync(ClaimsPrincipal user, int id, string note, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(note)) + throw new InvalidOperationException("A note is required when requesting changes"); + + var req = await _upliftData.GetByIdAsync(id, cancellationToken); + if (req == null) throw new KeyNotFoundException("Uplift request not found"); + if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.ChangesRequested)) + throw new InvalidOperationException($"Cannot request changes on a '{UpliftStatus.ToCanonical(req.Status)}' request"); + if (!UserCanApprove(user, req.RequiredTier)) + throw new UpliftForbiddenException($"Requesting changes requires a Tier {req.RequiredTier} role"); + + var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId); + var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var now = _timeProvider.GetUtcNow().UtcDateTime; + + var previous = UpliftStatus.ToCanonical(req.Status); + req.Status = UpliftStatus.ChangesRequested; + req.DecidedAt = now; + req.DecidedByUserId = userId; + req.DecisionNote = note.Trim(); + req.LastModificationTime = now; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch?.WorkOrderId ?? 0, + UserId = userId, + FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift", + OldValue = previous, + NewValue = UpliftStatus.ChangesRequested, + Action = "uplift_changes_requested", + CreatedAt = now + }, cancellationToken); + + await _upliftData.SaveChangesAsync(cancellationToken); + return new UpliftDecisionResultDTO { Id = req.Id, Status = UpliftStatus.ChangesRequested }; } public bool CanApprove(ClaimsPrincipal user, int tier) => UserCanApprove(user, tier); + // 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; + // completion documents resolve to NotFound. A scan that has not Passed is Locked. + public async Task GetEvidenceForDownloadAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken) + { + var evidence = await _upliftData.GetEvidenceForInternalDownloadAsync(id, cancellationToken); + if (evidence == null + || evidence.EvidenceDocumentId == null + || !string.Equals(evidence.Purpose, VendorDocumentPurpose.UpliftEvidence, StringComparison.Ordinal)) + { + return UpliftEvidenceDownloadResultDTO.NotFound(); + } + + if (!UserCanApprove(user, evidence.RequiredTier)) + { + throw new UpliftForbiddenException($"Evidence access requires a Tier {evidence.RequiredTier} role"); + } + + if (!string.Equals(evidence.ScanStatus, "Passed", StringComparison.OrdinalIgnoreCase)) + { + return UpliftEvidenceDownloadResultDTO.Locked(); + } + + var content = _documentStorage.OpenRead(evidence.VendorId, evidence.DispatchId, evidence.StoredFileName ?? string.Empty); + return UpliftEvidenceDownloadResultDTO.Ok(content, evidence.ContentType ?? "application/octet-stream", evidence.OriginalFileName ?? "evidence"); + } + private bool UserCanApprove(ClaimsPrincipal user, int requiredTier) { var roles = RolesForTier(requiredTier); @@ -174,5 +281,11 @@ namespace SeaHaven.Services.Implementation 2 => _approvalsOptions.Tier2Roles, _ => Array.Empty() }; + + // Terminal dispatch guard for uplift decisions. "Refused" is the SH-98 + // dispatch-refusal workflow; a refused dispatch is terminal for uplift just + // like Verified/Cancelled. + private static bool IsTerminalForUplift(string? status) => + status is "Verified" or "Cancelled" or "Canceled" or "Refused"; } } diff --git a/SeaHaven.Services/Implementation/UpliftStatus.cs b/SeaHaven.Services/Implementation/UpliftStatus.cs new file mode 100644 index 0000000..e38d0a6 --- /dev/null +++ b/SeaHaven.Services/Implementation/UpliftStatus.cs @@ -0,0 +1,94 @@ +namespace SeaHaven.Services.Implementation +{ + /// + /// Server-owned uplift state machine (SH-101). Canonical states are + /// Pending, Approved, Rejected, ChangesRequested, Withdrawn, Expired. + /// Legacy stored values "Denied" (-> Rejected) and "Cancelled" (-> Withdrawn) + /// are read as their canonical equivalents but never (re)written. + /// + public static class UpliftStatus + { + public const string Pending = "Pending"; + public const string Approved = "Approved"; + public const string Rejected = "Rejected"; + public const string ChangesRequested = "ChangesRequested"; + public const string Withdrawn = "Withdrawn"; + public const string Expired = "Expired"; + public const string NoApprovalRequired = "NoApprovalRequired"; + + // Legacy aliases retained only for reads; never written by new code. + public const string LegacyDenied = "Denied"; + public const string LegacyCancelled = "Cancelled"; + + private static readonly HashSet TerminalStates = + new(StringComparer.Ordinal) { Approved, Rejected, Withdrawn, Expired }; + + private static readonly HashSet ActiveStates = + new(StringComparer.Ordinal) { Pending, ChangesRequested }; + + /// + /// Maps a stored status to its canonical DTO value. Legacy "Denied" is + /// read as "Rejected" and "Cancelled" as "Withdrawn". + /// + public static string ToCanonical(string? status) => status switch + { + LegacyDenied => Rejected, + LegacyCancelled => Withdrawn, + _ => status ?? Pending + }; + + /// True for terminal (immutable) states, including legacy aliases. + public static bool IsTerminal(string? status) => + TerminalStates.Contains(ToCanonical(status)); + + /// True for actionable states that block a new active request. + public static bool IsActive(string? status) => + ActiveStates.Contains(ToCanonical(status)); + + /// + /// Validates a server-owned transition. Only legal transitions return true. + /// Terminal states (incl. legacy aliases) never transition out. + /// + public static bool CanTransition(string? from, string to) + { + var canonicalFrom = ToCanonical(from); + + if (TerminalStates.Contains(canonicalFrom)) + return false; + + return canonicalFrom switch + { + Pending => to is Approved or Rejected or ChangesRequested or Withdrawn or Expired, + ChangesRequested => to is Pending or Withdrawn or Expired, + _ => false + }; + } + + /// Snapshot-readable active states, used by data-service filters. + public static IReadOnlyCollection ActiveStatuses => ActiveStates; + } + + /// + /// Notification pipeline status (separate from the workflow ). + /// A notification failure never destroys an actionable request. + /// + public static class UpliftNotificationStatus + { + public const string Pending = "Pending"; + public const string Sent = "Sent"; + public const string Error = "Error"; + } + + /// + /// Vendor completion document purposes (SH-101). Completion is the legacy/default + /// behavior; UpliftEvidence is immutable supporting evidence for an uplift request. + /// + public static class VendorDocumentPurpose + { + public const string Completion = "Completion"; + public const string UpliftEvidence = "UpliftEvidence"; + + public static bool IsValid(string? purpose) => + purpose is Completion or UpliftEvidence; + } +} diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index f2deb76..5fc5140 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -28,6 +28,7 @@ namespace SeaHaven.Services.Implementation private readonly FrontendOptions _frontendOptions; private readonly ApprovalsOptions _approvalsOptions; private readonly VendorDocumentsOptions _documentOptions; + private readonly TimeProvider _timeProvider; public VendorPortalService( IVendorPortalTokenService tokens, @@ -40,7 +41,8 @@ namespace SeaHaven.Services.Implementation IVendorDocumentStoragePort documentStorage, IOptions frontendOptions, IOptions approvalsOptions, - IOptions documentOptions) + IOptions documentOptions, + TimeProvider timeProvider) { _tokens = tokens; _dispatchData = dispatchData; @@ -53,6 +55,7 @@ namespace SeaHaven.Services.Implementation _frontendOptions = frontendOptions.Value; _approvalsOptions = approvalsOptions.Value; _documentOptions = documentOptions.Value; + _timeProvider = timeProvider; } public async Task ResolveSessionAsync(string? token, CancellationToken cancellationToken) @@ -100,14 +103,23 @@ namespace SeaHaven.Services.Implementation CurrentNTE = u.CurrentNTE, RequestedNTE = u.RequestedNTE, VendorReason = u.VendorReason, - Status = u.Status, + Status = UpliftStatus.ToCanonical(u.Status), RequiredTier = u.RequiredTier, RequestedByVendorName = u.RequestedByVendorName, RequestedAt = u.CreatedDate, DecidedAt = u.DecidedAt, DecisionNote = u.DecisionNote, DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName } - .Where(s => !string.IsNullOrWhiteSpace(s))).Trim() + .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(), + EvidenceDocumentId = u.EvidenceDocumentId, + EvidenceFileName = u.EvidenceFileName, + EvidenceContentType = u.EvidenceContentType, + EvidenceSizeBytes = u.EvidenceSizeBytes, + EvidenceScanPassed = u.EvidenceScanPassed, + ExpiresAt = u.ExpiresAt, + NotificationStatus = u.NotificationStatus, + NotificationError = u.NotificationError, + HasChangesRequested = UpliftStatus.ToCanonical(u.Status) == UpliftStatus.ChangesRequested }).ToList(); var comments = rawComments.Select(c => @@ -503,7 +515,7 @@ namespace SeaHaven.Services.Implementation }; } - public async Task RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken) + public async Task RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, string? requestKey, int? evidenceDocumentId, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); @@ -518,32 +530,112 @@ namespace SeaHaven.Services.Implementation throw new InvalidOperationException("Requested NTE must be greater than zero"); } + var now = _timeProvider.GetUtcNow().UtcDateTime; var current = dispatch.NTEAmount ?? 0m; + + // NoApprovalRequired: requestedNTE <= current. Do not create an uplift or change NTE; + // record an audit event documenting the routed non-uplift outcome. if (requestedNTE <= current) { - throw new InvalidOperationException("Requested NTE must be greater than the current NTE"); + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", + OldValue = $"${current:F2}", + NewValue = $"${requestedNTE:F2}", + Action = "uplift_no_approval_required", + ActorType = "vendor", + CreatedAt = now + }, cancellationToken); + await _upliftData.SaveChangesAsync(cancellationToken); + + return new UpliftRequestResultDTO + { + Id = 0, + Status = UpliftStatus.NoApprovalRequired, + RequiredTier = 0, + CurrentNTE = current, + RequestedNTE = requestedNTE, + NoApprovalRequired = true + }; } - var pendingExists = await _upliftData.HasPendingAsync(id, cancellationToken); - if (pendingExists) + // requestedNTE > current: reason and scanned evidence are mandatory. + if (string.IsNullOrWhiteSpace(reason)) { - throw new InvalidOperationException("A pending uplift request already exists for this dispatch"); + throw new InvalidOperationException("A reason is required when requesting an uplift above the current NTE"); + } + + if (!evidenceDocumentId.HasValue) + { + throw new InvalidOperationException("Supporting evidence is required when requesting an uplift above the current NTE"); + } + + var evidence = await _documentData.GetUpliftEvidenceAsync(evidenceDocumentId.Value, id, session.Id, cancellationToken); + if (evidence == null) + { + throw new InvalidOperationException("The selected evidence document is not available or has not passed scanning"); + } + + // Idempotency: a repeated identical RequestKey returns the same request; mismatched reuse rejects. + if (!string.IsNullOrWhiteSpace(requestKey)) + { + var normalizedKey = requestKey.Trim(); + if (normalizedKey.Length > 100) + { + throw new InvalidOperationException("RequestKey is too long"); + } + var existing = await _upliftData.GetByRequestKeyAsync(id, normalizedKey, cancellationToken); + if (existing != null) + { + if (existing.RequestedNTE != requestedNTE + || existing.EvidenceDocumentId != evidence.Id + || !string.Equals(existing.VendorReason ?? "", reason.Trim(), StringComparison.Ordinal)) + { + throw new InvalidOperationException("RequestKey was already used with different uplift details"); + } + + return new UpliftRequestResultDTO + { + Id = existing.Id, + Status = UpliftStatus.ToCanonical(existing.Status), + RequiredTier = existing.RequiredTier, + CurrentNTE = existing.CurrentNTE, + RequestedNTE = existing.RequestedNTE, + NoApprovalRequired = false, + EvidenceDocumentId = existing.EvidenceDocumentId, + ExpiresAt = existing.ExpiresAt, + NotificationStatus = existing.NotificationStatus, + IdempotentReplay = true + }; + } + } + + // At most one active (Pending or ChangesRequested) request per dispatch. + var activeExists = await _upliftData.HasActiveAsync(id, cancellationToken); + if (activeExists) + { + throw new InvalidOperationException("An active uplift request already exists for this dispatch"); } var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m; var delta = requestedNTE - current; var requiredTier = delta > tier1Max ? 2 : 1; + var expiresAt = now + _approvalsOptions.EffectiveExpiration; - var now = DateTime.UtcNow; var req = new DispatchUpliftRequest { DispatchId = id, CurrentNTE = current, RequestedNTE = requestedNTE, - VendorReason = string.IsNullOrWhiteSpace(reason) ? null : reason!.Trim(), - Status = "Pending", + VendorReason = reason.Trim(), + Status = UpliftStatus.Pending, RequiredTier = requiredTier, RequestedByVendorName = session.CompanyName, + EvidenceDocumentId = evidence.Id, + RequestKey = string.IsNullOrWhiteSpace(requestKey) ? null : requestKey.Trim(), + ExpiresAt = expiresAt, + NotificationStatus = UpliftNotificationStatus.Pending, CreatedDate = now }; await _upliftData.StageAsync(req, cancellationToken); @@ -555,11 +647,14 @@ namespace SeaHaven.Services.Implementation OldValue = $"${current:F2}", NewValue = $"${requestedNTE:F2}", Action = "uplift_requested", + ActorType = "vendor", CreatedAt = now }, cancellationToken); await _upliftData.SaveChangesAsync(cancellationToken); + // Notification is best-effort and persisted separately from workflow state: a failure + // never destroys the actionable request (the lifecycle sweep retries by sentinel). await NotifyDispatcherOfUpliftAsync(dispatch, req, session, cancellationToken); return new UpliftRequestResultDTO @@ -568,11 +663,24 @@ namespace SeaHaven.Services.Implementation Status = req.Status, RequiredTier = req.RequiredTier, CurrentNTE = req.CurrentNTE, - RequestedNTE = req.RequestedNTE + RequestedNTE = req.RequestedNTE, + EvidenceDocumentId = req.EvidenceDocumentId, + ExpiresAt = req.ExpiresAt, + NotificationStatus = req.NotificationStatus }; } + // Cancel alias stays route-compatible; canonical stored status is Withdrawn. public async Task CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken) + { + var result = await WithdrawInternalAsync(session, id, requestId, "cancel", cancellationToken); + return new CancelUpliftResultDTO { Id = result.Id, Status = result.Status }; + } + + public Task WithdrawUpliftAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken) + => WithdrawInternalAsync(session, id, requestId, "withdraw", cancellationToken); + + private async Task WithdrawInternalAsync(VendorPortalSession session, int id, int requestId, string actionSuffix, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); @@ -580,13 +688,14 @@ namespace SeaHaven.Services.Implementation var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken); if (req == null) throw new KeyNotFoundException("Uplift request not found"); - if (req.Status != "Pending") + if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Withdrawn)) { - throw new InvalidOperationException($"Cannot cancel a '{req.Status}' uplift request"); + throw new InvalidOperationException($"Cannot withdraw a '{UpliftStatus.ToCanonical(req.Status)}' uplift request"); } - var now = DateTime.UtcNow; - req.Status = "Cancelled"; + var now = _timeProvider.GetUtcNow().UtcDateTime; + var previous = UpliftStatus.ToCanonical(req.Status); + req.Status = UpliftStatus.Withdrawn; req.DecidedAt = now; req.LastModificationTime = now; @@ -594,14 +703,108 @@ namespace SeaHaven.Services.Implementation { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", - OldValue = "Pending", - NewValue = "Cancelled", - Action = "uplift_cancelled", + OldValue = previous, + NewValue = UpliftStatus.Withdrawn, + Action = $"uplift_{actionSuffix}", + ActorType = "vendor", CreatedAt = now }, cancellationToken); await _upliftData.SaveChangesAsync(cancellationToken); - return new CancelUpliftResultDTO { Id = req.Id, Status = req.Status }; + return new WithdrawUpliftResultDTO { Id = req.Id, Status = UpliftStatus.Withdrawn }; + } + + // Vendor revise endpoint on ChangesRequested: updates requested amount/reason/evidence, + // recomputes tier/expiry, returns Pending, and audits old/new values while retaining the + // same row/history. + public async Task ReviseUpliftAsync(VendorPortalSession session, int id, int requestId, decimal requestedNTE, string? reason, int? evidenceDocumentId, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + if (requestedNTE <= 0) + { + throw new InvalidOperationException("Requested NTE must be greater than zero"); + } + + var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken); + if (req == null) throw new KeyNotFoundException("Uplift request not found"); + + if (UpliftStatus.ToCanonical(req.Status) != UpliftStatus.ChangesRequested) + { + throw new InvalidOperationException($"Cannot revise a '{UpliftStatus.ToCanonical(req.Status)}' uplift request"); + } + + var current = dispatch.NTEAmount ?? 0m; + if (requestedNTE <= current) + { + throw new InvalidOperationException("Requested NTE must be greater than the current NTE"); + } + + if (string.IsNullOrWhiteSpace(reason)) + { + throw new InvalidOperationException("A reason is required when revising an uplift request"); + } + + if (!evidenceDocumentId.HasValue) + { + throw new InvalidOperationException("Supporting evidence is required when revising an uplift request"); + } + + var evidence = await _documentData.GetUpliftEvidenceAsync(evidenceDocumentId.Value, id, session.Id, cancellationToken); + if (evidence == null) + { + throw new InvalidOperationException("The selected evidence document is not available or has not passed scanning"); + } + + var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m; + var now = _timeProvider.GetUtcNow().UtcDateTime; + var oldRequested = req.RequestedNTE; + var oldReason = req.VendorReason; + var oldTier = req.RequiredTier; + + req.RequestedNTE = requestedNTE; + req.VendorReason = reason.Trim(); + req.EvidenceDocumentId = evidence.Id; + req.CurrentNTE = current; + req.RequiredTier = (requestedNTE - current) > tier1Max ? 2 : 1; + req.ExpiresAt = now + _approvalsOptions.EffectiveExpiration; + req.Status = UpliftStatus.Pending; + req.DecisionNote = null; + req.DecidedAt = null; + req.DecidedByUserId = null; + req.NotificationStatus = UpliftNotificationStatus.Pending; + req.NotificationError = null; + req.InitialNotificationSentAt = null; + req.EscalatedAt = null; + req.LastModificationTime = now; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", + OldValue = $"${oldRequested:F2} (Tier {oldTier})", + NewValue = $"${requestedNTE:F2} (Tier {req.RequiredTier})", + Action = "uplift_revised", + ActorType = "vendor", + CreatedAt = now + }, cancellationToken); + + await _upliftData.SaveChangesAsync(cancellationToken); + + await NotifyDispatcherOfUpliftAsync(dispatch, req, session, cancellationToken); + + return new ReviseUpliftResultDTO + { + Id = req.Id, + Status = req.Status, + RequiredTier = req.RequiredTier, + CurrentNTE = req.CurrentNTE, + RequestedNTE = req.RequestedNTE, + EvidenceDocumentId = req.EvidenceDocumentId, + ExpiresAt = req.ExpiresAt, + NotificationStatus = req.NotificationStatus + }; } public async Task UploadCompletionDocumentAsync( @@ -609,6 +812,7 @@ namespace SeaHaven.Services.Implementation int dispatchId, IFormFile file, int? replacesDocumentId, + string? purpose, CancellationToken cancellationToken) { if (file is null || file.Length == 0) @@ -627,6 +831,22 @@ namespace SeaHaven.Services.Implementation throw new InvalidOperationException("Only PDF, JPG, and PNG completion documents are accepted."); } + var resolvedPurpose = string.IsNullOrWhiteSpace(purpose) + ? VendorDocumentPurpose.Completion + : purpose.Trim(); + + if (!VendorDocumentPurpose.IsValid(resolvedPurpose)) + { + throw new InvalidOperationException("The document purpose is not valid."); + } + + // Uplift evidence is immutable supporting evidence: it never participates in completion + // replacement/version semantics. + if (resolvedPurpose == VendorDocumentPurpose.UpliftEvidence && replacesDocumentId.HasValue) + { + throw new InvalidOperationException("Uplift evidence documents cannot replace another document."); + } + using var buffer = new MemoryStream(); await file.CopyToAsync(buffer, cancellationToken); var bytes = buffer.ToArray(); @@ -679,18 +899,29 @@ namespace SeaHaven.Services.Implementation 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 { WorkOrderId = dispatch.WorkOrderId ?? 0, DispatchId = dispatchId, - FieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document", - OldValue = replacedDocument == null ? null : $"v{replacedDocument.Version}", - NewValue = $"v{version}", - Action = "vendor_completion_document_uploaded", + 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); @@ -704,7 +935,8 @@ namespace SeaHaven.Services.Implementation Id = document.Id, Version = document.Version, ScanStatus = document.ScanStatus, - ReviewStatus = document.ReviewStatus + ReviewStatus = document.ReviewStatus, + Purpose = document.Purpose }; } @@ -732,6 +964,26 @@ namespace SeaHaven.Services.Implementation return DownloadCompletionDocumentResultDTO.Ok(content, document.ContentType, document.OriginalFileName); } + public async Task GetDocumentStatusAsync( + VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken) + { + var document = await _documentData.GetMetadataForVendorDispatchAsync( + documentId, dispatchId, session.Id, cancellationToken); + if (document == null) + { + return null; + } + + return new VendorDocumentStatusDTO + { + Id = document.Id, + OriginalFileName = document.OriginalFileName, + ScanStatus = document.ScanStatus, + ReviewStatus = document.ReviewStatus, + Purpose = document.Purpose + }; + } + private static bool MatchesSignature(string contentType, byte[] bytes) { if (bytes.Length == 0) @@ -769,46 +1021,69 @@ namespace SeaHaven.Services.Implementation private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, VendorPortalSession session, CancellationToken cancellationToken) { + var recipients = new HashSet(StringComparer.OrdinalIgnoreCase); + + var dispatcherUserId = await _dispatchData.GetDispatchDispatcherUserIdAsync(dispatch.WorkOrderId ?? 0, cancellationToken); + if (!string.IsNullOrWhiteSpace(dispatcherUserId)) + { + var dispatcherEmail = await _userData.GetEmailByIdAsync(dispatcherUserId, cancellationToken); + if (!string.IsNullOrWhiteSpace(dispatcherEmail)) + recipients.Add(dispatcherEmail); + } + + // Tier recipients come from configured Approvals options; dedupe against the dispatcher. + var tierRecipients = req.RequiredTier == 2 + ? _approvalsOptions.Tier2NotificationRecipients + : _approvalsOptions.Tier1NotificationRecipients; + if (tierRecipients != null) + { + foreach (var recipient in tierRecipients) + { + if (!string.IsNullOrWhiteSpace(recipient)) + recipients.Add(recipient.Trim()); + } + } + + if (recipients.Count == 0) + { + // No one to notify: record a safe persisted signal without leaking internal detail. + req.NotificationStatus = UpliftNotificationStatus.Error; + req.NotificationError = "No notification recipients are configured for this dispatch."; + req.LastModificationTime = _timeProvider.GetUtcNow().UtcDateTime; + await _upliftData.SaveChangesAsync(cancellationToken); + return; + } + + var frontendBase = _frontendOptions.FrontendBaseUrl ?? string.Empty; + var (subject, body) = UpliftNotificationMessage.BuildInitial(dispatch, req, session.CompanyName ?? "Vendor", frontendBase); + + var now = _timeProvider.GetUtcNow().UtcDateTime; try { - var dispatcherUserId = await _dispatchData.GetDispatchDispatcherUserIdAsync(dispatch.WorkOrderId ?? 0, cancellationToken); - if (string.IsNullOrWhiteSpace(dispatcherUserId)) return; + foreach (var recipient in recipients) + { + var delivered = await _emailSender.SendEmailAsync(recipient, subject, body); + if (!delivered) + throw new InvalidOperationException("The notification provider reported a delivery failure."); + } - var dispatcherEmail = await _userData.GetEmailByIdAsync(dispatcherUserId, cancellationToken); - if (string.IsNullOrWhiteSpace(dispatcherEmail)) return; - - var frontendBase = _frontendOptions.FrontendBaseUrl?.TrimEnd('/') ?? ""; - var workOrderLink = dispatch.WorkOrderId.HasValue - ? $"{frontendBase}/workorders/{dispatch.WorkOrderId.Value}" - : frontendBase; - - var vendorName = System.Net.WebUtility.HtmlEncode(session.CompanyName ?? "Vendor"); - var reason = System.Net.WebUtility.HtmlEncode(req.VendorReason ?? "(no reason provided)"); - var dispatchNum = System.Net.WebUtility.HtmlEncode(dispatch.DispatchNumber ?? ""); - var tierText = req.RequiredTier == 2 ? "Tier 2 (Manager approval required)" : "Tier 1"; - - var subject = $"[Uplift Request] {dispatch.DispatchNumber} — {session.CompanyName} requests ${req.RequestedNTE:F2} (was ${req.CurrentNTE ?? 0m:F2})"; - var body = $@" -

Vendor Uplift Request

-

Dispatch: {dispatchNum}

-

Vendor: {vendorName}

- - - - - -
Current NTE${req.CurrentNTE ?? 0m:F2}
Requested NTE${req.RequestedNTE:F2}
Delta${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}
Required Approval{tierText}
-

Vendor Reason

-

{reason}

- "; - - await _emailSender.SendEmailAsync(dispatcherEmail, subject, body); + req.InitialNotificationSentAt = now; + req.NotificationStatus = UpliftNotificationStatus.Sent; + req.NotificationError = null; } - catch + catch (OperationCanceledException) { + throw; } + catch (Exception) + { + // Persist a safe signal; never store exception text or addresses. + req.NotificationStatus = UpliftNotificationStatus.Error; + req.NotificationError = "The uplift notification could not be delivered."; + } + + req.LastModificationTime = now; + await _upliftData.SaveChangesAsync(cancellationToken); } private static bool IsAllowedVendorTransition(string? from, string? to) @@ -819,6 +1094,6 @@ namespace SeaHaven.Services.Implementation } private static bool IsPortalLockedStatus(string? status) - => status == "Verified" || status == "Cancelled" || status == "Refused"; + => status is "Verified" or "Cancelled" or "Canceled" or "Refused"; } } diff --git a/SeaHaven.Services/Interfaces/IUpliftLifecycleService.cs b/SeaHaven.Services/Interfaces/IUpliftLifecycleService.cs new file mode 100644 index 0000000..55303bc --- /dev/null +++ b/SeaHaven.Services/Interfaces/IUpliftLifecycleService.cs @@ -0,0 +1,13 @@ +namespace SeaHaven.Services.Interfaces +{ + /// + /// Uplift lifecycle processing (SH-101). Owns expiry, initial-notification backstop, + /// and once-only escalation. Scoped; invoked by the hosted sweep. + /// + public interface IUpliftLifecycleService + { + Task ExpireDueAsync(CancellationToken cancellationToken); + Task SendDueInitialNotificationsAsync(CancellationToken cancellationToken); + Task EscalateDueAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IUpliftService.cs b/SeaHaven.Services/Interfaces/IUpliftService.cs index 3fb0b53..04dcadc 100644 --- a/SeaHaven.Services/Interfaces/IUpliftService.cs +++ b/SeaHaven.Services/Interfaces/IUpliftService.cs @@ -9,6 +9,12 @@ namespace SeaHaven.Services.Interfaces Task> ListForDispatchAsync(ClaimsPrincipal user, int dispatchId, CancellationToken cancellationToken); Task ApproveAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken); Task DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken); + // SH-101: canonical reject (alias of deny; writes Rejected). + Task RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken); + // SH-101: internal request-changes route (note + tier authorization + audit). + Task RequestChangesAsync(ClaimsPrincipal user, int id, string note, CancellationToken cancellationToken); + // SH-101: authorized internal download of a Passed UpliftEvidence file linked to an uplift request. + Task GetEvidenceForDownloadAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken); bool CanApprove(ClaimsPrincipal user, int tier); } } diff --git a/SeaHaven.Services/Interfaces/IVendorPortalService.cs b/SeaHaven.Services/Interfaces/IVendorPortalService.cs index d379319..2284c7d 100644 --- a/SeaHaven.Services/Interfaces/IVendorPortalService.cs +++ b/SeaHaven.Services/Interfaces/IVendorPortalService.cs @@ -14,9 +14,12 @@ namespace SeaHaven.Services.Interfaces Task UpdateChecklistItemAsync(VendorPortalSession session, int id, int itemId, bool isCompleted, CancellationToken cancellationToken); Task AddSignoffAsync(VendorPortalSession session, int id, string? signoffType, string? name, string? signature, string? signatureMethod, CancellationToken cancellationToken); Task AddCommentAsync(VendorPortalSession session, int id, string? commentText, CancellationToken cancellationToken); - Task RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken); + Task RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, string? requestKey, int? evidenceDocumentId, CancellationToken cancellationToken); Task CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken); - Task UploadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, Microsoft.AspNetCore.Http.IFormFile file, int? replacesDocumentId, CancellationToken cancellationToken); + Task WithdrawUpliftAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken); + Task ReviseUpliftAsync(VendorPortalSession session, int id, int requestId, decimal requestedNTE, string? reason, int? evidenceDocumentId, CancellationToken cancellationToken); + Task UploadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, Microsoft.AspNetCore.Http.IFormFile file, int? replacesDocumentId, string? purpose, CancellationToken cancellationToken); + Task GetDocumentStatusAsync(VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken); Task DownloadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken); } }