mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
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:
parent
16845a55f3
commit
e15de6e90b
9 changed files with 229 additions and 20 deletions
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)}";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue