Merge pull request #62 from Sea-Haven-Industries/feat/sh-101-uplift-lifecycle
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions

feat(uplifts): complete vendor approval lifecycle (SH-101)
This commit is contained in:
Alexandre Brandizzi 2026-08-11 13:51:40 -03:00 • committed by GitHub
commit 3e8a7115fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 6518 additions and 95 deletions

View file

@ -210,4 +210,79 @@ public class UpliftControllerTests
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
envelope.Status.Should().Be("Success");
}
[Fact]
public async Task DownloadEvidence_NotFound_Returns404()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 99, It.IsAny<CancellationToken>()))
.ReturnsAsync(UpliftEvidenceDownloadResultDTO.NotFound());
var controller = NewController(service);
var result = await controller.DownloadEvidence(99);
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task DownloadEvidence_Locked_Returns423()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>()))
.ReturnsAsync(UpliftEvidenceDownloadResultDTO.Locked());
var controller = NewController(service);
var result = await controller.DownloadEvidence(5);
var status = result.Should().BeOfType<ObjectResult>().Subject;
status.StatusCode.Should().Be(StatusCodes.Status423Locked);
var response = status.Value.Should().BeOfType<Response>().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<IUpliftService>();
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>()))
.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<FileStreamResult>();
}
[Fact]
public async Task DownloadEvidence_Forbidden_ReturnsSanitized403()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>()))
.ThrowsAsync(new UpliftForbiddenException("SECRET-role-policy"));
var controller = NewController(service);
var result = await controller.DownloadEvidence(5);
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
var response = forbidden.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().NotContain("SECRET");
response.Message.Should().Contain("reference");
}
[Fact]
public async Task RequestChanges_InvalidOperation_ReturnsSanitized400()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.RequestChangesAsync(It.IsAny<ClaimsPrincipal>(), 5, "note", It.IsAny<CancellationToken>()))
.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<BadRequestObjectResult>().Subject;
var resp = bad.Value.Should().BeOfType<Response>().Subject;
resp.Message.Should().NotContain("SECRET");
resp.Message.Should().Contain("reference");
}
}

View file

@ -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<IVendorDocumentStoragePort>(),
TimeProvider.System,
Microsoft.Extensions.Options.Options.Create(
new ApprovalsOptions { Tier1Roles = new[] { "UpliftApprover" } }));
var user = new ClaimsPrincipal(new ClaimsIdentity(

View file

@ -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<ApplicationDbContext>()
.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<Claim> { 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<string, byte[]> _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<bool> 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<ICommentDataService>();
commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PortalCommentData>());
return new VendorPortalService(
tokenService,
new DispatchDataService(context),
new UpliftDataService(context),
commentData.Object,
Mock.Of<IUserDataService>(),
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<InvalidOperationException>();
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<InvalidOperationException>();
}
// --- 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<InvalidOperationException>();
}
[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<InvalidOperationException>();
}
// --- 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<InvalidOperationException>();
}
// --- 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<InvalidOperationException>();
}
[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<InvalidOperationException>();
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<IUserDataService>(),
new FakeEmailSender(deliver: true),
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
Microsoft.Extensions.Options.Options.Create(NewOptions()),
TimeProvider.System,
Mock.Of<ILogger<UpliftLifecycleService>>());
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<IUserDataService>(),
new FakeEmailSender(deliver: true),
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
Microsoft.Extensions.Options.Options.Create(NewOptions()),
TimeProvider.System,
Mock.Of<ILogger<UpliftLifecycleService>>());
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<IUpliftDataService>();
upliftData.Setup(u => u.GetDueForExpiryAsync(It.IsAny<DateTime>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DispatchUpliftRequest> { orphanReq, validReq });
upliftData.Setup(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var dispatch = new Dispatch { Id = 7, DispatchNumber = "DIS-7", WorkOrderId = 99 };
var dispatchData = new Mock<IDispatchDataService>();
dispatchData.Setup(d => d.GetByIdAsync(404)).ReturnsAsync((Dispatch?)null);
dispatchData.Setup(d => d.GetByIdAsync(7)).ReturnsAsync(dispatch);
dispatchData.Setup(d => d.StageAuditLogAsync(It.IsAny<WorkOrderAuditLog>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var lifecycle = new UpliftLifecycleService(
upliftData.Object,
dispatchData.Object,
Mock.Of<IUserDataService>(),
new FakeEmailSender(deliver: true),
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
Microsoft.Extensions.Options.Options.Create(NewOptions()),
TimeProvider.System,
Mock.Of<ILogger<UpliftLifecycleService>>());
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<WorkOrderAuditLog>(), It.IsAny<CancellationToken>()), Times.Once, "only the valid request stages an audit");
upliftData.Verify(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()), 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<IUserDataService>(),
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<ILogger<UpliftLifecycleService>>());
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<UpliftForbiddenException>();
}
[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);
}
}

