From d366f319e9343b7f153970b6650513ea425a6083 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 20:11:26 -0300 Subject: [PATCH 1/2] feat(uplifts): add approval decision actions (SH-210) --- .../UpliftControllerTests.cs | 43 +++++++++++++++ .../UpliftServiceRefusedTests.cs | 53 +++++++++++++++++++ .../Controllers/UpliftController.cs | 29 ++++++++++ .../Implementation/UpliftDataService.cs | 21 ++++++++ .../Interfaces/IUpliftDataService.cs | 1 + .../Implementation/UpliftService.cs | 40 +++++++++++++- .../Interfaces/IUpliftService.cs | 1 + .../WorkOrderBoardCancelServiceTests.cs | 1 + 8 files changed, 188 insertions(+), 1 deletion(-) diff --git a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs index dc634aa..7d49f24 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs @@ -117,6 +117,49 @@ public class UpliftControllerTests nf.Value.Should().BeOfType(); } + [Fact] + public async Task Revoke_WithReason_DelegatesToService() + { + var service = new Mock(); + service.Setup(x => x.RevokeAsync( + It.IsAny(), + 7, + "Policy change", + It.IsAny())) + .ReturnsAsync(new UpliftDecisionResultDTO { Id = 7, Status = "Revoked" }); + + var controller = NewController(service, "Admin"); + + var result = await controller.Revoke( + 7, + new UpliftController.DecisionRequest { Note = "Policy change" }); + + result.Should().BeOfType(); + service.Verify(x => x.RevokeAsync( + It.IsAny(), + 7, + "Policy change", + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Revoke_WithoutReason_ReturnsBadRequest() + { + var service = new Mock(); + service.Setup(x => x.RevokeAsync( + It.IsAny(), + 7, + string.Empty, + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("reason required")); + + var controller = NewController(service, "Admin"); + + var result = await controller.Revoke(7, null); + + result.Should().BeOfType(); + } + [Fact] public async Task Approve_Forbidden_ReturnsSanitized403AndLogsInternally() { diff --git a/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs b/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs index 9fa989c..a420208 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Options; using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using Xunit; @@ -13,6 +14,58 @@ namespace Api.SeaHavenIndustries.Tests; public sealed class UpliftServiceRefusedTests { + [Fact] + public async Task RevokeAsync_DelegatesApprovedAdminRevocationToWorkOrderFlow() + { + var request = new DispatchUpliftRequest + { + Id = 7, + DispatchId = 42, + Status = "Approved", + RequiredTier = 1, + RequestedNTE = 900m + }; + var upliftData = new Mock(); + upliftData.Setup(data => data.GetByIdAsync(7, It.IsAny())) + .ReturnsAsync(request); + upliftData.Setup(data => data.GetWorkOrderIdForUpliftAsync(7, It.IsAny())) + .ReturnsAsync(77); + var workOrderFlow = new Mock(); + workOrderFlow.Setup(flow => flow.RevokeAsync( + 77, + 7, + It.Is(r => r.Reason == "Policy change"), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new WorkOrderUpliftDto { Id = 7, Status = "revoked" }); + + var service = new UpliftService( + upliftData.Object, + Mock.Of(), + Mock.Of(), + TimeProvider.System, + Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()), + workOrderFlow.Object); + var user = new ClaimsPrincipal(new ClaimsIdentity( + new[] + { + new Claim(ClaimTypes.NameIdentifier, "admin-1"), + new Claim(ClaimTypes.Role, "Admin") + }, + "test")); + + var result = await service.RevokeAsync(user, 7, " Policy change ", CancellationToken.None); + + result.Id.Should().Be(7); + result.Status.Should().Be("Revoked"); + workOrderFlow.Verify(flow => flow.RevokeAsync( + 77, + 7, + It.Is(r => r.Reason == "Policy change"), + It.IsAny(), + It.IsAny()), Times.Once); + } + [Fact] public async Task ApproveAsync_RefusedDispatch_RejectsWithoutChangingNteOrRequest() { diff --git a/Api.SeaHavenIndustries/Controllers/UpliftController.cs b/Api.SeaHavenIndustries/Controllers/UpliftController.cs index cc2d9ca..fcba99f 100644 --- a/Api.SeaHavenIndustries/Controllers/UpliftController.cs +++ b/Api.SeaHavenIndustries/Controllers/UpliftController.cs @@ -127,6 +127,35 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpPost("{id:int}/revoke")] + public async Task Revoke( + int id, + [FromBody] DecisionRequest? body, + CancellationToken cancellationToken = default) + { + try + { + var result = await _upliftService.RevokeAsync( + 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 revoke this uplift") }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "This uplift request cannot be revoked") }); + } + } + [HttpGet("can-approve")] public IActionResult CanApprove([FromQuery] int tier) { diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 8d7ac66..6c52434 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -221,6 +221,27 @@ namespace SeaHaven.DataServices.Implementation .FirstOrDefaultAsync(u => u.Id == id, cancellationToken); } + public async Task GetWorkOrderIdForUpliftAsync( + int upliftRequestId, + CancellationToken cancellationToken) + { + var link = await _context.DispatchUpliftRequests + .Where(u => u.Id == upliftRequestId + && (u.IsDeleted == null || u.IsDeleted == false) + && u.Dispatch != null + && (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)) + .Select(u => new + { + PrimaryWorkOrderId = u.Dispatch!.WorkOrderId, + LinkedWorkOrderId = u.Dispatch.DispatchWorkOrders! + .Select(dispatchWorkOrder => (int?)dispatchWorkOrder.WorkOrderId) + .FirstOrDefault() + }) + .FirstOrDefaultAsync(cancellationToken); + + return link?.PrimaryWorkOrderId ?? link?.LinkedWorkOrderId; + } + public async Task GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken) { return await _context.DispatchUpliftRequests diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs index 15be0ab..9ca8a5d 100644 --- a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -11,6 +11,7 @@ namespace SeaHaven.DataServices.Interfaces Task GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken); Task> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken); Task GetByIdAsync(int id, CancellationToken cancellationToken); + Task GetWorkOrderIdForUpliftAsync(int upliftRequestId, CancellationToken cancellationToken); Task GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken); // SH-101: server-side join of an uplift request with its linked evidence document. // Returns null when the request, the linked evidence, or the dispatch linkage is absent. diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs index 034f102..9e86960 100644 --- a/SeaHaven.Services/Implementation/UpliftService.cs +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -15,19 +15,22 @@ namespace SeaHaven.Services.Implementation private readonly IVendorDocumentStoragePort _documentStorage; private readonly TimeProvider _timeProvider; private readonly ApprovalsOptions _approvalsOptions; + private readonly IWorkOrderUpliftService? _workOrderUpliftService; public UpliftService( IUpliftDataService upliftData, IDispatchDataService dispatchData, IVendorDocumentStoragePort documentStorage, TimeProvider timeProvider, - IOptions approvalsOptions) + IOptions approvalsOptions, + IWorkOrderUpliftService? workOrderUpliftService = null) { _upliftData = upliftData; _dispatchData = dispatchData; _documentStorage = documentStorage; _timeProvider = timeProvider; _approvalsOptions = approvalsOptions.Value; + _workOrderUpliftService = workOrderUpliftService; } public async Task ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) @@ -180,6 +183,41 @@ namespace SeaHaven.Services.Implementation public Task RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken) => RejectInternalAsync(user, id, note, "reject", cancellationToken); + public async Task RevokeAsync( + ClaimsPrincipal user, + int id, + string reason, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(reason)) + throw new InvalidOperationException("A reason is required when revoking an approved uplift"); + + var request = await _upliftData.GetByIdAsync(id, cancellationToken); + if (request == null) + throw new KeyNotFoundException("Uplift request not found"); + if (!string.Equals(UpliftStatus.ToCanonical(request.Status), UpliftStatus.Approved, StringComparison.Ordinal)) + throw new InvalidOperationException($"Cannot revoke a '{UpliftStatus.ToCanonical(request.Status)}' uplift request"); + if (!user.IsInRole("Admin")) + throw new UpliftForbiddenException("Admin role is required to revoke an approved uplift"); + if (_workOrderUpliftService == null) + throw new InvalidOperationException("The work-order uplift flow is unavailable"); + + var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(id, cancellationToken); + if (!workOrderId.HasValue) + throw new KeyNotFoundException("Work order not found"); + + var revoked = await _workOrderUpliftService.RevokeAsync( + workOrderId.Value, + id, + new RevokeWorkOrderUpliftRequestDto { Reason = reason.Trim() }, + user, + cancellationToken); + if (revoked == null) + throw new KeyNotFoundException("Work order not found"); + + return new UpliftDecisionResultDTO { Id = revoked.Id, Status = UpliftStatus.Revoked }; + } + private async Task RejectInternalAsync(ClaimsPrincipal user, int id, string? note, string actionSuffix, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(note)) diff --git a/SeaHaven.Services/Interfaces/IUpliftService.cs b/SeaHaven.Services/Interfaces/IUpliftService.cs index 04dcadc..c84142f 100644 --- a/SeaHaven.Services/Interfaces/IUpliftService.cs +++ b/SeaHaven.Services/Interfaces/IUpliftService.cs @@ -11,6 +11,7 @@ namespace SeaHaven.Services.Interfaces Task DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken); // SH-101: canonical reject (alias of deny; writes Rejected). Task RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken); + Task RevokeAsync(ClaimsPrincipal user, int id, string reason, CancellationToken cancellationToken); // SH-101: internal request-changes route (note + tier authorization + audit). Task RequestChangesAsync(ClaimsPrincipal user, int id, string note, CancellationToken cancellationToken); // SH-101: authorized internal download of a Passed UpliftEvidence file linked to an uplift request. diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index 12ddd97..bfc163f 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -436,6 +436,7 @@ public class WorkOrderBoardCancelServiceTests public Task GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetByIdAsync(int id, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetWorkOrderIdForUpliftAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); From 316d1e6a6390ea52042633f41d9a114b2e6fcd4c Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 22:19:43 -0300 Subject: [PATCH 2/2] fix(uplifts): correct queue exposure projections (SH-210) --- .../UpliftQueueReadTests.cs | 62 ++++++++++++++++++- .../Implementation/UpliftDataService.cs | 16 ++++- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs index ad539bd..39d8d91 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -86,6 +86,7 @@ public sealed class UpliftQueueReadTests var vendor = new Vendor { CompanyName = vendorName, IsActive = true }; var workOrder = new WorkOrder { + InternalWONumber = number, WorkerOrderNumber = number, SiteCode = site, Service = service, @@ -280,6 +281,35 @@ public sealed class UpliftQueueReadTests item.Status.Should().Be("Pending"); } + [Fact] + public async Task List_WorkOrderNumber_RendersInternalShNumber_NotTheCrmExternalId() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + // Synced work orders carry the CRM external id in WorkerOrderNumber; the SH + // display number the board renders is stamped on InternalWONumber. + var workOrder = new WorkOrder + { + InternalWONumber = "10000000001", + WorkerOrderNumber = "CRM-EXT-77", + SiteCode = "SITE-EAST", + Service = "HVAC", + WorkerOrderTitle = "Repair" + }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 3, 2))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + var item = result.Items.Should().ContainSingle().Subject; + item.WorkOrderNumber.Should().Be("10000000001"); + item.WorkOrderNumber.Should().NotBe("CRM-EXT-77"); + } + [Fact] public async Task List_RequesterLabelFallsBackToCreatingUserName() { @@ -353,7 +383,9 @@ public sealed class UpliftQueueReadTests var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); context.DispatchUpliftRequests.AddRange( - Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 300m), + // Vendor-portal row (createdby null): stores the requested new NTE total, + // so the granted amount is 400 - 100 = 300. + Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 400m, currentNte: 100m), new DispatchUpliftRequest { DispatchId = dispatchA.Id, @@ -390,7 +422,7 @@ public sealed class UpliftQueueReadTests RequiredTier = 1, RequestedNTE = 999m }, - Request(dispatchB, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 75m)); + Request(dispatchB, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 75m, currentNte: 0m)); await context.SaveChangesAsync(); var service = NewService(context); @@ -440,6 +472,32 @@ public sealed class UpliftQueueReadTests total.AutoApprovedTotal.Should().Be(0m); } + [Fact] + public async Task Exposure_AdminApprovedSumsGrantedAmountsPerCreationPath() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A"); + context.DispatchUpliftRequests.AddRange( + // Vendor-portal rows (createdby null) store the requested new NTE total: + // 1000 -> 1500 grants 500, then 1500 -> 1800 grants 300. + Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 1500m, currentNte: 1000m), + Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 1800m, currentNte: 1500m), + // Work-order-path rows (createdby set) store the granted increment: 200. + Request(dispatch, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 200m, currentNte: 1800m, createdBy: "user-7")); + await context.SaveChangesAsync(); + var data = new UpliftDataService(context); + + var exposure = await data.GetApprovedExposureForWorkOrdersAsync( + new[] { workOrder.Id }, CancellationToken.None); + + // Granted exposure is 500 + 300 + 200; summing raw RequestedNTE would + // double-count whole NTE totals and report 3500. + var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrder.Id).Subject; + total.AdminApprovedTotal.Should().Be(1000m); + total.AutoApprovedTotal.Should().Be(0m); + } + // --- Read authorization (tier roles) --- [Fact] diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 6c52434..5cb7013 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -61,7 +61,10 @@ namespace SeaHaven.DataServices.Implementation PONumber = x.d.PONumber, WorkOrderId = x.d.WorkOrderId, VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName, - WorkOrderNumber = x.wo != null ? x.wo.WorkerOrderNumber : null, + // The queue renders the internal display number (InternalWONumber, what the + // board shows); WorkerOrderNumber holds the CRM external id on synced + // work orders and must not leak into the queue display. + WorkOrderNumber = x.wo != null ? x.wo.InternalWONumber : null, WorkOrderSiteCode = x.wo != null ? x.wo.SiteCode : null, WorkOrderService = x.wo != null ? x.wo.Service : null, RequestedByVendorName = x.u.RequestedByVendorName, @@ -339,8 +342,17 @@ namespace SeaHaven.DataServices.Implementation DispatchId = g.Key, AutoApproved = g.Where(x => x.Status == "NoApprovalRequired") .Sum(x => (decimal?)x.RequestedNTE), + // The two creation paths store different meanings in RequestedNTE. + // Vendor-portal rows store the requested new NTE total, so the + // granted amount is RequestedNTE - CurrentNTE; work-order-path rows + // store the granted increment directly. Vendor sessions have no + // identity user, so createdby is null only on vendor-portal rows. Summing + // granted amounts keeps sequential approvals from double-counting whole + // NTE totals. AdminApproved = g.Where(x => x.Status == "Approved") - .Sum(x => (decimal?)x.RequestedNTE) + .Sum(x => (decimal?)(x.createdby == null + ? x.RequestedNTE - (x.CurrentNTE ?? 0m) + : x.RequestedNTE)) }) .ToListAsync(cancellationToken);