Merge branch 'dev' into feature/wo-board-completed-date-media

This commit is contained in:
Arthur Bassi 2026-08-11 15:20:17 -03:00
commit afcb4fde8d
36 changed files with 7102 additions and 115 deletions

View file

@ -116,7 +116,7 @@ jobs:
- name: Deploy prebuilt bundle to existing environment
# aws-actions/aws-elasticbeanstalk-deploy @ v1.0.6
uses: aws-actions/aws-elasticbeanstalk-deploy@cfad3e5e4452cd9c8923cbee2f862e96ba4b52c4
uses: aws-actions/aws-elasticbeanstalk-deploy@7883cdd454c162051bf6fc13389536b045149b4c
with:
aws-region: us-east-1
application-name: shoc-backend

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

@ -0,0 +1,64 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class UpliftServiceRefusedTests
{
[Fact]
public async Task ApproveAsync_RefusedDispatch_RejectsWithoutChangingNteOrRequest()
{
var request = new DispatchUpliftRequest
{
Id = 7,
DispatchId = 42,
Status = "Pending",
RequiredTier = 1,
RequestedNTE = 900m
};
var dispatch = new Dispatch
{
Id = 42,
Status = "Refused",
NTEAmount = 500m,
DispatchNumber = "DSP-42"
};
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(data => data.GetByIdAsync(7, It.IsAny<CancellationToken>()))
.ReturnsAsync(request);
var dispatchData = new Mock<IDispatchDataService>();
dispatchData.Setup(data => data.GetByIdAsync(42)).ReturnsAsync(dispatch);
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(
new[]
{
new Claim(ClaimTypes.NameIdentifier, "dispatcher-1"),
new Claim(ClaimTypes.Role, "UpliftApprover")
},
"test"));
var act = () => service.ApproveAsync(user, 7, "approve", CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>().WithMessage("*Refused*");
dispatch.NTEAmount.Should().Be(500m);
request.Status.Should().Be("Pending");
dispatchData.Verify(
data => data.StageAuditLogAsync(It.IsAny<WorkOrderAuditLog>(), It.IsAny<CancellationToken>()),
Times.Never);
upliftData.Verify(data => data.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Never);
}
}

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

