fix(media): serialize SH-116 media counts under the work-order lock and serve HEIC as image/heic

The 10-photo / 3-video cap was a check-then-insert with no lock on both
upload surfaces, so two overlapping uploads could both take the last slot.
The board media upload and the vendor portal upload now run count, insert
and save inside ExecuteWorkOrderMutationAsync. GetMediaContent maps .heic
to image/heic.
This commit is contained in:
Alexandre Brandizzi 2026-09-24 22:56:21 -03:00
parent eecc2a61bd
commit 3e3f0f383d
7 changed files with 200 additions and 81 deletions

View file

@ -54,6 +54,11 @@ public sealed class VendorPortalDocumentTests : IDisposable
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PortalUpliftData>());
upliftData.Setup(u => u.ExecuteWorkOrderMutationAsync(
It.IsAny<int>(),
It.IsAny<Func<CancellationToken, Task<VendorCompletionDocument>>>(),
It.IsAny<CancellationToken>()))
.Returns((int _, Func<CancellationToken, Task<VendorCompletionDocument>> work, CancellationToken ct) => work(ct));
var commentData = new Mock<ICommentDataService>();
commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PortalCommentData>());

View file

@ -395,7 +395,8 @@ public class WorkOrderMediaServiceCancellationTests
mediaData.Object,
new Mock<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
storage.Object);
storage.Object,
new Mock<IUpliftDataService>(MockBehavior.Strict).Object);
var user = new ClaimsPrincipal(new ClaimsIdentity(
new[]
{
@ -412,4 +413,37 @@ public class WorkOrderMediaServiceCancellationTests
mediaData.Verify(candidate => candidate.GetAttachmentForReadAsync(10, 1, token), Times.Once);
mediaData.VerifyNoOtherCalls();
}
[Fact]
public async Task GetMediaContent_ServesHeicAsImageHeic()
{
const string url = "https://example.test/Assets/Images/photo.heic";
var mediaData = new Mock<IWorkOrderMediaDataService>(MockBehavior.Strict);
mediaData.Setup(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new WorkOrder { Id = 1 });
mediaData.Setup(candidate => candidate.GetAttachmentForReadAsync(10, 1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = url });
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
storage.Setup(candidate => candidate.OpenRead(url)).Returns(new MemoryStream([1, 2, 3]));
var service = new WorkOrderMediaService(
mediaData.Object,
new Mock<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
storage.Object,
new Mock<IUpliftDataService>(MockBehavior.Strict).Object);
var user = new ClaimsPrincipal(new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.NameIdentifier, "actor-1"),
new Claim(ClaimTypes.Role, "Admin"),
new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll)
},
"Test"));
var result = await service.GetMediaContentAsync(1, 10, user, "actor-1");
result.Content.Dispose();
Assert.Equal("image/heic", result.ContentType);
Assert.Equal("photo.heic", result.FileName);
}
}

View file

