Compare commits

...

23 commits

Author SHA1 Message Date
Alexandre Brandizzi
2c8ffaf10e
Merge pull request #173 from Sea-Haven-Industries/fix/ab/sh-383-media-contract
Some checks are pending
Backend CI / Build and test (push) Waiting to run
Backend CI / architecture (push) Waiting to run
Backend CI / review (push) Waiting to run
Backend CI / ci-complete (push) Blocked by required conditions
fix(media): lift the 1 MB proxy body cap and apply the SH-116 media contract
2026-09-25 06:02:21 +00:00
Alexandre Brandizzi
c1ed5dc98d
Merge pull request #181 from Sea-Haven-Industries/fix/ab/sh-400-vendor-readonly-uplifts
Keep work-order uplift requests read-only in the Vendor Portal
2026-09-25 05:58:38 +00:00
Alexandre Brandizzi
207bf59208 fix(media): name HEIC in the unsupported-type message and keep ticket keys out of comments
The rejection message now lists every type the media allowlist accepts, and a
test fails if the message and the allowlist drift apart.
2026-09-25 02:58:15 -03:00
Alexandre Brandizzi
203fc92e4a
Merge pull request #172 from Sea-Haven-Industries/fix/ab/sh-391-adv-search-date-range
fix(board-search): make the Advanced Filters date range narrow results (SH-391)
2026-09-25 05:53:42 +00:00
Alexandre Brandizzi
e02f9774dc Keep work-order uplift requests read-only in the Vendor Portal
A vendor could withdraw (or cancel) an uplift a dispatcher raised from the work
order. Withdraw and its cancel alias now refuse requests with createdby set,
using the portal's not-found response, and the portal read model reports
RaisedByVendor so the portal can hide Revise and Withdraw on those requests.
2026-09-25 02:52:03 -03:00
Alexandre Brandizzi
3b69b9fbcc
Merge pull request #178 from Sea-Haven-Industries/fix/ab/sh-398-pending-exposure
fix(uplifts): one per-path uplift amount for queue, approval and exposure
2026-09-25 05:46:17 +00:00
Alexandre Brandizzi
85f2e709c9 test(uplifts): board summary and notification delta use the per-path amount
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 02:36:17 -03:00
Alexandre Brandizzi
d33ba34db9 fix(vendor-portal): vendors revise only uplift requests they raised
A work-order request stores the requested increase, not a total. Letting
the vendor revise one after changes were requested rewrote RequestedNTE
as a total while it still read as a work-order request, corrupting its
amount and the NTE it would be approved to. Revise now answers not-found
for any request the vendor did not raise and leaves the row untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 02:35:06 -03:00
Alexandre Brandizzi
eb2b442775 fix(uplifts): one per-path uplift amount for queue, approval and exposure
Work-order requests store the requested increase in RequestedNTE; vendor
portal requests store the requested NTE total. The queue Delta, the
pending and approved exposure totals, the work-order uplift list, the
board summary and the notification Delta now all read one definition
(UpliftAmount) that honours both meanings and translates to SQL.

Approving a work-order request now adds its increase to the dispatch NTE
instead of replacing the NTE with the increase; vendor requests still end
at their requested total.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 02:32:58 -03:00
Alexandre Brandizzi
f40ad7c1ef fix(uplifts): pending exposure header sums the row deltas
The approvals header summed the whole RequestedNTE for work-order-path
requests, while each Pending row shows RequestedNTE - CurrentNTE. When a
work order already had an NTE the header overstated exposure by that NTE.

The header now sums the same Delta the rows display, over the same rows
the Pending tab lists (non-deleted request on a non-deleted dispatch).
The unused duplicate aggregate is removed so one definition remains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 02:10:41 -03:00
Alexandre Brandizzi
67cd9c589b fix(board-search): keep undated rows for clients that omit includeDateless (SH-391)
The strict date range made undated open work orders disappear for every
client that predates the includeDateless flag. The frontend on main sends
the all-weeks window (2000-01-01..2099-12-31) for "no range", the
1970-01-01..2099-12-31 window for the pinned Unassigned queue, and no date
input at all for the WO# duplicate lookup. None of those send the flag, so
deploying this backend before the frontend would have dropped undated WOs
from all three flows.

includeDateless is now optional. An explicit value still wins. When it is
omitted, a request with no date input or with an all-weeks Custom window
keeps its open undated rows, as before SH-391. Any other range stays
strict. The backend and frontend can therefore deploy in either order.
2026-09-25 01:41:49 -03:00
Alexandre Brandizzi
0d8f32d148 fix(media): check the file type before the size cap on media upload
AddMedia sized a file before validating its type, and ValidateSize's Unknown
arm returned the video message, so a 150 MB .exe sent as
application/octet-stream was rejected as FileTooLarge with "Videos must be
100 MB or smaller." EnsureAllowed now runs first, so an unsupported file always
reports UnsupportedMediaType, and the Unknown arm uses a type-neutral message.
The oversize controller tests now use real file headers so they pass the type
check before reaching the size cap.
2026-09-24 23:47:18 -03:00
Alexandre Brandizzi
f3ef11b504 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	Api.SeaHavenIndustries/Controllers/VendorPortalController.cs
#	SeaHaven.Services/Implementation/VendorPortalService.cs
2026-09-24 23:05:17 -03:00
Alexandre Brandizzi
3e3f0f383d fix(media): serialize SH-116 media counts under the work-order lock and serve HEIC as image/heic
The 10-photo / 3-video cap was a check-then-insert with no lock on both
upload surfaces, so two overlapping uploads could both take the last slot.
The board media upload and the vendor portal upload now run count, insert
and save inside ExecuteWorkOrderMutationAsync. GetMediaContent maps .heic
to image/heic.
2026-09-24 22:56:21 -03:00
Alexandre Brandizzi
eecc2a61bd feat(vendor-portal): report work-order media counts on the dispatch detail
Adds MediaCounts (limits, current photos/videos, and the counts a new
completion version would see) so the portal can refuse an 11th photo or
4th video before uploading it. Uses the same count and replacement rule
the upload check enforces.
2026-09-24 22:24:07 -03:00
Alexandre Brandizzi
fd59a3da13 fix(media): enforce the 90-second video limit on the server
Read the duration from the MP4/MOV movie header (moov/mvhd) on both the
dispatcher media endpoint and the vendor portal completion upload, so a
direct request cannot bypass the browser check. Unreadable metadata still
never blocks an upload.
2026-09-24 21:38:00 -03:00
Alexandre Brandizzi
a8414cd4fa style(tests): format vendor quota test initializers 2026-09-24 21:29:51 -03:00
Alexandre Brandizzi
e15de6e90b fix(media): enforce the per-work-order photo/video limit across both surfaces
The 10-photo / 3-video limit only counted dispatcher attachments, so vendor
portal uploads could push a work order past it (and vice versa). Count the
work order's current vendor documents alongside its attachments on both the
dispatcher media endpoint and the vendor portal. A new completion version
does not count the version it replaces.
2026-09-24 21:27:01 -03:00
Alexandre Brandizzi
16845a55f3 test(vendor-portal): use a valid PDF signature in the size-class regression 2026-09-24 21:18:49 -03:00
Alexandre Brandizzi
07bc81cc52 fix(media): size uploads by the validated content type
A misleading file name could move a file into a larger size class: a real
PDF or JPEG named .mp4/.mov got the 100 MB video cap on the vendor portal,
and a .jpg declared with a foreign video type got it on the media endpoint.
Classify by the same resolved type the signature check validates; the
extension only decides when no allowlisted type is known.
2026-09-24 21:18:00 -03:00
Alexandre Brandizzi
9d5798437d test(workorders): pin the week-only date range rule shared with the board page (SH-391) 2026-09-24 21:11:20 -03:00
Alexandre Brandizzi
dc251c42d4 fix(media): apply SH-116 media contract and lift the 1 MB proxy body cap
The Elastic Beanstalk nginx proxy kept its 1 MB default body limit, so every
media upload over ~1 MB got an nginx 413 before reaching the API. Ship a
.platform nginx override (120M) in the bundle and assert it in the bundle
contract.

Apply the client-confirmed contract: photos up to 10 MB (JPEG/PNG/HEIC),
videos up to 100 MB (MP4/MOV), at most 10 photos and 3 videos per work order,
with stable generic rejection messages. The request ceiling (110 MB) sits
between the per-kind caps and the proxy so oversize files get the generic
message. The vendor portal accepts the same photo/video types and caps.
2026-09-24 20:52:44 -03:00
Alexandre Brandizzi
028908bd5a fix(board-search): make the Advanced Filters date range narrow results (SH-391)
An explicit date range let every undated, non-terminal work order through,
so Unassigned plus a range still returned all ~1,780 unassigned rows. The
range now matches Schedule On, or the Target Week for week-only rows
(overlap), the same date the weekly board groups by. Undated rows are added
only when the caller sends includeDateless, which the client uses for
searches with no range selected and for the Unassigned queue.
2026-09-24 20:51:44 -03:00
45 changed files with 2810 additions and 183 deletions

View file

@ -0,0 +1,6 @@
# 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,7 +278,8 @@ public sealed class UpliftQueueReadTests
item.RequestedByName.Should().Be("Gateway");
item.RequestedAt.Should().Be(new DateTime(2026, 3, 2));
item.VendorReason.Should().Be("Scope grew");
item.Delta.Should().Be(300m);
// A work-order request (createdby set) stores the increase itself in RequestedNTE.
item.Delta.Should().Be(400m);
item.Status.Should().Be("Pending");
}
@ -558,7 +559,7 @@ public sealed class UpliftQueueReadTests
}
[Fact]
public async Task List_PendingExposureTotal_SumsGrantedAmountsAcrossAllPendingRequests()
public async Task List_PendingExposureTotal_SumsEachPendingRequestsIncreaseAcrossTheQueue()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
@ -567,10 +568,10 @@ public sealed class UpliftQueueReadTests
await context.SaveChangesAsync();
context.DispatchUpliftRequests.AddRange(
// Vendor-portal rows (createdby null) store the requested new NTE total,
// so the pending exposure is the delta: 400 - 100 = 300 and 600 - 250 = 350.
// so their increase is 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-path rows (createdby set) store the requested increment: 90.
// Work-order rows (createdby set) store the increase itself: 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));
@ -584,6 +585,38 @@ 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,6 +419,44 @@ 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()
{
@ -458,6 +496,92 @@ 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,6 +54,11 @@ 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>());
@ -363,6 +368,278 @@ 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,5 +1,6 @@
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
@ -31,7 +32,7 @@ public class WorkOrderMediaControllerTests
}
[Fact]
public void AddMedia_HasTwoHundredMegabyteRequestLimit()
public void AddMedia_HasContractRequestLimit()
{
var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia));
var attribute = Assert.Single(
@ -39,36 +40,293 @@ public class WorkOrderMediaControllerTests
candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute));
var bytes = Assert.Single(attribute.ConstructorArguments);
Assert.Equal(200_000_000L, bytes.Value);
Assert.Equal(110_000_000L, bytes.Value);
}
[Fact]
public void AddMedia_HasTwoHundredMegabyteMultipartBodyLimit()
public void AddMedia_HasContractMultipartBodyLimit()
{
var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia));
var attribute = Assert.IsType<RequestFormLimitsAttribute>(Assert.Single(
method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: true)));
Assert.Equal(200_000_000L, attribute.MultipartBodyLengthLimit);
Assert.Equal(110_000_000L, attribute.MultipartBodyLengthLimit);
}
[Fact]
public async Task AddMedia_FileOverLimit_ReturnsStableUnprocessableEntity()
public async Task AddMedia_VideoOverHundredMegabytes_ReturnsStableUnprocessableEntity()
{
var file = new Mock<IFormFile>();
file.SetupGet(candidate => candidate.Length).Returns(200_000_001);
var file = OversizeFile(100_000_001, "clip.mp4", "video/mp4", FtypHeader);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None);
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("The uploaded file must not exceed 200 MB.", error.Message);
Assert.Equal("Videos must be 100 MB or smaller.", 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()
{
@ -167,7 +425,8 @@ public class WorkOrderMediaServiceCancellationTests
mediaData.Object,
new Mock<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
storage.Object);
storage.Object,
new Mock<IUpliftDataService>(MockBehavior.Strict).Object);
var user = new ClaimsPrincipal(new ClaimsIdentity(
new[]
{
@ -184,4 +443,37 @@ 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,6 +5,7 @@ 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
@ -299,7 +300,7 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("dispatches/{id:int}/completion-documents")]
[HttpPost("dispatches/{id:int}/documents")]
[RequestSizeLimit(10_000_000)]
[RequestSizeLimit(WorkOrderMediaContract.MaxUploadRequestBytes)]
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 = 200_000_000;
public const long MaxUploadBytes = WorkOrderMediaContract.MaxUploadRequestBytes;
private readonly IWorkOrderMediaService _workOrderMediaService;
private readonly IFileStoragePort _fileStorage;
@ -88,14 +88,28 @@ namespace Api.SeaHavenIndustries.Controllers
string? fileUrl = null;
try
{
if (file.Length > MaxUploadBytes)
// Type first, so an unsupported file always reports UnsupportedMediaType instead
// of being sized under a kind it does not have.
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",
"The uploaded file must not exceed 200 MB.");
throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage);
}
WorkOrderMediaFileRules.EnsureAllowed(file, category);
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,6 +45,9 @@ 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; }
@ -100,6 +103,8 @@ 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; }
@ -127,6 +132,8 @@ 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

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

View file

@ -220,13 +220,26 @@ 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)
DateOnly dateTo,
bool includeDateless = false)
{
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
@ -234,9 +247,10 @@ namespace SeaHaven.DataServices.Helpers
&& w.ScheduledDate.Value.Date <= toDate)
|| (w.ScheduleWeekOnly == true
&& w.TargetWeek != null
&& w.TargetWeek >= dateFrom
&& w.TargetWeek >= earliestWeekStart
&& w.TargetWeek <= dateTo)
|| (w.ScheduledDate == null
|| (includeDateless
&& w.ScheduledDate == null
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled));
}

View file

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

View file

@ -3,6 +3,7 @@ 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
@ -109,6 +110,7 @@ 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,
@ -128,16 +130,6 @@ 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
@ -153,6 +145,7 @@ 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,
@ -193,6 +186,7 @@ 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,
@ -249,6 +243,9 @@ 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,
@ -402,25 +399,14 @@ namespace SeaHaven.DataServices.Implementation
.Where(u => (u.IsDeleted == null || u.IsDeleted == false)
&& dispatchIds.Contains(u.DispatchId)
&& (u.Status == "Approved" || u.Status == "NoApprovalRequired"))
.GroupBy(u => u.DispatchId)
// 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)
.Select(g => new
{
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))
g.Key.DispatchId,
g.Key.Status,
Total = g.Sum()
})
.ToListAsync(cancellationToken);
@ -438,17 +424,20 @@ namespace SeaHaven.DataServices.Implementation
totals[workOrderId] = total;
}
total.AutoApprovedTotal += sum.AutoApproved ?? 0m;
total.AdminApprovedTotal += sum.AdminApproved ?? 0m;
if (sum.Status == "NoApprovalRequired")
total.AutoApprovedTotal += sum.Total;
else
total.AdminApprovedTotal += sum.Total;
}
}
return totals.Values.ToList();
}
// 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.
// 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.
public async Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken)
{
return await _context.DispatchUpliftRequests
@ -456,7 +445,8 @@ namespace SeaHaven.DataServices.Implementation
&& u.Status == "Pending"
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false))
.SumAsync(u => (decimal?)u.RequestedNTE, cancellationToken) ?? 0m;
.Select(UpliftAmount.ForQuery)
.SumAsync(cancellationToken);
}
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(

View file

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

View file

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

View file

@ -56,6 +56,36 @@ 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,7 +6,6 @@ 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,6 +9,19 @@ namespace SeaHaven.DataServices.Interfaces
Task<VendorCompletionDocument?> GetMetadataForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
Task<List<VendorCompletionDocument>> ListForVendorDispatchAsync(int dispatchId, int vendorId, CancellationToken cancellationToken);
Task<VendorCompletionDocument?> GetUpliftEvidenceAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
/// <summary>
/// Content types of the work order's current vendor documents (not deleted, not replaced by a
/// newer completion version), optionally excluding one being replaced. 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,6 +22,19 @@ 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,7 +32,8 @@ namespace SeaHaven.DataServices.Interfaces
int PageSize,
string SortBy,
string SortDir,
int? AccountId = null);
int? AccountId = null,
bool IncludeDateless = false);
public record WorkOrderAdvancedSearchResult(
IReadOnlyList<WorkOrderBoardRawRow> Rows,

View file

@ -54,6 +54,8 @@ 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; }
@ -134,6 +136,26 @@ 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,6 +87,14 @@ 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

@ -0,0 +1,129 @@
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

@ -0,0 +1,149 @@
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,6 +12,7 @@ namespace SeaHaven.Services.Helpers
"image/jpeg",
"image/jpg",
"image/png",
"image/heic",
"video/mp4",
"video/quicktime",
"application/pdf",
@ -24,6 +25,7 @@ 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" },
@ -42,7 +44,11 @@ namespace SeaHaven.Services.Helpers
// the accepted set of files.
private static string? ResolveContentType(string declaredType, string extension)
{
if (AllowedContentTypes.Contains(declaredType))
// 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)
return declaredType;
foreach (var (contentType, extensions) in ExtensionsByContentType)
@ -61,6 +67,19 @@ 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)
@ -68,13 +87,11 @@ 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 resolvedType = ResolveContentType(declaredType, extension);
if (resolvedType == null)
var contentType = ResolveUploadContentType(file);
if (contentType == null)
return false;
var contentType = CanonicalContentType(resolvedType);
var resolvedCategory = category ?? WorkOrderMediaCategory.Extra;
if (IsDocument(contentType)
&& resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta)
@ -123,7 +140,7 @@ namespace SeaHaven.Services.Helpers
{
throw new Exceptions.WorkOrderBoardValidationException(
"UnsupportedMediaType",
"Supported file types are JPG, PNG, MP4, MOV, PDF, DOC, and DOCX for Extra Docs; photos accept media only.");
"Supported file types are JPG, PNG, HEIC, MP4, MOV, PDF, DOC, and DOCX for Extra Docs; photos accept media only.");
}
}
@ -139,6 +156,11 @@ 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;
@ -206,5 +228,20 @@ 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,6 +15,36 @@ 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,4 +1,5 @@
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
@ -42,7 +43,7 @@ namespace SeaHaven.Services.Helpers
{
Id = row.Id,
Status = ToFrontendStatus(row.Status),
Amount = row.RequestedNTE,
Amount = row.Amount,
Notes = row.VendorReason ?? "",
RequestedAt = row.CreatedDate,
RequestedById = row.CreatedByUserId,
@ -58,7 +59,7 @@ namespace SeaHaven.Services.Helpers
{
Id = request.Id,
Status = ToFrontendStatus(request.Status),
Amount = request.RequestedNTE,
Amount = UpliftAmount.Of(request),
Notes = request.VendorReason ?? "",
RequestedAt = request.CreatedDate,
RequestedById = request.createdby,

View file

@ -1,4 +1,5 @@
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Helpers;
namespace SeaHaven.Services.Implementation
{
@ -23,7 +24,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;'>${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):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>Required Approval</strong></td><td style='padding:4px 10px;'>{tierText}</td></tr>
</table>
<h3>Vendor Reason</h3>

View file

@ -1,5 +1,6 @@
using Data.SeaHavenIndustries;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
@ -36,7 +37,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.GetPendingExposureAsync(cancellationToken);
var pendingExposureTotal = await _upliftData.GetPendingExposureTotalAsync(cancellationToken);
var mapped = items.Select(r => new UpliftListItemDTO
{
@ -56,7 +57,7 @@ namespace SeaHaven.Services.Implementation
DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName),
CurrentNTE = r.CurrentNTE,
RequestedNTE = r.RequestedNTE,
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
Delta = r.Amount,
VendorReason = r.VendorReason,
RequiredTier = r.RequiredTier,
Status = UpliftStatus.ToCanonical(r.Status),
@ -115,7 +116,7 @@ namespace SeaHaven.Services.Implementation
DispatchId = r.DispatchId,
CurrentNTE = r.CurrentNTE,
RequestedNTE = r.RequestedNTE,
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
Delta = r.Amount,
VendorReason = r.VendorReason,
Status = UpliftStatus.ToCanonical(r.Status),
RequiredTier = r.RequiredTier,
@ -157,7 +158,12 @@ namespace SeaHaven.Services.Implementation
var now = _timeProvider.GetUtcNow().UtcDateTime;
var oldNTE = dispatch.NTEAmount ?? 0m;
dispatch.NTEAmount = req.RequestedNTE;
// 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.LastModificationTime = now;
req.Status = UpliftStatus.Approved;
@ -172,7 +178,7 @@ namespace SeaHaven.Services.Implementation
UserId = userId,
FieldName = $"Dispatch {dispatch.DispatchNumber} NTE",
OldValue = $"${oldNTE:F2}",
NewValue = $"${req.RequestedNTE:F2}",
NewValue = $"${dispatch.NTEAmount:F2}",
Action = "uplift_approved",
CreatedAt = now
}, cancellationToken);

View file

@ -5,6 +5,7 @@ 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
@ -13,9 +14,36 @@ namespace SeaHaven.Services.Implementation
{
private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
"application/pdf", "image/jpeg", "image/jpg", "image/png"
"application/pdf", "image/jpeg", "image/jpg", "image/png", "image/heic",
"video/mp4", "video/quicktime"
};
// 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;
@ -107,6 +135,7 @@ 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,
@ -179,8 +208,37 @@ 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,
@ -694,7 +752,9 @@ namespace SeaHaven.Services.Implementation
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken);
if (req == null) throw new KeyNotFoundException("Uplift request not found");
// 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 (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Withdrawn))
{
@ -736,7 +796,9 @@ namespace SeaHaven.Services.Implementation
}
var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken);
if (req == null) throw new KeyNotFoundException("Uplift request not found");
// 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 (UpliftStatus.ToCanonical(req.Status) != UpliftStatus.ChangesRequested)
{
@ -828,15 +890,31 @@ namespace SeaHaven.Services.Implementation
throw new InvalidOperationException("A completion document file is required.");
}
if (file.Length > _documentOptions.MaxSizeBytes)
// 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("The uploaded file exceeds the maximum allowed size.");
throw new InvalidOperationException("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are accepted.");
}
var contentType = (file.ContentType ?? string.Empty).Trim();
if (!AllowedContentTypes.Contains(contentType))
// 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("Only PDF, JPG, and PNG completion documents are accepted.");
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)
{
throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size.");
}
var resolvedPurpose = string.IsNullOrWhiteSpace(purpose)
@ -859,11 +937,21 @@ namespace SeaHaven.Services.Implementation
await file.CopyToAsync(buffer, cancellationToken);
var bytes = buffer.ToArray();
if (!MatchesSignature(contentType, bytes))
if (!WorkOrderMediaFileRules.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)
{
@ -890,50 +978,80 @@ 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 now = DateTime.UtcNow;
var document = 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);
var isUpliftEvidence = resolvedPurpose == VendorDocumentPurpose.UpliftEvidence;
var fieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document";
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
async Task<VendorCompletionDocument> PersistAsync(CancellationToken ct)
{
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);
// 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);
}
}
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(created, ct);
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);
using var stored = new MemoryStream(bytes);
await _documentStorage.SaveAsync(session.Id, dispatchId, storedFileName, stored, cancellationToken);
@ -992,35 +1110,6 @@ 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,7 +72,12 @@ namespace SeaHaven.Services.Implementation
pageSize,
sortBy,
query.SortDir ?? "asc",
accountId);
accountId,
WorkOrderSearchDateRangeResolver.ResolveIncludeDateless(
query.IncludeDateless,
query.DatePreset,
query.DateFrom,
query.DateTo));
var result = await _searchDataService.SearchAsync(dataQuery);
var utcNow = DateTime.UtcNow;

View file

@ -16,17 +16,20 @@ 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)
IFileStoragePort fileStorage,
IUpliftDataService upliftData)
{
_mediaData = mediaData;
_detailData = detailData;
_auditService = auditService;
_fileStorage = fileStorage;
_upliftData = upliftData;
}
public async Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(
@ -153,23 +156,34 @@ namespace SeaHaven.Services.Implementation
};
}
var attachment = new WorkOrderAttachments
{
WorkorderId = workOrderId,
Attachments = fileUrl,
Category = category.HasValue ? resolvedCategory : null,
CreatedDate = DateTime.UtcNow,
createdby = actorId
};
_mediaData.TrackAttachment(attachment);
await _auditService.StageFieldChangedAsync(
// 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(
workOrderId,
"MediaCategory",
null,
FormatMediaAuditValue(null, (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString()),
actorId);
await SaveMediaAsync(cancellationToken);
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);
return new WorkOrderMediaFileDto
{
@ -348,6 +362,29 @@ 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.
@ -432,6 +469,7 @@ 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

@ -0,0 +1,336 @@
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

@ -0,0 +1,112 @@
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

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

View file

@ -451,6 +451,57 @@ 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,7 +41,8 @@ public class WorkOrderCompletedSelectiveLockTests
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit,
new TestFileStoragePort());
new TestFileStoragePort(),
new UpliftDataService(context));
return (context, service);
}

View file

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

View file

@ -1,6 +1,8 @@
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;
@ -812,7 +814,8 @@ public class WorkOrderMediaServiceTests
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit,
fileStorage ?? new TestFileStoragePort());
fileStorage ?? new TestFileStoragePort(),
new UpliftDataService(context));
return (context, service);
}
@ -1309,6 +1312,410 @@ 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()
{
@ -1968,6 +2375,32 @@ 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/"):
if normalized.startswith((".ebextensions/", ".platform/")):
return True
return normalized.endswith(APP_SUFFIXES)

View file

@ -8,6 +8,7 @@
# ./ 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
@ -137,6 +138,10 @@ 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,6 +54,17 @@ 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,13 +15,15 @@ die() {
contents_file="$(mktemp)"
webhook_file="$(mktemp)"
trap 'rm -f "$contents_file" "$webhook_file"' EXIT
nginx_file="$(mktemp)"
trap 'rm -f "$contents_file" "$webhook_file" "$nginx_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"
@ -30,5 +32,9 @@ 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 ' ')"