@ -157,6 +157,72 @@ public class VendorPortalControllerTests
acceptData.Should().Be(acceptResult);
}
[Fact]
public async Task Refuse_KeyNotFound_Returns404()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.RefuseDispatchAsync(Session, 99, It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new KeyNotFoundException());
var controller = NewController(service);
var result = await controller.Refuse(99, new VendorPortalController.RefuseRequest { Reason = null });
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task Refuse_InvalidOperation_Returns400WithoutInternalDetail()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.RefuseDispatchAsync(Session, 10, "no", It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Cannot refuse a dispatch with status 'Acknowledged'"));
var controller = NewController(service);
var result = await controller.Refuse(10, new VendorPortalController.RefuseRequest { Reason = "no" });
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var resp = bad.Value.Should().BeOfType<Response>().Subject;
resp.Message.Should().NotContain("Cannot refuse");
resp.Message.Should().Contain("reference");
}
[Fact]
public async Task Refuse_Success_ReturnsRefusedResult()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
var refusedAt = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
var refuseResult = new RefuseDispatchResultDTO { Id = 10, Status = "Refused", RefusedAt = refusedAt };
service.Setup(x => x.RefuseDispatchAsync(Session, 10, "Too far", It.IsAny<CancellationToken>()))
.ReturnsAsync(refuseResult);
var controller = NewController(service);
var result = await controller.Refuse(10, new VendorPortalController.RefuseRequest { Reason = "Too far" });
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
var data = envelope.Data as RefuseDispatchResultDTO;
data.Should().Be(refuseResult);
service.Verify(x => x.RefuseDispatchAsync(Session, 10, "Too far", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public void RefuseEndpoint_AdvertisesDualCompatibilityRoutes()
{
var routes = typeof(VendorPortalController)
.GetMethod(nameof(VendorPortalController.Refuse))!
.GetCustomAttributes(typeof(HttpPostAttribute), inherit: false)
.Cast<HttpPostAttribute>()
.Select(attribute => attribute.Template);
routes.Should().BeEquivalentTo("dispatches/{id:int}/refuse");
}
[Fact]
public async Task ChangeStatus_ForbiddenTransition_Returns400()
{
@ -234,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);
@ -250,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

@ -0,0 +1,312 @@
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Dto;
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;
namespace Api.SeaHavenIndustries.Tests;
public sealed class VendorPortalRefuseTests
{
private const string Token = "vendor-refuse-test-token";
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static async Task<(Vendor Vendor, WorkOrder WorkOrder, Dispatch Dispatch)> SeedSentDispatch(
ApplicationDbContext context, string status = "Sent")
{
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder
{
WorkerOrderTitle = "Repair",
SiteCode = "SITE-42",
Service = "Emergency plumbing"
};
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = workOrder.Id,
Status = status,
DispatchNumber = "DSP-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 VendorPortalService NewService(
ApplicationDbContext context,
Mock<IUserDataService>? userData = null)
{
userData ??= new Mock<IUserDataService>();
var vendorData = new VendorDataService(context);
var tokenService = new VendorPortalTokenService(
vendorData,
Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions()));
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PortalUpliftData>());
return new VendorPortalService(
tokenService,
new DispatchDataService(context),
upliftData.Object,
new CommentDataService(context),
userData.Object,
Mock.Of<IEmailSender>(),
new VendorDocumentDataService(context),
Mock.Of<IVendorDocumentStoragePort>(),
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions()),
TimeProvider.System);
}
private static VendorPortalSession SessionFor(Vendor vendor) => new()
{
Id = vendor.Id,
CompanyName = vendor.CompanyName
};
[Fact]
public async Task RefuseDispatch_NoReason_RefusesStagesAuditAndOmitsComment()
{
using var context = NewContext();
var (vendor, _, dispatch) = await SeedSentDispatch(context);
var service = NewService(context);
var result = await service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None);
result.Status.Should().Be("Refused");
result.Id.Should().Be(dispatch.Id);
result.RefusedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
var reloaded = await context.Dispatches.FindAsync(dispatch.Id);
reloaded!.Status.Should().Be("Refused");
reloaded.LastModificationTime.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
var audit = await context.WorkOrderAuditLogs.SingleAsync();
audit.Action.Should().Be("vendor_refuse");
audit.OldValue.Should().Be("Sent");
audit.NewValue.Should().Be("Refused");
context.Comments.Should().BeEmpty();
}
[Fact]
public async Task RefuseDispatch_NonblankReason_TrimsAndStagesRefusalComment()
{
using var context = NewContext();
var (vendor, _, dispatch) = await SeedSentDispatch(context);
var service = NewService(context);
var result = await service.RefuseDispatchAsync(
SessionFor(vendor), dispatch.Id, " Out of capacity ", CancellationToken.None);
result.Status.Should().Be("Refused");
var comment = await context.Comments.SingleAsync();
comment.RecordType.Should().Be("refusal");
comment.CommentType.Should().Be("vendor");
comment.Commenttext.Should().Be("Out of capacity");
comment.Commenter.Should().Be(vendor.CompanyName);
comment.CreatedDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public async Task RefuseDispatch_FiveHundredCharReason_Accepted()
{
using var context = NewContext();
var (vendor, _, dispatch) = await SeedSentDispatch(context);
var service = NewService(context);
var reason = new string('x', 500);
var result = await service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, reason, CancellationToken.None);
result.Status.Should().Be("Refused");
var comment = await context.Comments.SingleAsync();
comment.Commenttext.Should().HaveLength(500);
}
[Fact]
public async Task RefuseDispatch_OverFiveHundredChars_Rejected()
{
using var context = NewContext();
var (vendor, _, dispatch) = await SeedSentDispatch(context);
var service = NewService(context);
var reason = new string('x', 501);
var act = () => service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, reason, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
var reloaded = await context.Dispatches.FindAsync(dispatch.Id);
reloaded!.Status.Should().Be("Sent");
context.WorkOrderAuditLogs.Should().BeEmpty();
context.Comments.Should().BeEmpty();
}
[Fact]
public async Task RefuseDispatch_NonSentStatus_Rejected()
{
using var context = NewContext();
var (vendor, _, dispatch) = await SeedSentDispatch(context, status: "Acknowledged");
var service = NewService(context);
var act = () => service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None);
(await act.Should().ThrowAsync<InvalidOperationException>())
.WithMessage("*Acknowledged*");
context.WorkOrderAuditLogs.Should().BeEmpty();
}
[Fact]
public async Task RefuseDispatch_WrongVendor_ReturnsKeyNotFoundViaScopedLookup()
{
using var context = NewContext();
var (vendor, _, dispatch) = await SeedSentDispatch(context);
var service = NewService(context);
var otherSession = new VendorPortalSession { Id = vendor.Id + 999, CompanyName = "Other" };
var act = () => service.RefuseDispatchAsync(otherSession, dispatch.Id, null, CancellationToken.None);
await act.Should().ThrowAsync<KeyNotFoundException>();
var reloaded = await context.Dispatches.FindAsync(dispatch.Id);
reloaded!.Status.Should().Be("Sent");
}
[Fact]
public async Task RefuseDispatch_ForwardsCancellationToken()
{
using var context = NewContext();
var (vendor, _, dispatch) = await SeedSentDispatch(context);
var service = NewService(context);
using var cts = new CancellationTokenSource();
cts.Cancel();
var act = () => service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, cts.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
}
[Fact]
public async Task GetDispatchDetail_IncludesSiteCode_StatusUpdatedAt_AndAllowlistedDispatcherContact()
{
using var context = NewContext();
var (vendor, workOrder, dispatch) = await SeedSentDispatch(context);
var statusUpdatedAt = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);
dispatch.LastModificationTime = statusUpdatedAt;
await context.SaveChangesAsync();
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = workOrder.Id,
Action = "dispatch",
UserId = "dispatcher-1",
CreatedAt = DateTime.UtcNow
});
await context.SaveChangesAsync();
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetProfileAsync("dispatcher-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new UserProfileData
{
Id = "dispatcher-1",
FirstName = "Dana",
Email = "dana@shoc.test",
Contact = "555-0199"
});
var service = NewService(context, userData);
var detail = await service.GetDispatchDetailAsync(SessionFor(vendor), dispatch.Id, CancellationToken.None);
detail.Should().NotBeNull();
detail!.WorkOrder!.SiteCode.Should().Be("SITE-42");
detail.WorkOrder.Service.Should().Be("Emergency plumbing");
detail.StatusUpdatedAt.Should().Be(statusUpdatedAt);
detail.DispatcherContact.Should().NotBeNull();
detail.DispatcherContact!.Name.Should().Be("Dana");
detail.DispatcherContact.Email.Should().Be("dana@shoc.test");
detail.DispatcherContact.Phone.Should().Be("555-0199");
var contactJson = System.Text.Json.JsonSerializer.Serialize(detail.DispatcherContact);
contactJson.Should().NotContain("dispatcher-1");
contactJson.Should().NotContain("Id");
}
[Fact]
public async Task RefuseDispatch_ThenRefuseAgain_IsRejectedAsNonSent()
{
using var context = NewContext();
var (vendor, _, dispatch) = await SeedSentDispatch(context);
var service = NewService(context);
await service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None);
var act = () => service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
}
[Fact]
public async Task RefuseDispatch_MarksRefusedSoPortalLocksApply()
{
using var context = NewContext();
var (vendor, _, dispatch) = await SeedSentDispatch(context);
var service = NewService(context);
await service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None);
var act = () => service.RequestCancelAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None);
(await act.Should().ThrowAsync<InvalidOperationException>())
.WithMessage("*Refused*");
}
[Fact]
public async Task UploadCompletionDocument_RefusedDispatch_IsLocked()
{
using var context = NewContext();
var (vendor, _, dispatch) = await SeedSentDispatch(context);
var service = NewService(context);
await service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None);
var bytes = "%PDF-1.4\ncompletion"u8.ToArray();
var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "completion.pdf")
{
Headers = new HeaderDictionary(),
ContentType = "application/pdf"
};
var act = () => service.UploadCompletionDocumentAsync(
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

@ -80,6 +80,27 @@ namespace Api.SeaHavenIndustries.Controllers
}
}
[HttpPost("dispatches/{id:int}/refuse")]
public async Task<IActionResult> Refuse(int id, [FromBody] RefuseRequest? 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.RefuseDispatchAsync(session, id, body?.Reason, 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}/status")]
public async Task<IActionResult> ChangeStatus(int id, [FromBody] ChangeStatusRequest body, CancellationToken cancellationToken = default)
{
@ -193,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)
@ -227,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)]
@ -234,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);
@ -246,6 +312,7 @@ namespace Api.SeaHavenIndustries.Controllers
id,
file,
replacesDocumentId,
purpose,
cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
}
@ -275,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;
@ -292,6 +371,11 @@ namespace Api.SeaHavenIndustries.Controllers
public string? Reason { get; set; }
}
public class RefuseRequest
{
public string? Reason { get; set; }
}
public class ChecklistUpdateRequest
{
public bool IsCompleted { get; set; }
@ -314,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 })
@ -270,6 +279,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

