Compare commits

..

20 commits

Author SHA1 Message Date
Arthur Bassi
afcb4fde8d Merge branch 'dev' into feature/wo-board-completed-date-media 2026-08-11 15:20:17 -03:00
Arthur Bassi
de0f6da8fb fix(work-orders): scope legacy GET GetComments by account [SH-221]
Pass ClaimsPrincipal into GetCommentsAsync and filter via
GetAllForAccountAsync so account-scoped callers cannot enumerate
cross-tenant comments. ADR + cross-account tests updated.
2026-08-11 15:18:29 -03:00
Arthur Bassi
3c090e2757 fix(work-orders): scope comments and completion-doc by account [SH-221]
Close the remaining SH-221 bypass: board/legacy comments and completion-doc now enforce server-derived account scope, authorize before blob storage, and cover cross-account regressions.
2026-08-11 14:52:02 -03:00
Alexandre Brandizzi
3e8a7115fe
Merge pull request #62 from Sea-Haven-Industries/feat/sh-101-uplift-lifecycle
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions
feat(uplifts): complete vendor approval lifecycle (SH-101)
2026-08-11 13:51:40 -03:00
Alexandre Brandizzi
c049721b6a
Merge branch 'dev' into feat/sh-101-uplift-lifecycle 2026-08-11 09:02:42 -03:00
Alexandre Brandizzi
f959cc45d0
Merge pull request #57 from Sea-Haven-Industries/feat/sh-98-portal-lifecycle
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions
feat(vendor-portal): complete service request lifecycle (SH-98)
2026-08-11 09:01:59 -03:00
Alexandre Brandizzi
24283b320a feat(uplifts): complete SH-101 approval lifecycle 2026-08-11 08:58:19 -03:00
Alexandre Brandizzi
32387bc913
Merge branch 'dev' into feat/sh-98-portal-lifecycle 2026-08-11 08:53:33 -03:00
Alexandre Brandizzi
c4bac19520
Merge pull request #61 from Sea-Haven-Industries/dependabot/npm_and_yarn/infra/cdk/aws-cdk-2.1135.1
chore(deps-dev): bump aws-cdk from 2.1134.0 to 2.1135.1 in /infra/cdk
2026-08-11 08:49:08 -03:00
dependabot[bot]
03591907e5
chore(deps-dev): bump aws-cdk from 2.1134.0 to 2.1135.1 in /infra/cdk
Bumps [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk) from 2.1134.0 to 2.1135.1.
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1135.1/packages/aws-cdk)

---
updated-dependencies:
- dependency-name: aws-cdk
  dependency-version: 2.1135.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 11:44:16 +00:00
Alexandre Brandizzi
1716608b99
Merge pull request #60 from Sea-Haven-Industries/dependabot/npm_and_yarn/infra/cdk/types/node-26.2.0
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions
chore(deps-dev): bump @types/node from 26.1.2 to 26.2.0 in /infra/cdk
2026-08-11 08:42:16 -03:00
Alexandre Brandizzi
548a59de6a
Merge branch 'dev' into dependabot/npm_and_yarn/infra/cdk/types/node-26.2.0 2026-08-11 08:37:17 -03:00
Alexandre Brandizzi
30806b9552
Merge pull request #59 from Sea-Haven-Industries/dependabot/npm_and_yarn/infra/cdk/constructs-10.8.1
chore(deps): bump constructs from 10.7.2 to 10.8.1 in /infra/cdk
2026-08-11 08:37:09 -03:00
Alexandre Brandizzi
3b7302abd6
Merge branch 'dev' into dependabot/npm_and_yarn/infra/cdk/constructs-10.8.1 2026-08-11 08:32:13 -03:00
Alexandre Brandizzi
16ca752558
Merge pull request #58 from Sea-Haven-Industries/dependabot/github_actions/aws-actions/aws-elasticbeanstalk-deploy-1.0.8
chore(deps): bump aws-actions/aws-elasticbeanstalk-deploy from 1.0.6 to 1.0.8
2026-08-11 08:31:43 -03:00
dependabot[bot]
26a7ad53f0
chore(deps-dev): bump @types/node from 26.1.2 to 26.2.0 in /infra/cdk
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 26.1.2 to 26.2.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 02:44:51 +00:00
dependabot[bot]
d52b73eba9
chore(deps): bump constructs from 10.7.2 to 10.8.1 in /infra/cdk
Bumps [constructs](https://github.com/aws/constructs) from 10.7.2 to 10.8.1.
- [Release notes](https://github.com/aws/constructs/releases)
- [Commits](https://github.com/aws/constructs/compare/v10.7.2...v10.8.1)

---
updated-dependencies:
- dependency-name: constructs
  dependency-version: 10.8.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 02:44:46 +00:00
dependabot[bot]
c0f6f78661
chore(deps): bump aws-actions/aws-elasticbeanstalk-deploy
Bumps [aws-actions/aws-elasticbeanstalk-deploy](https://github.com/aws-actions/aws-elasticbeanstalk-deploy) from 1.0.6 to 1.0.8.
- [Release notes](https://github.com/aws-actions/aws-elasticbeanstalk-deploy/releases)
- [Changelog](https://github.com/aws-actions/aws-elasticbeanstalk-deploy/blob/main/CHANGELOG.md)
- [Commits](cfad3e5e44...7883cdd454)

---
updated-dependencies:
- dependency-name: aws-actions/aws-elasticbeanstalk-deploy
  dependency-version: 1.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 02:44:28 +00:00
Alexandre Brandizzi
5dcd68b0a1 fix(vendor-portal): block uplift approval after refusal 2026-08-10 13:22:16 -03:00
Alexandre Brandizzi
7dabd25155 feat(vendor-portal): add refusal lifecycle (SH-98) 2026-08-10 13:00:32 -03:00
52 changed files with 7635 additions and 165 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

@ -106,10 +106,13 @@ namespace Api.SeaHavenIndustries.Controllers
if (file == null || file.Length == 0)
return BadRequest(new Response { Status = "Error", Message = "file is required." });
string? fileUrl = null;
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var fileUrl = await _fileStorage.SaveFileAsync(file);
await _workOrderCompletionService.EnsureCanUploadCompletionDocAsync(id, User, actorId);
fileUrl = await _fileStorage.SaveFileAsync(file);
var result = await _workOrderCompletionService.UploadCompletionDocAsync(
id,
new WorkOrderCompletionDocUploadDto
@ -119,21 +122,37 @@ namespace Api.SeaHavenIndustries.Controllers
WorkOrderVersion = workOrderVersion
},
fileUrl,
User,
actorId);
return Ok(result);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code is "NotFound")
{
TryCompensateUpload(fileUrl);
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code is "Forbidden")
{
TryCompensateUpload(fileUrl);
return StatusCode(StatusCodes.Status403Forbidden,
new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code is "ConcurrencyConflict")
{
TryCompensateUpload(fileUrl);
return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
TryCompensateUpload(fileUrl);
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
private void TryCompensateUpload(string? fileUrl)
{
if (!string.IsNullOrWhiteSpace(fileUrl))
_fileStorage.TryDelete(fileUrl);
}
}
}

View file

@ -318,9 +318,17 @@ namespace Api.SeaHavenIndustries.Controllers
Document = model.Document
};
var saved = await _workOrderService.AddCommentAsync(input, userId!);
var saved = await _workOrderService.AddCommentAsync(input, User, userId!);
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200", Data = saved });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new Response { Status = "Error", Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
@ -341,9 +349,17 @@ namespace Api.SeaHavenIndustries.Controllers
CommentType = model.CommentType
};
var result = await _workOrderService.AddCommentJsonAsync(input, userId!);
var result = await _workOrderService.AddCommentJsonAsync(input, User, userId!);
return Ok(result);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new Response { Status = "Error", Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
@ -354,16 +370,32 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("GetComments")]
public async Task<IActionResult> GetComments()
{
var data = await _workOrderService.GetCommentsAsync();
return Ok(data);
try
{
var data = await _workOrderService.GetCommentsAsync(User);
return Ok(data);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
}
}
[HttpGet]
[Route("GetCommentsByWorkorderId")]
public async Task<IActionResult> GetCommentsByWorkorderId(int woid)
{
var data = await _workOrderService.GetCommentsByWorkorderIdAsync(woid);
return Ok(data);
try
{
var data = await _workOrderService.GetCommentsByWorkorderIdAsync(woid, User);
if (data == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(data);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
}
}
[HttpGet]

View file

@ -61,10 +61,18 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet("{id:int}/comments")]
public async Task<IActionResult> GetBoardComments(int id)
{
var comments = await _workOrderCommentService.GetCommentsAsync(id);
if (comments == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(comments);
try
{
var comments = await _workOrderCommentService.GetCommentsAsync(id, User);
if (comments == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(comments);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden,
new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[HttpPost("{id:int}/comments")]
@ -73,13 +81,18 @@ namespace Api.SeaHavenIndustries.Controllers
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var comment = await _workOrderCommentService.AddCommentAsync(id, request, actorId);
var comment = await _workOrderCommentService.AddCommentAsync(id, request, User, actorId);
return Ok(comment);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden,
new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
@ -96,7 +109,7 @@ namespace Api.SeaHavenIndustries.Controllers
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var comment = await _workOrderCommentService.UpdateCommentAsync(
id, commentId, request, actorId);
id, commentId, request, User, actorId);
return Ok(comment);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")

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

@ -1,5 +1,6 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
@ -25,6 +26,20 @@ namespace SeaHaven.DataServices.Implementation
.ToListAsync();
}
public async Task<IEnumerable<Comments>> GetAllForAccountAsync(int? accountId)
{
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (accountId.HasValue)
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, accountId.Value);
return await (
from c in _context.Comments.AsNoTracking().Include(c => c.ApplicationUser)
join w in workOrders on c.WorkerOrderId equals w.Id
orderby c.CreatedDate
select c
).ToListAsync();
}
public async Task<IEnumerable<Comments>> GetByWorkOrderIdAsync(int workOrderId)
{
return await _context.Comments

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

@ -1,5 +1,6 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
@ -13,10 +14,29 @@ namespace SeaHaven.DataServices.Implementation
_context = context;
}
public Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
=> _context.workOrders.FirstOrDefaultAsync(
w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null),
cancellationToken);
public Task<WorkOrder?> GetWorkOrderForCompletionAuthAsync(
int workOrderId,
int? accountId,
CancellationToken cancellationToken)
{
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
}
public Task<WorkOrder?> GetTrackedWorkOrderAsync(
int workOrderId,
int? accountId,
CancellationToken cancellationToken)
{
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders);
if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
}
public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version)
=> _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version;

