shoc-backend/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs
Cursor Agent 93dda60425
fix(uplifts): serialize evidence photo count with the work-order lock
Overlapping evidence uploads could both read nine photos and both save.
The count and the insert now run under ExecuteWorkOrderMutationAsync, the
same gate the dispatcher media path uses.

Co-authored-by: Arthur Bassi <bassi-arthurr@users.noreply.github.com>
2026-10-05 15:01:59 +00:00

298 lines
13 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.Helpers;
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 ITeamPermissionOverrideDataService _permissionOverrideData;
private readonly ITeamPermissionPolicy _permissionPolicy;
private readonly VendorDocumentsOptions _documentOptions;
public WorkOrderUpliftEvidenceService(
IWorkOrderDetailDataService detailData,
IUpliftDataService upliftData,
IDispatchDataService dispatchData,
IVendorDocumentDataService documentData,
IVendorDocumentStoragePort documentStorage,
IWorkOrderAccountResolver accountResolver,
ITeamPermissionOverrideDataService permissionOverrideData,
ITeamPermissionPolicy permissionPolicy,
IOptions<VendorDocumentsOptions> documentOptions)
{
_detailData = detailData;
_upliftData = upliftData;
_dispatchData = dispatchData;
_documentData = documentData;
_documentStorage = documentStorage;
_accountResolver = accountResolver;
_permissionOverrideData = permissionOverrideData;
_permissionPolicy = permissionPolicy;
_documentOptions = documentOptions.Value;
}
public async Task<UploadCompletionDocumentResultDTO?> UploadAsync(
int workOrderId,
IFormFile file,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
await WorkOrderUpliftRequestAuthorization.EnsureCanRequestUpliftAsync(
_permissionOverrideData,
_permissionPolicy,
user,
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 passWithoutScanner = _documentOptions.PassWhenScannerUnavailable;
var actorId = user.FindFirstValue(ClaimTypes.NameIdentifier);
// Photo/video caps are a work-order aggregate shared with dispatcher media
// and the vendor portal, so the count and the insert share that mutation lock.
var document = await _upliftData.ExecuteWorkOrderMutationAsync(
workOrderId,
async ct =>
{
await EnsureWithinMediaCountsAsync(workOrderId, contentType, file.FileName, ct);
var now = DateTime.UtcNow;
var created = 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(created, ct);
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = workOrderId,
DispatchId = dispatch.Id,
UserId = actorId,
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift Evidence",
NewValue = created.OriginalFileName,
Action = "uplift_evidence_uploaded",
ActorType = "internal",
CreatedAt = now
}, ct);
await _documentData.SaveChangesAsync(ct);
return created;
},
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 EnsureWithinMediaCountsAsync(
int workOrderId,
string contentType,
string fileName,
CancellationToken cancellationToken)
{
// Same per-work-order photo/video count as the vendor uplift-evidence upload:
// dispatcher attachments plus every active vendor document, with no purpose filter.
var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync(
workOrderId,
null,
cancellationToken);
var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync(
workOrderId,
cancellationToken);
var countMessage = WorkOrderMediaContract.ValidateCount(
WorkOrderMediaContract.ResolveKind(contentType, fileName),
attachmentUrls,
vendorTypes);
if (countMessage != null)
throw new InvalidOperationException(countMessage);
}
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;
}
}
}