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>
This commit is contained in:
Cursor Agent 2026-10-05 15:01:59 +00:00
parent 98bd45423d
commit 93dda60425
No known key found for this signature in database
2 changed files with 151 additions and 34 deletions

View file

@ -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(

View file

@ -26,7 +26,8 @@ public sealed class WorkOrderUpliftEvidenceServiceTests
private static async Task<WorkOrder> 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<ApplicationDbContext>()
.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<InvalidOperationException>());
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<Exception?> Observe(Task<UploadCompletionDocumentResultDTO?> upload)
{
try
{
await upload;
return null;
}
catch (Exception ex)
{
return ex;
}
}
}
[Fact]
public async Task UploadAsync_RequestUpliftsDenied_PersistsNothing()
{