fix(media): enforce the per-work-order photo/video limit across both surfaces

The 10-photo / 3-video limit only counted dispatcher attachments, so vendor
portal uploads could push a work order past it (and vice versa). Count the
work order's current vendor documents alongside its attachments on both the
dispatcher media endpoint and the vendor portal. A new completion version
does not count the version it replaces.
This commit is contained in:
Alexandre Brandizzi 2026-09-24 21:27:01 -03:00
parent 16845a55f3
commit e15de6e90b
9 changed files with 229 additions and 20 deletions

View file

@ -453,6 +453,62 @@ public sealed class VendorPortalDocumentTests : IDisposable
context.VendorCompletionDocuments.Should().BeEmpty();
}
[Fact]
public async Task UploadCompletionDocument_RejectsFourthVideoAcrossDispatcherAndVendorUploads()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV", "clip.mp4" })
{
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = dispatch.WorkOrderId!.Value,
Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}",
});
}
await context.SaveChangesAsync();
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(FtypVideoBytes(2_048), "site-clip.MOV", "video/quicktime"));
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
[Fact]
public async Task UploadCompletionDocument_NewVersionDoesNotCountTheVideoItReplaces()
{
using var context = NewContext();
var (vendor, dispatch) = await SeedDispatch(context);
foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV" })
{
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = dispatch.WorkOrderId!.Value,
Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}",
});
}
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
{
VendorId = vendor.Id,
DispatchId = dispatch.Id,
WorkOrderId = dispatch.WorkOrderId!.Value,
ContentType = "video/mp4",
StoredFileName = "v1.mp4",
Version = 1,
Purpose = "Completion"
});
await context.SaveChangesAsync();
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(FtypVideoBytes(2_048), "site-clip-v2.mp4", "video/mp4"));
result.Should().BeOfType<OkObjectResult>();
context.VendorCompletionDocuments.Should().HaveCount(2);
}
[Fact]
public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes()
{

View file

@ -17,6 +17,39 @@ namespace SeaHaven.DataServices.Implementation
// supporting evidence never participates in completion versioning or replacement.
private const string CompletionPurpose = "Completion";
public async Task<IReadOnlyList<string>> ListActiveContentTypesForWorkOrderAsync(
int workOrderId,
int? excludingDocumentId,
CancellationToken cancellationToken)
{
// Uplift evidence also records the latest completion id in ReplacesDocumentId, so only
// a newer completion version supersedes a document.
return await _context.VendorCompletionDocuments
.AsNoTracking()
.Where(document => document.WorkOrderId == workOrderId
&& (document.IsDeleted == null || document.IsDeleted == false)
&& (excludingDocumentId == null || document.Id != excludingDocumentId)
&& !_context.VendorCompletionDocuments.Any(newer =>
newer.ReplacesDocumentId == document.Id
&& newer.Purpose == CompletionPurpose
&& (newer.IsDeleted == null || newer.IsDeleted == false)))
.Select(document => document.ContentType)
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<string>> ListActiveWorkOrderAttachmentUrlsAsync(
int workOrderId,
CancellationToken cancellationToken)
{
return await _context.workOrderAttachments
.AsNoTracking()
.Where(attachment => attachment.WorkorderId == workOrderId
&& attachment.IsDeleted != true
&& attachment.Attachments != null)
.Select(attachment => attachment.Attachments!)
.ToListAsync(cancellationToken);
}
public Task<VendorCompletionDocument?> GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
{
return _context.VendorCompletionDocuments

View file

@ -68,6 +68,24 @@ namespace SeaHaven.DataServices.Implementation
return urls;
}
public async Task<IReadOnlyList<string>> ListActiveVendorMediaContentTypesAsync(
int workOrderId,
CancellationToken cancellationToken)
{
// Uplift evidence also records the latest completion id in ReplacesDocumentId, so only
// a newer completion version supersedes a document.
return await _context.VendorCompletionDocuments
.AsNoTracking()
.Where(document => document.WorkOrderId == workOrderId
&& (document.IsDeleted == null || document.IsDeleted == false)
&& !_context.VendorCompletionDocuments.Any(newer =>
newer.ReplacesDocumentId == document.Id
&& newer.Purpose == "Completion"
&& (newer.IsDeleted == null || newer.IsDeleted == false)))
.Select(document => document.ContentType)
.ToListAsync(cancellationToken);
}
public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version)
=> _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version;

View file

@ -9,6 +9,19 @@ namespace SeaHaven.DataServices.Interfaces
Task<VendorCompletionDocument?> GetMetadataForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
Task<List<VendorCompletionDocument>> ListForVendorDispatchAsync(int dispatchId, int vendorId, CancellationToken cancellationToken);
Task<VendorCompletionDocument?> GetUpliftEvidenceAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
/// <summary>
/// Content types of the work order's current vendor documents (not deleted, not replaced by a
/// newer completion version), optionally excluding one being replaced. SH-116 photo/video counts.
/// </summary>
Task<IReadOnlyList<string>> ListActiveContentTypesForWorkOrderAsync(
int workOrderId,
int? excludingDocumentId,
CancellationToken cancellationToken);
/// <summary>Stored URLs of the work order's non-deleted dispatcher attachments (SH-116 counts).</summary>
Task<IReadOnlyList<string>> ListActiveWorkOrderAttachmentUrlsAsync(
int workOrderId,
CancellationToken cancellationToken);
Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken);
Task SaveChangesAsync(CancellationToken cancellationToken);
}

