shoc-backend/SeaHaven.Services/Implementation/UpliftService.cs
Alexandre Brandizzi c5d82a996a fix(uplifts): audit uplift decisions on the dispatch's resolved work order
Approve, reject, request-changes, expiry and escalation staged their work
order audit with WorkOrderId = dispatch.WorkOrderId ?? 0. WorkOrderAuditLogs
requires a real work order, so any uplift on a dispatch without an owning
work order failed to save: the decision returned a 500 and the request stayed
Pending, and the expiry sweep failed on it every run.

The audit now goes to the work order the uplift resolves to through the
existing owner-or-linked read that revoke already uses. When none resolves,
the status change is saved on the request and no audit row is written.
2026-09-24 23:30:16 -03:00

391 lines
20 KiB
C#

using Data.SeaHavenIndustries;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace SeaHaven.Services.Implementation
{
public class UpliftService : IUpliftService
{
private readonly IUpliftDataService _upliftData;
private readonly IDispatchDataService _dispatchData;
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,
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)
{
var (total, items) = await _upliftData.GetPagedAsync(status, tier, page, pageSize, cancellationToken);
var pendingExposureTotal = await _upliftData.GetPendingExposureAsync(cancellationToken);
var mapped = items.Select(r => new UpliftListItemDTO
{
Id = r.Id,
DispatchId = r.DispatchId,
DispatchNumber = r.DispatchNumber,
PONumber = r.PONumber,
WorkOrderId = r.WorkOrderId,
VendorCompanyName = r.VendorCompanyName,
WorkOrderNumber = r.WorkOrderNumber,
WorkOrderSite = r.WorkOrderSiteCode,
WorkOrderService = r.WorkOrderService,
WorkOrderScheduledDate = r.WorkOrderScheduledDate,
WorkOrderDispatcherName = ResolveRequestedByName(null, r.WorkOrderDispatcherFirstName, r.WorkOrderDispatcherLastName),
TechnicianName = string.IsNullOrWhiteSpace(r.TechnicianName) ? null : r.TechnicianName.Trim(),
RequestedByName = ResolveRequestedByName(r.RequestedByVendorName, r.RequestedByFirstName, r.RequestedByLastName),
DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName),
CurrentNTE = r.CurrentNTE,
RequestedNTE = r.RequestedNTE,
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
VendorReason = r.VendorReason,
RequiredTier = r.RequiredTier,
Status = UpliftStatus.ToCanonical(r.Status),
RequestedAt = r.CreatedDate,
DecidedAt = r.DecidedAt,
DecisionNote = r.DecisionNote,
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,
AttachmentCount = r.AttachmentCount,
WorkOrderClosed = r.WorkOrderClosed
}).ToList();
// Approved-on-WO exposure totals are aggregated once for the whole page
// (single set-based data-service call) and then attached per item.
var workOrderIds = mapped
.Where(i => i.WorkOrderId.HasValue)
.Select(i => i.WorkOrderId!.Value)
.Distinct()
.ToList();
var exposureByWorkOrder = (await _upliftData
.GetApprovedExposureForWorkOrdersAsync(workOrderIds, cancellationToken))
.ToDictionary(e => e.WorkOrderId);
foreach (var item in mapped)
{
if (!item.WorkOrderId.HasValue) continue;
if (!exposureByWorkOrder.TryGetValue(item.WorkOrderId.Value, out var exposure)) continue;
item.WorkOrderAutoApprovedTotal = exposure.AutoApprovedTotal;
item.WorkOrderAdminApprovedTotal = exposure.AdminApprovedTotal;
item.WorkOrderApprovedExposureTotal = exposure.AutoApprovedTotal + exposure.AdminApprovedTotal;
}
return new UpliftListResultDTO
{
Total = total,
Page = page,
PageSize = pageSize,
PendingExposureTotal = pendingExposureTotal,
Items = mapped
};
}
public async Task<IEnumerable<UpliftForDispatchDTO>> ListForDispatchAsync(ClaimsPrincipal user, int dispatchId, CancellationToken cancellationToken)
{
var rows = await _upliftData.GetForDispatchAsync(dispatchId, cancellationToken);
return rows.Select(r => new UpliftForDispatchDTO
{
Id = r.Id,
DispatchId = r.DispatchId,
CurrentNTE = r.CurrentNTE,
RequestedNTE = r.RequestedNTE,
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
VendorReason = r.VendorReason,
Status = UpliftStatus.ToCanonical(r.Status),
RequiredTier = r.RequiredTier,
RequestedByVendorName = r.RequestedByVendorName,
RequestedAt = r.CreatedDate,
DecidedAt = r.DecidedAt,
DecisionNote = r.DecisionNote,
DecidedByName = string.Join(" ", new[] { r.DecidedByFirstName, r.DecidedByLastName }
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(),
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();
}
public async Task<UpliftApproveResultDTO> ApproveAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken)
{
var req = await _upliftData.GetByIdAsync(id, cancellationToken);
if (req == null) throw new KeyNotFoundException("Uplift request not found");
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 (IsTerminalForUplift(dispatch.Status))
throw new InvalidOperationException($"Cannot approve uplift on a '{dispatch.Status}' dispatch");
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var now = _timeProvider.GetUtcNow().UtcDateTime;
var oldNTE = dispatch.NTEAmount ?? 0m;
dispatch.NTEAmount = req.RequestedNTE;
dispatch.LastModificationTime = now;
req.Status = UpliftStatus.Approved;
req.DecidedAt = now;
req.DecidedByUserId = userId;
req.DecisionNote = string.IsNullOrWhiteSpace(note) ? null : note!.Trim();
req.LastModificationTime = now;
await StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
{
WorkOrderId = workOrderId,
UserId = userId,
FieldName = $"Dispatch {dispatch.DispatchNumber} NTE",
OldValue = $"${oldNTE:F2}",
NewValue = $"${req.RequestedNTE:F2}",
Action = "uplift_approved",
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 = 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);
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))
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 (!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 = _timeProvider.GetUtcNow().UtcDateTime;
// 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();
req.LastModificationTime = now;
await StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
{
WorkOrderId = workOrderId,
UserId = userId,
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
OldValue = previous,
NewValue = UpliftStatus.Rejected,
Action = $"uplift_{actionSuffix}",
CreatedAt = now
}, cancellationToken);
await _upliftData.SaveChangesAsync(cancellationToken);
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 StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
{
WorkOrderId = workOrderId,
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);
// The decision audit goes to the work order the uplift's dispatch belongs to (owner,
// else the one it is linked to). A dispatch with neither has no work order to audit
// against, so the decision is recorded on the request alone.
private async Task StageDecisionAuditAsync(
int upliftId,
Func<int, WorkOrderAuditLog> buildEntry,
CancellationToken cancellationToken)
{
var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(upliftId, cancellationToken);
if (workOrderId is int resolved)
await _dispatchData.StageAuditLogAsync(buildEntry(resolved), cancellationToken);
}
// 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");
}
// Admin passes every permission check regardless of stored configuration,
// so the tier-role lists only govern non-Admin approvers.
private bool UserCanApprove(ClaimsPrincipal user, int requiredTier)
{
if (user.IsInRole("Admin")) return true;
var roles = RolesForTier(requiredTier);
foreach (var r in roles)
{
if (!string.IsNullOrWhiteSpace(r) && user.IsInRole(r)) return true;
}
return false;
}
// Approval queue read contract: the requester label prefers the recorded
// requester (vendor or internal display name captured at creation) and falls
// back to the creating user's name resolved server-side.
private static string? ResolveRequestedByName(string? vendorName, string? firstName, string? lastName)
{
if (!string.IsNullOrWhiteSpace(vendorName))
return vendorName;
var composed = string.Join(" ", new[] { firstName, lastName }
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim();
return composed.Length > 0 ? composed : null;
}
private string[] RolesForTier(int requiredTier) => requiredTier switch
{
1 => _approvalsOptions.Tier1Roles,
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";
}
}