View file

@ -6,6 +6,11 @@ namespace SeaHaven.DataServices.Interfaces
{
Task<Comments?> GetByIdAsync(int id);
Task<IEnumerable<Comments>> GetAllAsync();
/// <summary>
/// Comments linked to non-deleted, non-template work orders.
/// When <paramref name="accountId"/> is set, only that account's WOs; null = org-wide.
/// </summary>
Task<IEnumerable<Comments>> GetAllForAccountAsync(int? accountId);
Task<IEnumerable<Comments>> GetByWorkOrderIdAsync(int workOrderId);
Task<IEnumerable<Comments>> GetByDispatchIdAsync(int dispatchId);
Task<Comments> AddAsync(Comments comment);

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

@ -4,7 +4,16 @@ namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderCompletionDataService
{
Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<WorkOrder?> GetWorkOrderForCompletionAuthAsync(
int workOrderId,
int? accountId,
CancellationToken cancellationToken);
Task<WorkOrder?> GetTrackedWorkOrderAsync(
int workOrderId,
int? accountId,
CancellationToken cancellationToken);
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
Task<CompletionDocTemplate?> GetTrackedTemplateAsync(int id, CancellationToken cancellationToken);
Task SaveAsync(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

@ -1,4 +1,5 @@
using Data.SeaHavenIndustries;
using System.Security.Claims;
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
@ -12,20 +13,26 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderDetailDataService _detailData;
private readonly ICommentDataService _commentData;
private readonly IUserDataService _userDataService;
private readonly IWorkOrderAccountResolver _accountResolver;
public WorkOrderCommentService(
IWorkOrderDetailDataService detailData,
ICommentDataService commentData,
IUserDataService userDataService)
IUserDataService userDataService,
IWorkOrderAccountResolver accountResolver)
{
_detailData = detailData;
_commentData = commentData;
_userDataService = userDataService;
_accountResolver = accountResolver;
}
public async Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId)
public async Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(
int workOrderId,
ClaimsPrincipal user)
{
if (!await _detailData.ExistsAsync(workOrderId))
var accountId = _accountResolver.ResolveAccountFilter(user);
if (!await _detailData.ExistsAsync(workOrderId, CancellationToken.None, accountId))
return null;
var comments = await _detailData.GetCommentsAsync(workOrderId);
@ -36,10 +43,12 @@ namespace SeaHaven.Services.Implementation
public async Task<WorkOrderCommentDto> AddCommentAsync(
int workOrderId,
WorkOrderCommentCreateDto request,
ClaimsPrincipal user,
string? actorId,
string? documentUrl = null)
{
if (!await _detailData.ExistsAsync(workOrderId))
var accountId = _accountResolver.ResolveAccountFilter(user);
if (!await _detailData.ExistsAsync(workOrderId, CancellationToken.None, accountId))
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (string.IsNullOrWhiteSpace(request.Text))
@ -65,9 +74,12 @@ namespace SeaHaven.Services.Implementation
int workOrderId,
int commentId,
WorkOrderCommentCreateDto request,
ClaimsPrincipal user,
string? actorId)
{
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId);
var accountId = _accountResolver.ResolveAccountFilter(user);
var workOrder = await _detailData.GetWorkOrderForMediaAsync(
workOrderId, CancellationToken.None, accountId);
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces;
@ -14,17 +15,20 @@ namespace SeaHaven.Services.Implementation
private readonly ICompletionDocTemplateDataService _templateData;
private readonly IWorkOrderDetailDataService _detailData;
private readonly IWorkOrderAuditService _auditService;
private readonly IWorkOrderAccountResolver _accountResolver;
public WorkOrderCompletionService(
IWorkOrderCompletionDataService completionData,
ICompletionDocTemplateDataService templateData,
IWorkOrderDetailDataService detailData,
IWorkOrderAuditService auditService)
IWorkOrderAuditService auditService,
IWorkOrderAccountResolver accountResolver)
{
_completionData = completionData;
_templateData = templateData;
_detailData = detailData;
_auditService = auditService;
_accountResolver = accountResolver;
}
public async Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType)
@ -39,13 +43,34 @@ namespace SeaHaven.Services.Implementation
return row == null ? null : WorkOrderDetailService.MapTemplate(row);
}
public async Task EnsureCanUploadCompletionDocAsync(
int workOrderId,
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(actorId) || user?.Identity?.IsAuthenticated != true)
{
throw new WorkOrderBoardValidationException(
"Forbidden",
"You are not allowed to upload completion documents.");
}
// AsNoTracking pre-check so UploadCompletionDocAsync load is not stale-cached.
await GetMutableWorkOrderForAuthAsync(workOrderId, user, cancellationToken);
}
public async Task<WorkOrderCompletionDto> UploadCompletionDocAsync(
int workOrderId,
WorkOrderCompletionDocUploadDto request,
string fileUrl,
string? actorId)
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default)
{
var workOrder = await _completionData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None);
var accountId = _accountResolver.ResolveAccountFilter(user);
var workOrder = await _completionData.GetTrackedWorkOrderAsync(
workOrderId, accountId, cancellationToken);
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
@ -77,7 +102,7 @@ namespace SeaHaven.Services.Implementation
if (oldDocStatus != DocStatus.Yes.ToString())
await _auditService.StageFieldChangedAsync(workOrderId, "DocStatus", oldDocStatus, DocStatus.Yes.ToString(), actorId);
await _completionData.SaveAsync(CancellationToken.None);
await _completionData.SaveAsync(cancellationToken);
var extended = await _detailData.GetExtendedFieldsAsync(workOrderId);
var template = await _templateData.ResolveForWorkOrderAsync(workOrder.Trade, workOrder.WorkOrderType);
@ -143,6 +168,23 @@ namespace SeaHaven.Services.Implementation
throw new WorkOrderBoardValidationException("NotFound", "Template not found.");
}
private async Task<WorkOrder> GetMutableWorkOrderForAuthAsync(
int workOrderId,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var workOrder = await _completionData.GetWorkOrderForCompletionAuthAsync(
workOrderId, accountId, cancellationToken);
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
return workOrder;
}
private static void ValidateTemplateRequest(CompletionDocTemplateCreateDto request)
{
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.ServiceKey))

View file

@ -5,6 +5,7 @@ using FluentValidation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
@ -439,8 +440,10 @@ namespace SeaHaven.Services.Implementation
return true;
}
public async Task<Comments> AddCommentAsync(AddCommentInput input, string userId)
public async Task<Comments> AddCommentAsync(AddCommentInput input, ClaimsPrincipal user, string userId)
{
await EnsureWorkOrderInAccountScopeAsync(input.WorkorderId, user);
var comment = new Comments
{
UserId = userId,
@ -458,8 +461,10 @@ namespace SeaHaven.Services.Implementation
return await _commentDataService.AddAsync(comment);
}
public async Task<CommentResult> AddCommentJsonAsync(AddCommentInput input, string userId)
public async Task<CommentResult> AddCommentJsonAsync(AddCommentInput input, ClaimsPrincipal user, string userId)
{
await EnsureWorkOrderInAccountScopeAsync(input.WorkorderId, user);
var comment = new Comments
{
UserId = userId,
@ -469,7 +474,7 @@ namespace SeaHaven.Services.Implementation
};
var saved = await _commentDataService.AddAsync(comment);
var user = await _userDataService.GetByIdAsync(userId);
var displayUser = await _userDataService.GetByIdAsync(userId);
return new CommentResult
{
@ -477,13 +482,14 @@ namespace SeaHaven.Services.Implementation
CreatedDate = saved.CreatedDate,
Commenttext = saved.Commenttext,
CommentType = saved.CommentType,
UserName = user != null ? (user.FirstName + " " + user.LastName).Trim() : ""
UserName = displayUser != null ? (displayUser.FirstName + " " + displayUser.LastName).Trim() : ""
};
}
public async Task<IEnumerable<CommentListItemReadModel>> GetCommentsAsync()
public async Task<IEnumerable<CommentListItemReadModel>> GetCommentsAsync(ClaimsPrincipal user)
{
var comments = await _commentDataService.GetAllAsync();
var accountId = _accountResolver.ResolveAccountFilter(user);
var comments = await _commentDataService.GetAllForAccountAsync(accountId);
return comments.Select(s => new CommentListItemReadModel
{
Id = s.Id,
@ -493,8 +499,15 @@ namespace SeaHaven.Services.Implementation
}).ToList();
}
public async Task<IEnumerable<CommentListItemReadModel>> GetCommentsByWorkorderIdAsync(int woid)
public async Task<IEnumerable<CommentListItemReadModel>?> GetCommentsByWorkorderIdAsync(
int woid,
ClaimsPrincipal user)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var workOrder = await _workOrderDataService.GetWorkOrderDetailAsync(woid, accountId);
if (workOrder == null)
return null;
var comments = await _commentDataService.GetByWorkOrderIdAsync(woid);
return comments.Select(s => new CommentListItemReadModel
{
@ -505,6 +518,14 @@ namespace SeaHaven.Services.Implementation
}).ToList();
}
private async Task EnsureWorkOrderInAccountScopeAsync(int workOrderId, ClaimsPrincipal user)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var workOrder = await _workOrderDataService.GetWorkOrderDetailAsync(workOrderId, accountId);
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
public async Task<IEnumerable<WorkOrder>> GetWorkordersDDAsync(int? accountId = null)
{
var data = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(accountId);

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

@ -1,16 +1,22 @@
using Data.SeaHavenIndustries.Enums;
using System.Security.Claims;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderCommentService
{
Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId);
Task<WorkOrderCommentDto> AddCommentAsync(int workOrderId, WorkOrderCommentCreateDto request, string? actorId, string? documentUrl = null);
Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId, ClaimsPrincipal user);
Task<WorkOrderCommentDto> AddCommentAsync(
int workOrderId,
WorkOrderCommentCreateDto request,
ClaimsPrincipal user,
string? actorId,
string? documentUrl = null);
Task<WorkOrderCommentDto> UpdateCommentAsync(
int workOrderId,
int commentId,
WorkOrderCommentCreateDto request,
ClaimsPrincipal user,
string? actorId);
}
}

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.DTOs;
@ -7,11 +8,18 @@ namespace SeaHaven.Services.Interfaces
{
Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType);
Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id);
Task EnsureCanUploadCompletionDocAsync(
int workOrderId,
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default);
Task<WorkOrderCompletionDto> UploadCompletionDocAsync(
int workOrderId,
WorkOrderCompletionDocUploadDto request,
string fileUrl,
string? actorId);
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default);
Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request);
Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request);
Task DeleteTemplateAsync(int id);