@ -957,6 +957,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");
@ -966,6 +978,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)");
@ -975,6 +1000,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)
@ -988,9 +1018,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");
});
@ -2261,6 +2297,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)");
@ -3250,7 +3291,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
@ -79,7 +89,16 @@ namespace SeaHaven.Services.DTOs
public DateTime? DueDate { get; set; }
public string? Trade { get; set; }
public string? SubTrade { get; set; }
public string? Service { get; set; }
public string? Problem { get; set; }
public string? SiteCode { get; set; }
}
public class DispatcherContactDTO
{
public string? Name { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
}
public class PortalLocationDTO
@ -106,8 +125,10 @@ namespace SeaHaven.Services.DTOs
public DateTime? CompletedDate { get; set; }
public DateTime? DispatchedAt { get; set; }
public DateTime? AcknowledgedAt { get; set; }
public DateTime? StatusUpdatedAt { get; set; }
public PortalWorkOrderDTO? WorkOrder { get; set; }
public PortalLocationDTO? Location { get; set; }
public DispatcherContactDTO? DispatcherContact { get; set; }
public IEnumerable<PortalChecklistItemDTO> Checklist { get; set; } = Enumerable.Empty<PortalChecklistItemDTO>();
public IEnumerable<PortalSignoffDTO> Signoffs { get; set; } = Enumerable.Empty<PortalSignoffDTO>();
public IEnumerable<PortalCommentDTO> Comments { get; set; } = Enumerable.Empty<PortalCommentDTO>();
@ -139,6 +160,13 @@ namespace SeaHaven.Services.DTOs
public DateTime? AcknowledgedAt { get; set; }
}
public class RefuseDispatchResultDTO
{
public int Id { get; set; }
public string? Status { get; set; }
public DateTime? RefusedAt { get; set; }
}
public class ChangeStatusResultDTO
{
public int Id { get; set; }
@ -175,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
@ -182,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")
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

@ -15,6 +15,8 @@ namespace SeaHaven.Services.Implementation
"application/pdf", "image/jpeg", "image/jpg", "image/png"
};
private const int MaxRefusalReasonLength = 500;
private readonly IVendorPortalTokenService _tokens;
private readonly IDispatchDataService _dispatchData;
private readonly IUpliftDataService _upliftData;
@ -26,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,
@ -38,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;
@ -51,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)
@ -98,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 =>
@ -144,6 +158,26 @@ namespace SeaHaven.Services.Implementation
};
}).ToList();
DispatcherContactDTO? dispatcherContact = null;
if (dispatch.WorkOrderId.HasValue)
{
var dispatcherUserId = await _dispatchData.GetDispatchDispatcherUserIdAsync(
dispatch.WorkOrderId.Value, cancellationToken);
if (!string.IsNullOrWhiteSpace(dispatcherUserId))
{
var profile = await _userData.GetProfileAsync(dispatcherUserId, cancellationToken);
if (profile != null)
{
dispatcherContact = new DispatcherContactDTO
{
Name = profile.FirstName,
Email = profile.Email,
Phone = profile.Contact
};
}
}
}
return new VendorDispatchDetailDTO
{
Id = dispatch.Id,
@ -159,6 +193,7 @@ namespace SeaHaven.Services.Implementation
CompletedDate = dispatch.CompletedDate,
DispatchedAt = dispatch.DispatchedAt,
AcknowledgedAt = dispatch.AcknowledgedAt,
StatusUpdatedAt = dispatch.LastModificationTime,
WorkOrder = dispatch.WorkOrder == null ? null : new PortalWorkOrderDTO
{
Id = dispatch.WorkOrder.Id,
@ -169,7 +204,9 @@ namespace SeaHaven.Services.Implementation
DueDate = dispatch.WorkOrder.DueDate,
Trade = dispatch.WorkOrder.Trade,
SubTrade = dispatch.WorkOrder.SubTrade,
Problem = dispatch.WorkOrder.Problem
Service = dispatch.WorkOrder.Service,
Problem = dispatch.WorkOrder.Problem,
SiteCode = dispatch.WorkOrder.SiteCode
},
Location = dispatch.WorkOrder?.Locations == null ? null : new PortalLocationDTO
{
@ -179,6 +216,7 @@ namespace SeaHaven.Services.Implementation
State = dispatch.WorkOrder.Locations.State,
Zip = dispatch.WorkOrder.Locations.Zip
},
DispatcherContact = dispatcherContact,
Checklist = checklist.Select(c => new PortalChecklistItemDTO
{
Id = c.Id,
@ -212,8 +250,7 @@ namespace SeaHaven.Services.Implementation
ScannedAt = document.ScannedAt,
ReviewedAt = document.ReviewedAt,
CanDownload = string.Equals(document.ScanStatus, "Passed", StringComparison.OrdinalIgnoreCase)
&& dispatch.Status != "Verified"
&& dispatch.Status != "Cancelled"
&& !IsPortalLockedStatus(dispatch.Status)
})
};
}
@ -247,6 +284,54 @@ namespace SeaHaven.Services.Implementation
return new AcceptDispatchResultDTO { Id = dispatch.Id, Status = dispatch.Status, AcknowledgedAt = dispatch.AcknowledgedAt };
}
public async Task<RefuseDispatchResultDTO> RefuseDispatchAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken)
{
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
if (dispatch.Status != "Sent")
{
throw new InvalidOperationException($"Cannot refuse a dispatch with status '{dispatch.Status}'");
}
var reasonText = reason?.Trim();
if (reasonText != null && reasonText.Length > MaxRefusalReasonLength)
{
throw new InvalidOperationException($"Refusal reason must be {MaxRefusalReasonLength} characters or fewer");
}
var now = DateTime.UtcNow;
dispatch.Status = "Refused";
dispatch.LastModificationTime = now;
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
FieldName = $"Dispatch {dispatch.DispatchNumber} Status",
OldValue = "Sent",
NewValue = "Refused",
Action = "vendor_refuse",
CreatedAt = now
}, cancellationToken);
if (!string.IsNullOrWhiteSpace(reasonText))
{
await _commentData.StageAsync(new Comments
{
DispatchId = id,
WorkerOrderId = dispatch.WorkOrderId,
Commenter = session.CompanyName,
CommentType = "vendor",
RecordType = "refusal",
Commenttext = reasonText,
CreatedDate = now
}, cancellationToken);
}
await _dispatchData.SaveChangesAsync(cancellationToken);
return new RefuseDispatchResultDTO { Id = dispatch.Id, Status = dispatch.Status, RefusedAt = now };
}
public async Task<ChangeStatusResultDTO> ChangeStatusAsync(VendorPortalSession session, int id, string? to, CancellationToken cancellationToken)
{
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
@ -282,7 +367,7 @@ namespace SeaHaven.Services.Implementation
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
if (IsPortalLockedStatus(dispatch.Status))
{
throw new InvalidOperationException($"Cannot request cancel on a '{dispatch.Status}' dispatch");
}
@ -319,7 +404,7 @@ namespace SeaHaven.Services.Implementation
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
if (IsPortalLockedStatus(dispatch.Status))
{
throw new InvalidOperationException("Dispatch is locked");
}
@ -430,12 +515,12 @@ 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");
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
if (IsPortalLockedStatus(dispatch.Status))
{
throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch");
}
@ -445,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);
@ -482,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
@ -495,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");
@ -507,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;
@ -521,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(
@ -536,6 +812,7 @@ namespace SeaHaven.Services.Implementation
int dispatchId,
IFormFile file,
int? replacesDocumentId,
string? purpose,
CancellationToken cancellationToken)
{
if (file is null || file.Length == 0)
@ -554,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();
@ -569,6 +862,11 @@ namespace SeaHaven.Services.Implementation
throw new KeyNotFoundException("Dispatch not found");
}
if (IsPortalLockedStatus(dispatch.Status))
{
throw new InvalidOperationException("Dispatch is locked");
}
var latest = await _documentData.GetLatestForDispatchAsync(dispatchId, cancellationToken);
var replacedDocument = latest;
if (replacesDocumentId.HasValue)
@ -601,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);
@ -626,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
};
}
@ -645,7 +955,7 @@ namespace SeaHaven.Services.Implementation
}
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken);
if (dispatch != null && (dispatch.Status == "Verified" || dispatch.Status == "Cancelled"))
if (dispatch != null && IsPortalLockedStatus(dispatch.Status))
{
return DownloadCompletionDocumentResultDTO.Locked();
}
@ -654,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)
@ -691,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)
@ -739,5 +1092,8 @@ namespace SeaHaven.Services.Implementation
if (from == "In Progress" && to == "Completed") return true;
return false;
}
private static bool IsPortalLockedStatus(string? status)
=> 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