View file

@ -300,7 +300,7 @@ public class VendorPortalControllerTests
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.RequestUpliftAsync(Session, 10, 100m, null, It.IsAny<CancellationToken>()))
service.Setup(x => x.RequestUpliftAsync(Session, 10, 100m, null, It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
.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<string>(), It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
service.Setup(x => x.RequestUpliftAsync(Session, 10, 500m, "Need more parts", It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(upliftResult);
var controller = NewController(service);

View file

@ -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()
{

View file

@ -85,7 +85,8 @@ public sealed class VendorPortalRefuseTests
Mock.Of<IVendorDocumentStoragePort>(),
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<InvalidOperationException>().WithMessage("*locked*");
context.VendorCompletionDocuments.Should().BeEmpty();

View file

@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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; }

View file

@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<VendorPortalSession?> 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; }
}
}
}

View file

@ -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<UpliftLifecycleHostedService> _logger;
private readonly ApprovalsOptions _options;
public UpliftLifecycleHostedService(
IServiceScopeFactory scopeFactory,
IOptions<ApprovalsOptions> options,
ILogger<UpliftLifecycleHostedService> 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<IUpliftLifecycleService>();
await lifecycle.ExpireDueAsync(cancellationToken);
await lifecycle.SendDueInitialNotificationsAsync(cancellationToken);
await lifecycle.EscalateDueAsync(cancellationToken);
}
}
}

View file

@ -111,6 +111,7 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<WorkOrderJobRunStateAc
builder.Services.AddScoped<Api.SeaHavenIndustries.Filters.IngestApiKeyFilter>();
builder.Services.AddHostedService<WorkOrderWeekRolledHostedService>();
builder.Services.AddHostedService<PastDueCacheHostedService>();
builder.Services.AddHostedService<UpliftLifecycleHostedService>();
builder.Services.AddOptions<SeaHaven.Services.Implementation.WorkOrderOpsHealthOptions>()
.Configure<Microsoft.Extensions.Options.IOptions<SyncOptions>,
Microsoft.Extensions.Options.IOptions<WorkOrderIngestOptions>,

View file

@ -30,9 +30,18 @@ namespace Data.SeaHavenIndustries
.IsUnique();
builder.Entity<DispatchUpliftRequest>()
.HasIndex(u => new { u.DispatchId, u.Status })
.HasIndex(u => u.DispatchId)
.IsUnique()
.HasFilter("[Status] = 'Pending'");
.HasFilter("[Status] IN ('Pending', 'ChangesRequested')");
builder.Entity<DispatchUpliftRequest>()
.HasIndex(u => new { u.DispatchId, u.RequestKey })
.IsUnique()
.HasFilter("[RequestKey] IS NOT NULL");
builder.Entity<DispatchUpliftRequest>()
.Property(u => u.RowVersion)
.IsRowVersion();
builder.Entity<WorkOrderFieldLock>()
.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<DispatchUpliftRequest>())
{
if (entry.State == EntityState.Modified)
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
}
}
private static byte[] IncrementRowVersion(byte[]? current)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,170 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class SH101_UpliftApprovalWorkflow : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_DispatchUpliftRequests_DispatchId_Status",
table: "DispatchUpliftRequests");
migrationBuilder.AddColumn<string>(
name: "Purpose",
table: "VendorCompletionDocuments",
type: "nvarchar(30)",
maxLength: 30,
nullable: false,
defaultValue: "Completion");
migrationBuilder.AddColumn<DateTime>(
name: "EscalatedAt",
table: "DispatchUpliftRequests",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "EvidenceDocumentId",
table: "DispatchUpliftRequests",
type: "int",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "ExpiresAt",
table: "DispatchUpliftRequests",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "InitialNotificationSentAt",
table: "DispatchUpliftRequests",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "NotificationError",
table: "DispatchUpliftRequests",
type: "nvarchar(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "NotificationStatus",
table: "DispatchUpliftRequests",
type: "nvarchar(20)",
maxLength: 20,
nullable: false,
defaultValue: "Pending");
migrationBuilder.AddColumn<string>(
name: "RequestKey",
table: "DispatchUpliftRequests",
type: "nvarchar(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<byte[]>(
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);
}
/// <inheritdoc />
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'");
}
}
}

View file