@ -966,69 +966,79 @@ namespace SeaHaven.Services.Implementation
}
}
// SH-116: the 10-photo / 3-video limit is per work order across the dispatcher and
// vendor surfaces. A new completion version replaces its predecessor, so that one
// does not count against the upload that supersedes it.
if (dispatch.WorkOrderId is int workOrderId)
{
var excluded = resolvedPurpose == VendorDocumentPurpose.Completion
? replacedDocument?.Id
: null;
var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync(
workOrderId, excluded, cancellationToken);
var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync(
workOrderId, cancellationToken);
var countMessage = WorkOrderMediaContract.ValidateCount(kind, attachmentUrls, vendorTypes);
if (countMessage != null)
{
throw new InvalidOperationException(countMessage);
}
}
var version = (latest?.Version ?? 0) + 1;
var storedFileName = $"{dispatchId}_{version}_{Guid.NewGuid():N}{GetSafeExtension(file.FileName)}";
var now = DateTime.UtcNow;
var document = new VendorCompletionDocument
{
VendorId = session.Id,
DispatchId = dispatchId,
WorkOrderId = dispatch.WorkOrderId ?? 0,
OriginalFileName = Path.GetFileName(file.FileName),
StoredFileName = storedFileName,
ContentType = contentType,
SizeBytes = bytes.Length,
ScanStatus = "Pending",
ReviewStatus = "Processing",
Version = version,
ReplacesDocumentId = replacedDocument?.Id,
Purpose = resolvedPurpose,
CreatedDate = now
};
await _documentData.AddAsync(document, cancellationToken);
var isUpliftEvidence = resolvedPurpose == VendorDocumentPurpose.UpliftEvidence;
var fieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document";
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
async Task<VendorCompletionDocument> PersistAsync(CancellationToken ct)
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
DispatchId = dispatchId,
FieldName = fieldName,
OldValue = isUpliftEvidence || replacedDocument == null
? null
: $"v{replacedDocument.Version}",
NewValue = isUpliftEvidence
? $"evidence {document.OriginalFileName}"
: $"v{version}",
Action = isUpliftEvidence
? "vendor_uplift_evidence_uploaded"
: "vendor_completion_document_uploaded",
ActorType = "vendor",
CreatedAt = now
}, cancellationToken);
await _documentData.SaveChangesAsync(cancellationToken);
// SH-116: the 10-photo / 3-video limit is per work order across the dispatcher and
// vendor surfaces. A new completion version replaces its predecessor, so that one
// does not count against the upload that supersedes it.
if (dispatch.WorkOrderId is int workOrderId)
{
var excluded = resolvedPurpose == VendorDocumentPurpose.Completion
? replacedDocument?.Id
: null;
var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync(
workOrderId, excluded, ct);
var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync(
workOrderId, ct);
var countMessage = WorkOrderMediaContract.ValidateCount(kind, attachmentUrls, vendorTypes);
if (countMessage != null)
{
throw new InvalidOperationException(countMessage);
}
}
var now = DateTime.UtcNow;
var created = new VendorCompletionDocument
{
VendorId = session.Id,
DispatchId = dispatchId,
WorkOrderId = dispatch.WorkOrderId ?? 0,
OriginalFileName = Path.GetFileName(file.FileName),
StoredFileName = storedFileName,
ContentType = contentType,
SizeBytes = bytes.Length,
ScanStatus = "Pending",
ReviewStatus = "Processing",
Version = version,
ReplacesDocumentId = replacedDocument?.Id,
Purpose = resolvedPurpose,
CreatedDate = now
};
await _documentData.AddAsync(created, ct);
var fieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document";
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
DispatchId = dispatchId,
FieldName = fieldName,
OldValue = isUpliftEvidence || replacedDocument == null
? null
: $"v{replacedDocument.Version}",
NewValue = isUpliftEvidence
? $"evidence {created.OriginalFileName}"
: $"v{version}",
Action = isUpliftEvidence
? "vendor_uplift_evidence_uploaded"
: "vendor_completion_document_uploaded",
ActorType = "vendor",
CreatedAt = now
}, ct);
await _documentData.SaveChangesAsync(ct);
return created;
}
// The count and the insert run under the per-work-order mutation lock the dispatcher
// media upload also takes, so concurrent uploads cannot both claim the last slot.
var document = dispatch.WorkOrderId is int lockedWorkOrderId
? await _upliftData.ExecuteWorkOrderMutationAsync(lockedWorkOrderId, PersistAsync, cancellationToken)
: await PersistAsync(cancellationToken);
using var stored = new MemoryStream(bytes);
await _documentStorage.SaveAsync(session.Id, dispatchId, storedFileName, stored, cancellationToken);

View file

