diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 132f538..2ef7eca 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -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(); + 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(); + context.VendorCompletionDocuments.Should().HaveCount(2); + } + [Fact] public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes() { diff --git a/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs b/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs index a952f45..d2a6890 100644 --- a/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs @@ -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> 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> 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 GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken) { return _context.VendorCompletionDocuments diff --git a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs index 9ad9dbe..2d226e6 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs @@ -68,6 +68,24 @@ namespace SeaHaven.DataServices.Implementation return urls; } + public async Task> 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; diff --git a/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs index b56948c..6986ef2 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs @@ -9,6 +9,19 @@ namespace SeaHaven.DataServices.Interfaces Task GetMetadataForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken); Task> ListForVendorDispatchAsync(int dispatchId, int vendorId, CancellationToken cancellationToken); Task GetUpliftEvidenceAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken); + /// + /// 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. + /// + Task> ListActiveContentTypesForWorkOrderAsync( + int workOrderId, + int? excludingDocumentId, + CancellationToken cancellationToken); + + /// Stored URLs of the work order's non-deleted dispatcher attachments (SH-116 counts). + Task> ListActiveWorkOrderAttachmentUrlsAsync( + int workOrderId, + CancellationToken cancellationToken); Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken); Task SaveChangesAsync(CancellationToken cancellationToken); } diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs index 30d0005..caf6071 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs @@ -27,6 +27,14 @@ namespace SeaHaven.DataServices.Interfaces Task> ListActiveAttachmentUrlsAsync( int workOrderId, CancellationToken cancellationToken); + + /// + /// 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. + /// + Task> ListActiveVendorMediaContentTypesAsync( + int workOrderId, + CancellationToken cancellationToken); void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version); void MarkWorkOrderModified(WorkOrder workOrder); Task SaveAsync(CancellationToken cancellationToken); diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs index f36fcf9..5ba60e3 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -80,6 +80,29 @@ namespace SeaHaven.Services.Helpers : UploadKind.Unknown; } + /// + /// 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. + /// + public static string? ValidateCount( + UploadKind kind, + IEnumerable attachmentUrls, + IEnumerable 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; + } + /// Stable, generic per-kind size message; never echoes file metadata. public static string? ValidateSize(long length, UploadKind kind) { diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 0893fbb..0aa4353 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -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)}"; diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index dccbffd..10ad8ff 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -352,8 +352,9 @@ namespace SeaHaven.Services.Implementation /// /// 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. /// 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); } /// diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index d5771a9..b1fe06f 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -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(() => + 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() {