@ -952,6 +952,18 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<int>("DispatchId")
.HasColumnType("int");
b.Property<DateTime?>("EscalatedAt")
.HasColumnType("datetime2");
b.Property<int?>("EvidenceDocumentId")
.HasColumnType("int");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("InitialNotificationSentAt")
.HasColumnType("datetime2");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
@ -961,6 +973,19 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("NotificationError")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("NotificationStatus")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("RequestKey")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("RequestedByVendorName")
.HasColumnType("nvarchar(max)");
@ -970,6 +995,11 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<int>("RequiredTier")
.HasColumnType("int");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<string>("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<string>("Purpose")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.Property<string>("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 =>

View file

@ -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; }
}
}

View file

@ -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";
}
}

View file

@ -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; }
}
}

View file

@ -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<UpliftEvidenceDownloadData?> 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<bool> 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<bool> 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<DispatchUpliftRequest?> 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<DispatchUpliftRequest?> 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<List<DispatchUpliftRequest>> 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<List<DispatchUpliftRequest>> 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<List<DispatchUpliftRequest>> 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);

View file

@ -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<VendorCompletionDocument?> 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<VendorCompletionDocument?> 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<VendorCompletionDocument?> 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<VendorCompletionDocument?> 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);

View file

@ -10,7 +10,17 @@ namespace SeaHaven.DataServices.Interfaces
Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> 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<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken);
Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken);
// SH-101: active = Pending or ChangesRequested (the only states that block a new request).
Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetByRequestKeyAsync(int dispatchId, string requestKey, CancellationToken cancellationToken);
Task<List<DispatchUpliftRequest>> GetDueForExpiryAsync(DateTime utcNow, int count, CancellationToken cancellationToken);
Task<List<DispatchUpliftRequest>> GetDueForInitialNotificationAsync(int count, CancellationToken cancellationToken);
Task<List<DispatchUpliftRequest>> GetDueForEscalationAsync(int count, CancellationToken cancellationToken);
Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken);
Task SaveChangesAsync(CancellationToken cancellationToken);
}

View file

@ -6,7 +6,9 @@ namespace SeaHaven.DataServices.Interfaces
{
Task<VendorCompletionDocument?> GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
Task<VendorCompletionDocument?> GetForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
Task<VendorCompletionDocument?> GetMetadataForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
Task<List<VendorCompletionDocument>> ListForVendorDispatchAsync(int dispatchId, int vendorId, CancellationToken cancellationToken);
Task<VendorCompletionDocument?> GetUpliftEvidenceAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken);
Task SaveChangesAsync(CancellationToken cancellationToken);
}

View file

@ -21,6 +21,18 @@ public sealed class ApprovalsOptions
public decimal? UpliftTier1MaxUsd { get; set; }
public string[] Tier1Roles { get; set; } = Array.Empty<string>();
public string[] Tier2Roles { get; set; } = Array.Empty<string>();
// 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<string>();
public string[] Tier2NotificationRecipients { get; set; } = Array.Empty<string>();
public string[] EscalationRecipients { get; set; } = Array.Empty<string>();
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

View file

@ -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 };
}
}

View file

@ -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

View file

@ -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; }
}
}

View file

@ -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<UpliftLifecycleService> _logger;
public UpliftLifecycleService(
IUpliftDataService upliftData,
IDispatchDataService dispatchData,
IUserDataService userData,
IEmailSender emailSender,
IOptions<FrontendOptions> frontendOptions,
IOptions<ApprovalsOptions> approvalsOptions,
TimeProvider timeProvider,
ILogger<UpliftLifecycleService> logger)
{
_upliftData = upliftData;
_dispatchData = dispatchData;
_userData = userData;
_emailSender = emailSender;
_frontendOptions = frontendOptions.Value;
_approvalsOptions = approvalsOptions.Value;
_timeProvider = timeProvider;
_logger = logger;
}
public async Task<int> 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<int> 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<int> 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<HashSet<string>> ResolveInitialRecipientsAsync(Dispatch dispatch, DispatchUpliftRequest req, CancellationToken cancellationToken)
{
var recipients = new HashSet<string>(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<HashSet<string>> ResolveEscalationRecipientsAsync(DispatchUpliftRequest req, CancellationToken cancellationToken)
{
var recipients = new HashSet<string>(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<bool> SendToAllAsync(HashSet<string> 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);
}
}
}

View file

@ -0,0 +1,64 @@
using Data.SeaHavenIndustries;
namespace SeaHaven.Services.Implementation
{
/// <summary>
/// Builds uplift notification email content. Shared by the synchronous request path
/// and the lifecycle sweep so content stays consistent without duplication.
/// </summary>
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 = $@"
<h2>Vendor Uplift Request</h2>
<p><strong>Dispatch:</strong> {dispatchNum}</p>
<p><strong>Vendor:</strong> {vendor}</p>
<table style='border-collapse:collapse;font-family:Arial,sans-serif;'>
<tr><td style='padding:4px 10px;'><strong>Current NTE</strong></td><td style='padding:4px 10px;'>${req.CurrentNTE ?? 0m:F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Requested NTE</strong></td><td style='padding:4px 10px;'>${req.RequestedNTE:F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Delta</strong></td><td style='padding:4px 10px;'>${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Required Approval</strong></td><td style='padding:4px 10px;'>{tierText}</td></tr>
</table>
<h3>Vendor Reason</h3>
<p>{reason}</p>
{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 = $@"
<h2>Uplift Request Escalation</h2>
<p>An uplift request is still awaiting a decision and has passed the escalation threshold.</p>
<p><strong>Dispatch:</strong> {dispatchNum}</p>
<p><strong>Vendor:</strong> {vendor}</p>
<table style='border-collapse:collapse;font-family:Arial,sans-serif;'>
<tr><td style='padding:4px 10px;'><strong>Current NTE</strong></td><td style='padding:4px 10px;'>${req.CurrentNTE ?? 0m:F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Requested NTE</strong></td><td style='padding:4px 10px;'>${req.RequestedNTE:F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Required Approval</strong></td><td style='padding:4px 10px;'>{tierText}</td></tr>
</table>
{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 $"<div style='margin:20px 0;'><a href='{link}' style='display:inline-block;padding:10px 22px;background:#2563eb;color:white;text-decoration:none;border-radius:6px;font-weight:bold;'>Review in SHOC</a></div>";
}
}
}

View file

@ -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> approvalsOptions)
public UpliftService(
IUpliftDataService upliftData,
IDispatchDataService dispatchData,
IVendorDocumentStoragePort documentStorage,
TimeProvider timeProvider,
IOptions<ApprovalsOptions> 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<UpliftDenyResultDTO> 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<UpliftDecisionResultDTO> RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken)
=> RejectInternalAsync(user, id, note, "reject", cancellationToken);
private async Task<UpliftDecisionResultDTO> 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<UpliftDecisionResultDTO> 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<UpliftEvidenceDownloadResultDTO> 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<string>()
};
// 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";
}
}

View file

@ -0,0 +1,94 @@
namespace SeaHaven.Services.Implementation
{
/// <summary>
/// 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.
/// </summary>
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<string> TerminalStates =
new(StringComparer.Ordinal) { Approved, Rejected, Withdrawn, Expired };
private static readonly HashSet<string> ActiveStates =
new(StringComparer.Ordinal) { Pending, ChangesRequested };
/// <summary>
/// Maps a stored status to its canonical DTO value. Legacy "Denied" is
/// read as "Rejected" and "Cancelled" as "Withdrawn".
/// </summary>
public static string ToCanonical(string? status) => status switch
{
LegacyDenied => Rejected,
LegacyCancelled => Withdrawn,
_ => status ?? Pending
};
/// <summary>True for terminal (immutable) states, including legacy aliases.</summary>
public static bool IsTerminal(string? status) =>
TerminalStates.Contains(ToCanonical(status));
/// <summary>True for actionable states that block a new active request.</summary>
public static bool IsActive(string? status) =>
ActiveStates.Contains(ToCanonical(status));
/// <summary>
/// Validates a server-owned transition. Only legal transitions return true.
/// Terminal states (incl. legacy aliases) never transition out.
/// </summary>
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
};
}
/// <summary>Snapshot-readable active states, used by data-service filters.</summary>
public static IReadOnlyCollection<string> ActiveStatuses => ActiveStates;
}
/// <summary>
/// Notification pipeline status (separate from the workflow <see cref="UpliftStatus"/>).
/// A notification failure never destroys an actionable request.
/// </summary>
public static class UpliftNotificationStatus
{
public const string Pending = "Pending";
public const string Sent = "Sent";
public const string Error = "Error";
}
/// <summary>
/// Vendor completion document purposes (SH-101). Completion is the legacy/default
/// behavior; UpliftEvidence is immutable supporting evidence for an uplift request.
/// </summary>
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;
}
}

View file