@ -8,14 +8,18 @@ namespace SeaHaven.Services.Interfaces
Task<IEnumerable<VendorDispatchSummaryDTO>> ListDispatchesAsync(VendorPortalSession session, string? status, CancellationToken cancellationToken);
Task<VendorDispatchDetailDTO?> GetDispatchDetailAsync(VendorPortalSession session, int id, CancellationToken cancellationToken);
Task<AcceptDispatchResultDTO> AcceptDispatchAsync(VendorPortalSession session, int id, CancellationToken cancellationToken);
Task<RefuseDispatchResultDTO> RefuseDispatchAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken);
Task<ChangeStatusResultDTO> ChangeStatusAsync(VendorPortalSession session, int id, string? to, CancellationToken cancellationToken);
Task RequestCancelAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken);
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);
}
}

View file

@ -9,11 +9,11 @@
"version": "0.1.0",
"dependencies": {
"aws-cdk-lib": "2.262.1",
"constructs": "10.7.2"
"constructs": "10.8.1"
},
"devDependencies": {
"@types/node": "26.1.2",
"aws-cdk": "2.1134.0",
"@types/node": "26.2.0",
"aws-cdk": "2.1135.1",
"typescript": "7.0.2"
},
"engines": {
@ -69,9 +69,9 @@
}
},
"node_modules/@types/node": {
"version": "26.1.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -419,9 +419,9 @@
}
},
"node_modules/aws-cdk": {
"version": "2.1134.0",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1134.0.tgz",
"integrity": "sha512-Fy/g+gdpMpkhAAoCNlZtX0s4mD7q58bHlDV6QfbnTeifmQ5cEge26c5rB+U4qKB/bDudxNuJjQk/mSSk0ysnug==",
"version": "2.1135.1",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1135.1.tgz",
"integrity": "sha512-g1jcMfWlyYtGamFJ/kPBOCuchl3NfwTF2UwOLTIDN0nJbGm84EAO+c8DlYnaemM8UmKFkdoq4BGdmiNL5nHWwA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -643,9 +643,9 @@
}
},
"node_modules/constructs": {
"version": "10.7.2",
"resolved": "https://registry.npmjs.org/constructs/-/constructs-10.7.2.tgz",
"integrity": "sha512-vA7JOqvO/xwq2HBCuq3qc6V2R9mHZCxJ8HPoucUcUWJY88YULe7bzMcsdBy27/gJJIphZi73U1oIXDY2Eqg6nA==",
"version": "10.8.1",
"resolved": "https://registry.npmjs.org/constructs/-/constructs-10.8.1.tgz",
"integrity": "sha512-98yGXYyhePqPYh3cYu8nzBERmAhC0DONe3UD03okK0nehZ7hYP4wgZuf02a04+uOWxnTJ5Rpp5m0GRNpwyLGGA==",
"license": "Apache-2.0"
},
"node_modules/typescript": {

View file

@ -14,11 +14,11 @@
},
"dependencies": {
"aws-cdk-lib": "2.262.1",
"constructs": "10.7.2"
"constructs": "10.8.1"
},
"devDependencies": {
"@types/node": "26.1.2",
"aws-cdk": "2.1134.0",
"@types/node": "26.2.0",
"aws-cdk": "2.1135.1",
"typescript": "7.0.2"
}
}