@ -16,17 +16,20 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderDetailDataService _detailData;
private readonly IWorkOrderAuditService _auditService;
private readonly IFileStoragePort _fileStorage;
private readonly IUpliftDataService _upliftData;
public WorkOrderMediaService(
IWorkOrderMediaDataService mediaData,
IWorkOrderDetailDataService detailData,
IWorkOrderAuditService auditService,
IFileStoragePort fileStorage)
IFileStoragePort fileStorage,
IUpliftDataService upliftData)
{
_mediaData = mediaData;
_detailData = detailData;
_auditService = auditService;
_fileStorage = fileStorage;
_upliftData = upliftData;
}
public async Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(
@ -153,25 +156,34 @@ namespace SeaHaven.Services.Implementation
};
}
await EnsureWithinMediaCountsAsync(workOrderId, fileUrl, cancellationToken);
var attachment = new WorkOrderAttachments
{
WorkorderId = workOrderId,
Attachments = fileUrl,
Category = category.HasValue ? resolvedCategory : null,
CreatedDate = DateTime.UtcNow,
createdby = actorId
};
_mediaData.TrackAttachment(attachment);
await _auditService.StageFieldChangedAsync(
// SH-116 photo/video caps are a work-order aggregate shared with the vendor portal
// upload, so the count and the insert run under the per-work-order mutation lock.
var attachment = await _upliftData.ExecuteWorkOrderMutationAsync(
workOrderId,
"MediaCategory",
null,
FormatMediaAuditValue(null, (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString()),
actorId);
await SaveMediaAsync(cancellationToken);
async ct =>
{
await EnsureWithinMediaCountsAsync(workOrderId, fileUrl, ct);
var created = new WorkOrderAttachments
{
WorkorderId = workOrderId,
Attachments = fileUrl,
Category = category.HasValue ? resolvedCategory : null,
CreatedDate = DateTime.UtcNow,
createdby = actorId
};
_mediaData.TrackAttachment(created);
await _auditService.StageFieldChangedAsync(
workOrderId,
"MediaCategory",
null,
FormatMediaAuditValue(null, (created.Category ?? WorkOrderMediaCategory.Extra).ToString()),
actorId);
await SaveMediaAsync(ct);
return created;
},
cancellationToken);
return new WorkOrderMediaFileDto
{
@ -457,6 +469,7 @@ namespace SeaHaven.Services.Implementation
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".heic" => "image/heic",
".mp4" => "video/mp4",
".mov" => "video/quicktime",
".pdf" => "application/pdf",

View file

@ -41,7 +41,8 @@ public class WorkOrderCompletedSelectiveLockTests
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit,
new TestFileStoragePort());
new TestFileStoragePort(),
new UpliftDataService(context));
return (context, service);
}

View file

@ -176,7 +176,8 @@ public class WorkOrderMediaConcurrencyRelationalTests
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit,
new TestFileStoragePort());
new TestFileStoragePort(),
new UpliftDataService(context));
}
private static async Task<string> LoadVersionAsync(ApplicationDbContext context, int workOrderId)

View file

@ -812,7 +812,8 @@ public class WorkOrderMediaServiceTests
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit,
fileStorage ?? new TestFileStoragePort());
fileStorage ?? new TestFileStoragePort(),
new UpliftDataService(context));
return (context, service);
}
@ -1343,6 +1344,60 @@ public class WorkOrderMediaServiceTests
Assert.Equal(10, context.workOrderAttachments.Count(a => a.IsDeleted != true));
}
[Fact]
public async Task AddMedia_CountsAndInsertsUnderTheWorkOrderMutationLock()
{
// Distinct id: the mutation gate is process-wide per work order.
const int workOrderId = 173_001;
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = workOrderId,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
for (var i = 0; i < 9; i++)
{
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = workOrderId,
Attachments = $"https://example.com/photo-{i}.jpg",
Category = WorkOrderMediaCategory.Extra
});
}
await context.SaveChangesAsync();
var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var holder = new UpliftDataService(context).ExecuteWorkOrderMutationAsync(
workOrderId,
async _ =>
{
held.SetResult();
await release.Task;
return 0;
},
CancellationToken.None);
await held.Task;
var upload = service.AddMediaAsync(
workOrderId,
null,
"https://example.com/photo-9.jpg",
AuthenticatedUser(),
"actor-1");
await Task.Delay(200);
Assert.False(upload.IsCompleted);
Assert.Equal(9, context.workOrderAttachments.Count(a => a.WorkorderId == workOrderId && a.IsDeleted != true));
release.SetResult();
await holder;
await upload;
Assert.Equal(10, context.workOrderAttachments.Count(a => a.WorkorderId == workOrderId && a.IsDeleted != true));
}
[Fact]
public async Task AddMedia_FourthVideo_CountsStoredPhoneFileUrls()
{