mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
feat(uplifts): add approval decision actions (SH-210)
This commit is contained in:
parent
3cb1e3e3f3
commit
d366f319e9
8 changed files with 188 additions and 1 deletions
|
|
@ -117,6 +117,49 @@ public class UpliftControllerTests
|
|||
nf.Value.Should().BeOfType<Response>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_WithReason_DelegatesToService()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.RevokeAsync(
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
7,
|
||||
"Policy change",
|
||||
It.IsAny<CancellationToken>()))
|
||||
.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<OkObjectResult>();
|
||||
service.Verify(x => x.RevokeAsync(
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
7,
|
||||
"Policy change",
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_WithoutReason_ReturnsBadRequest()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.RevokeAsync(
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
7,
|
||||
string.Empty,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("reason required"));
|
||||
|
||||
var controller = NewController(service, "Admin");
|
||||
|
||||
var result = await controller.Revoke(7, null);
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approve_Forbidden_ReturnsSanitized403AndLogsInternally()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<IUpliftDataService>();
|
||||
upliftData.Setup(data => data.GetByIdAsync(7, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(request);
|
||||
upliftData.Setup(data => data.GetWorkOrderIdForUpliftAsync(7, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(77);
|
||||
var workOrderFlow = new Mock<IWorkOrderUpliftService>();
|
||||
workOrderFlow.Setup(flow => flow.RevokeAsync(
|
||||
77,
|
||||
7,
|
||||
It.Is<RevokeWorkOrderUpliftRequestDto>(r => r.Reason == "Policy change"),
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new WorkOrderUpliftDto { Id = 7, Status = "revoked" });
|
||||
|
||||
var service = new UpliftService(
|
||||
upliftData.Object,
|
||||
Mock.Of<IDispatchDataService>(),
|
||||
Mock.Of<IVendorDocumentStoragePort>(),
|
||||
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<RevokeWorkOrderUpliftRequestDto>(r => r.Reason == "Policy change"),
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveAsync_RefusedDispatch_RejectsWithoutChangingNteOrRequest()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -127,6 +127,35 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/revoke")]
|
||||
public async Task<IActionResult> 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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -221,6 +221,27 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<int?> 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<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.DispatchUpliftRequests
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken);
|
||||
Task<int?> GetWorkOrderIdForUpliftAsync(int upliftRequestId, 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.
|
||||
|
|
|
|||
|
|
@ -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> approvalsOptions)
|
||||
IOptions<ApprovalsOptions> approvalsOptions,
|
||||
IWorkOrderUpliftService? workOrderUpliftService = null)
|
||||
{
|
||||
_upliftData = upliftData;
|
||||
_dispatchData = dispatchData;
|
||||
_documentStorage = documentStorage;
|
||||
_timeProvider = timeProvider;
|
||||
_approvalsOptions = approvalsOptions.Value;
|
||||
_workOrderUpliftService = workOrderUpliftService;
|
||||
}
|
||||
|
||||
public async Task<UpliftListResultDTO> ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken)
|
||||
|
|
@ -180,6 +183,41 @@ namespace SeaHaven.Services.Implementation
|
|||
public Task<UpliftDecisionResultDTO> RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken)
|
||||
=> RejectInternalAsync(user, id, note, "reject", cancellationToken);
|
||||
|
||||
public async Task<UpliftDecisionResultDTO> 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<UpliftDecisionResultDTO> RejectInternalAsync(ClaimsPrincipal user, int id, string? note, string actionSuffix, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(note))
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ namespace SeaHaven.Services.Interfaces
|
|||
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);
|
||||
Task<UpliftDecisionResultDTO> RevokeAsync(ClaimsPrincipal user, int id, string reason, 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.
|
||||
|
|
|
|||
|
|
@ -436,6 +436,7 @@ public class WorkOrderBoardCancelServiceTests
|
|||
public Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<int?> GetWorkOrderIdForUpliftAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue