Compare commits

..

No commits in common. "2c8ffaf10ee6b72b4af08a5369543a5093b8f2a3" and "50e5553e578fc91d6c944aa18435e8bbbf06c97e" have entirely different histories.

45 changed files with 185 additions and 2812 deletions

View file

@ -1,6 +0,0 @@
# The Elastic Beanstalk nginx proxy defaults client_max_body_size to 1m,
# which returned 413 for every media upload over ~1 MB before the request reached
# the API. The cap sits above the API's own request limit
# (WorkOrderMediaContract.MaxUploadRequestBytes = 110 MB) so oversize uploads get
# the API's generic per-kind message instead of an nginx error page.
client_max_body_size 120M;

View file

@ -278,8 +278,7 @@ public sealed class UpliftQueueReadTests
item.RequestedByName.Should().Be("Gateway");
item.RequestedAt.Should().Be(new DateTime(2026, 3, 2));
item.VendorReason.Should().Be("Scope grew");
// A work-order request (createdby set) stores the increase itself in RequestedNTE.
item.Delta.Should().Be(400m);
item.Delta.Should().Be(300m);
item.Status.Should().Be("Pending");
}
@ -559,7 +558,7 @@ public sealed class UpliftQueueReadTests
}
[Fact]
public async Task List_PendingExposureTotal_SumsEachPendingRequestsIncreaseAcrossTheQueue()
public async Task List_PendingExposureTotal_SumsGrantedAmountsAcrossAllPendingRequests()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
@ -568,10 +567,10 @@ public sealed class UpliftQueueReadTests
await context.SaveChangesAsync();
context.DispatchUpliftRequests.AddRange(
// Vendor-portal rows (createdby null) store the requested new NTE total,
// so their increase is 400 - 100 = 300 and 600 - 250 = 350.
// so the pending exposure is the delta: 400 - 100 = 300 and 600 - 250 = 350.
Request(dispatch, "Pending", new DateTime(2026, 3, 1), requested: 400m, currentNte: 100m),
Request(dispatch, "Pending", new DateTime(2026, 3, 2), requested: 600m, currentNte: 250m),
// Work-order rows (createdby set) store the increase itself: 90.
// Work-order-path rows (createdby set) store the requested increment: 90.
Request(dispatch, "Pending", new DateTime(2026, 3, 3), requested: 90m, currentNte: 600m, createdBy: "user-7"),
// Non-pending rows never add pending exposure.
Request(dispatch, "Approved", new DateTime(2026, 2, 1), decided: new DateTime(2026, 2, 2), requested: 500m, currentNte: 100m));
@ -585,38 +584,6 @@ public sealed class UpliftQueueReadTests
result.PendingExposureTotal.Should().Be(740m);
}
[Fact]
public async Task List_PendingExposureTotal_EqualsSumOfPendingRowAmounts_ForBothCreationPaths()
{
using var context = NewContext();
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "HVAC");
var (vendorC, workOrderC) = await SeedWorkOrderAsync(context, "WO-C", "SITE-C", "HVAC");
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
var deletedDispatch = await SeedDispatchAsync(context, vendorC, workOrderC, "DIS-C");
deletedDispatch.IsDeleted = true;
context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" });
await context.SaveChangesAsync();
context.DispatchUpliftRequests.AddRange(
// Raised from the work order, which already carries a 3,000 NTE: RequestedNTE
// is the 5,000,000,000 increase itself.
Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 5_000_000_000m, currentNte: 3_000m, createdBy: "user-7"),
// Vendor-portal request for a 3,333 total on a 3,000 NTE: a 333 increase.
Request(dispatchB, "Pending", new DateTime(2026, 3, 2), requested: 3_333m, currentNte: 3_000m),
// A pending request on a deleted dispatch is not a queue row, so it adds nothing.
Request(deletedDispatch, "Pending", new DateTime(2026, 3, 3), requested: 700m, currentNte: 100m));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
result.Items.Should().HaveCount(2);
result.Items.Select(i => i.Delta).Should().BeEquivalentTo(new[] { 5_000_000_000m, 333m });
result.PendingExposureTotal.Should().Be(5_000_000_333m);
result.PendingExposureTotal.Should().Be(result.Items.Sum(i => i.Delta));
}
// --- Approved-on-WO exposure totals ---
[Fact]

View file

@ -419,44 +419,6 @@ public sealed class UpliftWorkflowTests
context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_revised");
}
[Fact]
public async Task Revise_WorkOrderRequest_IsRefusedAsNotFound_AndLeavesTheRowUnchanged()
{
using var context = NewContext();
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 600m);
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
// Raised from the work order by an internal user and sent back for changes:
// RequestedNTE holds the 90 increase, not a total.
var workOrderRequest = new DispatchUpliftRequest
{
DispatchId = dispatch.Id,
CurrentNTE = 600m,
RequestedNTE = 90m,
Status = UpliftStatus.ChangesRequested,
RequiredTier = 1,
VendorReason = "Extra parts",
RequestedByVendorName = "Alex Dispatcher",
NotificationStatus = "Sent",
createdby = "dispatcher-1",
CreatedDate = new DateTime(2026, 3, 1),
};
context.DispatchUpliftRequests.Add(workOrderRequest);
await context.SaveChangesAsync();
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var act = () => service.ReviseUpliftAsync(session!, dispatch.Id, workOrderRequest.Id, 900m, "vendor total", 1, CancellationToken.None);
await act.Should().ThrowAsync<KeyNotFoundException>();
var after = context.DispatchUpliftRequests.AsNoTracking().Single(u => u.Id == workOrderRequest.Id);
after.Status.Should().Be(UpliftStatus.ChangesRequested);
after.RequestedNTE.Should().Be(90m);
after.CurrentNTE.Should().Be(600m);
after.VendorReason.Should().Be("Extra parts");
after.createdby.Should().Be("dispatcher-1");
context.WorkOrderAuditLogs.Should().NotContain(a => a.Action == "uplift_revised");
}
[Fact]
public async Task Withdraw_Pending_TransitionsToWithdrawn_SetsDecidedAt()
{
@ -496,92 +458,6 @@ public sealed class UpliftWorkflowTests
await act.Should().ThrowAsync<InvalidOperationException>();
}
// Raised from the work order by an internal user: createdby is set and RequestedNTE
// holds the increase. Vendor sessions have no identity user, so they never set it.
private static DispatchUpliftRequest WorkOrderRequest(int dispatchId, string status) => new()
{
DispatchId = dispatchId,
CurrentNTE = 600m,
RequestedNTE = 90m,
Status = status,
RequiredTier = 1,
VendorReason = "Extra parts",
RequestedByVendorName = "Alex Dispatcher",
NotificationStatus = "Sent",
createdby = "dispatcher-1",
CreatedDate = new DateTime(2026, 3, 1),
};
[Theory]
[InlineData("Pending", "withdraw")]
[InlineData("ChangesRequested", "withdraw")]
[InlineData("Pending", "cancel")]
[InlineData("ChangesRequested", "cancel")]
public async Task Withdraw_WorkOrderRequest_IsRefusedAsNotFound_AndLeavesTheRowUnchanged(string status, string route)
{
using var context = NewContext();
var (_, _, dispatch) = await SeedAsync(context, nte: 600m);
var workOrderRequest = WorkOrderRequest(dispatch.Id, status);
context.DispatchUpliftRequests.Add(workOrderRequest);
await context.SaveChangesAsync();
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
Func<Task> act = route == "cancel"
? () => service.CancelUpliftRequestAsync(session!, dispatch.Id, workOrderRequest.Id, CancellationToken.None)
: () => service.WithdrawUpliftAsync(session!, dispatch.Id, workOrderRequest.Id, CancellationToken.None);
await act.Should().ThrowAsync<KeyNotFoundException>();
var after = context.DispatchUpliftRequests.AsNoTracking().Single(u => u.Id == workOrderRequest.Id);
after.Status.Should().Be(status);
after.DecidedAt.Should().BeNull();
after.RequestedNTE.Should().Be(90m);
after.createdby.Should().Be("dispatcher-1");
context.WorkOrderAuditLogs.Should().NotContain(a => a.Action == "uplift_withdraw" || a.Action == "uplift_cancel");
}
[Fact]
public async Task Cancel_VendorRaisedChangesRequested_StillWithdraws()
{
using var context = NewContext();
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
await context.SaveChangesAsync();
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var created = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None);
context.DispatchUpliftRequests.Single().Status = UpliftStatus.ChangesRequested;
await context.SaveChangesAsync();
var result = await service.CancelUpliftRequestAsync(session!, dispatch.Id, created.Id, CancellationToken.None);
result.Status.Should().Be(UpliftStatus.Withdrawn);
context.DispatchUpliftRequests.AsNoTracking().Single().Status.Should().Be(UpliftStatus.Withdrawn);
context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_cancel" && a.OldValue == UpliftStatus.ChangesRequested);
}
[Fact]
public async Task DispatchDetail_ReportsRaisedByVendor_PerCreationPath()
{
using var context = NewContext();
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
await context.SaveChangesAsync();
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var vendorRaised = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None);
await service.WithdrawUpliftAsync(session!, dispatch.Id, vendorRaised.Id, CancellationToken.None);
var workOrderRequest = WorkOrderRequest(dispatch.Id, UpliftStatus.Pending);
context.DispatchUpliftRequests.Add(workOrderRequest);
await context.SaveChangesAsync();
var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None);
detail!.UpliftRequests.Should().HaveCount(2);
detail.UpliftRequests.Single(u => u.Id == vendorRaised.Id).RaisedByVendor.Should().BeTrue();
detail.UpliftRequests.Single(u => u.Id == workOrderRequest.Id).RaisedByVendor.Should().BeFalse();
}
[Fact]
public async Task RequestChanges_Pending_TransitionsToChangesRequested_AuditsOldValue()
{

View file

@ -54,11 +54,6 @@ public sealed class VendorPortalDocumentTests : IDisposable
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PortalUpliftData>());
upliftData.Setup(u => u.ExecuteWorkOrderMutationAsync(
It.IsAny<int>(),
It.IsAny<Func<CancellationToken, Task<VendorCompletionDocument>>>(),
It.IsAny<CancellationToken>()))
.Returns((int _, Func<CancellationToken, Task<VendorCompletionDocument>> work, CancellationToken ct) => work(ct));
var commentData = new Mock<ICommentDataService>();
commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PortalCommentData>());
@ -368,278 +363,6 @@ public sealed class VendorPortalDocumentTests : IDisposable
context.VendorCompletionDocuments.Should().HaveCount(1);
}
[Fact]
public void UploadCompletionDocument_AdvertisesContractRequestLimit()
{
var attribute = Assert.Single(
typeof(VendorPortalController)
.GetMethod(nameof(VendorPortalController.UploadCompletionDocument))!
.CustomAttributes,
candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute));
var bytes = Assert.Single(attribute.ConstructorArguments);
bytes.Value.Should().Be(110_000_000L);
}
private static byte[] MediaBytes(int size, params byte[] header)
{
var bytes = new byte[size];
header.AsSpan().CopyTo(bytes);
return bytes;
}
private static byte[] FtypVideoBytes(int size) => MediaBytes(
size, 0x00, 0x00, 0x00, 0x20, (byte)'f', (byte)'t', (byte)'y', (byte)'p',
(byte)'i', (byte)'s', (byte)'o', (byte)'m');
[Fact]
public async Task UploadCompletionDocument_AcceptsSixtyMegabyteVideo()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(FtypVideoBytes(60_000_000), "site-clip.mp4", "video/mp4"));
result.Should().BeOfType<OkObjectResult>();
var document = await context.VendorCompletionDocuments.SingleAsync();
document.ContentType.Should().Be("video/mp4");
document.SizeBytes.Should().Be(60_000_000L);
}
[Fact]
public async Task UploadCompletionDocument_AcceptsMovWithEmptyContentType()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(FtypVideoBytes(2_048), "site-clip.MOV", ""));
result.Should().BeOfType<OkObjectResult>();
(await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("video/quicktime");
}
[Fact]
public async Task UploadCompletionDocument_AcceptsIosTranscodedJpegLabelledHeic()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(MediaBytes(4_096, 0xFF, 0xD8, 0xFF, 0xE0), "IMG_2044.jpg", "image/heic"));
result.Should().BeOfType<OkObjectResult>();
(await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("image/jpeg");
}
[Theory]
// Genuine PDF/JPEG bytes and declared type, but a video file name: the size class must
// follow the validated type, not the name, or the document/photo caps are bypassed.
[InlineData("report.mp4", "application/pdf", 12_000_000, new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D })]
[InlineData("site.mov", "image/jpeg", 11_000_000, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 })]
public async Task UploadCompletionDocument_VideoExtensionDoesNotWidenSizeCap(
string fileName,
string contentType,
int size,
byte[] header)
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(MediaBytes(size, header), fileName, contentType));
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
/// <summary>ftyp + mdat + moov/mvhd (moov last, as phones write it).</summary>
private static byte[] PhoneVideoBytes(uint durationSeconds)
{
static byte[] Box(string type, byte[] body)
{
var box = new byte[8 + body.Length];
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length);
System.Text.Encoding.ASCII.GetBytes(type).CopyTo(box, 4);
body.CopyTo(box, 8);
return box;
}
var mvhd = new byte[20];
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(12), 600);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(16), durationSeconds * 600);
return Box("ftyp", System.Text.Encoding.ASCII.GetBytes("qt \0\0\0\0"))
.Concat(Box("mdat", new byte[4096]))
.Concat(Box("moov", Box("mvhd", mvhd)))
.ToArray();
}
[Theory]
[InlineData(95u, false)]
[InlineData(89u, true)]
public async Task UploadCompletionDocument_EnforcesNinetySecondVideoLimit(uint seconds, bool accepted)
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(PhoneVideoBytes(seconds), "IMG_0042.MOV", "video/quicktime"));
if (accepted)
{
result.Should().BeOfType<OkObjectResult>();
}
else
{
result.Should().BeOfType<BadRequestObjectResult>();
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 GetDispatchDetail_ReportsWorkOrderMediaCountsForThePortalPreCheck()
{
using var context = NewContext();
var (vendor, dispatch) = await SeedDispatch(context);
foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV", "IMG_0003.jpg" })
{
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 service = NewService(context);
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None);
detail!.MediaCounts.Should().BeEquivalentTo(new SeaHaven.Services.DTOs.PortalMediaCountsDTO
{
MaxPhotos = 10,
MaxVideos = 3,
Photos = 1,
Videos = 3,
CompletionPhotos = 1,
CompletionVideos = 2,
});
}
[Fact]
public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(FtypVideoBytes(100_000_001), "site-clip.mp4", "video/mp4"));
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
[Fact]
public async Task UploadCompletionDocument_RejectsPhotoOverTenMegabytes()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(MediaBytes(10_000_001, 0xFF, 0xD8, 0xFF, 0xE0), "photo.jpg", "image/jpeg"));
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
[Fact]
public async Task UploadCompletionDocument_RejectsUnsupportedVideoContainer()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile("not a video at all"u8.ToArray(), "clip.mp4", "video/mp4"));
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
[Fact]
public async Task GetDispatchDetail_ReturnsUploadedDocumentWithQuarantineAndReplacementMetadata()
{

View file

@ -1,6 +1,5 @@
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
@ -32,7 +31,7 @@ public class WorkOrderMediaControllerTests
}
[Fact]
public void AddMedia_HasContractRequestLimit()
public void AddMedia_HasTwoHundredMegabyteRequestLimit()
{
var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia));
var attribute = Assert.Single(
@ -40,293 +39,36 @@ public class WorkOrderMediaControllerTests
candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute));
var bytes = Assert.Single(attribute.ConstructorArguments);
Assert.Equal(110_000_000L, bytes.Value);
Assert.Equal(200_000_000L, bytes.Value);
}
[Fact]
public void AddMedia_HasContractMultipartBodyLimit()
public void AddMedia_HasTwoHundredMegabyteMultipartBodyLimit()
{
var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia));
var attribute = Assert.IsType<RequestFormLimitsAttribute>(Assert.Single(
method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: true)));
Assert.Equal(110_000_000L, attribute.MultipartBodyLengthLimit);
Assert.Equal(200_000_000L, attribute.MultipartBodyLengthLimit);
}
[Fact]
public async Task AddMedia_VideoOverHundredMegabytes_ReturnsStableUnprocessableEntity()
public async Task AddMedia_FileOverLimit_ReturnsStableUnprocessableEntity()
{
var file = OversizeFile(100_000_001, "clip.mp4", "video/mp4", FtypHeader);
var file = new Mock<IFormFile>();
file.SetupGet(candidate => candidate.Length).Returns(200_000_001);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("FileTooLarge", error.Code);
Assert.Equal("Videos must be 100 MB or smaller.", error.Message);
Assert.Equal("The uploaded file must not exceed 200 MB.", error.Message);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task AddMedia_PhotoOverTenMegabytes_ReturnsStableUnprocessableEntity()
{
var file = OversizeFile(10_000_001, "photo.jpg", "image/jpeg", JpegHeader);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("FileTooLarge", error.Code);
Assert.Equal("Photos must be 10 MB or smaller.", error.Message);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task AddMedia_PhotoDeclaredAsForeignVideoType_IsSizedAsAPhoto()
{
// A .jpg with a foreign video type resolves to image/jpeg for validation, so it must
// also be sized as a photo, not given the 100 MB video allowance.
var file = OversizeFile(60_000_000, "photo.jpg", "video/3gpp", JpegHeader);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("FileTooLarge", error.Code);
Assert.Equal("Photos must be 10 MB or smaller.", error.Message);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity()
{
var file = OversizeFile(50_000_001, "report.pdf", "application/pdf", PdfHeader);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, WorkOrderMediaCategory.Extra, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("FileTooLarge", error.Code);
Assert.Equal("Documents must be 50 MB or smaller.", error.Message);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task AddMedia_OversizeUnsupportedType_ReportsUnsupportedTypeNotOversizeVideo()
{
var file = OversizeFile(150_000_000, "payload.exe", "application/octet-stream", [0x4D, 0x5A]);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("UnsupportedMediaType", error.Code);
storage.VerifyNoOtherCalls();
}
[Fact]
public void ValidateSize_OversizeUnknownKind_UsesTypeNeutralMessage()
{
Assert.Equal(
"Files must be 100 MB or smaller.",
WorkOrderMediaContract.ValidateSize(150_000_000, WorkOrderMediaContract.UploadKind.Unknown));
}
private static readonly byte[] FtypHeader = [0, 0, 0, 0x18, (byte)'f', (byte)'t', (byte)'y', (byte)'p', (byte)'i', (byte)'s', (byte)'o', (byte)'m'];
private static readonly byte[] JpegHeader = [0xFF, 0xD8, 0xFF, 0xE0];
private static readonly byte[] PdfHeader = [0x25, 0x50, 0x44, 0x46, 0x2D];
/// <summary>
/// A file that reports <paramref name="length"/> bytes but only backs the 512-byte header the
/// type rules read, so oversize cases run through real signature validation cheaply.
/// </summary>
private static FormFile OversizeFile(long length, string fileName, string contentType, byte[] header)
{
var bytes = new byte[512];
header.CopyTo(bytes, 0);
return new FormFile(new MemoryStream(bytes), 0, length, "file", fileName)
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
}
private static FormFile VideoFormFile(int size, string fileName, string contentType)
{
var bytes = new byte[size];
// ISO BMFF ftyp box so the media type rules accept the payload as MP4/MOV.
bytes[4] = (byte)'f';
bytes[5] = (byte)'t';
bytes[6] = (byte)'y';
bytes[7] = (byte)'p';
bytes[8] = (byte)'i';
bytes[9] = (byte)'s';
bytes[10] = (byte)'o';
bytes[11] = (byte)'m';
return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
}
private static (Mock<IWorkOrderMediaService> Service, Mock<IFileStoragePort> Storage) SetupSuccessfulAddMedia()
{
var service = new Mock<IWorkOrderMediaService>(MockBehavior.Strict);
service
.Setup(candidate => candidate.EnsureCanMutateMediaAsync(
1, It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(), It.IsAny<CancellationToken>(), null))
.Returns(Task.CompletedTask);
service
.Setup(candidate => candidate.AddMediaAsync(
1, null, "https://storage.test/stored", It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new WorkOrderMediaFileDto { Id = 5, Url = "https://storage.test/stored" });
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
storage
.Setup(candidate => candidate.SaveFileAsync(It.IsAny<IFormFile>()))
.ReturnsAsync("https://storage.test/stored");
return (service, storage);
}
[Fact]
public async Task AddMedia_SixtyMegabyteMp4_IsAccepted()
{
var (service, storage) = SetupSuccessfulAddMedia();
var controller = CreateController(service, storage);
var file = VideoFormFile(60_000_000, "site-clip.mp4", "video/mp4");
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
Assert.Equal(5, Assert.IsType<WorkOrderMediaFileDto>(ok.Value).Id);
}
[Fact]
public async Task AddMedia_VideoLongerThanNinetySeconds_ReturnsStableUnprocessableEntity()
{
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var file = PhoneVideo(durationSeconds: 95, "IMG_0042.MOV", "video/quicktime");
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("VideoTooLong", error.Code);
Assert.Equal("Videos must be 90 seconds or shorter.", error.Message);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task AddMedia_VideoWithinNinetySeconds_IsAccepted()
{
var (service, storage) = SetupSuccessfulAddMedia();
var controller = CreateController(service, storage);
var file = PhoneVideo(durationSeconds: 60, "IMG_0043.MOV", "");
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
}
/// <summary>ftyp + mdat + moov/mvhd (moov last, as phones write it).</summary>
private static FormFile PhoneVideo(uint durationSeconds, string fileName, string contentType)
{
static byte[] Box(string type, byte[] body)
{
var box = new byte[8 + body.Length];
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length);
System.Text.Encoding.ASCII.GetBytes(type).CopyTo(box, 4);
body.CopyTo(box, 8);
return box;
}
var mvhd = new byte[20];
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(12), 1000);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(16), durationSeconds * 1000);
var bytes = Box("ftyp", System.Text.Encoding.ASCII.GetBytes("qt \0\0\0\0"))
.Concat(Box("mdat", new byte[4096]))
.Concat(Box("moov", Box("mvhd", mvhd)))
.ToArray();
return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
}
[Fact]
public async Task AddMedia_SixtyMegabyteQuicktimeMov_IsAccepted()
{
var (service, storage) = SetupSuccessfulAddMedia();
var controller = CreateController(service, storage);
var file = VideoFormFile(60_000_000, "site-clip.mov", "video/quicktime");
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
}
[Fact]
public async Task AddMedia_SixtyMegabyteMovWithEmptyContentType_IsAccepted()
{
var (service, storage) = SetupSuccessfulAddMedia();
var controller = CreateController(service, storage);
var file = VideoFormFile(60_000_000, "site-clip.MOV", "");
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
}
[Fact]
public async Task AddMedia_SixtyMegabyteMp4WithOctetStreamContentType_IsAccepted()
{
var (service, storage) = SetupSuccessfulAddMedia();
var controller = CreateController(service, storage);
var file = VideoFormFile(60_000_000, "site-clip.mp4", "application/octet-stream");
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
}
[Fact]
public async Task AddMedia_HeicPhoto_IsAccepted()
{
var (service, storage) = SetupSuccessfulAddMedia();
var controller = CreateController(service, storage);
var bytes = new byte[512];
bytes[4] = (byte)'f';
bytes[5] = (byte)'t';
bytes[6] = (byte)'y';
bytes[7] = (byte)'p';
bytes[8] = (byte)'h';
bytes[9] = (byte)'e';
bytes[10] = (byte)'i';
bytes[11] = (byte)'c';
var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "capture.heic")
{
Headers = new HeaderDictionary(),
ContentType = "image/heic"
};
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
}
[Fact]
public async Task AddMedia_UnsupportedType_ReturnsUnprocessableEntity()
{
@ -425,8 +167,7 @@ public class WorkOrderMediaServiceCancellationTests
mediaData.Object,
new Mock<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
storage.Object,
new Mock<IUpliftDataService>(MockBehavior.Strict).Object);
storage.Object);
var user = new ClaimsPrincipal(new ClaimsIdentity(
new[]
{
@ -443,37 +184,4 @@ public class WorkOrderMediaServiceCancellationTests
mediaData.Verify(candidate => candidate.GetAttachmentForReadAsync(10, 1, token), Times.Once);
mediaData.VerifyNoOtherCalls();
}
[Fact]
public async Task GetMediaContent_ServesHeicAsImageHeic()
{
const string url = "https://example.test/Assets/Images/photo.heic";
var mediaData = new Mock<IWorkOrderMediaDataService>(MockBehavior.Strict);
mediaData.Setup(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new WorkOrder { Id = 1 });
mediaData.Setup(candidate => candidate.GetAttachmentForReadAsync(10, 1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = url });
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
storage.Setup(candidate => candidate.OpenRead(url)).Returns(new MemoryStream([1, 2, 3]));
var service = new WorkOrderMediaService(
mediaData.Object,
new Mock<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
storage.Object,
new Mock<IUpliftDataService>(MockBehavior.Strict).Object);
var user = new ClaimsPrincipal(new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.NameIdentifier, "actor-1"),
new Claim(ClaimTypes.Role, "Admin"),
new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll)
},
"Test"));
var result = await service.GetMediaContentAsync(1, 10, user, "actor-1");
result.Content.Dispose();
Assert.Equal("image/heic", result.ContentType);
Assert.Equal("photo.heic", result.FileName);
}
}

View file

@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
@ -300,7 +299,7 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("dispatches/{id:int}/completion-documents")]
[HttpPost("dispatches/{id:int}/documents")]
[RequestSizeLimit(WorkOrderMediaContract.MaxUploadRequestBytes)]
[RequestSizeLimit(10_000_000)]
public async Task<IActionResult> UploadCompletionDocument(
int id,
[FromForm] IFormFile file,

View file

@ -17,7 +17,7 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/workorders")]
public class WorkOrderMediaController : Controller
{
public const long MaxUploadBytes = WorkOrderMediaContract.MaxUploadRequestBytes;
public const long MaxUploadBytes = 200_000_000;
private readonly IWorkOrderMediaService _workOrderMediaService;
private readonly IFileStoragePort _fileStorage;
@ -88,28 +88,14 @@ namespace Api.SeaHavenIndustries.Controllers
string? fileUrl = null;
try
{
// Type first, so an unsupported file always reports UnsupportedMediaType instead
// of being sized under a kind it does not have.
if (file.Length > MaxUploadBytes)
{
throw new WorkOrderBoardValidationException(
"FileTooLarge",
"The uploaded file must not exceed 200 MB.");
}
WorkOrderMediaFileRules.EnsureAllowed(file, category);
// Media contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB).
// Classify by the same resolved type EnsureAllowed validates against.
var sizeMessage = WorkOrderMediaContract.ValidateSize(
WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName, file.Length);
if (sizeMessage != null)
{
throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage);
}
if (WorkOrderMediaContract.ResolveKind(
WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName)
== WorkOrderMediaContract.UploadKind.Video)
{
using var content = file.OpenReadStream();
var durationMessage = WorkOrderMediaContract.ValidateVideoDuration(content);
if (durationMessage != null)
throw new WorkOrderBoardValidationException("VideoTooLong", durationMessage);
}
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
await _workOrderMediaService.EnsureCanMutateMediaAsync(id, User, actorId, cancellationToken, category);

View file

@ -45,9 +45,6 @@ namespace Data.SeaHavenIndustries
public string? Status { get; set; }
public int RequiredTier { get; set; }
public string? RequestedByVendorName { get; set; }
// True when the vendor raised the request in the portal; false when it was raised
// from the work order. Only vendor-raised requests are revisable or withdrawable.
public bool RaisedByVendor { get; set; }
public DateTime? CreatedDate { get; set; }
public DateTime? DecidedAt { get; set; }
public string? DecisionNote { get; set; }
@ -103,8 +100,6 @@ namespace Data.SeaHavenIndustries
public string? DecidedByLastName { get; set; }
public decimal? CurrentNTE { get; set; }
public decimal RequestedNTE { get; set; }
/// <summary>The requested NTE increase; see UpliftAmount in SeaHaven.DataServices.</summary>
public decimal Amount { get; set; }
public string? VendorReason { get; set; }
public int RequiredTier { get; set; }
public string? Status { get; set; }
@ -132,8 +127,6 @@ namespace Data.SeaHavenIndustries
public int DispatchId { get; set; }
public decimal? CurrentNTE { get; set; }
public decimal RequestedNTE { get; set; }
/// <summary>The requested NTE increase; see UpliftAmount in SeaHaven.DataServices.</summary>
public decimal Amount { get; set; }
public string? VendorReason { get; set; }
public string? Status { get; set; }
public int RequiredTier { get; set; }

View file

@ -1,33 +0,0 @@
using System.Linq.Expressions;
using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Helpers
{
/// <summary>
/// The single definition of an uplift's amount: the NTE increase being requested.
/// The two creation paths store different meanings in RequestedNTE. Vendor-portal
/// requests store the requested new NTE total, so the amount is RequestedNTE minus
/// the NTE at request time. Work-order requests store the increase itself. Vendor
/// sessions have no identity user, so createdby is null only on vendor-portal rows.
/// </summary>
public static class UpliftAmount
{
/// <summary>Translatable form for EF queries and aggregates.</summary>
public static readonly Expression<Func<DispatchUpliftRequest, decimal>> ForQuery =
u => u.createdby == null
? u.RequestedNTE - (u.CurrentNTE ?? 0m)
: u.RequestedNTE;
private static readonly Func<DispatchUpliftRequest, decimal> Compiled = ForQuery.Compile();
public static decimal Of(DispatchUpliftRequest request) => Compiled(request);
public static decimal Of(string? createdBy, decimal requestedNte, decimal? currentNte) =>
Compiled(new DispatchUpliftRequest
{
createdby = createdBy,
RequestedNTE = requestedNte,
CurrentNTE = currentNte,
});
}
}

View file

@ -255,8 +255,6 @@ namespace SeaHaven.DataServices.Helpers
WorkOrderId = u.Dispatch!.WorkOrderId!.Value,
u.Status,
u.RequestedNTE,
u.CurrentNTE,
u.createdby,
u.CreatedDate
});
@ -270,8 +268,6 @@ namespace SeaHaven.DataServices.Helpers
WorkOrderId = link.WorkOrderId,
u.Status,
u.RequestedNTE,
u.CurrentNTE,
u.createdby,
u.CreatedDate
});
@ -280,7 +276,7 @@ namespace SeaHaven.DataServices.Helpers
l.UpliftId,
l.WorkOrderId,
l.Status,
UpliftAmount.Of(l.createdby, l.RequestedNTE, l.CurrentNTE),
l.RequestedNTE,
l.CreatedDate)));
}
@ -320,7 +316,7 @@ namespace SeaHaven.DataServices.Helpers
pendingCount,
HasUplift: true,
primary?.Status,
primary?.Amount);
primary?.RequestedNTE);
}
return result;
@ -341,7 +337,7 @@ namespace SeaHaven.DataServices.Helpers
int UpliftId,
int WorkOrderId,
string Status,
decimal Amount,
decimal RequestedNTE,
DateTime? CreatedDate);
private sealed record BoardUpliftAgg(

View file

@ -220,26 +220,13 @@ namespace SeaHaven.DataServices.Helpers
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled);
/// <summary>
/// Narrows to rows whose Schedule On date falls in the range, or, for
/// week-only rows, whose Target Week overlaps it: the same date the
/// weekly board groups by (SH-391). Rows with no date never match a
/// range; they are added only when <paramref name="includeDateless"/>
/// is set, which callers send for searches with no date range selected.
/// Completed and canceled dateless rows stay out, matching
/// <see cref="ApplyUnscheduledOnlyFilter"/>.
/// </summary>
public static IQueryable<WorkOrder> ApplyDateRangeFilter(
IQueryable<WorkOrder> query,
DateOnly dateFrom,
DateOnly dateTo,
bool includeDateless = false)
DateOnly dateTo)
{
var fromDate = dateFrom.ToDateTime(TimeOnly.MinValue).Date;
var toDate = dateTo.ToDateTime(TimeOnly.MinValue).Date;
// A target week (stored as its Monday) overlaps the range when it
// starts by the range end and its Sunday is on or after the range start.
var earliestWeekStart = dateFrom.DayNumber >= 6 ? dateFrom.AddDays(-6) : dateFrom;
return query.Where(w =>
(w.ScheduledDate != null
@ -247,10 +234,9 @@ namespace SeaHaven.DataServices.Helpers
&& w.ScheduledDate.Value.Date <= toDate)
|| (w.ScheduleWeekOnly == true
&& w.TargetWeek != null
&& w.TargetWeek >= earliestWeekStart
&& w.TargetWeek >= dateFrom
&& w.TargetWeek <= dateTo)
|| (includeDateless
&& w.ScheduledDate == null
|| (w.ScheduledDate == null
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled));
}

View file

@ -1,7 +1,6 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
@ -458,7 +457,6 @@ namespace SeaHaven.DataServices.Implementation
u.Id,
u.CurrentNTE,
u.RequestedNTE,
u.createdby,
u.VendorReason,
u.Status,
u.RequiredTier,
@ -476,7 +474,7 @@ namespace SeaHaven.DataServices.Implementation
Id = u.Id,
CurrentNTE = u.CurrentNTE,
RequestedNTE = u.RequestedNTE,
Delta = UpliftAmount.Of(u.createdby, u.RequestedNTE, u.CurrentNTE),
Delta = u.RequestedNTE - (u.CurrentNTE ?? 0m),
VendorReason = u.VendorReason,
Status = u.Status,
RequiredTier = u.RequiredTier,

View file

@ -3,7 +3,6 @@ using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Exceptions;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
@ -110,7 +109,6 @@ namespace SeaHaven.DataServices.Implementation
DecidedByLastName = x.decUser != null ? x.decUser.LastName : null,
CurrentNTE = x.u.CurrentNTE,
RequestedNTE = x.u.RequestedNTE,
Amount = UpliftAmount.Of(x.u.createdby, x.u.RequestedNTE, x.u.CurrentNTE),
VendorReason = x.u.VendorReason,
RequiredTier = x.u.RequiredTier,
Status = x.u.Status,
@ -130,6 +128,16 @@ namespace SeaHaven.DataServices.Implementation
return (total, items);
}
public Task<decimal> GetPendingExposureAsync(CancellationToken cancellationToken)
{
return _context.DispatchUpliftRequests
.Where(u => (u.IsDeleted == null || u.IsDeleted == false)
&& u.Status == "Pending")
.SumAsync(u => u.createdby == null
? u.RequestedNTE - (u.CurrentNTE ?? 0m)
: u.RequestedNTE, cancellationToken);
}
public async Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
{
return await (from u in _context.DispatchUpliftRequests
@ -145,7 +153,6 @@ namespace SeaHaven.DataServices.Implementation
DispatchId = u.DispatchId,
CurrentNTE = u.CurrentNTE,
RequestedNTE = u.RequestedNTE,
Amount = UpliftAmount.Of(u.createdby, u.RequestedNTE, u.CurrentNTE),
VendorReason = u.VendorReason,
Status = u.Status,
RequiredTier = u.RequiredTier,
@ -186,7 +193,6 @@ namespace SeaHaven.DataServices.Implementation
DispatchId = u.DispatchId,
CurrentNTE = u.CurrentNTE,
RequestedNTE = u.RequestedNTE,
Amount = UpliftAmount.Of(u.createdby, u.RequestedNTE, u.CurrentNTE),
VendorReason = u.VendorReason,
Status = u.Status,
RequiredTier = u.RequiredTier,
@ -243,9 +249,6 @@ namespace SeaHaven.DataServices.Implementation
Status = u.Status,
RequiredTier = u.RequiredTier,
RequestedByVendorName = u.RequestedByVendorName,
// Vendor sessions have no identity user, so createdby is null
// only on requests the vendor raised in the portal.
RaisedByVendor = u.createdby == null,
CreatedDate = u.CreatedDate,
DecidedAt = u.DecidedAt,
DecisionNote = u.DecisionNote,
@ -399,14 +402,25 @@ namespace SeaHaven.DataServices.Implementation
.Where(u => (u.IsDeleted == null || u.IsDeleted == false)
&& dispatchIds.Contains(u.DispatchId)
&& (u.Status == "Approved" || u.Status == "NoApprovalRequired"))
// Summing each request's amount (the granted increase) keeps sequential
// approvals from double-counting whole NTE totals in both buckets.
.GroupBy(u => new { u.DispatchId, u.Status }, UpliftAmount.ForQuery)
.GroupBy(u => u.DispatchId)
.Select(g => new
{
g.Key.DispatchId,
g.Key.Status,
Total = g.Sum()
DispatchId = g.Key,
// The two creation paths store different meanings in RequestedNTE.
// Vendor-portal rows store the requested new NTE total, so the
// granted amount is RequestedNTE - CurrentNTE; work-order-path rows
// store the granted increment directly. Vendor sessions have no
// identity user, so createdby is null only on vendor-portal rows. Summing
// granted amounts keeps sequential approvals from double-counting whole
// NTE totals. This applies to both buckets.
AutoApproved = g.Where(x => x.Status == "NoApprovalRequired")
.Sum(x => (decimal?)(x.createdby == null
? x.RequestedNTE - (x.CurrentNTE ?? 0m)
: x.RequestedNTE)),
AdminApproved = g.Where(x => x.Status == "Approved")
.Sum(x => (decimal?)(x.createdby == null
? x.RequestedNTE - (x.CurrentNTE ?? 0m)
: x.RequestedNTE))
})
.ToListAsync(cancellationToken);
@ -424,20 +438,17 @@ namespace SeaHaven.DataServices.Implementation
totals[workOrderId] = total;
}
if (sum.Status == "NoApprovalRequired")
total.AutoApprovedTotal += sum.Total;
else
total.AdminApprovedTotal += sum.Total;
total.AutoApprovedTotal += sum.AutoApproved ?? 0m;
total.AdminApprovedTotal += sum.AdminApproved ?? 0m;
}
}
return totals.Values.ToList();
}
// Queue-wide pending exposure for the approvals header: the sum of the amount each
// Pending queue row displays (UpliftAmount), over the same rows the Pending tab
// lists (non-deleted request on a non-deleted dispatch), independent of page or
// filters.
// SH-207: queue-wide pending exposure for the approvals header. Mirrors the
// approved-exposure convention (sum of RequestedNTE) over every non-deleted
// Pending request on a non-deleted dispatch, independent of page or filters.
public async Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken)
{
return await _context.DispatchUpliftRequests
@ -445,8 +456,7 @@ namespace SeaHaven.DataServices.Implementation
&& u.Status == "Pending"
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false))
.Select(UpliftAmount.ForQuery)
.SumAsync(cancellationToken);
.SumAsync(u => (decimal?)u.RequestedNTE, cancellationToken) ?? 0m;
}
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(

View file

@ -17,39 +17,6 @@ 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

@ -22,8 +22,7 @@ namespace SeaHaven.DataServices.Implementation
if (query.UnscheduledOnly)
baseQuery = WorkOrderBoardQueryFilters.ApplyUnscheduledOnlyFilter(baseQuery);
else
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(
baseQuery, query.DateFrom, query.DateTo, query.IncludeDateless);
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo);
baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites);
baseQuery = WorkOrderBoardQueryFilters.ApplyRegionFilter(baseQuery, query.Regions);
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue);

View file

@ -56,36 +56,6 @@ namespace SeaHaven.DataServices.Implementation
public void TrackAttachment(WorkOrderAttachments attachment)
=> _context.workOrderAttachments.Add(attachment);
public async Task<IReadOnlyList<string>> ListActiveAttachmentUrlsAsync(
int workOrderId,
CancellationToken cancellationToken)
{
var urls = await _context.workOrderAttachments
.AsNoTracking()
.Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true && a.Attachments != null)
.Select(a => a.Attachments!)
.ToListAsync(cancellationToken);
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

@ -6,6 +6,7 @@ namespace SeaHaven.DataServices.Interfaces
{
Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken);
Task<decimal> GetPendingExposureAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken);

View file

@ -9,19 +9,6 @@ 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. Feeds the per-work-order 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 (photo/video counts).</summary>
Task<IReadOnlyList<string>> ListActiveWorkOrderAttachmentUrlsAsync(
int workOrderId,
CancellationToken cancellationToken);
Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken);
Task SaveChangesAsync(CancellationToken cancellationToken);
}

View file

@ -22,19 +22,6 @@ namespace SeaHaven.DataServices.Interfaces
Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken);
void TrackAttachment(WorkOrderAttachments attachment);
/// <summary>Stored URLs of the work order's non-deleted attachments (photo/video counts).</summary>
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). The 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

@ -32,8 +32,7 @@ namespace SeaHaven.DataServices.Interfaces
int PageSize,
string SortBy,
string SortDir,
int? AccountId = null,
bool IncludeDateless = false);
int? AccountId = null);
public record WorkOrderAdvancedSearchResult(
IReadOnlyList<WorkOrderBoardRawRow> Rows,

View file

@ -54,8 +54,6 @@ namespace SeaHaven.Services.DTOs
public string? Status { get; set; }
public int RequiredTier { get; set; }
public string? RequestedByVendorName { get; set; }
// False for requests raised from the work order: the portal shows them read-only.
public bool RaisedByVendor { get; set; }
public DateTime? RequestedAt { get; set; }
public DateTime? DecidedAt { get; set; }
public string? DecisionNote { get; set; }
@ -136,26 +134,6 @@ namespace SeaHaven.Services.DTOs
public IEnumerable<PortalCommentDTO> Comments { get; set; } = Enumerable.Empty<PortalCommentDTO>();
public IEnumerable<PortalUpliftDTO> UpliftRequests { get; set; } = Enumerable.Empty<PortalUpliftDTO>();
public IEnumerable<VendorPortalDocumentDTO> Documents { get; set; } = Enumerable.Empty<VendorPortalDocumentDTO>();
public PortalMediaCountsDTO? MediaCounts { get; set; }
}
/// <summary>
/// Per-work-order photo/video usage, so the portal can pre-check an upload
/// before sending it. The server still enforces the limit on upload.
/// </summary>
public class PortalMediaCountsDTO
{
public int MaxPhotos { get; set; }
public int MaxVideos { get; set; }
/// <summary>Photos/videos on the work order, counted for evidence uploads.</summary>
public int Photos { get; set; }
public int Videos { get; set; }
/// <summary>
/// Photos/videos counted for a new completion version, which replaces the dispatch's
/// latest document and so does not count it.
/// </summary>
public int CompletionPhotos { get; set; }
public int CompletionVideos { get; set; }
}
public class VendorPortalDocumentDTO

View file

@ -87,14 +87,6 @@ namespace SeaHaven.Services.DTOs
/// filter; every other facet still applies.
/// </summary>
public bool UnscheduledOnly { get; set; }
/// <summary>
/// When true, rows with no date (no Schedule On) are returned alongside
/// the in-range rows. Sent for searches with no date range selected; an
/// explicit range leaves it unset or false so the range strictly narrows
/// (SH-391). When it is omitted, clients that predate the flag keep their
/// undated rows: see <see cref="WorkOrderSearchDateRangeResolver.ResolveIncludeDateless"/>.
/// </summary>
public bool? IncludeDateless { get; set; }
public List<string>? Sites { get; set; }
public List<string>? Regions { get; set; }
public List<WorkOrderType>? Types { get; set; }

View file

@ -1,129 +0,0 @@
using System.Buffers.Binary;
using System.Text;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// Reads a video's duration from the ISO base media (MP4 / QuickTime) movie header:
/// top-level <c>moov</c> box → <c>mvhd</c> timescale and duration. It seeks over box
/// headers only (the <c>moov</c> box may sit after a large <c>mdat</c>), never decodes
/// media and never allocates more than a few bytes. Returns null whenever the structure
/// cannot be read, so callers can let unreadable files through (per the media contract: unreadable
/// metadata never blocks an upload).
/// </summary>
public static class VideoDurationProbe
{
private const int MaxBoxesPerLevel = 1024;
public static double? TryReadDurationSeconds(Stream? stream)
{
if (stream == null || !stream.CanSeek || !stream.CanRead)
return null;
try
{
var moov = FindBox(stream, 0, stream.Length, "moov");
if (moov == null)
return null;
var mvhd = FindBox(stream, moov.Value.BodyStart, moov.Value.End, "mvhd");
return mvhd == null ? null : ReadMovieHeaderSeconds(stream, mvhd.Value);
}
catch (IOException)
{
return null;
}
}
private readonly record struct Box(long BodyStart, long End);
private static Box? FindBox(Stream stream, long start, long end, string type)
{
var header = new byte[8];
var position = start;
for (var i = 0; i < MaxBoxesPerLevel && position + 8 <= end; i++)
{
stream.Position = position;
if (!ReadExactly(stream, header, 8))
return null;
long size = BinaryPrimitives.ReadUInt32BigEndian(header);
var boxType = Encoding.ASCII.GetString(header, 4, 4);
var headerLength = 8;
if (size == 1)
{
// 64-bit "largesize" follows the type.
var large = new byte[8];
if (!ReadExactly(stream, large, 8))
return null;
var largeSize = BinaryPrimitives.ReadUInt64BigEndian(large);
if (largeSize > long.MaxValue)
return null;
size = (long)largeSize;
headerLength = 16;
}
else if (size == 0)
{
size = end - position;
}
if (size < headerLength || position + size > end)
return null;
if (boxType == type)
return new Box(position + headerLength, position + size);
position += size;
}
return null;
}
private static double? ReadMovieHeaderSeconds(Stream stream, Box mvhd)
{
// version(1) flags(3), then v0: creation(4) modification(4) timescale(4) duration(4)
// v1: creation(8) modification(8) timescale(4) duration(8)
var body = new byte[32];
stream.Position = mvhd.BodyStart;
var available = (int)Math.Min(body.Length, mvhd.End - mvhd.BodyStart);
if (available < 20 || !ReadExactly(stream, body, available))
return null;
uint timescale;
ulong duration;
if (body[0] == 0)
{
timescale = BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(12, 4));
duration = BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(16, 4));
}
else if (body[0] == 1 && available >= 32)
{
timescale = BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(20, 4));
duration = BinaryPrimitives.ReadUInt64BigEndian(body.AsSpan(24, 8));
}
else
{
return null;
}
// All-ones duration means "unknown" in the spec.
if (timescale == 0 || duration == uint.MaxValue || duration == ulong.MaxValue)
return null;
return (double)duration / timescale;
}
private static bool ReadExactly(Stream stream, byte[] buffer, int count)
{
var read = 0;
while (read < count)
{
var n = stream.Read(buffer, read, count - read);
if (n <= 0)
return false;
read += n;
}
return true;
}
}
}

View file

@ -1,149 +0,0 @@
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// Client-confirmed media contract (2026-09-22): photos up to 10 MB (JPEG/PNG/HEIC),
/// videos up to 100 MB and 90 seconds (MP4/MOV), at most 10 photos and 3 videos per work
/// order, across the dispatcher media modal, the completion-doc media tab and the vendor
/// portal upload. Documents (PDF/DOC/DOCX) keep the 50 MB document cap. Duration is read
/// from the MP4/MOV movie header (<see cref="VideoDurationProbe"/>); the browser pre-checks
/// it and the server enforces it, and unreadable metadata never blocks an upload.
/// </summary>
public static class WorkOrderMediaContract
{
public const long MaxPhotoBytes = 10_000_000;
public const long MaxVideoBytes = 100_000_000;
public const long MaxDocumentBytes = 50_000_000;
public const int MaxPhotosPerWorkOrder = 10;
public const int MaxVideosPerWorkOrder = 3;
public const int MaxVideoDurationSeconds = 90;
/// <summary>
/// Request-level ceiling for media endpoints (RequestSizeLimit / multipart limit). It sits
/// above <see cref="MaxVideoBytes"/> plus multipart overhead so an oversize file reaches the
/// per-kind check and gets its generic message instead of a framework 413. The EB nginx
/// proxy (.platform/nginx/conf.d/01_upload_body_size.conf) must stay above this value.
/// </summary>
public const long MaxUploadRequestBytes = 110_000_000;
public enum UploadKind
{
Photo,
Video,
Document,
Unknown
}
private static readonly Dictionary<string, UploadKind> KindByContentType =
new(StringComparer.OrdinalIgnoreCase)
{
["image/jpeg"] = UploadKind.Photo,
["image/jpg"] = UploadKind.Photo,
["image/png"] = UploadKind.Photo,
["image/heic"] = UploadKind.Photo,
["video/mp4"] = UploadKind.Video,
["video/quicktime"] = UploadKind.Video,
["application/pdf"] = UploadKind.Document,
["application/msword"] = UploadKind.Document,
["application/vnd.openxmlformats-officedocument.wordprocessingml.document"] =
UploadKind.Document,
};
private static readonly Dictionary<string, UploadKind> KindByExtension =
new(StringComparer.OrdinalIgnoreCase)
{
[".jpg"] = UploadKind.Photo,
[".jpeg"] = UploadKind.Photo,
[".png"] = UploadKind.Photo,
[".heic"] = UploadKind.Photo,
[".mp4"] = UploadKind.Video,
[".mov"] = UploadKind.Video,
[".pdf"] = UploadKind.Document,
[".doc"] = UploadKind.Document,
[".docx"] = UploadKind.Document,
};
/// <summary>
/// Classifies an upload. Pass the validated (resolved) content type: it decides whenever
/// it is an allowlisted type, so a misleading file name cannot move a file into a larger
/// size class. The extension only decides when no allowlisted type is known (for example
/// counting stored attachment URLs).
/// </summary>
public static UploadKind ResolveKind(string? contentType, string? fileName)
{
var type = (contentType ?? string.Empty).Trim();
if (KindByContentType.TryGetValue(type, out var byType))
return byType;
var extension = Path.GetExtension(fileName ?? string.Empty);
return KindByExtension.TryGetValue(extension, out var byExtension)
? byExtension
: 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 (photos, videos) = CountKinds(attachmentUrls, vendorContentTypes);
if (kind == UploadKind.Photo && photos >= MaxPhotosPerWorkOrder)
return "A work order can have at most 10 photos.";
if (kind == UploadKind.Video && videos >= MaxVideosPerWorkOrder)
return "A work order can have at most 3 videos.";
return null;
}
/// <summary>
/// Photos and videos on a work order: dispatcher attachments (kind from the stored URL)
/// plus current vendor-portal documents (kind from the validated content type).
/// </summary>
public static (int Photos, int Videos) CountKinds(
IEnumerable<string> attachmentUrls,
IEnumerable<string> vendorContentTypes)
{
var kinds = attachmentUrls.Select(url => ResolveKind(null, url))
.Concat(vendorContentTypes.Select(type => ResolveKind(type, null)))
.ToList();
return (kinds.Count(k => k == UploadKind.Photo), kinds.Count(k => k == UploadKind.Video));
}
/// <summary>
/// 90-second video limit, read from the MP4/QuickTime movie header. A video whose
/// duration cannot be read is not blocked. Returns the stable message or null.
/// </summary>
public static string? ValidateVideoDuration(Stream? content)
{
var seconds = VideoDurationProbe.TryReadDurationSeconds(content);
return seconds > MaxVideoDurationSeconds
? $"Videos must be {MaxVideoDurationSeconds} seconds or shorter."
: null;
}
/// <summary>Stable, generic per-kind size message; never echoes file metadata.</summary>
public static string? ValidateSize(long length, UploadKind kind)
{
return kind switch
{
UploadKind.Photo when length > MaxPhotoBytes => "Photos must be 10 MB or smaller.",
UploadKind.Video when length > MaxVideoBytes => "Videos must be 100 MB or smaller.",
UploadKind.Document when length > MaxDocumentBytes =>
"Documents must be 50 MB or smaller.",
UploadKind.Unknown when length > MaxVideoBytes =>
"Files must be 100 MB or smaller.",
_ => null
};
}
public static string? ValidateSize(string? contentType, string? fileName, long length)
=> ValidateSize(length, ResolveKind(contentType, fileName));
}
}

View file

@ -12,7 +12,6 @@ namespace SeaHaven.Services.Helpers
"image/jpeg",
"image/jpg",
"image/png",
"image/heic",
"video/mp4",
"video/quicktime",
"application/pdf",
@ -25,7 +24,6 @@ namespace SeaHaven.Services.Helpers
{
["image/jpeg"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" },
["image/png"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png" },
["image/heic"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".heic" },
["video/mp4"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mp4" },
["video/quicktime"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mov" },
["application/pdf"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".pdf" },
@ -44,11 +42,7 @@ namespace SeaHaven.Services.Helpers
// the accepted set of files.
private static string? ResolveContentType(string declaredType, string extension)
{
// iOS transcodes a picked HEIC photo to JPEG (named .jpg) but can keep image/heic as
// its type, so a declared image/heic is only authoritative on a real .heic file.
var isTranscodedHeic = declaredType.Equals("image/heic", StringComparison.OrdinalIgnoreCase)
&& !extension.Equals(".heic", StringComparison.OrdinalIgnoreCase);
if (AllowedContentTypes.Contains(declaredType) && !isTranscodedHeic)
if (AllowedContentTypes.Contains(declaredType))
return declaredType;
foreach (var (contentType, extensions) in ExtensionsByContentType)
@ -67,19 +61,6 @@ namespace SeaHaven.Services.Helpers
return contentType;
}
/// <summary>
/// The canonical content type <see cref="IsAllowed"/> validates the file as, or null when
/// no allowlisted type applies. Size classification must use this same type so a declared
/// type or a misleading extension cannot move a file into a larger size class.
/// </summary>
public static string? ResolveUploadContentType(IFormFile file)
{
var declaredType = (file.ContentType ?? string.Empty).Trim();
var extension = Path.GetExtension(file.FileName ?? string.Empty);
var resolvedType = ResolveContentType(declaredType, extension);
return resolvedType == null ? null : CanonicalContentType(resolvedType);
}
public static bool IsAllowed(
IFormFile file,
WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra)
@ -87,11 +68,13 @@ namespace SeaHaven.Services.Helpers
if (file == null || file.Length <= 0)
return false;
var declaredType = (file.ContentType ?? string.Empty).Trim();
var extension = Path.GetExtension(file.FileName ?? string.Empty);
var contentType = ResolveUploadContentType(file);
if (contentType == null)
var resolvedType = ResolveContentType(declaredType, extension);
if (resolvedType == null)
return false;
var contentType = CanonicalContentType(resolvedType);
var resolvedCategory = category ?? WorkOrderMediaCategory.Extra;
if (IsDocument(contentType)
&& resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta)
@ -140,7 +123,7 @@ namespace SeaHaven.Services.Helpers
{
throw new Exceptions.WorkOrderBoardValidationException(
"UnsupportedMediaType",
"Supported file types are JPG, PNG, HEIC, MP4, MOV, PDF, DOC, and DOCX for Extra Docs; photos accept media only.");
"Supported file types are JPG, PNG, MP4, MOV, PDF, DOC, and DOCX for Extra Docs; photos accept media only.");
}
}
@ -156,11 +139,6 @@ namespace SeaHaven.Services.Helpers
&& bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A;
}
if (contentType.Equals("image/heic", StringComparison.OrdinalIgnoreCase))
{
return IsHeifBrand(bytes);
}
if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase))
{
return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
@ -228,20 +206,5 @@ namespace SeaHaven.Services.Helpers
&& bytes[6] == (byte)'y'
&& bytes[7] == (byte)'p';
}
private static bool IsHeifBrand(byte[] bytes)
{
if (!HasFtypBox(bytes))
return false;
// HEIC/HEIF containers identify by the ftyp major brand (bytes 8..11).
var brand = System.Text.Encoding.ASCII.GetString(bytes, 8, 4);
return brand switch
{
"heic" or "heix" or "hevc" or "hevx" or "heim" or "heis" or "hevm" or "hevs"
or "mif1" or "msf1" => true,
_ => false
};
}
}
}

View file

@ -15,36 +15,6 @@ namespace SeaHaven.Services.Helpers
public static class WorkOrderSearchDateRangeResolver
{
// Widest windows the frontend sends for "every week": the no-range search
// (ADVANCED_SEARCH_ALL_WEEKS_FROM/TO, 2000-01-01..2099-12-31) and the pinned
// Unassigned queue (UNASSIGNED_QUEUE_DATE_FROM/TO, 1970-01-01..2099-12-31).
private static readonly DateOnly AllWeeksFrom = new(2000, 1, 1);
private static readonly DateOnly AllWeeksTo = new(2099, 12, 31);
/// <summary>
/// Whether a board search adds open undated rows to its date range (SH-391).
/// An explicit <paramref name="includeDateless"/> wins. When it is omitted,
/// as by clients that predate the flag, a search with no date input or with
/// an all-weeks Custom window still returns undated rows, as before SH-391:
/// those requests mean "no range selected". Any other range is strict.
/// </summary>
public static bool ResolveIncludeDateless(
bool? includeDateless,
WorkOrderAdvancedSearchDatePreset? preset,
DateOnly? dateFrom,
DateOnly? dateTo)
{
if (includeDateless.HasValue)
return includeDateless.Value;
if (!preset.HasValue && !dateFrom.HasValue && !dateTo.HasValue)
return true;
return preset == WorkOrderAdvancedSearchDatePreset.Custom
&& dateFrom <= AllWeeksFrom
&& dateTo >= AllWeeksTo;
}
public static WorkOrderSearchDateRange Resolve(
WorkOrderAdvancedSearchDatePreset? preset,
DateOnly? dateFrom,

View file

@ -1,5 +1,4 @@
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
@ -43,7 +42,7 @@ namespace SeaHaven.Services.Helpers
{
Id = row.Id,
Status = ToFrontendStatus(row.Status),
Amount = row.Amount,
Amount = row.RequestedNTE,
Notes = row.VendorReason ?? "",
RequestedAt = row.CreatedDate,
RequestedById = row.CreatedByUserId,
@ -59,7 +58,7 @@ namespace SeaHaven.Services.Helpers
{
Id = request.Id,
Status = ToFrontendStatus(request.Status),
Amount = UpliftAmount.Of(request),
Amount = request.RequestedNTE,
Notes = request.VendorReason ?? "",
RequestedAt = request.CreatedDate,
RequestedById = request.createdby,

View file

@ -1,5 +1,4 @@
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Helpers;
namespace SeaHaven.Services.Implementation
{
@ -24,7 +23,7 @@ namespace SeaHaven.Services.Implementation
<table style='border-collapse:collapse;font-family:Arial,sans-serif;'>
<tr><td style='padding:4px 10px;'><strong>Current NTE</strong></td><td style='padding:4px 10px;'>${req.CurrentNTE ?? 0m:F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Requested NTE</strong></td><td style='padding:4px 10px;'>${req.RequestedNTE:F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Delta</strong></td><td style='padding:4px 10px;'>${UpliftAmount.Of(req):F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Delta</strong></td><td style='padding:4px 10px;'>${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Required Approval</strong></td><td style='padding:4px 10px;'>{tierText}</td></tr>
</table>
<h3>Vendor Reason</h3>

View file

@ -1,6 +1,5 @@
using Data.SeaHavenIndustries;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
@ -37,7 +36,7 @@ namespace SeaHaven.Services.Implementation
public async Task<UpliftListResultDTO> ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken)
{
var (total, items) = await _upliftData.GetPagedAsync(status, tier, page, pageSize, cancellationToken);
var pendingExposureTotal = await _upliftData.GetPendingExposureTotalAsync(cancellationToken);
var pendingExposureTotal = await _upliftData.GetPendingExposureAsync(cancellationToken);
var mapped = items.Select(r => new UpliftListItemDTO
{
@ -57,7 +56,7 @@ namespace SeaHaven.Services.Implementation
DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName),
CurrentNTE = r.CurrentNTE,
RequestedNTE = r.RequestedNTE,
Delta = r.Amount,
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
VendorReason = r.VendorReason,
RequiredTier = r.RequiredTier,
Status = UpliftStatus.ToCanonical(r.Status),
@ -116,7 +115,7 @@ namespace SeaHaven.Services.Implementation
DispatchId = r.DispatchId,
CurrentNTE = r.CurrentNTE,
RequestedNTE = r.RequestedNTE,
Delta = r.Amount,
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
VendorReason = r.VendorReason,
Status = UpliftStatus.ToCanonical(r.Status),
RequiredTier = r.RequiredTier,
@ -158,12 +157,7 @@ namespace SeaHaven.Services.Implementation
var now = _timeProvider.GetUtcNow().UtcDateTime;
var oldNTE = dispatch.NTEAmount ?? 0m;
// Approval raises the NTE by the request's amount. A vendor-portal request
// stores its requested NTE total and ends exactly there; a work-order request
// stores the increase, so it is added to the dispatch's current NTE.
dispatch.NTEAmount = req.createdby == null
? req.RequestedNTE
: oldNTE + UpliftAmount.Of(req);
dispatch.NTEAmount = req.RequestedNTE;
dispatch.LastModificationTime = now;
req.Status = UpliftStatus.Approved;
@ -178,7 +172,7 @@ namespace SeaHaven.Services.Implementation
UserId = userId,
FieldName = $"Dispatch {dispatch.DispatchNumber} NTE",
OldValue = $"${oldNTE:F2}",
NewValue = $"${dispatch.NTEAmount:F2}",
NewValue = $"${req.RequestedNTE:F2}",
Action = "uplift_approved",
CreatedAt = now
}, cancellationToken);

View file

@ -5,7 +5,6 @@ using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
@ -14,36 +13,9 @@ namespace SeaHaven.Services.Implementation
{
private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
"application/pdf", "image/jpeg", "image/jpg", "image/png", "image/heic",
"video/mp4", "video/quicktime"
"application/pdf", "image/jpeg", "image/jpg", "image/png"
};
// The browser's File.type is unreliable on mobile (empty or application/octet-stream),
// so an extension pairing against the same allowlist resolves the real content type.
private static string? ResolveUploadContentType(IFormFile file)
{
var declaredType = (file.ContentType ?? string.Empty).Trim();
var extension = Path.GetExtension(file.FileName ?? string.Empty);
// iOS transcodes a picked HEIC photo to JPEG (named .jpg) but can keep image/heic.
var isTranscodedHeic = declaredType.Equals("image/heic", StringComparison.OrdinalIgnoreCase)
&& !extension.Equals(".heic", StringComparison.OrdinalIgnoreCase);
if (AllowedContentTypes.Contains(declaredType) && !isTranscodedHeic)
return declaredType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase)
? "image/jpeg"
: declaredType;
return extension.ToLowerInvariant() switch
{
".pdf" => "application/pdf",
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".heic" => "image/heic",
".mp4" => "video/mp4",
".mov" => "video/quicktime",
_ => null
};
}
private const int MaxRefusalReasonLength = 500;
private readonly IVendorPortalTokenService _tokens;
@ -135,7 +107,6 @@ namespace SeaHaven.Services.Implementation
Status = UpliftStatus.ToCanonical(u.Status),
RequiredTier = u.RequiredTier,
RequestedByVendorName = u.RequestedByVendorName,
RaisedByVendor = u.RaisedByVendor,
RequestedAt = u.CreatedDate,
DecidedAt = u.DecidedAt,
DecisionNote = u.DecisionNote,
@ -208,37 +179,8 @@ namespace SeaHaven.Services.Implementation
}
}
PortalMediaCountsDTO? mediaCounts = null;
if (dispatch.WorkOrderId is int workOrderId)
{
// Same basis as the upload check: a new completion version replaces the
// dispatch's latest document, so that one is not counted for it.
var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync(
workOrderId, cancellationToken);
var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync(
workOrderId, null, cancellationToken);
var latest = await _documentData.GetLatestForDispatchAsync(id, cancellationToken);
var completionTypes = latest == null
? vendorTypes
: await _documentData.ListActiveContentTypesForWorkOrderAsync(
workOrderId, latest.Id, cancellationToken);
var (photos, videos) = WorkOrderMediaContract.CountKinds(attachmentUrls, vendorTypes);
var (completionPhotos, completionVideos) =
WorkOrderMediaContract.CountKinds(attachmentUrls, completionTypes);
mediaCounts = new PortalMediaCountsDTO
{
MaxPhotos = WorkOrderMediaContract.MaxPhotosPerWorkOrder,
MaxVideos = WorkOrderMediaContract.MaxVideosPerWorkOrder,
Photos = photos,
Videos = videos,
CompletionPhotos = completionPhotos,
CompletionVideos = completionVideos
};
}
return new VendorDispatchDetailDTO
{
MediaCounts = mediaCounts,
Id = dispatch.Id,
DispatchNumber = dispatch.DispatchNumber,
PONumber = dispatch.PONumber,
@ -752,9 +694,7 @@ namespace SeaHaven.Services.Implementation
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken);
// A vendor withdraws only requests it raised; requests raised from the work order
// (createdby set) are read-only in the portal.
if (req == null || req.createdby != null) throw new KeyNotFoundException("Uplift request not found");
if (req == null) throw new KeyNotFoundException("Uplift request not found");
if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Withdrawn))
{
@ -796,9 +736,7 @@ namespace SeaHaven.Services.Implementation
}
var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken);
// A vendor revises only requests it raised. Work-order requests (createdby set)
// store the increase, not a total, so a vendor revise would corrupt their amount.
if (req == null || req.createdby != null) throw new KeyNotFoundException("Uplift request not found");
if (req == null) throw new KeyNotFoundException("Uplift request not found");
if (UpliftStatus.ToCanonical(req.Status) != UpliftStatus.ChangesRequested)
{
@ -890,33 +828,17 @@ namespace SeaHaven.Services.Implementation
throw new InvalidOperationException("A completion document file is required.");
}
// Media contract: photos up to 10 MB (JPEG/PNG/HEIC), videos up to 100 MB
// (MP4/MOV). Documents keep the configured VendorDocuments cap.
var contentType = ResolveUploadContentType(file);
if (contentType == null)
{
throw new InvalidOperationException("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are accepted.");
}
// Classify by the resolved type the signature check validates, never by the file name.
var kind = WorkOrderMediaContract.ResolveKind(contentType, fileName: null);
if (kind == WorkOrderMediaContract.UploadKind.Photo
&& file.Length > WorkOrderMediaContract.MaxPhotoBytes)
{
throw new InvalidOperationException("Photos must be 10 MB or smaller.");
}
if (kind == WorkOrderMediaContract.UploadKind.Video
&& file.Length > WorkOrderMediaContract.MaxVideoBytes)
{
throw new InvalidOperationException("Videos must be 100 MB or smaller.");
}
if (kind != WorkOrderMediaContract.UploadKind.Photo
&& kind != WorkOrderMediaContract.UploadKind.Video
&& file.Length > _documentOptions.MaxSizeBytes)
if (file.Length > _documentOptions.MaxSizeBytes)
{
throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size.");
}
var contentType = (file.ContentType ?? string.Empty).Trim();
if (!AllowedContentTypes.Contains(contentType))
{
throw new InvalidOperationException("Only PDF, JPG, and PNG completion documents are accepted.");
}
var resolvedPurpose = string.IsNullOrWhiteSpace(purpose)
? VendorDocumentPurpose.Completion
: purpose.Trim();
@ -937,21 +859,11 @@ namespace SeaHaven.Services.Implementation
await file.CopyToAsync(buffer, cancellationToken);
var bytes = buffer.ToArray();
if (!WorkOrderMediaFileRules.MatchesSignature(contentType, bytes))
if (!MatchesSignature(contentType, bytes))
{
throw new InvalidOperationException("The uploaded file signature does not match its declared content type.");
}
if (kind == WorkOrderMediaContract.UploadKind.Video)
{
using var content = new MemoryStream(bytes, writable: false);
var durationMessage = WorkOrderMediaContract.ValidateVideoDuration(content);
if (durationMessage != null)
{
throw new InvalidOperationException(durationMessage);
}
}
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken);
if (dispatch == null)
{
@ -978,80 +890,50 @@ namespace SeaHaven.Services.Implementation
"The completion document could not be replaced.");
}
}
var version = (latest?.Version ?? 0) + 1;
var storedFileName = $"{dispatchId}_{version}_{Guid.NewGuid():N}{GetSafeExtension(file.FileName)}";
var isUpliftEvidence = resolvedPurpose == VendorDocumentPurpose.UpliftEvidence;
async Task<VendorCompletionDocument> PersistAsync(CancellationToken ct)
var now = DateTime.UtcNow;
var document = new VendorCompletionDocument
{
// 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, ct);
var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync(
workOrderId, ct);
var countMessage = WorkOrderMediaContract.ValidateCount(kind, attachmentUrls, vendorTypes);
if (countMessage != null)
{
throw new InvalidOperationException(countMessage);
}
}
VendorId = session.Id,
DispatchId = dispatchId,
WorkOrderId = dispatch.WorkOrderId ?? 0,
OriginalFileName = Path.GetFileName(file.FileName),
StoredFileName = storedFileName,
ContentType = contentType,
SizeBytes = bytes.Length,
ScanStatus = "Pending",
ReviewStatus = "Processing",
Version = version,
ReplacesDocumentId = replacedDocument?.Id,
Purpose = resolvedPurpose,
CreatedDate = now
};
var now = DateTime.UtcNow;
var created = new VendorCompletionDocument
{
VendorId = session.Id,
DispatchId = dispatchId,
WorkOrderId = dispatch.WorkOrderId ?? 0,
OriginalFileName = Path.GetFileName(file.FileName),
StoredFileName = storedFileName,
ContentType = contentType,
SizeBytes = bytes.Length,
ScanStatus = "Pending",
ReviewStatus = "Processing",
Version = version,
ReplacesDocumentId = replacedDocument?.Id,
Purpose = resolvedPurpose,
CreatedDate = now
};
await _documentData.AddAsync(document, cancellationToken);
await _documentData.AddAsync(created, ct);
var isUpliftEvidence = resolvedPurpose == VendorDocumentPurpose.UpliftEvidence;
var fieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document";
var fieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document";
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
DispatchId = dispatchId,
FieldName = fieldName,
OldValue = isUpliftEvidence || replacedDocument == null
? null
: $"v{replacedDocument.Version}",
NewValue = isUpliftEvidence
? $"evidence {created.OriginalFileName}"
: $"v{version}",
Action = isUpliftEvidence
? "vendor_uplift_evidence_uploaded"
: "vendor_completion_document_uploaded",
ActorType = "vendor",
CreatedAt = now
}, ct);
await _documentData.SaveChangesAsync(ct);
return created;
}
// The count and the insert run under the per-work-order mutation lock the dispatcher
// media upload also takes, so concurrent uploads cannot both claim the last slot.
var document = dispatch.WorkOrderId is int lockedWorkOrderId
? await _upliftData.ExecuteWorkOrderMutationAsync(lockedWorkOrderId, PersistAsync, cancellationToken)
: await PersistAsync(cancellationToken);
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
DispatchId = dispatchId,
FieldName = fieldName,
OldValue = isUpliftEvidence || replacedDocument == null
? null
: $"v{replacedDocument.Version}",
NewValue = isUpliftEvidence
? $"evidence {document.OriginalFileName}"
: $"v{version}",
Action = isUpliftEvidence
? "vendor_uplift_evidence_uploaded"
: "vendor_completion_document_uploaded",
ActorType = "vendor",
CreatedAt = now
}, cancellationToken);
await _documentData.SaveChangesAsync(cancellationToken);
using var stored = new MemoryStream(bytes);
await _documentStorage.SaveAsync(session.Id, dispatchId, storedFileName, stored, cancellationToken);
@ -1110,6 +992,35 @@ namespace SeaHaven.Services.Implementation
};
}
private static bool MatchesSignature(string contentType, byte[] bytes)
{
if (bytes.Length == 0)
{
return false;
}
if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase))
{
return bytes.Length >= 4
&& bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46; // %PDF
}
if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase))
{
return bytes.Length >= 8
&& bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47
&& bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A;
}
if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase)
|| contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase))
{
return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
}
return false;
}
private static string GetSafeExtension(string fileName)
{
var extension = Path.GetExtension(fileName);

View file

@ -72,12 +72,7 @@ namespace SeaHaven.Services.Implementation
pageSize,
sortBy,
query.SortDir ?? "asc",
accountId,
WorkOrderSearchDateRangeResolver.ResolveIncludeDateless(
query.IncludeDateless,
query.DatePreset,
query.DateFrom,
query.DateTo));
accountId);
var result = await _searchDataService.SearchAsync(dataQuery);
var utcNow = DateTime.UtcNow;

View file

@ -16,20 +16,17 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderDetailDataService _detailData;
private readonly IWorkOrderAuditService _auditService;
private readonly IFileStoragePort _fileStorage;
private readonly IUpliftDataService _upliftData;
public WorkOrderMediaService(
IWorkOrderMediaDataService mediaData,
IWorkOrderDetailDataService detailData,
IWorkOrderAuditService auditService,
IFileStoragePort fileStorage,
IUpliftDataService upliftData)
IFileStoragePort fileStorage)
{
_mediaData = mediaData;
_detailData = detailData;
_auditService = auditService;
_fileStorage = fileStorage;
_upliftData = upliftData;
}
public async Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(
@ -156,34 +153,23 @@ namespace SeaHaven.Services.Implementation
};
}
// Photo/video caps are a work-order aggregate shared with the vendor portal
// upload, so the count and the insert run under the per-work-order mutation lock.
var attachment = await _upliftData.ExecuteWorkOrderMutationAsync(
var attachment = new WorkOrderAttachments
{
WorkorderId = workOrderId,
Attachments = fileUrl,
Category = category.HasValue ? resolvedCategory : null,
CreatedDate = DateTime.UtcNow,
createdby = actorId
};
_mediaData.TrackAttachment(attachment);
await _auditService.StageFieldChangedAsync(
workOrderId,
async ct =>
{
await EnsureWithinMediaCountsAsync(workOrderId, fileUrl, ct);
var created = new WorkOrderAttachments
{
WorkorderId = workOrderId,
Attachments = fileUrl,
Category = category.HasValue ? resolvedCategory : null,
CreatedDate = DateTime.UtcNow,
createdby = actorId
};
_mediaData.TrackAttachment(created);
await _auditService.StageFieldChangedAsync(
workOrderId,
"MediaCategory",
null,
FormatMediaAuditValue(null, (created.Category ?? WorkOrderMediaCategory.Extra).ToString()),
actorId);
await SaveMediaAsync(ct);
return created;
},
cancellationToken);
"MediaCategory",
null,
FormatMediaAuditValue(null, (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString()),
actorId);
await SaveMediaAsync(cancellationToken);
return new WorkOrderMediaFileDto
{
@ -362,29 +348,6 @@ namespace SeaHaven.Services.Implementation
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
}
/// <summary>
/// Media contract: at most 10 photos and 3 videos per work order, counted over the
/// 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,
string fileUrl,
CancellationToken cancellationToken)
{
var kind = WorkOrderMediaContract.ResolveKind(null, fileUrl);
if (kind != WorkOrderMediaContract.UploadKind.Photo
&& kind != WorkOrderMediaContract.UploadKind.Video)
return;
var urls = await _mediaData.ListActiveAttachmentUrlsAsync(workOrderId, cancellationToken);
var vendorTypes = await _mediaData.ListActiveVendorMediaContentTypesAsync(workOrderId, cancellationToken);
var countMessage = WorkOrderMediaContract.ValidateCount(kind, urls, vendorTypes);
if (countMessage != null)
throw new WorkOrderBoardValidationException("MediaCountExceeded", countMessage);
}
/// <summary>
/// Account filter for data queries. Null means org-wide (skip ApplyAccountScope).
/// Call only after EnsureCan* has verified scope is not Missing.
@ -469,7 +432,6 @@ namespace SeaHaven.Services.Implementation
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".heic" => "image/heic",
".mp4" => "video/mp4",
".mov" => "video/quicktime",
".pdf" => "application/pdf",

View file

@ -1,336 +0,0 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
namespace SeaHavenIndustries.Tests;
// An uplift's amount is the NTE increase being requested. Work-order requests store
// that increase in RequestedNTE; vendor-portal requests store the requested NTE total.
// These tests walk each path through the approvals queue, approve and revoke, and the
// queue-wide and per-work-order exposure totals.
public sealed class UpliftAmountPerCreationPathTests
{
private const string DispatcherId = "dispatcher-1";
private const int WorkOrderId = 1;
private const int PrimaryDispatchId = 10;
private const int SecondDispatchId = 11;
private static ApplicationDbContext CreateContext() =>
new(new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
private static ApprovalsOptions NewOptions() => new()
{
UpliftTier1MaxUsd = 2500m,
Tier1Roles = new[] { "Approver" },
Tier2Roles = new[] { "Manager" },
};
private static WorkOrderUpliftService NewWorkOrderUpliftService(ApplicationDbContext context) =>
new(new UpliftDataService(context),
new DispatchDataService(context),
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(NewOptions()));
private static UpliftService NewQueueService(ApplicationDbContext context) =>
new(new UpliftDataService(context),
new DispatchDataService(context),
new NoDocumentStorage(),
TimeProvider.System,
Options.Create(NewOptions()));
private static ClaimsPrincipal Admin(string userId = DispatcherId) =>
WorkOrderAccountTestHelpers.OrgWideAdmin(userId);
private static Dispatch DispatchOf(ApplicationDbContext context, int id) =>
context.Dispatches.Single(d => d.Id == id);
// A work order with a 600 NTE on its primary dispatch, and a second dispatch on the
// same work order, also at 600.
private static async Task SeedWorkOrderAsync(ApplicationDbContext context)
{
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
context.Users.Add(new ApplicationUser
{
Id = DispatcherId,
UserName = DispatcherId,
FirstName = "Alex",
LastName = "Dispatcher",
});
var role = new IdentityRole("Admin");
context.Roles.Add(role);
context.UserRoles.Add(new IdentityUserRole<string> { UserId = DispatcherId, RoleId = role.Id });
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.AddRange(
new Dispatch
{
Id = PrimaryDispatchId,
VendorId = 1,
WorkOrderId = WorkOrderId,
NTEAmount = 600m,
DispatchNumber = "DIS-10",
Status = "Completed",
},
new Dispatch
{
Id = SecondDispatchId,
VendorId = 1,
WorkOrderId = WorkOrderId,
NTEAmount = 600m,
DispatchNumber = "DIS-11",
Status = "Completed",
});
context.workOrders.Add(new WorkOrder
{
Id = WorkOrderId,
InternalWONumber = "10000000001",
PrimaryDispatchId = PrimaryDispatchId,
AccountId = 1,
WorkOrderType = WorkOrderType.PM,
});
await context.SaveChangesAsync();
}
// Uses up the work order's auto-approval allowance, so a new work-order request
// waits for approval instead of auto-approving.
private static async Task ConsumeAutoApprovalAllowanceAsync(ApplicationDbContext context)
{
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
DispatchId = SecondDispatchId,
CurrentNTE = 100m,
RequestedNTE = 500m,
Status = "NoApprovalRequired",
RequiredTier = 0,
NotificationStatus = "Sent",
createdby = DispatcherId,
CreatedDate = new DateTime(2026, 1, 2),
});
await context.SaveChangesAsync();
}
private static async Task<int> CreatePendingWorkOrderRequestAsync(ApplicationDbContext context, decimal amount)
{
await ConsumeAutoApprovalAllowanceAsync(context);
var created = await NewWorkOrderUpliftService(context).CreateAsync(
WorkOrderId,
new CreateWorkOrderUpliftRequestDto { Amount = amount, Notes = "Extra parts" },
Admin(),
CancellationToken.None);
Assert.Equal("pending", created!.Status);
return created.Id;
}
// Stored exactly as the vendor portal writes it: no identity user, and RequestedNTE
// is the requested new NTE total.
private static async Task<int> SeedVendorRequestAsync(
ApplicationDbContext context, int dispatchId, decimal currentNte, decimal requestedTotal)
{
var request = new DispatchUpliftRequest
{
DispatchId = dispatchId,
CurrentNTE = currentNte,
RequestedNTE = requestedTotal,
Status = "Pending",
RequiredTier = 1,
RequestedByVendorName = "Acme HVAC",
NotificationStatus = "Sent",
CreatedDate = new DateTime(2026, 3, 1),
};
context.DispatchUpliftRequests.Add(request);
await context.SaveChangesAsync();
return request.Id;
}
private static Task<WorkOrderUpliftDto?> RevokeAsync(ApplicationDbContext context, int upliftId) =>
NewWorkOrderUpliftService(context).RevokeAsync(
WorkOrderId,
upliftId,
new RevokeWorkOrderUpliftRequestDto { Reason = "Scope reduced" },
Admin(),
CancellationToken.None);
[Fact]
public async Task WorkOrderRequest_QueueShowsTheRequestedIncrease()
{
await using var context = CreateContext();
await SeedWorkOrderAsync(context);
var upliftId = await CreatePendingWorkOrderRequestAsync(context, 90m);
var pending = await NewQueueService(context)
.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None);
Assert.Equal(90m, Assert.Single(pending.Items, i => i.Id == upliftId).Delta);
}
[Fact]
public async Task WorkOrderRequest_ApproveAddsIncreaseToNte_RevokeRestoresNte()
{
await using var context = CreateContext();
await SeedWorkOrderAsync(context);
var upliftId = await CreatePendingWorkOrderRequestAsync(context, 90m);
var queue = NewQueueService(context);
var approved = await queue.ApproveAsync(Admin(), upliftId, null, CancellationToken.None);
Assert.Equal(690m, approved.NTEAmount);
Assert.Equal(690m, DispatchOf(context, PrimaryDispatchId).NTEAmount);
var revoked = await RevokeAsync(context, upliftId);
Assert.Equal("revoked", revoked!.Status);
Assert.Equal(600m, DispatchOf(context, PrimaryDispatchId).NTEAmount);
}
[Fact]
public async Task VendorRequest_QueueShowsIncrease_ApproveEndsAtRequestedTotal_RevokeRestoresNte()
{
await using var context = CreateContext();
await SeedWorkOrderAsync(context);
var upliftId = await SeedVendorRequestAsync(context, PrimaryDispatchId, currentNte: 600m, requestedTotal: 900m);
var queue = NewQueueService(context);
var pending = await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None);
Assert.Equal(300m, Assert.Single(pending.Items).Delta);
var approved = await queue.ApproveAsync(Admin(), upliftId, null, CancellationToken.None);
Assert.Equal(900m, approved.NTEAmount);
Assert.Equal(900m, DispatchOf(context, PrimaryDispatchId).NTEAmount);
var revoked = await RevokeAsync(context, upliftId);
Assert.Equal("revoked", revoked!.Status);
Assert.Equal(600m, DispatchOf(context, PrimaryDispatchId).NTEAmount);
}
[Fact]
public async Task PendingExposureAndApprovedOnWorkOrder_SumEachRequestsIncrease()
{
await using var context = CreateContext();
await SeedWorkOrderAsync(context);
var workOrderRequestId = await CreatePendingWorkOrderRequestAsync(context, 90m);
var vendorRequestId = await SeedVendorRequestAsync(context, SecondDispatchId, currentNte: 600m, requestedTotal: 900m);
var queue = NewQueueService(context);
var pending = await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None);
Assert.Equal(390m, pending.PendingExposureTotal);
Assert.Equal(pending.Items.Sum(i => i.Delta), pending.PendingExposureTotal);
await queue.ApproveAsync(Admin(), workOrderRequestId, null, CancellationToken.None);
await queue.ApproveAsync(Admin(), vendorRequestId, null, CancellationToken.None);
var decided = await queue.ListAsync(Admin(), "Approved", null, 1, 25, CancellationToken.None);
var row = Assert.Single(decided.Items, i => i.Id == workOrderRequestId);
Assert.Equal(390m, row.WorkOrderAdminApprovedTotal);
// The earlier auto-approved work-order request counts its stored increase.
Assert.Equal(500m, row.WorkOrderAutoApprovedTotal);
Assert.Equal(0m, (await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None)).PendingExposureTotal);
}
[Fact]
public async Task AutoApprovedWorkOrderRequest_ConsumesAllowanceByItsRequestedIncrease()
{
await using var context = CreateContext();
await SeedWorkOrderAsync(context);
var created = await NewWorkOrderUpliftService(context).CreateAsync(
WorkOrderId,
new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" },
Admin(),
CancellationToken.None);
Assert.Equal("auto_approved", created!.Status);
var stored = context.DispatchUpliftRequests.Single(u => u.Id == created.Id);
Assert.Equal(DispatcherId, stored.createdby);
Assert.Equal(400m, stored.RequestedNTE);
Assert.Equal(1000m, DispatchOf(context, PrimaryDispatchId).NTEAmount);
Assert.Equal(400m, await new UpliftDataService(context)
.SumAutoApprovedAmountForWorkOrderAsync(WorkOrderId, CancellationToken.None));
}
[Fact]
public async Task WorkOrderUpliftList_ShowsEachRequestsIncrease()
{
await using var context = CreateContext();
await SeedWorkOrderAsync(context);
var workOrderRequestId = await CreatePendingWorkOrderRequestAsync(context, 90m);
var vendorRequestId = await SeedVendorRequestAsync(context, SecondDispatchId, currentNte: 600m, requestedTotal: 900m);
var uplifts = await NewWorkOrderUpliftService(context)
.ListAsync(WorkOrderId, Admin(), CancellationToken.None);
Assert.Equal(90m, Assert.Single(uplifts!, u => u.Id == workOrderRequestId).Amount);
Assert.Equal(300m, Assert.Single(uplifts!, u => u.Id == vendorRequestId).Amount);
}
[Theory]
[InlineData(DispatcherId, 90, "$90.00")]
[InlineData(null, 900, "$300.00")]
public void NotificationDeltaRow_ShowsTheRequestedIncrease(string? createdBy, int requestedNte, string expectedDelta)
{
var dispatch = new Dispatch { Id = PrimaryDispatchId, DispatchNumber = "DIS-10", NTEAmount = 600m };
var request = new DispatchUpliftRequest
{
DispatchId = PrimaryDispatchId,
CurrentNTE = 600m,
RequestedNTE = requestedNte,
RequiredTier = 1,
createdby = createdBy,
};
// Builds the message content only; nothing is sent.
var (_, body) = UpliftNotificationMessage.BuildInitial(dispatch, request, "Acme HVAC", "https://example.test");
Assert.Contains(
$"<strong>Delta</strong></td><td style='padding:4px 10px;'>{expectedDelta}</td>",
body);
}
[Fact]
public void AmountDefinition_TranslatesToSqlServer_ForRowAndGroupedSums()
{
// ToQueryString only builds SQL; it never opens the connection.
using var context = new ApplicationDbContext(new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlServer("Server=translation-only;Database=none;Trusted_Connection=True;")
.Options);
var rowSql = context.DispatchUpliftRequests
.Select(UpliftAmount.ForQuery)
.ToQueryString();
var groupedSql = context.DispatchUpliftRequests
.GroupBy(u => new { u.DispatchId, u.Status }, UpliftAmount.ForQuery)
.Select(g => new { g.Key.DispatchId, g.Key.Status, Total = g.Sum() })
.ToQueryString();
Assert.Contains("CASE", rowSql);
Assert.Contains("[createdby] IS NULL", rowSql);
Assert.Contains("SUM(CASE", groupedSql);
Assert.Contains("GROUP BY", groupedSql);
}
private sealed class NoDocumentStorage : IVendorDocumentStoragePort
{
public Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken) =>
Task.CompletedTask;
public Stream OpenRead(int vendorId, int dispatchId, string storedFileName) =>
throw new NotSupportedException();
public void Delete(int vendorId, int dispatchId, string storedFileName)
{
}
}
}

View file

@ -1,112 +0,0 @@
using System.Buffers.Binary;
using System.Text;
using SeaHaven.Services.Helpers;
namespace SeaHavenIndustries.Tests;
public class VideoDurationProbeTests
{
internal static byte[] Box(string type, params byte[][] children)
{
var body = children.SelectMany(child => child).ToArray();
var box = new byte[8 + body.Length];
BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length);
Encoding.ASCII.GetBytes(type).CopyTo(box, 4);
body.CopyTo(box, 8);
return box;
}
internal static byte[] MovieHeader(uint timescale, ulong duration, bool version1 = false)
{
var body = new byte[version1 ? 32 : 20];
body[0] = version1 ? (byte)1 : (byte)0;
if (version1)
{
BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(20), timescale);
BinaryPrimitives.WriteUInt64BigEndian(body.AsSpan(24), duration);
}
else
{
BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(12), timescale);
BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(16), (uint)duration);
}
return Box("mvhd", body);
}
/// <summary>A phone-style file: ftyp, a large mdat, then moov at the end (not fast-start).</summary>
internal static byte[] Mp4(uint timescale, ulong duration, bool version1 = false, int mediaBytes = 4096)
{
var ftyp = Box("ftyp", Encoding.ASCII.GetBytes("isom"), new byte[4]);
var mdat = Box("mdat", new byte[mediaBytes]);
var moov = Box("moov", MovieHeader(timescale, duration, version1));
return ftyp.Concat(mdat).Concat(moov).ToArray();
}
[Fact]
public void ReadsDurationFromMoovAfterMediaData()
{
Assert.Equal(95.0, VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(Mp4(600, 57_000))));
}
[Fact]
public void ReadsVersionOneMovieHeader()
{
Assert.Equal(30.5, VideoDurationProbe.TryReadDurationSeconds(
new MemoryStream(Mp4(1000, 30_500, version1: true))));
}
[Fact]
public void FollowsSixtyFourBitBoxSizes()
{
var ftyp = Box("ftyp", Encoding.ASCII.GetBytes("qt "), new byte[4]);
var mdatBody = new byte[512];
var mdat = new byte[16 + mdatBody.Length];
BinaryPrimitives.WriteUInt32BigEndian(mdat, 1);
Encoding.ASCII.GetBytes("mdat").CopyTo(mdat, 4);
BinaryPrimitives.WriteUInt64BigEndian(mdat.AsSpan(8), (ulong)mdat.Length);
var moov = Box("moov", MovieHeader(90_000, 90_000UL * 120));
var file = ftyp.Concat(mdat).Concat(moov).ToArray();
Assert.Equal(120.0, VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(file)));
}
[Theory]
[InlineData("no-moov")]
[InlineData("truncated")]
[InlineData("zero-timescale")]
[InlineData("oversized-box")]
public void UnreadableStructureReturnsNull(string shape)
{
var bytes = shape switch
{
"no-moov" => Box("ftyp", Encoding.ASCII.GetBytes("isom"), new byte[4]).Concat(Box("mdat", new byte[64])).ToArray(),
"truncated" => Mp4(600, 57_000)[..^10],
"zero-timescale" => Mp4(0, 57_000),
_ => Box("ftyp", new byte[8]).Concat(new byte[] { 0x7F, 0xFF, 0xFF, 0xFF, (byte)'m', (byte)'o', (byte)'o', (byte)'v' }).ToArray(),
};
Assert.Null(VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(bytes)));
}
[Fact]
public void NonSeekableStreamReturnsNull()
{
Assert.Null(VideoDurationProbe.TryReadDurationSeconds(new NonSeekableStream(Mp4(600, 57_000))));
}
[Theory]
[InlineData(90.0, true)]
[InlineData(90.5, false)]
public void ContractAllowsUpToNinetySeconds(double seconds, bool allowed)
{
var message = WorkOrderMediaContract.ValidateVideoDuration(
new MemoryStream(Mp4(1000, (ulong)(seconds * 1000))));
Assert.Equal(allowed ? null : "Videos must be 90 seconds or shorter.", message);
}
private sealed class NonSeekableStream(byte[] bytes) : MemoryStream(bytes)
{
public override bool CanSeek => false;
}
}

View file

@ -1,264 +0,0 @@
using System.Globalization;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// SH-391: an Advanced Filters date range narrows GET /board/search to rows
/// whose Schedule On (or week-only Target Week) is in the range. Rows with no
/// date come back only when the caller asks for them (no range selected).
/// </summary>
public class WorkOrderAdvancedSearchDateRangeTests
{
private static readonly DateOnly RangeFrom = new(2026, 9, 21);
private static readonly DateOnly RangeTo = new(2026, 9, 25);
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static WorkOrderAdvancedSearchService CreateService(ApplicationDbContext context)
=> new(new WorkOrderAdvancedSearchDataService(context), WorkOrderAccountTestHelpers.Resolver(context));
private static WorkOrder Wo(
int id,
DateTime? scheduled,
string? assignTo = null,
LifecycleStatus status = LifecycleStatus.Scheduled,
DateOnly? targetWeek = null,
int? accountId = null)
=> new()
{
Id = id,
InternalWONumber = $"1000000000{id}",
AssignTo = assignTo,
ScheduledDate = scheduled,
ScheduleWeekOnly = targetWeek.HasValue,
TargetWeek = targetWeek,
LifecycleStatus = status,
AccountId = accountId,
istemplate = false
};
private static WorkOrder NotWeekOnly(WorkOrder wo)
{
wo.ScheduleWeekOnly = false;
return wo;
}
private static WorkOrderAdvancedSearchQueryDto UnassignedInRange(bool? includeDateless = null) => new()
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = RangeFrom,
DateTo = RangeTo,
Dispatchers = new List<string> { "__unassigned__" },
IncludeDateless = includeDateless,
PageSize = 50
};
[Fact]
public async Task SearchAsync_UnassignedWithRange_ReturnsOnlyUnassignedRowsScheduledInRange()
{
await using var context = CreateContext();
context.workOrders.AddRange(
Wo(1, new DateTime(2026, 9, 23)),
Wo(2, new DateTime(2026, 8, 4)),
Wo(3, null, status: LifecycleStatus.Incomplete),
Wo(4, new DateTime(2026, 9, 24), assignTo: "user-a"),
Wo(5, new DateTime(2026, 10, 2)));
await context.SaveChangesAsync();
var result = await CreateService(context).SearchAsync(
UnassignedInRange(), WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(1, result.TotalCount);
Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray());
}
[Fact]
public async Task SearchAsync_RangeWithoutDispatcherFilter_ExcludesUndatedRows()
{
await using var context = CreateContext();
context.workOrders.AddRange(
Wo(1, new DateTime(2026, 9, 22), assignTo: "user-a"),
Wo(2, null, assignTo: "user-a", status: LifecycleStatus.Incomplete),
Wo(3, new DateTime(2026, 9, 14), assignTo: "user-a", status: LifecycleStatus.Incomplete));
await context.SaveChangesAsync();
var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = RangeFrom,
DateTo = RangeTo
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray());
}
[Fact]
public async Task SearchAsync_WeekOnlyRow_MatchesRangeThatOverlapsItsTargetWeek()
{
await using var context = CreateContext();
context.workOrders.AddRange(
Wo(1, null, targetWeek: new DateOnly(2026, 9, 21)),
Wo(2, null, targetWeek: new DateOnly(2026, 9, 14)),
Wo(3, null, targetWeek: new DateOnly(2026, 9, 28)),
// Week-only with a leftover date outside the range: the week decides,
// as on the board page (SH-391).
Wo(4, new DateTime(2026, 10, 2), targetWeek: new DateOnly(2026, 9, 21)),
// Not week-only: its date decides, so a target week does not bring it in.
NotWeekOnly(Wo(5, new DateTime(2026, 10, 2), targetWeek: new DateOnly(2026, 9, 21))));
await context.SaveChangesAsync();
// Wed–Fri of the week of 9/21: the week-only row for that week overlaps it.
var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = new DateOnly(2026, 9, 23),
DateTo = RangeTo,
Dispatchers = new List<string> { "__unassigned__" }
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(new[] { 1, 4 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray());
}
[Fact]
public async Task SearchAsync_IncludeDateless_AddsOpenUndatedRowsToTheRange()
{
await using var context = CreateContext();
context.workOrders.AddRange(
Wo(1, new DateTime(2026, 9, 23)),
Wo(2, new DateTime(2026, 8, 4)),
Wo(3, null, status: LifecycleStatus.Incomplete),
Wo(4, null, status: LifecycleStatus.Completed),
Wo(5, null, status: LifecycleStatus.Canceled));
await context.SaveChangesAsync();
var result = await CreateService(context).SearchAsync(
UnassignedInRange(includeDateless: true), WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(new[] { 1, 3 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray());
}
// Payloads the frontend sent before includeDateless existed. They must keep
// their undated rows so this backend can deploy ahead of the client.
[Theory]
[InlineData(2000)] // no range selected: ADVANCED_SEARCH_ALL_WEEKS_FROM
[InlineData(1970)] // pinned Unassigned queue: UNASSIGNED_QUEUE_DATE_FROM
public async Task SearchAsync_AllWeeksWindowWithoutFlag_KeepsOpenUndatedRows(int fromYear)
{
await using var context = CreateContext();
context.workOrders.AddRange(
Wo(1, new DateTime(2026, 8, 4)),
Wo(2, null, status: LifecycleStatus.Incomplete),
Wo(3, null, status: LifecycleStatus.Completed));
await context.SaveChangesAsync();
var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = new DateOnly(fromYear, 1, 1),
DateTo = new DateOnly(2099, 12, 31),
Dispatchers = new List<string> { "__unassigned__" },
PageSize = 50
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(new[] { 1, 2 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray());
}
[Fact]
public async Task SearchAsync_NoDateInputWithoutFlag_KeepsOpenUndatedRows()
{
// The WO# duplicate lookup sends only the search text and paging.
await using var context = CreateContext();
var thisWeek = WorkOrderSearchDateRangeResolver.GetIsoWeekStart(DateOnly.FromDateTime(DateTime.UtcNow));
context.workOrders.AddRange(
Wo(1, thisWeek.ToDateTime(TimeOnly.MinValue)),
Wo(2, null, status: LifecycleStatus.Incomplete),
Wo(3, thisWeek.AddDays(-7).ToDateTime(TimeOnly.MinValue)));
await context.SaveChangesAsync();
var result = await CreateService(context).SearchAsync(
new WorkOrderAdvancedSearchQueryDto { PageSize = 100 },
WorkOrderAccountTestHelpers.OrgWideAdmin(),
null);
Assert.Equal(new[] { 1, 2 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray());
}
[Fact]
public async Task SearchAsync_AllWeeksWindowWithFlagFalse_ExcludesUndatedRows()
{
await using var context = CreateContext();
context.workOrders.AddRange(
Wo(1, new DateTime(2026, 8, 4)),
Wo(2, null, status: LifecycleStatus.Incomplete));
await context.SaveChangesAsync();
var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = new DateOnly(2000, 1, 1),
DateTo = new DateOnly(2099, 12, 31),
IncludeDateless = false,
PageSize = 50
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray());
}
[Theory]
[InlineData(null, null, null, null, true)]
[InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2000-01-01", "2099-12-31", true)]
[InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "1970-01-01", "2099-12-31", true)]
[InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2000-01-02", "2099-12-31", false)]
[InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2026-09-21", "2026-09-25", false)]
[InlineData(null, WorkOrderAdvancedSearchDatePreset.ThisWeek, null, null, false)]
[InlineData(false, null, null, null, false)]
[InlineData(true, WorkOrderAdvancedSearchDatePreset.ThisWeek, null, null, true)]
public void ResolveIncludeDateless_InfersOnlyForNoRangeRequests(
bool? flag,
WorkOrderAdvancedSearchDatePreset? preset,
string? from,
string? to,
bool expected)
{
Assert.Equal(expected, WorkOrderSearchDateRangeResolver.ResolveIncludeDateless(
flag,
preset,
from is null ? null : DateOnly.Parse(from, CultureInfo.InvariantCulture),
to is null ? null : DateOnly.Parse(to, CultureInfo.InvariantCulture)));
}
[Fact]
public async Task SearchAsync_RangeForAccountUser_NeverReturnsAnotherAccountsRows()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B");
context.workOrders.AddRange(
Wo(1, new DateTime(2026, 9, 23), accountId: 1),
Wo(2, new DateTime(2026, 9, 23), accountId: 2),
Wo(3, null, status: LifecycleStatus.Incomplete, accountId: 2),
Wo(4, null, targetWeek: new DateOnly(2026, 9, 21), accountId: 2));
await context.SaveChangesAsync();
var user = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher");
var strict = await CreateService(context).SearchAsync(UnassignedInRange(), user, "disp-1");
var withDateless = await CreateService(context).SearchAsync(
UnassignedInRange(includeDateless: true), user, "disp-1");
Assert.Equal(new[] { 1 }, strict.Items.Select(i => i.Id).ToArray());
Assert.Equal(new[] { 1 }, withDateless.Items.Select(i => i.Id).ToArray());
}
}

View file

@ -447,6 +447,7 @@ public class WorkOrderBoardCancelServiceTests
public Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) =>
throw new NotSupportedException();
public Task<decimal> GetPendingExposureAsync(CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();

View file

@ -319,7 +319,6 @@ public class WorkOrderAdvancedSearchServiceTests
DateFrom = new DateOnly(2000, 1, 1),
DateTo = new DateOnly(2099, 12, 31),
Dispatchers = new List<string> { "__unassigned__" },
IncludeDateless = true,
Page = 0,
PageSize = pageSize
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
@ -381,7 +380,6 @@ public class WorkOrderAdvancedSearchServiceTests
DateFrom = new DateOnly(2000, 1, 1),
DateTo = new DateOnly(2099, 12, 31),
Dispatchers = new List<string> { "__unassigned__" },
IncludeDateless = true,
Page = 0,
PageSize = 50
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
@ -438,7 +436,6 @@ public class WorkOrderAdvancedSearchServiceTests
DateFrom = new DateOnly(2000, 1, 1),
DateTo = new DateOnly(2099, 12, 31),
Dispatchers = new List<string> { "__unassigned__" },
IncludeDateless = true,
Page = 0,
PageSize = 2
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
@ -449,7 +446,6 @@ public class WorkOrderAdvancedSearchServiceTests
DateFrom = new DateOnly(2000, 1, 1),
DateTo = new DateOnly(2099, 12, 31),
Dispatchers = new List<string> { "__unassigned__" },
IncludeDateless = true,
Page = 1,
PageSize = 2
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
@ -777,7 +773,7 @@ public class WorkOrderAdvancedSearchServiceTests
}
[Fact]
public async Task SearchAsync_IncludeDateless_IncludesUnscheduledRows()
public async Task SearchAsync_IncludesUnscheduledRows()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
@ -807,8 +803,7 @@ public class WorkOrderAdvancedSearchServiceTests
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = weekStart,
DateTo = weekStart.AddDays(4),
IncludeDateless = true
DateTo = weekStart.AddDays(4)
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(2, result.TotalCount);
@ -1198,10 +1193,9 @@ public class WorkOrderAdvancedSearchServiceTests
Assert.Equal(
withFlagAbsent.Items.Select(i => i.Id).ToList(),
withFlagFalse.Items.Select(i => i.Id).ToList());
// SH-391: an explicit range narrows strictly, so the undated row 3 is out.
Assert.Equal(1, withFlagFalse.TotalCount);
Assert.Equal(2, withFlagFalse.TotalCount);
Assert.Contains(withFlagFalse.Items, i => i.Id == 1);
Assert.DoesNotContain(withFlagFalse.Items, i => i.Id == 3);
Assert.Contains(withFlagFalse.Items, i => i.Id == 3);
Assert.DoesNotContain(withFlagFalse.Items, i => i.Id == 2);
}

View file

@ -451,57 +451,6 @@ public class WorkOrderBoardPendingUpliftTests
Assert.Equal(1500m, dto.UpliftSummary.Amount);
}
[Fact]
public async Task GetBoardRows_VendorPortalUplift_SummaryAmountIsTheRequestedIncrease()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
WorkOrderId = 1,
NTEAmount = 600m,
ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0)
});
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
SiteCode = "BK5"
});
// A vendor-portal request (no identity user) stores the requested NTE total.
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = 10,
CurrentNTE = 600m,
RequestedNTE = 900m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending"
});
await context.SaveChangesAsync();
var result = await new WorkOrderBoardDataService(context).GetBoardRowsAsync(new WorkOrderBoardQuery(
weekStart,
weekStart.AddDays(4),
null,
false,
null,
false,
null,
null));
var dto = WorkOrderBoardService.MapRawRow(Assert.Single(result.ScheduledRows), DateTime.UtcNow);
Assert.Equal(300m, dto.UpliftSummary!.Amount);
}
[Fact]
public async Task GetBoardRows_ApprovedUplift_DoesNotBlock()
{

View file

@ -41,8 +41,7 @@ public class WorkOrderCompletedSelectiveLockTests
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit,
new TestFileStoragePort(),
new UpliftDataService(context));
new TestFileStoragePort());
return (context, service);
}

View file

@ -176,8 +176,7 @@ public class WorkOrderMediaConcurrencyRelationalTests
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit,
new TestFileStoragePort(),
new UpliftDataService(context));
new TestFileStoragePort());
}
private static async Task<string> LoadVersionAsync(ApplicationDbContext context, int workOrderId)

View file

@ -1,8 +1,6 @@
using System.Security.Claims;
using System.Text;
using System.IO.Compression;
using System.Reflection;
using System.Text.RegularExpressions;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Http;
@ -814,8 +812,7 @@ public class WorkOrderMediaServiceTests
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit,
fileStorage ?? new TestFileStoragePort(),
new UpliftDataService(context));
fileStorage ?? new TestFileStoragePort());
return (context, service);
}
@ -1312,410 +1309,6 @@ public class WorkOrderMediaServiceTests
Assert.Equal(WorkOrderMediaCategory.Extra, media.Category);
}
[Fact]
public async Task AddMedia_EleventhPhoto_ThrowsMediaCountExceeded()
{
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 }
});
for (var i = 0; i < 10; i++)
{
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = 1,
Attachments = $"https://example.com/photo-{i}.jpg",
Category = WorkOrderMediaCategory.Extra
});
}
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.AddMediaAsync(
1,
null,
"https://example.com/photo-10.jpg",
AuthenticatedUser(),
"actor-1"));
Assert.Equal("MediaCountExceeded", ex.Code);
Assert.Equal("A work order can have at most 10 photos.", ex.Message);
Assert.Equal(10, context.workOrderAttachments.Count(a => a.IsDeleted != true));
}
[Fact]
public async Task AddMedia_CountsAndInsertsUnderTheWorkOrderMutationLock()
{
// Distinct id: the mutation gate is process-wide per work order.
const int workOrderId = 173_001;
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = workOrderId,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
for (var i = 0; i < 9; i++)
{
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = workOrderId,
Attachments = $"https://example.com/photo-{i}.jpg",
Category = WorkOrderMediaCategory.Extra
});
}
await context.SaveChangesAsync();
var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var holder = new UpliftDataService(context).ExecuteWorkOrderMutationAsync(
workOrderId,
async _ =>
{
held.SetResult();
await release.Task;
return 0;
},
CancellationToken.None);
await held.Task;
var upload = service.AddMediaAsync(
workOrderId,
null,
"https://example.com/photo-9.jpg",
AuthenticatedUser(),
"actor-1");
await Task.Delay(200);
Assert.False(upload.IsCompleted);
Assert.Equal(9, context.workOrderAttachments.Count(a => a.WorkorderId == workOrderId && a.IsDeleted != true));
release.SetResult();
await holder;
await upload;
Assert.Equal(10, context.workOrderAttachments.Count(a => a.WorkorderId == workOrderId && a.IsDeleted != true));
}
[Fact]
public async Task AddMedia_FourthVideo_CountsStoredPhoneFileUrls()
{
// Same URL shape FileStorageAdapter.SaveFileAsync returns for an iPhone upload.
static string StoredUrl(string name) =>
$"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}";
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", "clip.MP4" })
{
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = 1,
Attachments = StoredUrl(name),
Category = WorkOrderMediaCategory.Extra
});
}
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.AddMediaAsync(
1,
null,
StoredUrl("IMG_0004.MOV"),
AuthenticatedUser(),
"actor-1"));
Assert.Equal("MediaCountExceeded", ex.Code);
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()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
AccountId = 20,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
for (var i = 0; i < 10; i++)
{
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = 1,
Attachments = $"https://example.com/photo-{i}.jpg",
Category = WorkOrderMediaCategory.Extra
});
}
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.AddMediaAsync(
1,
null,
"https://example.com/photo-10.jpg",
AuthenticatedUser(accountId: 10),
"actor-1"));
// Another account must not learn the work order exists or how full it is.
Assert.Equal("NotFound", ex.Code);
Assert.Equal(10, context.workOrderAttachments.Count(a => a.IsDeleted != true));
}
[Fact]
public async Task AddMedia_TenthPhoto_Succeeds()
{
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 }
});
for (var i = 0; i < 9; i++)
{
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = 1,
Attachments = $"https://example.com/photo-{i}.jpg",
Category = WorkOrderMediaCategory.Extra
});
}
await context.SaveChangesAsync();
var media = await service.AddMediaAsync(
1,
null,
"https://example.com/photo-9.jpg",
AuthenticatedUser(),
"actor-1");
Assert.True(media.Id > 0);
}
[Fact]
public async Task AddMedia_FourthVideo_ThrowsMediaCountExceeded()
{
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 }
});
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = 1,
Attachments = "https://example.com/clip-a.mp4",
Category = WorkOrderMediaCategory.Extra
});
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = 1,
Attachments = "https://example.com/clip-b.mov",
Category = WorkOrderMediaCategory.Extra
});
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = 1,
Attachments = "https://example.com/clip-c.mp4",
Category = WorkOrderMediaCategory.Extra
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.AddMediaAsync(
1,
null,
"https://example.com/clip-d.mov",
AuthenticatedUser(),
"actor-1"));
Assert.Equal("MediaCountExceeded", ex.Code);
Assert.Equal("A work order can have at most 3 videos.", ex.Message);
}
[Fact]
public async Task AddMedia_ThirdVideo_Succeeds()
{
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 }
});
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = 1,
Attachments = "https://example.com/clip-a.mp4",
Category = WorkOrderMediaCategory.Extra
});
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = 1,
Attachments = "https://example.com/clip-b.mov",
Category = WorkOrderMediaCategory.Extra
});
await context.SaveChangesAsync();
var media = await service.AddMediaAsync(
1,
null,
"https://example.com/clip-c.mp4",
AuthenticatedUser(),
"actor-1");
Assert.True(media.Id > 0);
}
[Fact]
public async Task AddMedia_DeletedPhoto_DoesNotConsumePhotoCount()
{
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 }
});
for (var i = 0; i < 10; i++)
{
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = 1,
Attachments = $"https://example.com/photo-{i}.jpg",
Category = WorkOrderMediaCategory.Extra,
IsDeleted = i == 0
});
}
await context.SaveChangesAsync();
var media = await service.AddMediaAsync(
1,
null,
"https://example.com/photo-new.jpg",
AuthenticatedUser(),
"actor-1");
Assert.True(media.Id > 0);
}
[Fact]
public async Task AddMedia_DocumentsDoNotConsumePhotoOrVideoCounts()
{
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 }
});
for (var i = 0; i < 5; i++)
{
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = 1,
Attachments = $"https://example.com/report-{i}.pdf",
Category = WorkOrderMediaCategory.Extra
});
}
await context.SaveChangesAsync();
var photo = await service.AddMediaAsync(
1,
null,
"https://example.com/photo.jpg",
AuthenticatedUser(),
"actor-1");
var video = await service.AddMediaAsync(
1,
null,
"https://example.com/clip.mp4",
AuthenticatedUser(),
"actor-1");
Assert.True(photo.Id > 0);
Assert.True(video.Id > 0);
}
[Fact]
public async Task DeleteMedia_Technician_ThrowsForbidden()
{
@ -2375,32 +1968,6 @@ public class WorkOrderMediaFileRulesTests
return stream.ToArray();
}
[Fact]
public void EnsureAllowed_RejectionMessage_NamesEveryAllowedType()
{
const BindingFlags privateStatic = BindingFlags.NonPublic | BindingFlags.Static;
var allowedTypes = (HashSet<string>?)typeof(WorkOrderMediaFileRules)
.GetField("AllowedContentTypes", privateStatic)?.GetValue(null);
var extensionsByType = (Dictionary<string, HashSet<string>>?)typeof(WorkOrderMediaFileRules)
.GetField("ExtensionsByContentType", privateStatic)?.GetValue(null);
Assert.NotNull(allowedTypes);
Assert.NotNull(extensionsByType);
Assert.NotEmpty(allowedTypes);
var ex = Assert.Throws<WorkOrderBoardValidationException>(
() => WorkOrderMediaFileRules.EnsureAllowed(FormFile(Encoding.UTF8.GetBytes("plain text"), "notes.txt", "text/plain")));
foreach (var contentType in allowedTypes)
{
var canonical = contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" : contentType;
Assert.True(extensionsByType.TryGetValue(canonical, out var extensions), $"No extensions mapped for {contentType}.");
var labels = extensions.Select(extension => extension.TrimStart('.').ToUpperInvariant()).ToList();
Assert.True(
labels.Any(label => Regex.IsMatch(ex.Message, $@"\b{Regex.Escape(label)}\b")),
$"Rejection message does not name {contentType} ({string.Join("/", labels)}): {ex.Message}");
}
}
[Fact]
public void IsAllowed_ValidJpeg_ReturnsTrue()
{

View file

@ -26,7 +26,7 @@ def is_app_path(path: str) -> bool:
normalized = path.replace("\\", "/")
if normalized in APP_SCRIPT_NAMES:
return True
if normalized.startswith((".ebextensions/", ".platform/")):
if normalized.startswith(".ebextensions/"):
return True
return normalized.endswith(APP_SUFFIXES)

View file

@ -8,7 +8,6 @@
# ./ published Api.SeaHavenIndustries (self-contained, linux-x64)
# ./efbundle self-contained EF Core 8.0.8 migrations bundle (linux-x64, +x)
# ./.ebextensions/* leader-only migration container command
# ./.platform/nginx/conf.d/* nginx proxy overrides (upload body size)
#
# The bundle reads ConnectionStrings__DefaultConnection from the runtime
# environment (Elastic Beanstalk property). No connection string or secret is
@ -138,10 +137,6 @@ log "copy .ebextensions into bundle root"
mkdir -p "$STAGING_DIR/.ebextensions"
cp -R .ebextensions/. "$STAGING_DIR/.ebextensions/"
log "copy .platform (nginx proxy overrides) into bundle root"
mkdir -p "$STAGING_DIR/.platform"
cp -R .platform/. "$STAGING_DIR/.platform/"
log "verify no committed secret placeholders survived publish"
if grep -rIEl -- 'Server=.*;.*Password=|AccountKey=|aws_secret|AKIA[0-9A-Z]{16}' "$STAGING_DIR" 2>/dev/null; then
die "potential secret detected in publish output; refusing to package."

View file

@ -54,17 +54,6 @@ class IsolationTests(unittest.TestCase):
self.assertEqual(terraform_files, ["terraform/live/dev/main.tf"])
self.assertTrue(any(path.endswith(".cs") for path in app_files))
def test_platform_proxy_config_is_app_side(self) -> None:
violation = isolation_violation(
[
"terraform/live/dev/main.tf",
".platform/nginx/conf.d/01_upload_body_size.conf",
]
)
self.assertIsNotNone(violation)
_, app_files = violation or ([], [])
self.assertEqual(app_files, [".platform/nginx/conf.d/01_upload_body_size.conf"])
if __name__ == "__main__":
unittest.main()

View file

@ -15,15 +15,13 @@ die() {
contents_file="$(mktemp)"
webhook_file="$(mktemp)"
nginx_file="$(mktemp)"
trap 'rm -f "$contents_file" "$webhook_file" "$nginx_file"' EXIT
trap 'rm -f "$contents_file" "$webhook_file"' EXIT
unzip -tq "$BUNDLE"
unzip -Z1 "$BUNDLE" > "$contents_file"
grep -Fxq "efbundle" "$contents_file"
grep -Fxq ".ebextensions/01_migrations.config" "$contents_file"
grep -Fxq ".ebextensions/02_webhook_config.config" "$contents_file"
grep -Fxq ".platform/nginx/conf.d/01_upload_body_size.conf" "$contents_file"
unzip -p "$BUNDLE" .ebextensions/02_webhook_config.config > "$webhook_file"
grep -Fxq ' WorkOrderWebhook__Enabled: "true"' "$webhook_file"
@ -32,9 +30,5 @@ grep -Fxq \
' WorkOrderWebhook__SecretId: arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB' \
"$webhook_file"
# Without this override the platform nginx caps request bodies at 1 MB.
unzip -p "$BUNDLE" .platform/nginx/conf.d/01_upload_body_size.conf > "$nginx_file"
grep -Fxq 'client_max_body_size 120M;' "$nginx_file"
printf 'PASS: Elastic Beanstalk bundle contract (%s bytes)\n' \
"$(wc -c < "$BUNDLE" | tr -d ' ')"