mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 10:43:13 +00:00
Dispatchers can attach one evidence file, and create links it only after that document has passed scanning.
226 lines
9.4 KiB
C#
226 lines
9.4 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 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<VendorDocumentsOptions> documentOptions)
|
|
{
|
|
_detailData = detailData;
|
|
_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 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<VendorDocumentStatusDTO?> 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<OpenDispatch?> 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);
|
|
}
|
|
}
|