@ -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> frontendOptions,
IOptions<ApprovalsOptions> approvalsOptions,
IOptions<VendorDocumentsOptions> documentOptions)
IOptions<VendorDocumentsOptions> 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<VendorPortalSession?> 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<UpliftRequestResultDTO> RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken)
public async Task<UpliftRequestResultDTO> 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<CancelUpliftResultDTO> 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<WithdrawUpliftResultDTO> WithdrawUpliftAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken)
=> WithdrawInternalAsync(session, id, requestId, "withdraw", cancellationToken);
private async Task<WithdrawUpliftResultDTO> 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<ReviseUpliftResultDTO> 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<UploadCompletionDocumentResultDTO> 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<VendorDocumentStatusDTO?> 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<string>(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 = $@"
<h2>Vendor Uplift Request</h2>
<p><strong>Dispatch:</strong> {dispatchNum}</p>
<p><strong>Vendor:</strong> {vendorName}</p>
<table style='border-collapse:collapse;font-family:Arial,sans-serif;'>
<tr><td style='padding:4px 10px;'><strong>Current NTE</strong></td><td style='padding:4px 10px;'>${req.CurrentNTE ?? 0m:F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Requested NTE</strong></td><td style='padding:4px 10px;'>${req.RequestedNTE:F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Delta</strong></td><td style='padding:4px 10px;'>${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Required Approval</strong></td><td style='padding:4px 10px;'>{tierText}</td></tr>
</table>
<h3>Vendor Reason</h3>
<p>{reason}</p>
<div style='margin:20px 0;'>
<a href='{workOrderLink}' style='display:inline-block;padding:10px 22px;background:#2563eb;color:white;text-decoration:none;border-radius:6px;font-weight:bold;'>Review in SHOC</a>
</div>";
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";
}
}

View file

@ -0,0 +1,13 @@
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// Uplift lifecycle processing (SH-101). Owns expiry, initial-notification backstop,
/// and once-only escalation. Scoped; invoked by the hosted sweep.
/// </summary>
public interface IUpliftLifecycleService
{
Task<int> ExpireDueAsync(CancellationToken cancellationToken);
Task<int> SendDueInitialNotificationsAsync(CancellationToken cancellationToken);
Task<int> EscalateDueAsync(CancellationToken cancellationToken);
}
}

View file

@ -9,6 +9,12 @@ namespace SeaHaven.Services.Interfaces
Task<IEnumerable<UpliftForDispatchDTO>> ListForDispatchAsync(ClaimsPrincipal user, int dispatchId, CancellationToken cancellationToken);
Task<UpliftApproveResultDTO> ApproveAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken);
Task<UpliftDenyResultDTO> DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken);
// SH-101: canonical reject (alias of deny; writes Rejected).
Task<UpliftDecisionResultDTO> RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken);
// SH-101: internal request-changes route (note + tier authorization + audit).
Task<UpliftDecisionResultDTO> 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<UpliftEvidenceDownloadResultDTO> GetEvidenceForDownloadAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken);
bool CanApprove(ClaimsPrincipal user, int tier);
}
}

View file

@ -14,9 +14,12 @@ namespace SeaHaven.Services.Interfaces
Task<ChecklistItemResultDTO> UpdateChecklistItemAsync(VendorPortalSession session, int id, int itemId, bool isCompleted, CancellationToken cancellationToken);
Task<SignoffResultDTO> AddSignoffAsync(VendorPortalSession session, int id, string? signoffType, string? name, string? signature, string? signatureMethod, CancellationToken cancellationToken);
Task<CommentResultDTO> AddCommentAsync(VendorPortalSession session, int id, string? commentText, CancellationToken cancellationToken);
Task<UpliftRequestResultDTO> RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken);
Task<UpliftRequestResultDTO> RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, string? requestKey, int? evidenceDocumentId, CancellationToken cancellationToken);
Task<CancelUpliftResultDTO> CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken);
Task<UploadCompletionDocumentResultDTO> UploadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, Microsoft.AspNetCore.Http.IFormFile file, int? replacesDocumentId, CancellationToken cancellationToken);
Task<WithdrawUpliftResultDTO> WithdrawUpliftAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken);
Task<ReviseUpliftResultDTO> ReviseUpliftAsync(VendorPortalSession session, int id, int requestId, decimal requestedNTE, string? reason, int? evidenceDocumentId, CancellationToken cancellationToken);
Task<UploadCompletionDocumentResultDTO> UploadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, Microsoft.AspNetCore.Http.IFormFile file, int? replacesDocumentId, string? purpose, CancellationToken cancellationToken);
Task<VendorDocumentStatusDTO?> GetDocumentStatusAsync(VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken);
Task<DownloadCompletionDocumentResultDTO> DownloadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken);
}
}