View file

@ -37,10 +37,10 @@ namespace SeaHaven.Services.Interfaces
Task<int> CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, ClaimsPrincipal user, string userId);
Task<bool> UpdateWorkOrderWithDetailsAsync(UpdateWorkOrderWithDetailsInput input, string userId);
Task<bool> DeleteWorkOrderCascadeAsync(int id, string userId);
Task<Comments> AddCommentAsync(AddCommentInput input, string userId);
Task<CommentResult> AddCommentJsonAsync(AddCommentInput input, string userId);
Task<IEnumerable<CommentListItemReadModel>> GetCommentsAsync();
Task<IEnumerable<CommentListItemReadModel>> GetCommentsByWorkorderIdAsync(int woid);
Task<Comments> AddCommentAsync(AddCommentInput input, ClaimsPrincipal user, string userId);
Task<CommentResult> AddCommentJsonAsync(AddCommentInput input, ClaimsPrincipal user, string userId);
Task<IEnumerable<CommentListItemReadModel>> GetCommentsAsync(ClaimsPrincipal user);
Task<IEnumerable<CommentListItemReadModel>?> GetCommentsByWorkorderIdAsync(int woid, ClaimsPrincipal user);
Task<IEnumerable<WorkOrder>> GetWorkordersDDAsync(int? accountId = null);
Task<IEnumerable<WorkorderFilterVM>> GetWorkordersAsync(int? accountId = null);
}

