From 93dda6042560d4af8c308fadd471805d1acf0db4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 15:01:59 +0000 Subject: [PATCH] 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 --- .../WorkOrderUpliftEvidenceService.cs | 74 +++++++----- .../WorkOrderUpliftEvidenceServiceTests.cs | 111 +++++++++++++++++- 2 files changed, 151 insertions(+), 34 deletions(-) diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs index be9b344..0c46148 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs @@ -76,42 +76,54 @@ namespace SeaHaven.Services.Implementation RejectTerminalTarget(workOrder, dispatch); var (contentType, bytes) = await ReadAcceptedFileAsync(file, cancellationToken); - await EnsureWithinMediaCountsAsync(workOrderId, contentType, file.FileName, 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 - }; + var actorId = user.FindFirstValue(ClaimTypes.NameIdentifier); - 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); + // 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( diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs index 60a5e14..3c0e506 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs @@ -26,7 +26,8 @@ public sealed class WorkOrderUpliftEvidenceServiceTests private static async Task SeedWorkOrderAsync( ApplicationDbContext context, - string role = "Admin") + string role = "Admin", + int workOrderId = 1) { await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); context.Users.Add(new ApplicationUser @@ -48,14 +49,14 @@ public sealed class WorkOrderUpliftEvidenceServiceTests { Id = 10, VendorId = 1, - WorkOrderId = 1, + WorkOrderId = workOrderId, NTEAmount = 1000m, DispatchNumber = "DIS-10", Status = "Scheduled", }); var workOrder = new WorkOrder { - Id = 1, + Id = workOrderId, InternalWONumber = "10000000001", PrimaryDispatchId = 10, AccountId = 1, @@ -207,6 +208,110 @@ public sealed class WorkOrderUpliftEvidenceServiceTests Assert.Empty(storage.Saved); } + [Fact] + public async Task UploadAsync_ConcurrentPhotos_PersistOnlyTheLastOpenSlot() + { + // Distinct id: the mutation gate is process-wide per work order. Nine photos + // leave one slot. Both uploads must wait on that gate; otherwise each reads 9 + // and both persist. + const int workOrderId = 388_171; + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + await using (var seed = new ApplicationDbContext(options)) + { + await SeedWorkOrderAsync(seed, workOrderId: workOrderId); + for (var i = 0; i < 9; i++) + { + seed.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + VendorId = 1, + DispatchId = 10, + WorkOrderId = workOrderId, + OriginalFileName = $"photo-{i}.jpg", + StoredFileName = $"photo-{i}.jpg", + ContentType = "image/jpeg", + Purpose = "Completion", + ScanStatus = "Passed", + Version = 1, + }); + } + + await seed.SaveChangesAsync(); + } + + var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var holderContext = new ApplicationDbContext(options); + var holder = new UpliftDataService(holderContext).ExecuteWorkOrderMutationAsync( + workOrderId, + async _ => + { + held.SetResult(); + await release.Task; + return 0; + }, + CancellationToken.None); + await held.Task; + + await using var firstContext = new ApplicationDbContext(options); + await using var secondContext = new ApplicationDbContext(options); + var (first, firstStorage) = NewService(firstContext); + var (second, secondStorage) = NewService(secondContext); + var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }; + var firstUpload = first.UploadAsync( + workOrderId, + FormFile(jpeg, "a.jpg", "image/jpeg"), + Dispatcher(), + CancellationToken.None); + var secondUpload = second.UploadAsync( + workOrderId, + FormFile(jpeg, "b.jpg", "image/jpeg"), + Dispatcher(), + CancellationToken.None); + + try + { + await Task.Delay(200); + Assert.False(firstUpload.IsCompleted); + Assert.False(secondUpload.IsCompleted); + await using var during = new ApplicationDbContext(options); + Assert.Equal(9, await during.VendorCompletionDocuments.CountAsync()); + } + finally + { + release.TrySetResult(); + } + + await holder; + var outcomes = await Task.WhenAll(Observe(firstUpload), Observe(secondUpload)); + + await using var verify = new ApplicationDbContext(options); + Assert.Equal(10, await verify.VendorCompletionDocuments.CountAsync()); + Assert.Equal( + 1, + await verify.VendorCompletionDocuments.CountAsync( + document => document.Purpose == VendorDocumentPurpose.UpliftEvidence)); + Assert.Equal(1, outcomes.Count(outcome => outcome is null)); + var rejected = Assert.Single(outcomes.OfType()); + Assert.Equal("A work order can have at most 10 photos.", rejected.Message); + Assert.Equal(1, firstStorage.Saved.Count + secondStorage.Saved.Count); + + static async Task Observe(Task upload) + { + try + { + await upload; + return null; + } + catch (Exception ex) + { + return ex; + } + } + } + [Fact] public async Task UploadAsync_RequestUpliftsDenied_PersistsNothing() {