View file

@ -27,6 +27,14 @@ namespace SeaHaven.DataServices.Interfaces
Task<IReadOnlyList<string>> ListActiveAttachmentUrlsAsync(
int workOrderId,
CancellationToken cancellationToken);
/// <summary>
/// Content types of the work order's current vendor-portal documents (not deleted, not
/// replaced by a newer completion version). SH-116 counts span both upload surfaces.
/// </summary>
Task<IReadOnlyList<string>> ListActiveVendorMediaContentTypesAsync(
int workOrderId,
CancellationToken cancellationToken);
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
void MarkWorkOrderModified(WorkOrder workOrder);
Task SaveAsync(CancellationToken cancellationToken);

View file

@ -80,6 +80,29 @@ namespace SeaHaven.Services.Helpers
: UploadKind.Unknown;
}
/// <summary>
/// Per-work-order count check across both upload surfaces: dispatcher attachments
/// (classified by stored URL) and vendor-portal documents (classified by validated
/// content type). Returns the stable rejection message, or null when there is room.
/// </summary>
public static string? ValidateCount(
UploadKind kind,
IEnumerable<string> attachmentUrls,
IEnumerable<string> vendorContentTypes)
{
if (kind != UploadKind.Photo && kind != UploadKind.Video)
return null;
var count = attachmentUrls.Count(url => ResolveKind(null, url) == kind)
+ vendorContentTypes.Count(type => ResolveKind(type, null) == kind);
if (kind == UploadKind.Photo && count >= MaxPhotosPerWorkOrder)
return "A work order can have at most 10 photos.";
if (kind == UploadKind.Video && count >= MaxVideosPerWorkOrder)
return "A work order can have at most 3 videos.";
return null;
}
/// <summary>Stable, generic per-kind size message; never echoes file metadata.</summary>
public static string? ValidateSize(long length, UploadKind kind)
{

View file

@ -926,6 +926,26 @@ namespace SeaHaven.Services.Implementation
"The completion document could not be replaced.");
}
}
// 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)}";

View file

@ -352,8 +352,9 @@ namespace SeaHaven.Services.Implementation
/// <summary>
/// SH-116 contract: at most 10 photos and 3 videos per work order, counted over the
/// stored attachment rows. Legacy Before/After column slots replace in place and are
/// not counted. Documents have no per-work-order count limit.
/// stored attachment rows plus the work order's current vendor-portal documents. Legacy
/// Before/After column slots replace in place and are not counted. Documents have no
/// per-work-order count limit.
/// </summary>
private async Task EnsureWithinMediaCountsAsync(
int workOrderId,
@ -366,24 +367,10 @@ namespace SeaHaven.Services.Implementation
return;
var urls = await _mediaData.ListActiveAttachmentUrlsAsync(workOrderId, cancellationToken);
var sameKindCount = urls.Count(url =>
WorkOrderMediaContract.ResolveKind(null, url) == kind);
if (kind == WorkOrderMediaContract.UploadKind.Photo
&& sameKindCount >= WorkOrderMediaContract.MaxPhotosPerWorkOrder)
{
throw new WorkOrderBoardValidationException(
"MediaCountExceeded",
"A work order can have at most 10 photos.");
}
if (kind == WorkOrderMediaContract.UploadKind.Video
&& sameKindCount >= WorkOrderMediaContract.MaxVideosPerWorkOrder)
{
throw new WorkOrderBoardValidationException(
"MediaCountExceeded",
"A work order can have at most 3 videos.");
}
var vendorTypes = await _mediaData.ListActiveVendorMediaContentTypesAsync(workOrderId, cancellationToken);
var countMessage = WorkOrderMediaContract.ValidateCount(kind, urls, vendorTypes);
if (countMessage != null)
throw new WorkOrderBoardValidationException("MediaCountExceeded", countMessage);
}
/// <summary>

View file

@ -1380,6 +1380,57 @@ public class WorkOrderMediaServiceTests
Assert.Equal("A work order can have at most 3 videos.", ex.Message);
}
[Fact]
public async Task AddMedia_FourthVideo_CountsVendorPortalVideos()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV" })
{
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = 1,
Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}",
Category = WorkOrderMediaCategory.Extra
});
}
// Vendor v1 video was replaced by v2 (also a video): only v2 is current.
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
{
Id = 50, WorkOrderId = 1, DispatchId = 7, VendorId = 3, Version = 1,
ContentType = "video/mp4", Purpose = "Completion"
});
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
{
Id = 51, WorkOrderId = 1, DispatchId = 7, VendorId = 3, Version = 2,
ContentType = "video/quicktime", Purpose = "Completion", ReplacesDocumentId = 50
});
// Another work order's vendor video never counts here.
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
{
Id = 60, WorkOrderId = 2, DispatchId = 8, VendorId = 3, Version = 1,
ContentType = "video/mp4", Purpose = "Completion"
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.AddMediaAsync(
1,
null,
"https://api.example.com/Assets/Documents/x_IMG_0004.MOV",
AuthenticatedUser(),
"actor-1"));
Assert.Equal("MediaCountExceeded", ex.Code);
Assert.Equal("A work order can have at most 3 videos.", ex.Message);
Assert.Equal(2, context.workOrderAttachments.Count());
}
[Fact]
public async Task AddMedia_CrossAccount_FullWorkOrder_ThrowsNotFoundNotCount()
{