View file

@ -429,4 +429,257 @@ public class WorkOrderAccountScopeTests
});
await context.SaveChangesAsync();
}
[Fact]
public async Task Comments_CrossAccount_GetAddUpdate_ReturnNotFound()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B");
context.Users.Add(new ApplicationUser
{
Id = "author-1",
UserName = "author",
FirstName = "Ann",
LastName = "Author"
});
context.workOrders.Add(new WorkOrder
{
Id = 20,
InternalWONumber = "00000000020",
AccountId = 2,
LifecycleStatus = LifecycleStatus.Scheduled,
istemplate = false
});
context.Comments.Add(new Comments
{
Id = 5,
WorkerOrderId = 20,
UserId = "author-1",
Commenttext = "Secret",
CommentType = "General",
RecordType = "WorkOrder",
CreatedDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
var service = new WorkOrderCommentService(
new WorkOrderDetailDataService(context),
new CommentDataService(context),
new UserDataService(context),
WorkOrderAccountTestHelpers.Resolver(context));
var scoped = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher");
Assert.Null(await service.GetCommentsAsync(20, scoped));
var addEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.AddCommentAsync(20, new WorkOrderCommentCreateDto { Text = "Nope" }, scoped, "disp-1"));
Assert.Equal("NotFound", addEx.Code);
var updateEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateCommentAsync(
20, 5, new WorkOrderCommentCreateDto { Text = "Nope" }, scoped, "author-1"));
Assert.Equal("NotFound", updateEx.Code);
}
[Fact]
public async Task Comments_SameAccount_AddSucceeds()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
context.Users.Add(new ApplicationUser
{
Id = "disp-1",
UserName = "disp",
FirstName = "Dee",
LastName = "Spatch"
});
context.workOrders.Add(new WorkOrder
{
Id = 21,
InternalWONumber = "00000000021",
AccountId = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
istemplate = false
});
await context.SaveChangesAsync();
var service = new WorkOrderCommentService(
new WorkOrderDetailDataService(context),
new CommentDataService(context),
new UserDataService(context),
WorkOrderAccountTestHelpers.Resolver(context));
var scoped = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher");
var result = await service.AddCommentAsync(
21, new WorkOrderCommentCreateDto { Text = "In scope" }, scoped, "disp-1");
Assert.Equal("In scope", result.Text);
Assert.Equal("disp-1", result.AuthorId);
}
[Fact]
public async Task LegacyCommentsByWorkOrder_CrossAccount_ReturnsNull()
{
await using var context = CreateContext();
await SeedThreeAccountRowsAsync(context);
context.Comments.Add(new Comments
{
WorkerOrderId = 2,
Commenttext = "Other account",
CreatedDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
var service = CreateLegacyReadService(context);
var result = await service.GetCommentsByWorkorderIdAsync(
2, WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"));
Assert.Null(result);
}
[Fact]
public async Task CompletionDoc_CrossAccount_EnsureAndUpload_ThrowNotFound()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B");
context.workOrders.Add(new WorkOrder
{
Id = 30,
InternalWONumber = "00000000030",
AccountId = 2,
LifecycleStatus = LifecycleStatus.Scheduled,
DocStatus = DocStatus.No,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 },
istemplate = false
});
await context.SaveChangesAsync();
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderCompletionService(
new WorkOrderCompletionDataService(context),
new CompletionDocTemplateDataService(context),
new WorkOrderDetailDataService(context),
audit,
WorkOrderAccountTestHelpers.Resolver(context));
var scoped = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher");
var ensureEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.EnsureCanUploadCompletionDocAsync(30, scoped, "disp-1"));
Assert.Equal("NotFound", ensureEx.Code);
var uploadEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UploadCompletionDocAsync(
30,
new WorkOrderCompletionDocUploadDto
{
WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
},
"https://example.com/should-not-stick.pdf",
scoped,
"disp-1"));
Assert.Equal("NotFound", uploadEx.Code);
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == 30);
Assert.Null(wo.SignOffAttachment);
Assert.Equal(DocStatus.No, wo.DocStatus);
}
[Fact]
public async Task CompletionDoc_SameAccount_EnsureAllowsUpload()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
var rowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 };
context.workOrders.Add(new WorkOrder
{
Id = 31,
InternalWONumber = "00000000031",
AccountId = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
DocStatus = DocStatus.No,
RowVersion = rowVersion,
istemplate = false
});
await context.SaveChangesAsync();
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderCompletionService(
new WorkOrderCompletionDataService(context),
new CompletionDocTemplateDataService(context),
new WorkOrderDetailDataService(context),
audit,
WorkOrderAccountTestHelpers.Resolver(context));
var scoped = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher");
await service.EnsureCanUploadCompletionDocAsync(31, scoped, "disp-1");
var result = await service.UploadCompletionDocAsync(
31,
new WorkOrderCompletionDocUploadDto
{
WorkOrderVersion = Convert.ToBase64String(rowVersion)
},
"https://example.com/ok.pdf",
scoped,
"disp-1");
Assert.Equal(DocStatus.Yes, result.DocStatus);
Assert.Equal("https://example.com/ok.pdf", result.SignOffAttachment);
}
[Fact]
public async Task LegacyGetComments_ScopedUser_HidesOtherAccountComments()
{
await using var context = CreateContext();
await SeedThreeAccountRowsAsync(context);
context.Comments.AddRange(
new Comments
{
WorkerOrderId = 1,
Commenttext = "Account A note",
Documents = "a.pdf",
CreatedDate = DateTime.UtcNow
},
new Comments
{
WorkerOrderId = 2,
Commenttext = "Account B secret",
Documents = "b.pdf",
CreatedDate = DateTime.UtcNow
},
new Comments
{
WorkerOrderId = 3,
Commenttext = "Null account note",
CreatedDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
var service = CreateLegacyReadService(context);
var scoped = await service.GetCommentsAsync(
WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"));
Assert.Single(scoped);
Assert.Equal("Account A note", scoped.Single().Commenttext);
Assert.DoesNotContain(scoped, c => c.Commenttext == "Account B secret");
Assert.DoesNotContain(scoped, c => c.Commenttext == "Null account note");
var orgWide = await service.GetCommentsAsync(WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.Equal(3, orgWide.Count());
var missingEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.GetCommentsAsync(WorkOrderAccountTestHelpers.MissingScope()));
Assert.Equal("Forbidden", missingEx.Code);
}
}

View file

@ -247,7 +247,12 @@ public class WorkOrderCompletionServiceTests
var detailData = new WorkOrderDetailDataService(context);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderCompletionService(completionData, templateData, detailData, audit);
var service = new WorkOrderCompletionService(
completionData,
templateData,
detailData,
audit,
WorkOrderAccountTestHelpers.Resolver(context));
return (context, service);
}
@ -274,6 +279,7 @@ public class WorkOrderCompletionServiceTests
WorkOrderVersion = Convert.ToBase64String(wo.RowVersion!)
},
"https://example.com/signed.pdf",
WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"),
"actor-1");
Assert.Equal(DocStatus.Yes, result.DocStatus);
@ -304,6 +310,7 @@ public class WorkOrderCompletionServiceTests
1,
new WorkOrderCompletionDocUploadDto { WorkOrderVersion = version },
"https://example.com/first.pdf",
WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"),
"actor-1");
// Simulate a new request scope: clear local tracker then reload
@ -319,6 +326,7 @@ public class WorkOrderCompletionServiceTests
WorkOrderVersion = Convert.ToBase64String(reloaded.RowVersion!)
},
"https://example.com/second.pdf",
WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"),
"actor-1");
var locks = await context.WorkOrderFieldLocks.ToListAsync();
@ -344,6 +352,7 @@ public class WorkOrderCompletionServiceTests
1,
new WorkOrderCompletionDocUploadDto(),
"https://example.com/signed.pdf",
WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"),
"actor-1"));
Assert.Equal("WorkOrderVersionRequired", ex.Code);
@ -368,6 +377,7 @@ public class WorkOrderCompletionServiceTests
WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
},
"https://example.com/signed.pdf",
WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"),
"actor-1"));
Assert.Equal("ReadOnly", ex.Code);
@ -396,9 +406,14 @@ public class WorkOrderCommentServiceTests
var detailData = new WorkOrderDetailDataService(context);
var commentData = new CommentDataService(context);
var userData = new UserDataService(context);
var service = new WorkOrderCommentService(detailData, commentData, userData);
var service = new WorkOrderCommentService(
detailData, commentData, userData, WorkOrderAccountTestHelpers.Resolver(context));
var result = await service.AddCommentAsync(1, new WorkOrderCommentCreateDto { Text = "Note" }, "user-abc");
var result = await service.AddCommentAsync(
1,
new WorkOrderCommentCreateDto { Text = "Note" },
WorkOrderAccountTestHelpers.OrgWideAdmin("user-abc"),
"user-abc");
Assert.Equal("user-abc", result.AuthorId);
Assert.Equal("Bob Tech", result.AuthorName);
@ -426,9 +441,10 @@ public class WorkOrderCommentServiceTests
var detailData = new WorkOrderDetailDataService(context);
var commentData = new CommentDataService(context);
var userData = new UserDataService(context);
var service = new WorkOrderCommentService(detailData, commentData, userData);
var service = new WorkOrderCommentService(
detailData, commentData, userData, WorkOrderAccountTestHelpers.Resolver(context));
var comments = await service.GetCommentsAsync(1);
var comments = await service.GetCommentsAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(comments);
Assert.Single(comments!);
@ -471,7 +487,8 @@ public class WorkOrderCommentServiceTests
var service = new WorkOrderCommentService(
new WorkOrderDetailDataService(context),
new CommentDataService(context),
new UserDataService(context));
new UserDataService(context),
WorkOrderAccountTestHelpers.Resolver(context));
return (context, service, comment);
}
@ -484,6 +501,7 @@ public class WorkOrderCommentServiceTests
1,
comment.Id,
new WorkOrderCommentCreateDto { Text = " Updated note " },
WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"),
"user-author");
Assert.Equal("Updated note", result.Text);
@ -501,6 +519,7 @@ public class WorkOrderCommentServiceTests
1,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Admin edit" },
WorkOrderAccountTestHelpers.OrgWideAdmin("admin-user"),
"admin-user"));
Assert.Equal("Forbidden", ex.Code);
@ -516,6 +535,7 @@ public class WorkOrderCommentServiceTests
1,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Nope" },
WorkOrderAccountTestHelpers.OrgWideAdmin("other-user"),
"other-user"));
Assert.Equal("Forbidden", ex.Code);
@ -533,6 +553,7 @@ public class WorkOrderCommentServiceTests
1,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Nope" },
WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"),
"user-author"));
Assert.Equal("NotEditable", ex.Code);
@ -548,6 +569,7 @@ public class WorkOrderCommentServiceTests
1,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Nope" },
WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"),
"user-author"));
Assert.Equal("ReadOnly", ex.Code);
@ -565,6 +587,7 @@ public class WorkOrderCommentServiceTests
2,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Nope" },
WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"),
"user-author"));
Assert.Equal("NotFound", ex.Code);

View file

@ -7,8 +7,11 @@
- `WorkOrder.AccountId` / `ApplicationUser.AccountId` schema keys (nullable; legacy null fail-closed)
- JWT `account_id` when `ApplicationUser.AccountId` is set
- JWT `org_scope=all` when Admin has no AccountId (explicit signed elevation)
- **Reads** (board, list, advanced search, detail, media): `ApplyBaseScope` +
- **Reads** (board, list, advanced search, detail, media, board comments,
legacy comments-by-work-order-id, legacy `GET GetComments`): `ApplyBaseScope` +
`ApplyAccountScope(int)` when account-scoped; org-wide path skips account filter
- **Writes** (board mutations, media, board/legacy comments, `POST …/completion-doc`):
same account filter at service/data entry; authorize before storing blobs
- **Creates** (board, AddWorkorder, ingest, webhook/recon, sync): stamp `AccountId`
from claim or unique `Accounts.Name` ↔ `Customer` match; unresolvable → reject/skip
- Missing/malformed scope → **Forbidden** (absence of claim does not elevate)
@ -37,6 +40,8 @@ in review (fail-open) and replaced by the contract below.
callers; only `org_scope=all` may read them.
7. **Authorization at service entry**: staff roles may read/mutate any resulting
work order; role `User` only when `AssignTo == actorId` (media); delete staff-only.
Board comments, legacy comments-by-WO-id, and completion-doc uploads apply the
same account filter before read/write (and before blob storage).
8. **User lifecycle** persists `AccountId` on Admin create/edit so non-Admin
principals can receive `account_id`.

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