From 31d4352a23c8bf40567faa1ca732f2b33d1876e9 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 24 Sep 2026 14:34:07 -0300 Subject: [PATCH] fix(work-orders): accept a scanned uplift evidence file (SH-388) Dispatchers can attach one evidence file, and create links it only after that document has passed scanning. --- .../WorkOrderRouteContractTests.cs | 6 +- .../WorkOrderUpliftControllerTests.cs | 1 + .../Controllers/WorkOrderDetailController.cs | 50 ++++ .../Helper/VendorDocumentScanWorker.cs | 15 +- .../appsettings.Development.json | 3 + .../Configuration/ServiceOptions.cs | 4 + SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs | 3 + .../Helpers/WorkOrderUpliftContractMapper.cs | 3 + .../WorkOrderUpliftEvidenceService.cs | 226 ++++++++++++++++++ .../Implementation/WorkOrderUpliftService.cs | 34 ++- .../IWorkOrderUpliftEvidenceService.cs | 21 ++ .../WorkOrderBoardCancelServiceTests.cs | 12 +- .../WorkOrderUpliftEvidenceServiceTests.cs | 219 +++++++++++++++++ .../WorkOrderUpliftServiceTests.cs | 89 ++++++- 14 files changed, 674 insertions(+), 12 deletions(-) create mode 100644 SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderUpliftEvidenceService.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs index 6411337..804a77b 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs @@ -38,8 +38,8 @@ public class WorkOrderRouteContractTests /// Baseline public endpoint set (verb + action-relative route) that the original single /// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122). /// Every action is reachable under both api/WorkOrder and api/workorders; that base-route - /// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes - /// come from 50 actions (Editworkorder and GetWorkorderById each bind two routes). + /// duplication is collapsed here, so this is the distinct action-relative contract. 54 routes + /// come from 52 actions (Editworkorder and GetWorkorderById each bind two routes). /// private static readonly HashSet ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal) { @@ -66,6 +66,7 @@ public class WorkOrderRouteContractTests "GET {id:int}/comments", "GET {id:int}/detail", "GET {id:int}/uplifts", + "GET {id:int}/uplift-evidence/{documentId:int}", "GET {id:int}/media", "GET {id:int}/media/{mediaId:int}/content", "PATCH {id:int}/board", @@ -91,6 +92,7 @@ public class WorkOrderRouteContractTests "POST {id:int}/completion-doc", "POST {id:int}/media", "POST {id:int}/uplifts", + "POST {id:int}/uplift-evidence", "POST {id:int}/uplifts/{upliftId:int}/cancel", "POST {id:int}/uplifts/{upliftId:int}/revoke", "PUT completion-templates/{id:int}", diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs index b3cdbfc..f8068a1 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs @@ -22,6 +22,7 @@ public sealed class WorkOrderUpliftControllerTests Mock.Of(), Mock.Of(), upliftService.Object, + Mock.Of(), Mock.Of>()); var claims = new List { new(ClaimTypes.NameIdentifier, "dispatcher-1") }; claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r))); diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs index d975b32..eb99b1b 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs @@ -20,17 +20,20 @@ namespace Api.SeaHavenIndustries.Controllers private readonly IWorkOrderDetailService _workOrderDetailService; private readonly IWorkOrderCommentService _workOrderCommentService; private readonly IWorkOrderUpliftService _workOrderUpliftService; + private readonly IWorkOrderUpliftEvidenceService _workOrderUpliftEvidenceService; private readonly ILogger _logger; public WorkOrderDetailController( IWorkOrderDetailService workOrderDetailService, IWorkOrderCommentService workOrderCommentService, IWorkOrderUpliftService workOrderUpliftService, + IWorkOrderUpliftEvidenceService workOrderUpliftEvidenceService, ILogger logger) { _workOrderDetailService = workOrderDetailService; _workOrderCommentService = workOrderCommentService; _workOrderUpliftService = workOrderUpliftService; + _workOrderUpliftEvidenceService = workOrderUpliftEvidenceService; _logger = logger; } @@ -178,6 +181,53 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpPost("{id:int}/uplift-evidence")] + [RequestSizeLimit(10_000_000)] + public async Task UploadUpliftEvidence( + int id, + [FromForm] IFormFile file, + CancellationToken cancellationToken) + { + try + { + var uploaded = await _workOrderUpliftEvidenceService.UploadAsync(id, file, User, cancellationToken); + if (uploaded == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(new DataResponse { Status = "Success", Data = uploaded }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Resource not found") }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The evidence file could not be uploaded") }); + } + } + + [HttpGet("{id:int}/uplift-evidence/{documentId:int}")] + public async Task GetUpliftEvidenceStatus( + int id, + int documentId, + CancellationToken cancellationToken) + { + try + { + var status = await _workOrderUpliftEvidenceService.GetStatusAsync(id, documentId, User, cancellationToken); + if (status == null) + return NotFound(new Response { Status = "Error", Message = "Evidence document not found." }); + return Ok(new DataResponse { Status = "Success", Data = status }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Resource not found") }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The evidence file could not be read") }); + } + } + [HttpPost("{id:int}/uplifts/{upliftId:int}/cancel")] public async Task CancelUplift(int id, int upliftId, CancellationToken cancellationToken) { diff --git a/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs b/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs index b0ac5fd..fe64b6a 100644 --- a/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs +++ b/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs @@ -1,6 +1,8 @@ using Api.SeaHavenIndustries.Observability; using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SeaHaven.Services.Configuration; using Sentry; namespace Api.SeaHavenIndustries.Helper @@ -11,17 +13,20 @@ namespace Api.SeaHavenIndustries.Helper private readonly IWebHostEnvironment _environment; private readonly ILogger _logger; private readonly IHub _sentryHub; + private readonly bool _passWhenScannerUnavailable; public VendorDocumentScanWorker( IServiceScopeFactory scopeFactory, IWebHostEnvironment environment, ILogger logger, - IHub sentryHub) + IHub sentryHub, + IOptions documentOptions) { _scopeFactory = scopeFactory; _environment = environment; _logger = logger; _sentryHub = sentryHub; + _passWhenScannerUnavailable = documentOptions.Value.PassWhenScannerUnavailable; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -54,14 +59,16 @@ namespace Api.SeaHavenIndustries.Helper { var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document); var result = await scanner.ScanAsync(path, cancellationToken); - if (result == DocumentScanResult.Unavailable) + if (result == DocumentScanResult.Unavailable && !_passWhenScannerUnavailable) { _logger.LogWarning("Vendor document scan service unavailable; document {DocumentId} remains quarantined", document.Id); continue; } - document.ScanStatus = result == DocumentScanResult.Passed ? "Passed" : "Rejected"; - document.ReviewStatus = result == DocumentScanResult.Passed ? "Processing" : "Rejected"; + var passed = result == DocumentScanResult.Passed + || (result == DocumentScanResult.Unavailable && _passWhenScannerUnavailable); + document.ScanStatus = passed ? "Passed" : "Rejected"; + document.ReviewStatus = passed ? "Processing" : "Rejected"; document.RejectionReason = result == DocumentScanResult.Infected ? "The upload failed malware scanning." : document.RejectionReason; diff --git a/Api.SeaHavenIndustries/appsettings.Development.json b/Api.SeaHavenIndustries/appsettings.Development.json index 93b5c5f..45d3338 100644 --- a/Api.SeaHavenIndustries/appsettings.Development.json +++ b/Api.SeaHavenIndustries/appsettings.Development.json @@ -17,6 +17,9 @@ // Provide the real value via environment variable SendGrid__ApiKey or user-secrets. "ApiKey": "${SENDGRID_API_KEY}" }, + "VendorDocuments": { + "PassWhenScannerUnavailable": true + }, "AllowedHosts": "*", "JWT": { "ValidAudience": "http://localhost:4200", diff --git a/SeaHaven.Services/Configuration/ServiceOptions.cs b/SeaHaven.Services/Configuration/ServiceOptions.cs index 901dab6..852658d 100644 --- a/SeaHaven.Services/Configuration/ServiceOptions.cs +++ b/SeaHaven.Services/Configuration/ServiceOptions.cs @@ -47,4 +47,8 @@ public sealed class VendorDocumentsOptions public const string SectionName = "VendorDocuments"; public long MaxSizeBytes { get; set; } = 10 * 1024 * 1024; + + // why: local Development has no ClamAV host, so a required scan would leave every + // uplift file Pending. Production leaves this false and keeps the file quarantined. + public bool PassWhenScannerUnavailable { get; set; } } diff --git a/SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs index 8a7e683..72d7578 100644 --- a/SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs @@ -12,6 +12,8 @@ namespace SeaHaven.Services.DTOs public DateTime? DecidedAt { get; set; } public string DecidedByName { get; set; } = ""; public string DecisionNote { get; set; } = ""; + public int? EvidenceDocumentId { get; set; } + public string EvidenceFileName { get; set; } = ""; } public class WorkOrderUpliftListDto @@ -23,6 +25,7 @@ namespace SeaHaven.Services.DTOs { public decimal Amount { get; set; } public string? Notes { get; set; } + public int? EvidenceDocumentId { get; set; } } public class RevokeWorkOrderUpliftRequestDto diff --git a/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs b/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs index 7596b8d..45fbde0 100644 --- a/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs +++ b/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs @@ -50,6 +50,8 @@ namespace SeaHaven.Services.Helpers DecidedAt = row.DecidedAt, DecidedByName = decidedByName, DecisionNote = row.DecisionNote ?? "", + EvidenceDocumentId = row.EvidenceDocumentId, + EvidenceFileName = row.EvidenceFileName ?? "", }; } @@ -66,6 +68,7 @@ namespace SeaHaven.Services.Helpers DecidedAt = request.DecidedAt, DecidedByName = decidedByName ?? "", DecisionNote = request.DecisionNote ?? "", + EvidenceDocumentId = request.EvidenceDocumentId, }; public static WorkOrderUpliftDto MapRevokedItem( diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs new file mode 100644 index 0000000..775b4fe --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs @@ -0,0 +1,226 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderUpliftEvidenceService : IWorkOrderUpliftEvidenceService + { + private static readonly HashSet AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase) + { + "application/pdf", "image/jpeg", "image/jpg", "image/png" + }; + + private readonly IWorkOrderDetailDataService _detailData; + private readonly IDispatchDataService _dispatchData; + private readonly IVendorDocumentDataService _documentData; + private readonly IVendorDocumentStoragePort _documentStorage; + private readonly IWorkOrderAccountResolver _accountResolver; + private readonly VendorDocumentsOptions _documentOptions; + + public WorkOrderUpliftEvidenceService( + IWorkOrderDetailDataService detailData, + IDispatchDataService dispatchData, + IVendorDocumentDataService documentData, + IVendorDocumentStoragePort documentStorage, + IWorkOrderAccountResolver accountResolver, + IOptions documentOptions) + { + _detailData = detailData; + _dispatchData = dispatchData; + _documentData = documentData; + _documentStorage = documentStorage; + _accountResolver = accountResolver; + _documentOptions = documentOptions.Value; + } + + public async Task UploadAsync( + int workOrderId, + IFormFile file, + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + var target = await ResolveOpenDispatchAsync(workOrderId, user, cancellationToken); + if (target == null) + return null; + + var (contentType, bytes) = await ReadAcceptedFileAsync(file, cancellationToken); + var latest = await _documentData.GetLatestForDispatchAsync(target.Dispatch.Id, cancellationToken); + var version = (latest?.Version ?? 0) + 1; + var storedFileName = $"{target.Dispatch.Id}_{version}_{Guid.NewGuid():N}{SafeExtension(file.FileName)}"; + var now = DateTime.UtcNow; + var passWithoutScanner = _documentOptions.PassWhenScannerUnavailable; + var document = new VendorCompletionDocument + { + VendorId = target.Dispatch.VendorId, + DispatchId = target.Dispatch.Id, + WorkOrderId = workOrderId, + OriginalFileName = Path.GetFileName(file.FileName), + StoredFileName = storedFileName, + ContentType = contentType, + SizeBytes = bytes.Length, + ScanStatus = passWithoutScanner ? "Passed" : "Pending", + ReviewStatus = "Processing", + ScannedAt = passWithoutScanner ? now : null, + Version = version, + Purpose = VendorDocumentPurpose.UpliftEvidence, + CreatedDate = now + }; + + await _documentData.AddAsync(document, cancellationToken); + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = workOrderId, + DispatchId = target.Dispatch.Id, + UserId = user.FindFirstValue(ClaimTypes.NameIdentifier), + FieldName = $"Dispatch {target.Dispatch.DispatchNumber} Uplift Evidence", + NewValue = document.OriginalFileName, + Action = "uplift_evidence_uploaded", + ActorType = "internal", + CreatedAt = now + }, cancellationToken); + await _documentData.SaveChangesAsync(cancellationToken); + + using var stored = new MemoryStream(bytes); + await _documentStorage.SaveAsync( + target.Dispatch.VendorId, + target.Dispatch.Id, + storedFileName, + stored, + cancellationToken); + + return new UploadCompletionDocumentResultDTO + { + Id = document.Id, + Version = document.Version, + ScanStatus = document.ScanStatus, + ReviewStatus = document.ReviewStatus, + Purpose = document.Purpose + }; + } + + public async Task GetStatusAsync( + int workOrderId, + int documentId, + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + var target = await ResolveOpenDispatchAsync(workOrderId, user, cancellationToken); + if (target == null) + return null; + + var document = await _documentData.GetMetadataForVendorDispatchAsync( + documentId, + target.Dispatch.Id, + target.Dispatch.VendorId, + cancellationToken); + if (document == null || document.Purpose != VendorDocumentPurpose.UpliftEvidence) + return null; + + return new VendorDocumentStatusDTO + { + Id = document.Id, + OriginalFileName = document.OriginalFileName, + ScanStatus = document.ScanStatus, + ReviewStatus = document.ReviewStatus, + Purpose = document.Purpose + }; + } + + private async Task ResolveOpenDispatchAsync( + int workOrderId, + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + var accountFilter = _accountResolver.ResolveAccountFilter(user); + if (!await _detailData.ExistsAsync(workOrderId, cancellationToken, accountFilter)) + return null; + + var workOrder = await _detailData.GetWorkOrderForMediaAsync( + workOrderId, + cancellationToken, + accountFilter); + if (workOrder?.PrimaryDispatchId is not int dispatchId) + throw new InvalidOperationException("Work order has no primary dispatch for uplift requests"); + + if (workOrder.LifecycleStatus is LifecycleStatus.Completed or LifecycleStatus.Canceled) + { + throw new InvalidOperationException( + $"Cannot attach uplift evidence on a '{workOrder.LifecycleStatus}' work order"); + } + + var dispatch = await _dispatchData.GetByIdAsync(dispatchId); + if (dispatch == null) + throw new KeyNotFoundException("Dispatch not found"); + + if (dispatch.Status is "Verified" or "Cancelled" or "Canceled" or "Refused") + throw new InvalidOperationException($"Cannot attach uplift evidence on a '{dispatch.Status}' dispatch"); + + return new OpenDispatch(dispatch); + } + + private async Task<(string ContentType, byte[] Bytes)> ReadAcceptedFileAsync( + IFormFile file, + CancellationToken cancellationToken) + { + if (file is null || file.Length == 0) + throw new InvalidOperationException("An evidence file is required."); + + if (file.Length > _documentOptions.MaxSizeBytes) + throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size."); + + var contentType = (file.ContentType ?? string.Empty).Trim(); + if (!AllowedContentTypes.Contains(contentType)) + throw new InvalidOperationException("Only PDF, JPG, and PNG documents are accepted."); + + using var buffer = new MemoryStream(); + await file.CopyToAsync(buffer, cancellationToken); + var bytes = buffer.ToArray(); + if (!MatchesSignature(contentType, bytes)) + throw new InvalidOperationException("The uploaded file signature does not match its declared content type."); + + return (contentType, bytes); + } + + private static bool MatchesSignature(string contentType, byte[] bytes) + { + if (bytes.Length == 0) + return false; + + if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)) + { + return bytes.Length >= 4 + && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46; + } + + if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase)) + { + return bytes.Length >= 8 + && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 + && bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A; + } + + if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) + || contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase)) + { + return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; + } + + return false; + } + + private static string SafeExtension(string fileName) + { + var extension = Path.GetExtension(fileName); + return string.IsNullOrWhiteSpace(extension) ? string.Empty : extension; + } + + private sealed record OpenDispatch(Dispatch Dispatch); + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 6a2c388..e85c927 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -21,6 +21,7 @@ namespace SeaHaven.Services.Implementation private readonly IUserDataService _userData; private readonly TimeProvider _timeProvider; private readonly ApprovalsOptions _approvalsOptions; + private readonly IVendorDocumentDataService _documentData; public WorkOrderUpliftService( IUpliftDataService upliftData, @@ -29,7 +30,8 @@ namespace SeaHaven.Services.Implementation IWorkOrderAccountResolver accountResolver, IUserDataService userData, TimeProvider timeProvider, - IOptions approvalsOptions) + IOptions approvalsOptions, + IVendorDocumentDataService documentData) { _upliftData = upliftData; _dispatchData = dispatchData; @@ -38,6 +40,7 @@ namespace SeaHaven.Services.Implementation _userData = userData; _timeProvider = timeProvider; _approvalsOptions = approvalsOptions.Value; + _documentData = documentData; } public async Task?> ListAsync( @@ -75,6 +78,7 @@ namespace SeaHaven.Services.Implementation workOrderId, request.Amount, notes, + request.EvidenceDocumentId, userId, requesterName, accountFilter, @@ -86,6 +90,7 @@ namespace SeaHaven.Services.Implementation int workOrderId, decimal amount, string notes, + int? evidenceDocumentId, string? userId, string requesterName, int? accountFilter, @@ -114,6 +119,8 @@ namespace SeaHaven.Services.Implementation if (await _upliftData.HasPendingForWorkOrderAsync(workOrderId, cancellationToken)) throw new InvalidOperationException("An open uplift request already exists for this work order"); + await RequirePassedEvidenceAsync(evidenceDocumentId, dispatch.Id, dispatch.VendorId, cancellationToken); + var now = _timeProvider.GetUtcNow().UtcDateTime; var current = dispatch.NTEAmount ?? 0m; var consumed = await _upliftData.SumAutoApprovedAmountForWorkOrderAsync(workOrderId, cancellationToken); @@ -131,6 +138,7 @@ namespace SeaHaven.Services.Implementation current, amount, notes, + evidenceDocumentId, UpliftStatus.NoApprovalRequired, requiredTier: 0, expiresAt: null, @@ -148,6 +156,7 @@ namespace SeaHaven.Services.Implementation current, amount, notes, + evidenceDocumentId, UpliftStatus.Pending, requiredTier: 1, now + _approvalsOptions.EffectiveExpiration, @@ -337,6 +346,7 @@ namespace SeaHaven.Services.Implementation decimal currentNte, decimal amount, string notes, + int? evidenceDocumentId, string status, int requiredTier, DateTime? expiresAt, @@ -358,6 +368,7 @@ namespace SeaHaven.Services.Implementation createdby = userId, ExpiresAt = expiresAt, NotificationStatus = notificationStatus, + EvidenceDocumentId = evidenceDocumentId, }; if (status == UpliftStatus.NoApprovalRequired) { @@ -399,6 +410,27 @@ namespace SeaHaven.Services.Implementation return await _detailData.ExistsAsync(workOrderId, cancellationToken, accountId); } + private async Task RequirePassedEvidenceAsync( + int? evidenceDocumentId, + int dispatchId, + int vendorId, + CancellationToken cancellationToken) + { + if (!evidenceDocumentId.HasValue) + return; + + var evidence = await _documentData.GetUpliftEvidenceAsync( + evidenceDocumentId.Value, + dispatchId, + vendorId, + cancellationToken); + if (evidence == null) + { + throw new InvalidOperationException( + "The selected evidence document is not available or has not passed scanning"); + } + } + private async Task ResolveUserDisplayNameAsync(string? userId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userId)) diff --git a/SeaHaven.Services/Interfaces/IWorkOrderUpliftEvidenceService.cs b/SeaHaven.Services/Interfaces/IWorkOrderUpliftEvidenceService.cs new file mode 100644 index 0000000..31dc42c --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderUpliftEvidenceService.cs @@ -0,0 +1,21 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderUpliftEvidenceService + { + Task UploadAsync( + int workOrderId, + IFormFile file, + ClaimsPrincipal user, + CancellationToken cancellationToken); + + Task GetStatusAsync( + int workOrderId, + int documentId, + ClaimsPrincipal user, + CancellationToken cancellationToken); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index f53f1ff..080f338 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -204,7 +204,8 @@ public class WorkOrderBoardCancelServiceTests WorkOrderAccountTestHelpers.Resolver(context), new UserDataService(context), TimeProvider.System, - Options.Create(new ApprovalsOptions())); + Options.Create(new ApprovalsOptions()), + new VendorDocumentDataService(context)); var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts, new PassThroughUpliftData()); var result = await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"); @@ -265,7 +266,8 @@ public class WorkOrderBoardCancelServiceTests WorkOrderAccountTestHelpers.Resolver(context), new UserDataService(context), TimeProvider.System, - Options.Create(new ApprovalsOptions())); + Options.Create(new ApprovalsOptions()), + new VendorDocumentDataService(context)); var cancel = new WorkOrderBoardCancelService( new ThrowingSaveMutationData(mutationData), boardService, @@ -333,7 +335,8 @@ public class WorkOrderBoardCancelServiceTests WorkOrderAccountTestHelpers.Resolver(createContext), new UserDataService(createContext), TimeProvider.System, - Options.Create(new ApprovalsOptions())); + Options.Create(new ApprovalsOptions()), + new VendorDocumentDataService(createContext)); var boardData = new WorkOrderBoardDataService(cancelContext); var mutationData = new WorkOrderBoardMutationDataService(cancelContext); var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(cancelContext)); @@ -346,7 +349,8 @@ public class WorkOrderBoardCancelServiceTests WorkOrderAccountTestHelpers.Resolver(cancelContext), new UserDataService(cancelContext), TimeProvider.System, - Options.Create(new ApprovalsOptions())); + Options.Create(new ApprovalsOptions()), + new VendorDocumentDataService(cancelContext)); var cancel = new WorkOrderBoardCancelService( mutationData, boardService, diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs new file mode 100644 index 0000000..e0b90ab --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs @@ -0,0 +1,219 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace SeaHavenIndustries.Tests; + +public sealed class WorkOrderUpliftEvidenceServiceTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static async Task SeedWorkOrderAsync(ApplicationDbContext context) + { + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); + context.Users.Add(new ApplicationUser + { + Id = "dispatcher-1", + UserName = "dispatcher-1", + FirstName = "Alex", + LastName = "Dispatcher", + }); + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + context.Dispatches.Add(new Dispatch + { + Id = 10, + VendorId = 1, + WorkOrderId = 1, + NTEAmount = 1000m, + DispatchNumber = "DIS-10", + Status = "Scheduled", + }); + var workOrder = new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + PrimaryDispatchId = 10, + AccountId = 1, + WorkOrderType = WorkOrderType.PM, + }; + context.workOrders.Add(workOrder); + await context.SaveChangesAsync(); + return workOrder; + } + + private static (WorkOrderUpliftEvidenceService Service, MemoryStorage Storage) NewService( + ApplicationDbContext context, + bool passWhenScannerUnavailable = false) + { + var storage = new MemoryStorage(); + var service = new WorkOrderUpliftEvidenceService( + new WorkOrderDetailDataService(context), + new DispatchDataService(context), + new VendorDocumentDataService(context), + storage, + WorkOrderAccountTestHelpers.Resolver(context), + Options.Create(new VendorDocumentsOptions + { + PassWhenScannerUnavailable = passWhenScannerUnavailable, + })); + return (service, storage); + } + + private static ClaimsPrincipal Dispatcher() + => WorkOrderAccountTestHelpers.OrgWideAdmin("dispatcher-1"); + + private static FormFile FormFile(byte[] bytes, string fileName, string contentType) + { + return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType, + }; + } + + [Fact] + public async Task UploadAsync_StoresPendingUpliftEvidenceForThePrimaryDispatch() + { + await using var context = CreateContext(); + var workOrder = await SeedWorkOrderAsync(context); + var (service, storage) = NewService(context); + var pdf = "%PDF-1.4"u8.ToArray(); + + var uploaded = await service.UploadAsync( + workOrder.Id, + FormFile(pdf, "quote.pdf", "application/pdf"), + Dispatcher(), + CancellationToken.None); + + var document = Assert.Single(context.VendorCompletionDocuments); + Assert.Equal(document.Id, uploaded!.Id); + Assert.Equal("Pending", uploaded.ScanStatus); + Assert.Null(document.ScannedAt); + Assert.Equal(VendorDocumentPurpose.UpliftEvidence, document.Purpose); + Assert.Equal(1, document.VendorId); + Assert.Equal(10, document.DispatchId); + Assert.Equal("quote.pdf", document.OriginalFileName); + Assert.Null(document.ReplacesDocumentId); + var saved = Assert.Single(storage.Saved); + Assert.Equal((1, 10), (saved.VendorId, saved.DispatchId)); + var audit = Assert.Single(context.WorkOrderAuditLogs); + Assert.Equal("uplift_evidence_uploaded", audit.Action); + Assert.Equal("internal", audit.ActorType); + Assert.Equal("dispatcher-1", audit.UserId); + } + + [Fact] + public async Task UploadAsync_PassWhenScannerUnavailable_StoresPassedEvidence() + { + await using var context = CreateContext(); + var workOrder = await SeedWorkOrderAsync(context); + var (service, _) = NewService(context, passWhenScannerUnavailable: true); + + var uploaded = await service.UploadAsync( + workOrder.Id, + FormFile("%PDF-1.4"u8.ToArray(), "quote.pdf", "application/pdf"), + Dispatcher(), + CancellationToken.None); + + var document = Assert.Single(context.VendorCompletionDocuments); + Assert.Equal("Passed", uploaded!.ScanStatus); + Assert.Equal("Passed", document.ScanStatus); + Assert.NotNull(document.ScannedAt); + Assert.Equal("Processing", document.ReviewStatus); + } + + [Fact] + public async Task UploadAsync_MismatchedSignature_SavesNothing() + { + await using var context = CreateContext(); + var workOrder = await SeedWorkOrderAsync(context); + var (service, storage) = NewService(context); + + var ex = await Assert.ThrowsAsync(() => + service.UploadAsync( + workOrder.Id, + FormFile("not a pdf"u8.ToArray(), "quote.pdf", "application/pdf"), + Dispatcher(), + CancellationToken.None)); + + Assert.Contains("signature", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(context.VendorCompletionDocuments); + Assert.Empty(storage.Saved); + } + + [Fact] + public async Task GetStatusAsync_ReturnsScanStatusOnlyForUpliftEvidenceOnTheDispatch() + { + await using var context = CreateContext(); + var workOrder = await SeedWorkOrderAsync(context); + var (service, _) = NewService(context); + var uploaded = await service.UploadAsync( + workOrder.Id, + FormFile("%PDF-1.4"u8.ToArray(), "quote.pdf", "application/pdf"), + Dispatcher(), + CancellationToken.None); + + var pending = await service.GetStatusAsync(workOrder.Id, uploaded!.Id, Dispatcher(), CancellationToken.None); + Assert.Equal("Pending", pending!.ScanStatus); + Assert.Equal("quote.pdf", pending.OriginalFileName); + + var stored = Assert.Single(context.VendorCompletionDocuments); + stored.ScanStatus = "Passed"; + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + Id = 90, + VendorId = 1, + DispatchId = 10, + WorkOrderId = workOrder.Id, + OriginalFileName = "done.pdf", + StoredFileName = "done.pdf", + ContentType = "application/pdf", + Purpose = "Completion", + ScanStatus = "Passed", + Version = 1, + }); + await context.SaveChangesAsync(); + + var passed = await service.GetStatusAsync(workOrder.Id, uploaded.Id, Dispatcher(), CancellationToken.None); + Assert.Equal("Passed", passed!.ScanStatus); + Assert.Null(await service.GetStatusAsync(workOrder.Id, 90, Dispatcher(), CancellationToken.None)); + } + + private sealed class MemoryStorage : IVendorDocumentStoragePort + { + public List<(int VendorId, int DispatchId, string Name)> Saved { get; } = new(); + + public async Task SaveAsync( + int vendorId, + int dispatchId, + string storedFileName, + Stream content, + CancellationToken cancellationToken) + { + using var buffer = new MemoryStream(); + await content.CopyToAsync(buffer, cancellationToken); + Saved.Add((vendorId, dispatchId, storedFileName)); + } + + public Stream OpenRead(int vendorId, int dispatchId, string storedFileName) + => throw new NotSupportedException(); + + public void Delete(int vendorId, int dispatchId, string storedFileName) + { + } + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs index 7ec8856..92cc5e8 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -38,7 +38,8 @@ public sealed class WorkOrderUpliftServiceTests WorkOrderAccountTestHelpers.Resolver(context), new UserDataService(context), TimeProvider.System, - Options.Create(NewOptions())); + Options.Create(NewOptions()), + new VendorDocumentDataService(context)); } private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1") @@ -120,7 +121,9 @@ public sealed class WorkOrderUpliftServiceTests Assert.NotNull(created); Assert.Equal("auto_approved", created!.Status); + Assert.Null(created.EvidenceDocumentId); Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); + Assert.Null(Assert.Single(context.DispatchUpliftRequests).EvidenceDocumentId); } [Fact] @@ -564,4 +567,88 @@ public sealed class WorkOrderUpliftServiceTests service.CancelAsync(workOrder.Id, 100, Dispatcher(), CancellationToken.None)); Assert.Contains("work order", ex.Message, StringComparison.OrdinalIgnoreCase); } + + [Fact] + public async Task CreateAsync_PassedEvidenceOnSameDispatch_LinksDocument() + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + var document = SeedEvidence(context, dispatch.Id, dispatch.VendorId, workOrder.Id); + await context.SaveChangesAsync(); + var service = NewService(context); + + var created = await service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto + { + Amount = 400m, + Notes = "Quote", + EvidenceDocumentId = document.Id, + }, + Dispatcher(), + CancellationToken.None); + + Assert.Equal(document.Id, created!.EvidenceDocumentId); + Assert.Equal(document.Id, Assert.Single(context.DispatchUpliftRequests).EvidenceDocumentId); + } + + [Theory] + [InlineData("other-dispatch")] + [InlineData("wrong-purpose")] + [InlineData("pending-scan")] + public async Task CreateAsync_EvidenceThatDoesNotQualify_RejectsWithoutSaving(string defect) + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + var document = defect switch + { + "other-dispatch" => SeedEvidence(context, dispatchId: 99, dispatch.VendorId, workOrder.Id), + "wrong-purpose" => SeedEvidence(context, dispatch.Id, dispatch.VendorId, workOrder.Id, purpose: "Completion"), + _ => SeedEvidence(context, dispatch.Id, dispatch.VendorId, workOrder.Id, scanStatus: "Pending"), + }; + await context.SaveChangesAsync(); + var service = NewService(context); + + var ex = await Assert.ThrowsAsync(() => + service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto + { + Amount = 400m, + Notes = "Quote", + EvidenceDocumentId = document.Id, + }, + Dispatcher(), + CancellationToken.None)); + + Assert.Contains("not available", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(context.DispatchUpliftRequests); + } + + private static VendorCompletionDocument SeedEvidence( + ApplicationDbContext context, + int dispatchId, + int vendorId, + int workOrderId, + string scanStatus = "Passed", + string purpose = "UpliftEvidence") + { + var document = new VendorCompletionDocument + { + Id = 22, + VendorId = vendorId, + DispatchId = dispatchId, + WorkOrderId = workOrderId, + OriginalFileName = "quote.pdf", + StoredFileName = "stored.pdf", + ContentType = "application/pdf", + SizeBytes = 4, + ScanStatus = scanStatus, + ReviewStatus = "Processing", + Purpose = purpose, + Version = 1, + }; + context.VendorCompletionDocuments.Add(document); + return document; + } }