shoc-backend/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs
Cursor Agent b11b66538a
fix(uplifts): resolve evidence without a primary dispatch
Upload and status use the same dispatch create already selects, so a work
order with no primary can still attach evidence. Status no longer rejects a
terminal work order or dispatch, and create, cancel, and revoke return the
stored evidence file name and attachments.

Co-authored-by: Arthur Bassi <bassi-arthurr@users.noreply.github.com>
2026-10-01 14:55:07 +00:00

249 lines
10 KiB
C#

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<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
"application/pdf", "image/jpeg", "image/jpg", "image/png"
};
private readonly IWorkOrderDetailDataService _detailData;
private readonly IUpliftDataService _upliftData;
private readonly IDispatchDataService _dispatchData;
private readonly IVendorDocumentDataService _documentData;
private readonly IVendorDocumentStoragePort _documentStorage;
private readonly IWorkOrderAccountResolver _accountResolver;
private readonly VendorDocumentsOptions _documentOptions;
public WorkOrderUpliftEvidenceService(
IWorkOrderDetailDataService detailData,
IUpliftDataService upliftData,
IDispatchDataService dispatchData,
IVendorDocumentDataService documentData,
IVendorDocumentStoragePort documentStorage,
IWorkOrderAccountResolver accountResolver,
IOptions<VendorDocumentsOptions> documentOptions)
{
_detailData = detailData;
_upliftData = upliftData;
_dispatchData = dispatchData;
_documentData = documentData;
_documentStorage = documentStorage;
_accountResolver = accountResolver;
_documentOptions = documentOptions.Value;
}
public async Task<UploadCompletionDocumentResultDTO?> UploadAsync(
int workOrderId,
IFormFile file,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var workOrder = await FindAccessibleWorkOrderAsync(workOrderId, user, cancellationToken);
if (workOrder == null)
return null;
var dispatch = await ResolveUpliftDispatchAsync(
workOrderId,
workOrder.PrimaryDispatchId,
cancellationToken);
if (dispatch == null)
throw new InvalidOperationException("Work order has no primary dispatch for uplift requests");
RejectTerminalTarget(workOrder, dispatch);
var (contentType, bytes) = await ReadAcceptedFileAsync(file, cancellationToken);
var latest = await _documentData.GetLatestForDispatchAsync(dispatch.Id, cancellationToken);
var version = (latest?.Version ?? 0) + 1;
var storedFileName = $"{dispatch.Id}_{version}_{Guid.NewGuid():N}{SafeExtension(file.FileName)}";
var now = DateTime.UtcNow;
var passWithoutScanner = _documentOptions.PassWhenScannerUnavailable;
var document = new VendorCompletionDocument
{
VendorId = dispatch.VendorId,
DispatchId = 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 = dispatch.Id,
UserId = user.FindFirstValue(ClaimTypes.NameIdentifier),
FieldName = $"Dispatch {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(
dispatch.VendorId,
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<VendorDocumentStatusDTO?> GetStatusAsync(
int workOrderId,
int documentId,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
// A poll reads the document on the dispatch create would pick. It does not
// require that work order or dispatch to still accept a new upload.
var workOrder = await FindAccessibleWorkOrderAsync(workOrderId, user, cancellationToken);
if (workOrder == null)
return null;
var dispatch = await ResolveUpliftDispatchAsync(
workOrderId,
workOrder.PrimaryDispatchId,
cancellationToken);
if (dispatch == null)
return null;
var document = await _documentData.GetMetadataForVendorDispatchAsync(
documentId,
dispatch.Id,
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<WorkOrder?> FindAccessibleWorkOrderAsync(
int workOrderId,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var accountFilter = _accountResolver.ResolveAccountFilter(user);
if (!await _detailData.ExistsAsync(workOrderId, cancellationToken, accountFilter))
return null;
return await _detailData.GetWorkOrderForMediaAsync(
workOrderId,
cancellationToken,
accountFilter);
}
private Task<Dispatch?> ResolveUpliftDispatchAsync(
int workOrderId,
int? primaryDispatchId,
CancellationToken cancellationToken)
=> _upliftData.GetUpliftDispatchForWorkOrderAsync(
workOrderId,
primaryDispatchId,
cancellationToken);
private static void RejectTerminalTarget(WorkOrder workOrder, Dispatch dispatch)
{
if (workOrder.LifecycleStatus is LifecycleStatus.Completed or LifecycleStatus.Canceled)
{
throw new InvalidOperationException(
$"Cannot attach uplift evidence on a '{workOrder.LifecycleStatus}' work order");
}
if (dispatch.Status is "Verified" or "Cancelled" or "Canceled" or "Refused")
throw new InvalidOperationException($"Cannot attach uplift evidence on a '{dispatch.Status}' 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;
}
}
}