diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 2eefb5c..e454b5f 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -54,6 +54,11 @@ public sealed class VendorPortalDocumentTests : IDisposable var upliftData = new Mock(); upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); + upliftData.Setup(u => u.ExecuteWorkOrderMutationAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Returns((int _, Func> work, CancellationToken ct) => work(ct)); var commentData = new Mock(); commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index 8234c92..3aab33e 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -395,7 +395,8 @@ public class WorkOrderMediaServiceCancellationTests mediaData.Object, new Mock(MockBehavior.Strict).Object, new Mock(MockBehavior.Strict).Object, - storage.Object); + storage.Object, + new Mock(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(MockBehavior.Strict); + mediaData.Setup(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, It.IsAny())) + .ReturnsAsync(new WorkOrder { Id = 1 }); + mediaData.Setup(candidate => candidate.GetAttachmentForReadAsync(10, 1, It.IsAny())) + .ReturnsAsync(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = url }); + var storage = new Mock(MockBehavior.Strict); + storage.Setup(candidate => candidate.OpenRead(url)).Returns(new MemoryStream([1, 2, 3])); + var service = new WorkOrderMediaService( + mediaData.Object, + new Mock(MockBehavior.Strict).Object, + new Mock(MockBehavior.Strict).Object, + storage.Object, + new Mock(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); + } } diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 6ddcd95..b6c8b3c 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -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 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); diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index 10ad8ff..0a0d1d5 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -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?> 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", diff --git a/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs b/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs index a51311c..cc765ed 100644 --- a/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs @@ -41,7 +41,8 @@ public class WorkOrderCompletedSelectiveLockTests new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), audit, - new TestFileStoragePort()); + new TestFileStoragePort(), + new UpliftDataService(context)); return (context, service); } diff --git a/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs index 1f2eea6..191dcca 100644 --- a/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs @@ -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 LoadVersionAsync(ApplicationDbContext context, int workOrderId) diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 3c299ba..224d73b 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -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() {