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; public UpliftService( IUpliftDataService upliftData, IDispatchDataService dispatchData, IVendorDocumentStoragePort documentStorage, TimeProvider timeProvider, IOptions approvalsOptions) { _upliftData = upliftData; _dispatchData = dispatchData; _documentStorage = documentStorage; _timeProvider = timeProvider; _approvalsOptions = approvalsOptions.Value; } public async Task 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 mapped = items.Select(r => new UpliftListItemDTO { Id = r.Id, DispatchId = r.DispatchId, DispatchNumber = r.DispatchNumber, PONumber = r.PONumber, WorkOrderId = r.WorkOrderId, VendorCompanyName = r.VendorCompanyName, 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 }).ToList(); return new UpliftListResultDTO { Total = total, Page = page, PageSize = pageSize, Items = mapped }; } public async Task> 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 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 _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, 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 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 RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken) => RejectInternalAsync(user, id, note, "reject", cancellationToken); private async Task 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 _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch?.WorkOrderId ?? 0, 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 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 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); foreach (var r in roles) { if (!string.IsNullOrWhiteSpace(r) && user.IsInRole(r)) return true; } return false; } private string[] RolesForTier(int requiredTier) => requiredTier switch { 1 => _approvalsOptions.Tier1Roles, 2 => _approvalsOptions.Tier2Roles, _ => Array.Empty() }; // 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"; } }