diff --git a/e2e/vendors/vendor-uplift-workflow.spec.ts b/e2e/vendors/vendor-uplift-workflow.spec.ts
index f23f3ee1..9f87208e 100644
--- a/e2e/vendors/vendor-uplift-workflow.spec.ts
+++ b/e2e/vendors/vendor-uplift-workflow.spec.ts
@@ -97,7 +97,7 @@ test("vendor uploads scanned evidence and submits a dispatch-scoped uplift", asy
await page.getByLabel("New total NTE").fill("125");
await page.getByLabel("Reason").fill("Replacement compressor");
- await page.getByLabel("Evidence (PDF/JPG/PNG)").setInputFiles({
+ await page.getByLabel("Evidence (PDF, JPG, PNG, HEIC, MP4, MOV)").setInputFiles({
name: "estimate.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4\nmock estimate"),
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx
index 24e468d1..cd85ad9e 100644
--- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx
@@ -5,10 +5,12 @@ import {
} from "@/app/(protected)/workorders/_components/media/media-uploader";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
+import type { WorkOrderMediaKindCounts } from "@/domain/work-orders/utils/work-order-media-limits";
type SlideOverCompletionMediaProps = {
isReadOnly: boolean;
media: MediaFile[];
+ mediaCounts?: WorkOrderMediaKindCounts;
mediaPending: boolean;
onUploadMedia?: (requests: MediaUploadRequest[]) => void;
onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void;
@@ -20,6 +22,7 @@ type SlideOverCompletionMediaProps = {
export function SlideOverCompletionMedia({
isReadOnly,
media,
+ mediaCounts,
mediaPending,
onUploadMedia,
onCategorizeMedia,
@@ -38,6 +41,7 @@ export function SlideOverCompletionMedia({
void;
@@ -39,6 +41,7 @@ export function SlideOverCompletionTab({
isReadOnly,
completion,
media,
+ mediaCounts,
mediaPending = false,
completionPending = false,
onUploadMedia,
@@ -59,6 +62,7 @@ export function SlideOverCompletionTab({
= {
@@ -53,11 +57,16 @@ type SlideOverExtrasTabProps = {
pending?: boolean;
avetaRequired?: boolean;
workOrderId?: string | number;
+ /** Photos/videos on the whole work order; Extra Docs shows only its own share. */
+ mediaCounts?: WorkOrderMediaKindCounts;
onUpload: (files: File[]) => void;
onRemove: (id: string) => void;
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
};
+const UNSUPPORTED_TYPE_MESSAGE =
+ "Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV. Documents: PDF, DOC.";
+
async function openExtraDocContent(workOrderId: string | number, mediaId: string): Promise {
const tab = window.open("about:blank", "_blank");
if (!tab) {
@@ -176,27 +185,38 @@ export function SlideOverExtrasTab({
pending = false,
avetaRequired = false,
workOrderId,
+ mediaCounts,
onUpload,
onRemove,
onCategorize,
}: SlideOverExtrasTabProps) {
const inputRef = useRef(null);
const avetaAttached = hasExplicitAvetaFile(uploads);
+ // One selection at a time: a second pick during the async screen would read the
+ // same photo/video counts and could pass the per-work-order limit.
+ const screeningRef = useRef(false);
+ const [screening, setScreening] = useState(false);
+ const busy = pending || screening;
- const addFiles = (files: FileList | null) => {
- if (isReadOnly || pending || !files || files.length === 0) return;
- const accepted: File[] = [];
- for (const file of Array.from(files)) {
- if (!isWorkOrderExtraDocTypeAllowed(file)) {
- toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV, PDF, DOC).`);
- continue;
- }
- if (isWorkOrderFileTooLarge(file)) {
- toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
- continue;
- }
- accepted.push(file);
+ const addFiles = async (files: FileList | null) => {
+ if (isReadOnly || pending || screeningRef.current || !files || files.length === 0) return;
+ // Snapshot synchronously: the input is reset right after this handler returns.
+ const selected = Array.from(files);
+ screeningRef.current = true;
+ setScreening(true);
+ let screened: WorkOrderUploadScreenResult;
+ try {
+ screened = await screenWorkOrderUploads(selected, {
+ existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
+ isTypeAllowed: isWorkOrderExtraDocTypeAllowed,
+ unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
+ });
+ } finally {
+ screeningRef.current = false;
+ setScreening(false);
}
+ const { accepted, rejections } = screened;
+ for (const message of rejections) toast.error(message);
if (accepted.length > 0) {
onUpload(accepted);
}
@@ -228,12 +248,12 @@ export function SlideOverExtrasTab({
<>
{
- addFiles(event.target.files);
+ void addFiles(event.target.files);
event.target.value = "";
}}
/>
diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts
index 5bde5578..d4e26b70 100644
--- a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts
+++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts
@@ -17,6 +17,7 @@ import {
isExtrasSurfaceMedia,
type MediaSurfaceOwnership,
} from "@/domain/work-orders/utils/partition-slide-over-media";
+import { countWorkOrderMediaKinds } from "@/domain/work-orders/utils/work-order-media-limits";
type UseSlideOverMediaStateArgs = {
detailForRow?: WorkOrderBoardDetail;
@@ -130,6 +131,9 @@ export function useSlideOverMediaState({
[detailForRow?.media, ownership, completionHasGallery],
);
+ // Per-work-order photo/video counts span both tabs, so count the whole session.
+ const mediaCounts = useMemo(() => countWorkOrderMediaKinds(uploads), [uploads]);
+
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
const mediaPending =
mediaBatchPending ||
@@ -196,6 +200,7 @@ export function useSlideOverMediaState({
return {
completionMedia,
extrasUploads,
+ mediaCounts,
mediaPending,
uploadMediaRequests,
uploadExtraFiles,
diff --git a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts
index f9263dd1..78e95ac2 100644
--- a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts
+++ b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts
@@ -118,6 +118,7 @@ export function useWorkOrderSlideOver({
const {
completionMedia,
extrasUploads,
+ mediaCounts,
mediaPending,
uploadMediaRequests,
uploadExtraFiles,
@@ -174,6 +175,7 @@ export function useWorkOrderSlideOver({
...readFlags,
completionMedia,
extrasUploads,
+ mediaCounts,
mediaPending,
completionPending,
closeDisabled,
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx
index 7337ace1..12d015fa 100644
--- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx
+++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx
@@ -125,6 +125,7 @@ export function WorkOrderSlideOverTabs({
isReadOnly={state.isDocumentsReadOnly}
completion={state.detailForRow?.completion}
media={state.completionMedia}
+ mediaCounts={state.mediaCounts}
mediaPending={state.mediaPending}
completionPending={state.completionPending}
onUploadMedia={(requests) => {
@@ -151,6 +152,7 @@ export function WorkOrderSlideOverTabs({
>
void;
};
+function mediaContractHint(): string {
+ const photoLimit = Math.round(MAX_WORK_ORDER_PHOTO_BYTES / 1_000_000);
+ const videoLimit = Math.round(MAX_WORK_ORDER_VIDEO_BYTES / 1_000_000);
+ return `Photos up to ${photoLimit} MB (max ${MAX_PHOTOS_PER_WORK_ORDER}) · Videos up to ${videoLimit} MB and ${MAX_VIDEO_DURATION_SECONDS} s (max ${MAX_VIDEOS_PER_WORK_ORDER})`;
+}
+
export function MediaUploaderDropzone({
inputRef,
pending,
@@ -41,7 +51,7 @@ export function MediaUploaderDropzone({
{pending ? "Uploading…" : "Drag files here or click to browse"}
- Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit().replace(" ", "")} per file
+ Accepted: JPG, PNG, HEIC, MP4, MOV · {mediaContractHint()}
{categorizeAfterUpload ? " · categorize after upload" : ""}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader.tsx b/src/app/(protected)/workorders/_components/media/media-uploader.tsx
index aed81332..0bc1592c 100644
--- a/src/app/(protected)/workorders/_components/media/media-uploader.tsx
+++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx
@@ -1,14 +1,18 @@
-import { useRef } from "react";
+import { useRef, useState } from "react";
import { toast } from "react-toastify";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import {
canDeleteWorkOrderMedia,
- formatMediaSizeLimit,
- isWorkOrderFileTooLarge,
+ countWorkOrderMediaKinds,
isWorkOrderMediaTypeAllowed,
+ type WorkOrderMediaKindCounts,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
+import {
+ screenWorkOrderUploads,
+ type WorkOrderUploadScreenResult,
+} from "@/domain/work-orders/utils/screen-work-order-uploads";
import { MediaUploaderDropzone } from "./media-uploader-dropzone";
import { MediaUploaderGrid } from "./media-uploader-grid";
@@ -30,8 +34,12 @@ type MediaUploaderProps = {
onChange?: (next: MediaFile[]) => void;
readOnly?: boolean;
pending?: boolean;
+ /** Photos/videos on the whole work order when `uploads` is only one surface's share. */
+ mediaCounts?: WorkOrderMediaKindCounts;
};
+const UNSUPPORTED_TYPE_MESSAGE = "Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV.";
+
export function MediaUploader({
uploads,
onUpload,
@@ -42,24 +50,35 @@ export function MediaUploader({
onChange,
readOnly = false,
pending = false,
+ mediaCounts,
}: MediaUploaderProps) {
const inputRef = useRef(null);
+ // One selection at a time: a second pick during the async screen would read the
+ // same photo/video counts and could pass the per-work-order limit.
+ const screeningRef = useRef(false);
+ const [screening, setScreening] = useState(false);
- const addFiles = (files: FileList | null) => {
- if (readOnly || pending || !files || files.length === 0) return;
+ const addFiles = async (files: FileList | null) => {
+ if (readOnly || pending || screeningRef.current || !files || files.length === 0) return;
- const accepted: File[] = [];
- for (const file of Array.from(files)) {
- if (!isWorkOrderMediaTypeAllowed(file)) {
- toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
- continue;
- }
- if (isWorkOrderFileTooLarge(file)) {
- toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
- continue;
- }
- accepted.push(file);
+ // Snapshot synchronously: the caller resets the input value right after this
+ // handler returns, which can detach a live FileList across awaits.
+ const selected = Array.from(files);
+ screeningRef.current = true;
+ setScreening(true);
+ let screened: WorkOrderUploadScreenResult;
+ try {
+ screened = await screenWorkOrderUploads(selected, {
+ existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
+ isTypeAllowed: isWorkOrderMediaTypeAllowed,
+ unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
+ });
+ } finally {
+ screeningRef.current = false;
+ setScreening(false);
}
+ const { accepted, rejections } = screened;
+ for (const message of rejections) toast.error(message);
if (accepted.length === 0) return;
if (onUpload) {
@@ -93,9 +112,9 @@ export function MediaUploader({
{!readOnly && (
void addFiles(files)}
/>
)}
@@ -68,6 +69,7 @@ export function DispatchDetailSections({
dispatchId={dispatchId}
currentNte={data.nteAmount}
upliftRequests={data.upliftRequests ?? []}
+ mediaCounts={data.mediaCounts}
locked={locked}
onChanged={refresh}
pollDocument={pollEvidenceDocument}
diff --git a/src/app/v/[token]/dispatch/_components/uplift-request-card.tsx b/src/app/v/[token]/dispatch/_components/uplift-request-card.tsx
index c9f4845e..f9b6dd8f 100644
--- a/src/app/v/[token]/dispatch/_components/uplift-request-card.tsx
+++ b/src/app/v/[token]/dispatch/_components/uplift-request-card.tsx
@@ -1,6 +1,7 @@
import { useState } from "react";
import { Text } from "@/components/ui/text";
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
+import type { VendorMediaUsage } from "@/domain/vendor-portal/lib/document-validation";
import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation";
import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal";
import { formatDateTime } from "@/lib/time-utils";
@@ -15,6 +16,7 @@ type UpliftRequestCardProps = {
onWithdraw: (request: VendorPortalUpliftRequest) => void;
onRevise: (request: VendorPortalUpliftRequest, values: UpliftFormValues) => void;
onCancelRevise: () => void;
+ mediaUsage?: VendorMediaUsage;
};
const WITHDRAWABLE_STATUSES = new Set(["Pending", "ChangesRequested"]);
@@ -33,6 +35,7 @@ export function UpliftRequestCard({
onWithdraw,
onRevise,
onCancelRevise,
+ mediaUsage,
}: UpliftRequestCardProps) {
const [revising, setRevising] = useState(false);
const canWithdraw = !locked && canAct && WITHDRAWABLE_STATUSES.has(request.status);
@@ -100,6 +103,7 @@ export function UpliftRequestCard({
initialRequested={String(request.requestedNTE)}
initialReason={request.vendorReason ?? ""}
hasExistingEvidence={request.evidenceDocumentId != null}
+ mediaUsage={mediaUsage}
submitting={submitting}
uploadingPercent={uploadingPercent}
errorMessage=""
diff --git a/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx b/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx
index 22ddbffe..0ee1744a 100644
--- a/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx
+++ b/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx
@@ -1,6 +1,10 @@
-import { useState } from "react";
+import { useRef, useState } from "react";
import { Text } from "@/components/ui/text";
-import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation";
+import {
+ validateVendorDocument,
+ VENDOR_UPLOAD_ACCEPT,
+ type VendorMediaUsage,
+} from "@/domain/vendor-portal/lib/document-validation";
import {
isApprovalRequired,
validateUpliftAmount,
@@ -13,6 +17,8 @@ type UpliftRequestFormProps = {
initialRequested?: string;
initialReason?: string;
hasExistingEvidence?: boolean;
+ /** Work-order photo/video usage for the evidence pre-check. */
+ mediaUsage?: VendorMediaUsage;
submitting: boolean;
uploadingPercent: number | undefined;
errorMessage: string;
@@ -26,6 +32,7 @@ export function UpliftRequestForm({
initialRequested = "",
initialReason = "",
hasExistingEvidence = false,
+ mediaUsage,
submitting,
uploadingPercent,
errorMessage,
@@ -36,11 +43,15 @@ export function UpliftRequestForm({
const [reason, setReason] = useState(initialReason);
const [evidenceFile, setEvidenceFile] = useState();
const [formError, setFormError] = useState("");
+ const [checkingEvidence, setCheckingEvidence] = useState(false);
+ const latestSelection = useRef(0);
const showApprovalFields = mode === "revise" || isApprovalRequired(requested, currentNte);
const submit = (event: React.FormEvent) => {
event.preventDefault();
+ // The picked file is not in state until its check settles; submitting now would validate the old one.
+ if (checkingEvidence) return;
const amount = Number.parseFloat(requested);
const validationError = validateUpliftAmount(requested, currentNte, reason, {
file: evidenceFile,
@@ -54,10 +65,15 @@ export function UpliftRequestForm({
onSubmit({ amount, reason: reason.trim(), evidenceFile });
};
- const chooseFile = (file?: File) => {
- const validationMessage = file ? validateVendorDocument(file) : undefined;
+ const chooseFile = async (file?: File) => {
+ const selection = ++latestSelection.current;
+ setCheckingEvidence(Boolean(file));
+ const validationMessage = file ? await validateVendorDocument(file, mediaUsage) : undefined;
+ // A newer pick may have settled while this one's video metadata was still loading.
+ if (selection !== latestSelection.current) return;
setFormError(validationMessage ?? "");
setEvidenceFile(file);
+ setCheckingEvidence(false);
};
const shownError = errorMessage || formError;
@@ -112,14 +128,16 @@ export function UpliftRequestForm({
-
+
chooseFile(event.target.files?.[0])}
+ accept={VENDOR_UPLOAD_ACCEPT}
+ onChange={(event) => void chooseFile(event.target.files?.[0])}
/>
>
@@ -134,8 +152,14 @@ export function UpliftRequestForm({
{shownError}
-
)}
>
diff --git a/src/app/v/_components/vendor-portal.css b/src/app/v/_components/vendor-portal.css
index 1e57cb16..e670f930 100644
--- a/src/app/v/_components/vendor-portal.css
+++ b/src/app/v/_components/vendor-portal.css
@@ -190,6 +190,13 @@
margin-top: 12px;
}
+.vp-hint {
+ align-self: center;
+ flex-basis: 100%;
+ font-size: 12px;
+ color: var(--muted-foreground);
+}
+
.vp-checklist-item {
display: flex;
align-items: center;
diff --git a/src/domain/vendor-portal/lib/document-validation.ts b/src/domain/vendor-portal/lib/document-validation.ts
index 3c1f5203..ee7969ab 100644
--- a/src/domain/vendor-portal/lib/document-validation.ts
+++ b/src/domain/vendor-portal/lib/document-validation.ts
@@ -1,12 +1,129 @@
-const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024;
-const ALLOWED_DOCUMENT_TYPES = new Set(["application/pdf", "image/jpeg", "image/png"]);
+import type { VendorPortalMediaCounts } from "@/domain/vendor-portal/types/vendor-portal";
+import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
-export function validateVendorDocument(file: File): string | undefined {
- if (!ALLOWED_DOCUMENT_TYPES.has(file.type)) {
- return "Only PDF, JPG, and PNG files are allowed.";
+/**
+ * Client-confirmed media contract on the vendor portal: photos up to
+ * 10 MB (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV).
+ * PDF documents keep the previous 10 MB document cap.
+ */
+const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024;
+const MAX_PHOTO_BYTES = 10_000_000;
+const MAX_VIDEO_BYTES = 100_000_000;
+const MAX_VIDEO_DURATION_SECONDS = 90;
+
+const UNSUPPORTED_TYPE_MESSAGE = "Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.";
+
+/** File-picker filter for every vendor portal upload, matching the allowlist below. */
+export const VENDOR_UPLOAD_ACCEPT =
+ ".pdf,.jpg,.jpeg,.png,.heic,.mp4,.mov,application/pdf,image/jpeg,image/png,image/heic,video/mp4,video/quicktime";
+
+type VendorUploadKind = "document" | "photo" | "video";
+
+// Mirrors the server allowlist: an allowlisted browser type wins, otherwise the
+// extension decides (mobile browsers often send an empty or octet-stream type).
+const KIND_BY_MIME: Record = {
+ "application/pdf": "document",
+ "image/jpeg": "photo",
+ "image/jpg": "photo",
+ "image/png": "photo",
+ "image/heic": "photo",
+ "video/mp4": "video",
+ "video/quicktime": "video",
+};
+
+const KIND_BY_EXTENSION: Record = {
+ pdf: "document",
+ jpg: "photo",
+ jpeg: "photo",
+ png: "photo",
+ heic: "photo",
+ mp4: "video",
+ mov: "video",
+};
+
+function resolveKind(file: File): VendorUploadKind | null {
+ const name = file.name.toLowerCase();
+ const extension = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : "";
+ return KIND_BY_MIME[file.type.toLowerCase()] ?? KIND_BY_EXTENSION[extension] ?? null;
+}
+
+/** Photos/videos already counted against the work order for one upload, with the limits. */
+export type VendorMediaUsage = {
+ photos: number;
+ videos: number;
+ maxPhotos: number;
+ maxVideos: number;
+};
+
+/**
+ * The count basis the server uses for this upload. A completion upload replaces a
+ * document: the latest one by default (already excluded in `completion*`), or the
+ * one the vendor picked, which is not counted when it is the same kind. Evidence
+ * uploads count everything. Undefined when the backend does not report counts.
+ */
+export function vendorMediaUsage(
+ counts: VendorPortalMediaCounts | null | undefined,
+ purpose: "completion" | "evidence",
+ replacedContentType?: string,
+): VendorMediaUsage | undefined {
+ if (!counts) return undefined;
+ const limits = { maxPhotos: counts.maxPhotos, maxVideos: counts.maxVideos };
+ if (purpose === "evidence") {
+ return { ...limits, photos: counts.photos, videos: counts.videos };
+ }
+ if (replacedContentType === undefined) {
+ return { ...limits, photos: counts.completionPhotos, videos: counts.completionVideos };
+ }
+ const replacedKind = KIND_BY_MIME[replacedContentType.toLowerCase()];
+ return {
+ ...limits,
+ photos: Math.max(0, counts.photos - (replacedKind === "photo" ? 1 : 0)),
+ videos: Math.max(0, counts.videos - (replacedKind === "video" ? 1 : 0)),
+ };
+}
+
+function countRejection(kind: VendorUploadKind, usage: VendorMediaUsage): string | undefined {
+ if (kind === "photo" && usage.photos >= usage.maxPhotos) {
+ return `A work order can have at most ${usage.maxPhotos} photos.`;
+ }
+ if (kind === "video" && usage.videos >= usage.maxVideos) {
+ return `A work order can have at most ${usage.maxVideos} videos.`;
+ }
+ return undefined;
+}
+
+export async function validateVendorDocument(
+ file: File,
+ usage?: VendorMediaUsage,
+): Promise {
+ const kind = resolveKind(file);
+ if (!kind) {
+ return UNSUPPORTED_TYPE_MESSAGE;
}
- if (file.size > MAX_DOCUMENT_BYTES) {
+ const countMessage = usage && countRejection(kind, usage);
+ if (countMessage) {
+ return countMessage;
+ }
+
+ if (kind === "photo" && file.size > MAX_PHOTO_BYTES) {
+ return "Photos must be 10 MB or smaller.";
+ }
+
+ if (kind === "video") {
+ if (file.size > MAX_VIDEO_BYTES) {
+ return "Videos must be 100 MB or smaller.";
+ }
+ // Pre-check; the server enforces it too. Unreadable metadata never blocks.
+ const duration = await probeVideoDurationSeconds(file);
+ if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) {
+ return `Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`;
+ }
+ }
+
+ if (kind === "document" && file.size > MAX_DOCUMENT_BYTES) {
return "Files must be 10 MB or smaller.";
}
+
+ return undefined;
}
diff --git a/src/domain/vendor-portal/lib/uplift-validation.ts b/src/domain/vendor-portal/lib/uplift-validation.ts
index b8fafe04..7b3187ed 100644
--- a/src/domain/vendor-portal/lib/uplift-validation.ts
+++ b/src/domain/vendor-portal/lib/uplift-validation.ts
@@ -48,7 +48,7 @@ export function validateUpliftAmount(
return "Please include a reason for the uplift request.";
}
if (!options.file && !options.hasExisting) {
- return "Attach PDF, JPG, or PNG evidence — approval-required requests need evidence.";
+ return "Attach PDF, JPG, PNG, HEIC, MP4, or MOV evidence — approval-required requests need evidence.";
}
return "";
}
diff --git a/src/domain/vendor-portal/types/vendor-portal.ts b/src/domain/vendor-portal/types/vendor-portal.ts
index d9853b07..ec7da972 100644
--- a/src/domain/vendor-portal/types/vendor-portal.ts
+++ b/src/domain/vendor-portal/types/vendor-portal.ts
@@ -23,6 +23,16 @@ export interface VendorPortalDispatchSummary {
refusedAt?: string;
}
+/** Counts for a new completion version exclude the document it replaces. */
+export interface VendorPortalMediaCounts {
+ maxPhotos: number;
+ maxVideos: number;
+ photos: number;
+ videos: number;
+ completionPhotos: number;
+ completionVideos: number;
+}
+
export interface VendorPortalDocument {
id: number;
originalFileName: string;
@@ -137,4 +147,6 @@ export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary
upliftRequests: VendorPortalUpliftRequest[];
signoffs: VendorPortalSignoff[];
documents: VendorPortalDocument[];
+ /** Per-work-order photo/video usage; absent until the backend reports it. */
+ mediaCounts?: VendorPortalMediaCounts | null;
}
diff --git a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts
index 54b333ed..a1034796 100644
--- a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts
+++ b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts
@@ -233,7 +233,7 @@ function cryptoRandomId(): string {
function classifyFileName(fileName: string, url: string): FileCategory {
const name = `${fileName} ${url}`.toLowerCase();
- if (/\.(jpe?g|png|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) {
+ if (/\.(jpe?g|png|heic|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) {
return "image";
}
if (/\.(mp4|mov|webm|m4v)(\?|$)/i.test(name) || name.includes("video/")) {
diff --git a/src/domain/work-orders/utils/screen-work-order-uploads.ts b/src/domain/work-orders/utils/screen-work-order-uploads.ts
new file mode 100644
index 00000000..49988f80
--- /dev/null
+++ b/src/domain/work-orders/utils/screen-work-order-uploads.ts
@@ -0,0 +1,64 @@
+import {
+ MAX_PHOTOS_PER_WORK_ORDER,
+ MAX_VIDEOS_PER_WORK_ORDER,
+ MAX_VIDEO_DURATION_SECONDS,
+ resolveWorkOrderUploadKind,
+ workOrderFileSizeRejection,
+ type WorkOrderMediaKindCounts,
+} from "@/domain/work-orders/utils/work-order-media-limits";
+import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
+
+export type WorkOrderUploadScreen = {
+ /** Photos and videos already on the work order, across every surface. */
+ existing: WorkOrderMediaKindCounts;
+ isTypeAllowed: (file: File) => boolean;
+ unsupportedTypeMessage: string;
+};
+
+export type WorkOrderUploadScreenResult = { accepted: File[]; rejections: string[] };
+
+/**
+ * Browser pre-check shared by every dispatcher upload surface: type, size,
+ * per-work-order photo/video count (including earlier files in the same selection)
+ * and video duration. The server enforces the same contract; a video whose duration
+ * the browser cannot read is never blocked here.
+ */
+export async function screenWorkOrderUploads(
+ files: readonly File[],
+ { existing, isTypeAllowed, unsupportedTypeMessage }: WorkOrderUploadScreen,
+): Promise {
+ const counts = { ...existing };
+ const accepted: File[] = [];
+ const rejections: string[] = [];
+ for (const file of files) {
+ if (!isTypeAllowed(file)) {
+ rejections.push(unsupportedTypeMessage);
+ continue;
+ }
+ const sizeRejection = workOrderFileSizeRejection(file);
+ if (sizeRejection) {
+ rejections.push(sizeRejection);
+ continue;
+ }
+ const kind = resolveWorkOrderUploadKind(file);
+ if (kind === "photo" && counts.photos >= MAX_PHOTOS_PER_WORK_ORDER) {
+ rejections.push(`A work order can have at most ${MAX_PHOTOS_PER_WORK_ORDER} photos.`);
+ continue;
+ }
+ if (kind === "video" && counts.videos >= MAX_VIDEOS_PER_WORK_ORDER) {
+ rejections.push(`A work order can have at most ${MAX_VIDEOS_PER_WORK_ORDER} videos.`);
+ continue;
+ }
+ if (kind === "video") {
+ const duration = await probeVideoDurationSeconds(file);
+ if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) {
+ rejections.push(`Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`);
+ continue;
+ }
+ }
+ if (kind === "photo") counts.photos += 1;
+ if (kind === "video") counts.videos += 1;
+ accepted.push(file);
+ }
+ return { accepted, rejections };
+}
diff --git a/src/domain/work-orders/utils/work-order-media-limits.ts b/src/domain/work-orders/utils/work-order-media-limits.ts
index 00d9d261..0dd7cd49 100644
--- a/src/domain/work-orders/utils/work-order-media-limits.ts
+++ b/src/domain/work-orders/utils/work-order-media-limits.ts
@@ -1,20 +1,38 @@
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
-/** Backend RequestSizeLimit for media uploads and Extra Docs. */
-export const MAX_WORK_ORDER_MEDIA_BYTES = 200_000_000;
+/**
+ * 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. Applies to the Photos & Videos modal,
+ * the Completion Doc → Media tab and the vendor portal upload. Duration is
+ * pre-checked here and enforced by the server from the MP4/MOV header;
+ * unreadable metadata never blocks. Documents
+ * keep the 50 MB document cap; the signed completion PDF keeps its own cap.
+ */
+export const MAX_WORK_ORDER_PHOTO_BYTES = 10_000_000;
+export const MAX_WORK_ORDER_VIDEO_BYTES = 100_000_000;
+export const MAX_WORK_ORDER_DOCUMENT_BYTES = 50_000_000;
-/** Backend RequestSizeLimit for completion-document uploads. */
+/** Backend RequestSizeLimit for completion-document (signed PDF) uploads. */
export const MAX_WORK_ORDER_COMPLETION_DOC_BYTES = 50_000_000;
+/** Largest single upload the media endpoint accepts (RequestSizeLimit ceiling). */
+export const MAX_WORK_ORDER_MEDIA_REQUEST_BYTES = MAX_WORK_ORDER_VIDEO_BYTES;
+
+export const MAX_VIDEO_DURATION_SECONDS = 90;
+export const MAX_PHOTOS_PER_WORK_ORDER = 10;
+export const MAX_VIDEOS_PER_WORK_ORDER = 3;
+
const MEDIA_MIME_TYPES = new Set([
"image/jpeg",
"image/jpg",
"image/png",
+ "image/heic",
"video/mp4",
"video/quicktime",
]);
-const MEDIA_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]);
+const MEDIA_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".heic", ".mp4", ".mov"]);
const EXTRA_DOC_MIME_TYPES = new Set([
...MEDIA_MIME_TYPES,
@@ -25,6 +43,8 @@ const EXTRA_DOC_MIME_TYPES = new Set([
const EXTRA_DOC_EXTENSIONS = new Set([...MEDIA_EXTENSIONS, ".pdf", ".doc", ".docx"]);
+export type WorkOrderUploadKind = "photo" | "video" | "document";
+
function fileExtension(file: File): string {
const name = file.name.toLowerCase();
const dot = name.lastIndexOf(".");
@@ -37,11 +57,58 @@ function isTypeAllowed(file: File, mimes: Set, extensions: Set):
return mimeOk || extOk;
}
-export function isWorkOrderFileTooLarge(
- file: File,
- maxBytes = MAX_WORK_ORDER_MEDIA_BYTES,
-): boolean {
- return file.size > maxBytes;
+const KIND_BY_MIME: Record = {
+ "image/jpeg": "photo",
+ "image/jpg": "photo",
+ "image/png": "photo",
+ "image/heic": "photo",
+ "video/mp4": "video",
+ "video/quicktime": "video",
+};
+
+const KIND_BY_EXTENSION: Record = {
+ ".jpg": "photo",
+ ".jpeg": "photo",
+ ".png": "photo",
+ ".heic": "photo",
+ ".mp4": "video",
+ ".mov": "video",
+};
+
+/**
+ * Same rule as the server: an allowlisted browser type decides, otherwise the
+ * extension (mobile browsers often send an empty, octet-stream or foreign type).
+ */
+export function resolveWorkOrderUploadKind(file: File): WorkOrderUploadKind {
+ return (
+ KIND_BY_MIME[file.type.toLowerCase()] ?? KIND_BY_EXTENSION[fileExtension(file)] ?? "document"
+ );
+}
+
+function workOrderUploadKindLimit(kind: WorkOrderUploadKind): number {
+ if (kind === "photo") return MAX_WORK_ORDER_PHOTO_BYTES;
+ if (kind === "video") return MAX_WORK_ORDER_VIDEO_BYTES;
+ return MAX_WORK_ORDER_DOCUMENT_BYTES;
+}
+
+export function isWorkOrderFileTooLarge(file: File, maxBytes?: number): boolean {
+ const limit = maxBytes ?? workOrderUploadKindLimit(resolveWorkOrderUploadKind(file));
+ return file.size > limit;
+}
+
+/** Stable per-kind rejection message; never echoes file metadata. */
+export function workOrderFileSizeRejection(file: File): string | null {
+ const kind = resolveWorkOrderUploadKind(file);
+ if (kind === "photo" && file.size > MAX_WORK_ORDER_PHOTO_BYTES) {
+ return "Photos must be 10 MB or smaller.";
+ }
+ if (kind === "video" && file.size > MAX_WORK_ORDER_VIDEO_BYTES) {
+ return "Videos must be 100 MB or smaller.";
+ }
+ if (kind === "document" && file.size > MAX_WORK_ORDER_DOCUMENT_BYTES) {
+ return "Documents must be 50 MB or smaller.";
+ }
+ return null;
}
export function isWorkOrderMediaTypeAllowed(file: File): boolean {
@@ -53,17 +120,34 @@ export function isWorkOrderExtraDocTypeAllowed(file: File): boolean {
}
export function workOrderMediaAcceptAttribute(): string {
- return "image/jpeg,image/jpg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov";
+ return "image/jpeg,image/jpg,image/png,image/heic,video/mp4,video/quicktime,.jpg,.jpeg,.png,.heic,.mp4,.mov";
}
export function workOrderExtraDocsAcceptAttribute(): string {
return `${workOrderMediaAcceptAttribute()},.pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document`;
}
-export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string {
+export function formatMediaSizeLimit(maxBytes: number): string {
return `${Math.round(maxBytes / 1_000_000)} MB`;
}
+export type WorkOrderMediaKindCounts = { photos: number; videos: number };
+
+/** Counts the gallery items the server's per-work-order counts cover (non-legacy rows). */
+export function countWorkOrderMediaKinds(
+ uploads: Array<{ fileType?: string; isLegacy?: boolean; uploadStatus?: string }>,
+): WorkOrderMediaKindCounts {
+ return uploads.reduce(
+ (counts, item) => {
+ if (item.isLegacy || item.uploadStatus === "failed") return counts;
+ if (item.fileType === "image") counts.photos += 1;
+ if (item.fileType === "video") counts.videos += 1;
+ return counts;
+ },
+ { photos: 0, videos: 0 },
+ );
+}
+
/** Completion-doc / completion-media lock: Canceled or Completed. Extra Docs use canMutateWorkOrderExtraDocs. */
export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean {
return isWorkOrderCompletionSurfaceReadOnly(status);
diff --git a/src/domain/work-orders/utils/work-order-media-utils.ts b/src/domain/work-orders/utils/work-order-media-utils.ts
index 4deee9a0..42c77c1b 100644
--- a/src/domain/work-orders/utils/work-order-media-utils.ts
+++ b/src/domain/work-orders/utils/work-order-media-utils.ts
@@ -3,6 +3,7 @@ import type {
MediaCategory,
MediaFile,
} from "@/domain/work-orders/types/work-order-media";
+import { resolveWorkOrderUploadKind } from "@/domain/work-orders/utils/work-order-media-limits";
let mediaIdCounter = 0;
@@ -16,6 +17,10 @@ export function classifyUpload(f: File): {
if (t === "application/pdf" || /\.pdf$/i.test(f.name)) {
return { fileType: "pdf", category: "Report" };
}
+ // Phones often send an empty or octet-stream type; fall back to the extension.
+ const kind = resolveWorkOrderUploadKind(f);
+ if (kind === "photo") return { fileType: "image", category: "Photo" };
+ if (kind === "video") return { fileType: "video", category: "Video" };
return { fileType: "other", category: "Other" };
}
diff --git a/src/lib/probe-video-duration.ts b/src/lib/probe-video-duration.ts
new file mode 100644
index 00000000..f4e019fe
--- /dev/null
+++ b/src/lib/probe-video-duration.ts
@@ -0,0 +1,75 @@
+/**
+ * Best-effort video duration probe for MP4/MOV. Reads the movie header
+ * (`moov` → `mvhd`) the same way the server does, seeking over box headers so a
+ * phone file with `moov` after a large `mdat` costs only a few small reads.
+ * Resolves null when the header cannot be read — callers must not block the
+ * upload in that case (the server enforces the limit from the same
+ * header and also lets unreadable metadata through).
+ */
+const MAX_BOXES_PER_LEVEL = 1024;
+
+type Box = { bodyStart: number; end: number };
+
+async function readView(file: Blob, start: number, length: number): Promise {
+ if (start < 0 || length <= 0 || start + length > file.size) return null;
+ const buffer = await file.slice(start, start + length).arrayBuffer();
+ return buffer.byteLength === length ? new DataView(buffer) : null;
+}
+
+function boxType(view: DataView): string {
+ return String.fromCharCode(
+ view.getUint8(4),
+ view.getUint8(5),
+ view.getUint8(6),
+ view.getUint8(7),
+ );
+}
+
+async function findBox(file: Blob, start: number, end: number, type: string): Promise {
+ let offset = start;
+ for (let index = 0; index < MAX_BOXES_PER_LEVEL && offset + 8 <= end; index += 1) {
+ const header = await readView(file, offset, Math.min(16, end - offset));
+ if (!header) return null;
+ let size = header.getUint32(0);
+ let headerLength = 8;
+ if (size === 1) {
+ if (header.byteLength < 16) return null;
+ const largeSize = header.getBigUint64(8);
+ if (largeSize > BigInt(Number.MAX_SAFE_INTEGER)) return null;
+ size = Number(largeSize);
+ headerLength = 16;
+ } else if (size === 0) {
+ size = end - offset;
+ }
+ if (size < headerLength || offset + size > end) return null;
+ if (boxType(header) === type) {
+ return { bodyStart: offset + headerLength, end: offset + size };
+ }
+ offset += size;
+ }
+ return null;
+}
+
+async function readMovieHeaderSeconds(file: Blob, mvhd: Box): Promise {
+ const version = await readView(file, mvhd.bodyStart, 1);
+ if (!version) return null;
+ const isVersion1 = version.getUint8(0) === 1;
+ const fields = await readView(file, mvhd.bodyStart, isVersion1 ? 32 : 20);
+ if (!fields || mvhd.bodyStart + fields.byteLength > mvhd.end) return null;
+ const timescale = fields.getUint32(isVersion1 ? 20 : 12);
+ const duration = isVersion1 ? fields.getBigUint64(24) : BigInt(fields.getUint32(16));
+ const unknown = isVersion1 ? 0xffff_ffff_ffff_ffffn : 0xffff_ffffn;
+ if (timescale === 0 || duration === unknown) return null;
+ return Number(duration) / timescale;
+}
+
+export async function probeVideoDurationSeconds(file: Blob): Promise {
+ try {
+ const moov = await findBox(file, 0, file.size, "moov");
+ if (!moov) return null;
+ const mvhd = await findBox(file, moov.bodyStart, moov.end, "mvhd");
+ return mvhd ? await readMovieHeaderSeconds(file, mvhd) : null;
+ } catch {
+ return null;
+ }
+}
diff --git a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
index 48c4241c..7b7769df 100644
--- a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
+++ b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
@@ -117,7 +117,9 @@ describe("MediaUploadModal API wiring", () => {
});
expect(screen.getByText("Photos & Videos")).toBeInTheDocument();
expect(
- screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"),
+ screen.getByText(
+ "Accepted: JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB (max 10) · Videos up to 100 MB and 90 s (max 3)",
+ ),
).toBeInTheDocument();
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
});
diff --git a/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx b/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx
index 48ccb432..9aae2d73 100644
--- a/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx
+++ b/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx
@@ -1,19 +1,39 @@
import { createRef } from "react";
-import { fireEvent, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { MediaUploaderDropzone } from "@/app/(protected)/workorders/_components/media/media-uploader-dropzone";
-import {
- MAX_WORK_ORDER_MEDIA_BYTES,
- workOrderMediaAcceptAttribute,
-} from "@/domain/work-orders/utils/work-order-media-limits";
+import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
+import { workOrderMediaAcceptAttribute } from "@/domain/work-orders/utils/work-order-media-limits";
+import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
+vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() }));
+
+const MB = 1_000_000;
+
+function phoneFile(name: string, type: string, size: number): File {
+ return { name, type, size } as File;
+}
+
+function gallery(photos: number, videos: number): MediaFile[] {
+ const item = (id: string, fileType: "image" | "video") =>
+ ({ id, fileType, isLegacy: false }) as unknown as MediaFile;
+ return [
+ ...Array.from({ length: photos }, (_, i) => item(`p${i + 1}`, "image")),
+ ...Array.from({ length: videos }, (_, i) => item(`v${i + 1}`, "video")),
+ ];
+}
+
+function selectFiles(files: File[]) {
+ const input = document.querySelector('input[type="file"]');
+ fireEvent.change(input as HTMLInputElement, { target: { files } });
+}
describe("MediaUploaderDropzone", () => {
- it("matches the AAP Photos accept copy and keeps it media-only", () => {
+ it("shows the SH-116 media contract and keeps the picker media-only", () => {
renderWithProviders(
()}
@@ -23,28 +43,139 @@ describe("MediaUploaderDropzone", () => {
);
expect(
- screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"),
+ screen.getByText(
+ "Accepted: JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB (max 10) · Videos up to 100 MB and 90 s (max 3)",
+ ),
).toBeInTheDocument();
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
expect(screen.queryByText(/PDF/)).not.toBeInTheDocument();
- expect(screen.queryByText(/DOC/)).not.toBeInTheDocument();
const input = document.querySelector('input[type="file"]');
expect(input?.getAttribute("accept")).toBe(workOrderMediaAcceptAttribute());
expect(input?.getAttribute("accept")).not.toContain(".pdf");
});
+});
- it("rejects an oversized media file with the generic 200 MB toast", () => {
- renderWithProviders();
+describe("MediaUploader media contract (Completion Doc → Media, Photos & Videos modal)", () => {
+ beforeEach(() => {
+ vi.mocked(toast.error).mockClear();
+ vi.mocked(probeVideoDurationSeconds).mockReset();
+ vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
+ });
- const oversizedFile = {
- name: "mobile-clip.MP4",
- type: "application/octet-stream",
- size: MAX_WORK_ORDER_MEDIA_BYTES + 1,
- } as File;
- const input = document.querySelector('input[type="file"]');
+ it("accepts ~60 MB MP4 and MOV phone videos, including mobile MIME variants", async () => {
+ const onUpload = vi.fn();
+ renderWithProviders();
- fireEvent.change(input as HTMLInputElement, { target: { files: [oversizedFile] } });
+ const videos = [
+ phoneFile("clip.mp4", "video/mp4", 60 * MB),
+ phoneFile("IMG_0001.MOV", "video/quicktime", 60 * MB),
+ phoneFile("IMG_0002.MOV", "", 60 * MB),
+ ];
+ selectFiles(videos);
- expect(toast.error).toHaveBeenCalledWith('"mobile-clip.MP4" exceeds the 200 MB limit.');
+ await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
+ expect(onUpload.mock.calls[0][0].map((r: { file: File }) => r.file)).toEqual(videos);
+ expect(toast.error).not.toHaveBeenCalled();
+ });
+
+ it("accepts an octet-stream video with a video extension", async () => {
+ const onUpload = vi.fn();
+ renderWithProviders();
+
+ selectFiles([phoneFile("VID_0003.mp4", "application/octet-stream", 60 * MB)]);
+
+ await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
+ });
+
+ it("rejects a 101 MB video with the generic message and no file name", async () => {
+ const onUpload = vi.fn();
+ renderWithProviders();
+
+ selectFiles([phoneFile("secret-site-clip.mp4", "video/mp4", 101 * MB)]);
+
+ await waitFor(() =>
+ expect(toast.error).toHaveBeenCalledWith("Videos must be 100 MB or smaller."),
+ );
+ expect(onUpload).not.toHaveBeenCalled();
+ });
+
+ it("rejects an 11 MB photo", async () => {
+ const onUpload = vi.fn();
+ renderWithProviders();
+
+ selectFiles([phoneFile("IMG_0100.jpg", "image/jpeg", 11 * MB)]);
+
+ await waitFor(() =>
+ expect(toast.error).toHaveBeenCalledWith("Photos must be 10 MB or smaller."),
+ );
+ expect(onUpload).not.toHaveBeenCalled();
+ });
+
+ it("rejects the 11th photo and the 4th video on a full work order", async () => {
+ const onUpload = vi.fn();
+ renderWithProviders();
+
+ selectFiles([
+ phoneFile("IMG_0200.jpg", "image/jpeg", 2 * MB),
+ phoneFile("IMG_0201.MOV", "video/quicktime", 20 * MB),
+ ]);
+
+ await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(2));
+ expect(toast.error).toHaveBeenCalledWith("A work order can have at most 10 photos.");
+ expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos.");
+ expect(onUpload).not.toHaveBeenCalled();
+ });
+
+ it("counts the whole work order's media when this tab shows only its share", async () => {
+ const onUpload = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ selectFiles([
+ phoneFile("IMG_0210.jpg", "image/jpeg", 2 * MB),
+ phoneFile("IMG_0211.MOV", "video/quicktime", 20 * MB),
+ ]);
+
+ await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(2));
+ expect(toast.error).toHaveBeenCalledWith("A work order can have at most 10 photos.");
+ expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos.");
+ expect(onUpload).not.toHaveBeenCalled();
+ });
+
+ it("counts files within one selection against the remaining slots", async () => {
+ const onUpload = vi.fn();
+ renderWithProviders();
+
+ const first = phoneFile("IMG_0301.MOV", "video/quicktime", 20 * MB);
+ selectFiles([first, phoneFile("IMG_0302.MOV", "video/quicktime", 20 * MB)]);
+
+ await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
+ expect(onUpload.mock.calls[0][0].map((r: { file: File }) => r.file)).toEqual([first]);
+ expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos.");
+ });
+
+ it("rejects a video longer than 90 seconds when the duration is readable", async () => {
+ vi.mocked(probeVideoDurationSeconds).mockResolvedValue(95);
+ const onUpload = vi.fn();
+ renderWithProviders();
+
+ selectFiles([phoneFile("IMG_0400.MOV", "video/quicktime", 40 * MB)]);
+
+ await waitFor(() =>
+ expect(toast.error).toHaveBeenCalledWith("Videos must be 90 seconds or shorter."),
+ );
+ expect(onUpload).not.toHaveBeenCalled();
+ });
+
+ it("does not block a video whose duration the browser cannot read", async () => {
+ vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null);
+ const onUpload = vi.fn();
+ renderWithProviders();
+
+ selectFiles([phoneFile("IMG_0500.MOV", "video/quicktime", 40 * MB)]);
+
+ await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
+ expect(toast.error).not.toHaveBeenCalled();
});
});
diff --git a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx
index 7dcdc7d2..7633c94f 100644
--- a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx
+++ b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx
@@ -140,13 +140,15 @@ describe("SlideOverExtrasTab Aveta", () => {
expect(getMediaContent).not.toHaveBeenCalled();
});
- it("shows Extra Docs accept copy with PDF DOC and 200MB", () => {
+ it("shows Extra Docs accept copy with the SH-116 media contract", () => {
renderWithProviders(
,
);
expect(
- screen.getByText("Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max 200MB per file"),
+ screen.getByText(
+ "Accepted: JPG, PNG, HEIC, MP4, MOV, PDF, DOC · Photos up to 10 MB · Videos up to 100 MB and 90 s · Docs up to 50 MB",
+ ),
).toBeInTheDocument();
const input = document.querySelector('input[type="file"]');
expect(input?.getAttribute("accept")).toContain(".pdf");
diff --git a/src/test/app/(protected)/workorders/slide-over-extras-upload-contract.test.tsx b/src/test/app/(protected)/workorders/slide-over-extras-upload-contract.test.tsx
new file mode 100644
index 00000000..f179079d
--- /dev/null
+++ b/src/test/app/(protected)/workorders/slide-over-extras-upload-contract.test.tsx
@@ -0,0 +1,114 @@
+import { fireEvent, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { toast } from "react-toastify";
+import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab";
+import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
+import { renderWithProviders } from "@/test/test-utils";
+
+vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
+vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() }));
+
+const MB = 1_000_000;
+
+function phoneFile(name: string, type: string, size: number): File {
+ return { name, type, size } as File;
+}
+
+function selectFiles(files: File[]) {
+ const input = document.querySelector('input[type="file"]');
+ fireEvent.change(input as HTMLInputElement, { target: { files } });
+}
+
+describe("SlideOverExtrasTab SH-116 media contract", () => {
+ beforeEach(() => {
+ vi.mocked(toast.error).mockClear();
+ vi.mocked(probeVideoDurationSeconds).mockReset();
+ vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
+ });
+
+ it("rejects a video longer than 90 seconds before upload", async () => {
+ vi.mocked(probeVideoDurationSeconds).mockResolvedValue(95);
+ const onUpload = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ selectFiles([phoneFile("IMG_0700.MOV", "video/quicktime", 40 * MB)]);
+
+ await waitFor(() =>
+ expect(toast.error).toHaveBeenCalledWith("Videos must be 90 seconds or shorter."),
+ );
+ expect(onUpload).not.toHaveBeenCalled();
+ });
+
+ it("rejects the 11th photo and 4th video counted across the whole work order", async () => {
+ const onUpload = vi.fn();
+ const report = phoneFile("inspection.pdf", "application/pdf", 2 * MB);
+ renderWithProviders(
+ ,
+ );
+
+ selectFiles([
+ phoneFile("IMG_0701.jpg", "image/jpeg", 2 * MB),
+ phoneFile("IMG_0702.MOV", "video/quicktime", 20 * MB),
+ report,
+ ]);
+
+ await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
+ expect(onUpload).toHaveBeenCalledWith([report]);
+ expect(toast.error).toHaveBeenCalledWith("A work order can have at most 10 photos.");
+ expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos.");
+ });
+
+ it("accepts a ~60 MB phone video whose duration the browser cannot read", async () => {
+ vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null);
+ const onUpload = vi.fn();
+ const video = phoneFile("IMG_0703.MOV", "", 60 * MB);
+ renderWithProviders(
+ ,
+ );
+
+ selectFiles([video]);
+
+ await waitFor(() => expect(onUpload).toHaveBeenCalledWith([video]));
+ expect(toast.error).not.toHaveBeenCalled();
+ });
+
+ it("ignores a second pick while the first one is still being checked", async () => {
+ let finishProbe: ((seconds: number) => void) | undefined;
+ vi.mocked(probeVideoDurationSeconds).mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finishProbe = resolve;
+ }),
+ );
+ const onUpload = vi.fn();
+ renderWithProviders(
+ ,
+ );
+ const first = phoneFile("IMG_0710.MOV", "video/quicktime", 20 * MB);
+ const second = phoneFile("IMG_0711.MOV", "video/quicktime", 20 * MB);
+
+ selectFiles([first]);
+ await waitFor(() => expect(finishProbe).toBeDefined());
+ expect(document.querySelector('input[type="file"]')).toBeDisabled();
+ selectFiles([second]);
+ finishProbe?.(30);
+
+ await waitFor(() => expect(onUpload).toHaveBeenCalledOnce());
+ expect(onUpload).toHaveBeenCalledWith([first]);
+ expect(probeVideoDurationSeconds).toHaveBeenCalledOnce();
+ });
+});
diff --git a/src/test/app/v/uplift-request-form.test.tsx b/src/test/app/v/uplift-request-form.test.tsx
index 91cfc13e..d09e0cca 100644
--- a/src/test/app/v/uplift-request-form.test.tsx
+++ b/src/test/app/v/uplift-request-form.test.tsx
@@ -1,6 +1,9 @@
-import { fireEvent, render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
+import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
+
+vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() }));
function renderForm(overrides: Partial> = {}) {
return render(
@@ -38,6 +41,70 @@ describe("UpliftRequestForm revise mode", () => {
expect(screen.getByText(/must be greater than the current NTE/i)).toBeInTheDocument();
});
+ it("refuses photo evidence when the work order already has 10 photos", async () => {
+ renderForm({ mediaUsage: { photos: 10, videos: 0, maxPhotos: 10, maxVideos: 3 } });
+
+ fireEvent.change(screen.getByLabelText(/evidence/i), {
+ target: { files: [new File(["jpg"], "receipt.jpg", { type: "image/jpeg" })] },
+ });
+
+ expect(await screen.findByText("A work order can have at most 10 photos.")).toBeInTheDocument();
+ });
+
+ it("offers and accepts the same phone photo and video types as the upload allowlist", async () => {
+ renderForm();
+
+ const input = screen.getByLabelText("Evidence (PDF, JPG, PNG, HEIC, MP4, MOV)");
+ expect(input.getAttribute("accept")?.split(",")).toEqual(
+ expect.arrayContaining([".pdf", ".jpg", ".png", ".heic", ".mp4", ".mov"]),
+ );
+
+ fireEvent.change(input, {
+ target: { files: [new File(["gif"], "clip.gif", { type: "image/gif" })] },
+ });
+ expect(
+ await screen.findByText("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed."),
+ ).toBeInTheDocument();
+
+ fireEvent.change(input, {
+ target: { files: [new File(["heic"], "site.heic", { type: "" })] },
+ });
+ await waitFor(() =>
+ expect(
+ screen.queryByText("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed."),
+ ).not.toBeInTheDocument(),
+ );
+ });
+
+ it("holds submit while a picked video is still being checked, then submits that video", async () => {
+ let finishProbe: (seconds: number | null) => void = () => {};
+ vi.mocked(probeVideoDurationSeconds).mockImplementationOnce(
+ () => new Promise((resolve) => (finishProbe = resolve)),
+ );
+ const onSubmit = vi.fn();
+ renderForm({ initialRequested: "1200", initialReason: "Extra parts", onSubmit });
+
+ const video = new File(["mov"], "IMG_0001.MOV", { type: "video/quicktime" });
+ fireEvent.change(screen.getByLabelText(/evidence/i), { target: { files: [video] } });
+
+ const checking = await screen.findByRole("button", { name: "Checking evidence…" });
+ expect(checking).toBeDisabled();
+ fireEvent.submit(checking.closest("form")!);
+ expect(onSubmit).not.toHaveBeenCalled();
+ expect(screen.queryByText(/approval-required requests need evidence/i)).not.toBeInTheDocument();
+
+ finishProbe(30);
+ const submitButton = await screen.findByRole("button", { name: /submit revision/i });
+ expect(submitButton).toBeEnabled();
+ fireEvent.click(submitButton);
+
+ expect(onSubmit).toHaveBeenCalledWith({
+ amount: 1200,
+ reason: "Extra parts",
+ evidenceFile: video,
+ });
+ });
+
it("reports the current NTE threshold in the actionable error", () => {
renderForm({ initialRequested: "900", currentNte: 1000 });
diff --git a/src/test/app/v/vendor-portal-documents.test.tsx b/src/test/app/v/vendor-portal-documents.test.tsx
index e8a33e78..a0085787 100644
--- a/src/test/app/v/vendor-portal-documents.test.tsx
+++ b/src/test/app/v/vendor-portal-documents.test.tsx
@@ -4,8 +4,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal";
+import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
import { renderWithProviders } from "@/test/test-utils";
+vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() }));
+
const quarantinedDocument: VendorPortalDocument = {
id: 12,
originalFileName: "completion.pdf",
@@ -40,7 +43,7 @@ describe("VendorPortalDocuments", () => {
});
expect(await screen.findByRole("alert")).toHaveTextContent(
- "Only PDF, JPG, and PNG files are allowed.",
+ "Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.",
);
expect(upload).not.toHaveBeenCalled();
});
@@ -62,6 +65,163 @@ describe("VendorPortalDocuments", () => {
expect(screen.getByText("Processing")).toBeInTheDocument();
});
+ it.each([
+ ["animation.gif", "image/gif"],
+ ["clip.webm", "video/webm"],
+ ])("rejects %s (%s), which the server does not accept", async (name, type) => {
+ const upload = vi.spyOn(vendorPortalApi, "uploadDocument");
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+
+ fireEvent.change(screen.getByLabelText("Choose completion document"), {
+ target: { files: [new File(["x"], name, { type })] },
+ });
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.",
+ );
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ describe("SH-116 media contract", () => {
+ const MB = 1_000_000;
+ const renderDocuments = () =>
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+ const choose = (file: File) =>
+ fireEvent.change(screen.getByLabelText("Choose completion document"), {
+ target: { files: [file] },
+ });
+
+ it("states the limit for every accepted kind, including PDFs", () => {
+ renderDocuments();
+
+ expect(
+ screen.getByText(
+ "Accepted: PDF, JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB · Videos up to 100 MB and 90 s · PDFs up to 10 MB",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("uploads a ~60 MB phone MOV whose browser type is empty", async () => {
+ vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null);
+ const upload = vi
+ .spyOn(vendorPortalApi, "uploadDocument")
+ .mockResolvedValue(quarantinedDocument);
+ renderDocuments();
+
+ const video = { name: "IMG_0001.MOV", type: "", size: 60 * MB } as File;
+ choose(video);
+
+ await waitFor(() => expect(upload).toHaveBeenCalledOnce());
+ expect(upload.mock.calls[0][0].file).toBe(video);
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
+ it.each([
+ [
+ { name: "clip.mp4", type: "video/mp4", size: 101 * MB },
+ "Videos must be 100 MB or smaller.",
+ ],
+ [
+ { name: "IMG_1.jpg", type: "image/jpeg", size: 11 * MB },
+ "Photos must be 10 MB or smaller.",
+ ],
+ ])("rejects %o with a generic message", async (file, message) => {
+ vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
+ const upload = vi.spyOn(vendorPortalApi, "uploadDocument");
+ renderDocuments();
+
+ choose(file as File);
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(message);
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ // Three videos already on the work order (dispatcher and vendor uploads together).
+ const fullCounts = {
+ maxPhotos: 10,
+ maxVideos: 3,
+ photos: 10,
+ videos: 3,
+ completionPhotos: 10,
+ completionVideos: 3,
+ };
+ const videoDocument: VendorPortalDocument = {
+ ...quarantinedDocument,
+ id: 21,
+ originalFileName: "walkthrough.mp4",
+ contentType: "video/mp4",
+ };
+
+ it("rejects the 4th video and 11th photo counted across the whole work order", async () => {
+ vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
+ const upload = vi.spyOn(vendorPortalApi, "uploadDocument");
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+
+ choose({ name: "IMG_0004.MOV", type: "video/quicktime", size: 20 * MB } as File);
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "A work order can have at most 3 videos.",
+ );
+ choose({ name: "IMG_0011.jpg", type: "image/jpeg", size: 2 * MB } as File);
+ await waitFor(() =>
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "A work order can have at most 10 photos.",
+ ),
+ );
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ it("lets a vendor replace one of the counted videos on a full work order", async () => {
+ vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
+ const upload = vi.spyOn(vendorPortalApi, "uploadDocument").mockResolvedValue(videoDocument);
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Replace" }));
+ const replacement = { name: "walkthrough-v2.mp4", type: "video/mp4", size: 20 * MB } as File;
+ choose(replacement);
+
+ await waitFor(() => expect(upload).toHaveBeenCalledOnce());
+ expect(upload.mock.calls[0][0]).toMatchObject({ file: replacement, replacesDocumentId: 21 });
+ });
+ });
+
it("reports upload progress and refreshes the dispatch after success", async () => {
const onChanged = vi.fn().mockResolvedValue(undefined);
let finishUpload: (() => void) | undefined;
@@ -97,4 +257,40 @@ describe("VendorPortalDocuments", () => {
finishUpload?.();
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
});
+
+ it("drops an earlier pick whose video check finishes after a newer pick", async () => {
+ let finishProbe: ((seconds: number) => void) | undefined;
+ vi.mocked(probeVideoDurationSeconds).mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finishProbe = resolve;
+ }),
+ );
+ const upload = vi
+ .spyOn(vendorPortalApi, "uploadDocument")
+ .mockResolvedValue(quarantinedDocument);
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+ const input = screen.getByLabelText("Choose completion document");
+ const video = { name: "IMG_0001.MOV", type: "video/quicktime", size: 20_000_000 } as File;
+ const photo = { name: "IMG_0002.jpg", type: "image/jpeg", size: 2_000_000 } as File;
+
+ fireEvent.change(input, { target: { files: [video] } });
+ await waitFor(() => expect(finishProbe).toBeDefined());
+ fireEvent.change(input, { target: { files: [photo] } });
+ await waitFor(() => expect(upload).toHaveBeenCalledOnce());
+ finishProbe?.(30);
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(upload).toHaveBeenCalledOnce();
+ expect(upload.mock.calls[0][0].file).toBe(photo);
+ });
});
diff --git a/src/test/domain/work-orders/utils/work-order-media-kind-counts.test.ts b/src/test/domain/work-orders/utils/work-order-media-kind-counts.test.ts
new file mode 100644
index 00000000..dca523e7
--- /dev/null
+++ b/src/test/domain/work-orders/utils/work-order-media-kind-counts.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import {
+ boardMediaItemsToMediaFiles,
+ mapBoardDetailMediaItem,
+} from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import { countWorkOrderMediaKinds } from "@/domain/work-orders/utils/work-order-media-limits";
+import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils";
+
+describe("photo/video counting for the per-work-order limit", () => {
+ it("counts a persisted HEIC photo after a refresh", () => {
+ const heic = mapBoardDetailMediaItem({
+ id: 41,
+ category: 3,
+ url: "https://cdn.example.com/IMG_0042.HEIC",
+ fileName: "IMG_0042.HEIC",
+ });
+ const files = boardMediaItemsToMediaFiles([heic]);
+
+ expect(files[0]?.fileType).toBe("image");
+ expect(countWorkOrderMediaKinds(files)).toEqual({ photos: 1, videos: 0 });
+ });
+
+ it.each([
+ [{ name: "IMG_0043.HEIC", type: "" }, "image"],
+ [{ name: "IMG_0044.MOV", type: "" }, "video"],
+ [{ name: "IMG_0045.jpg", type: "application/octet-stream" }, "image"],
+ [{ name: "report.docx", type: "" }, "other"],
+ ])("classifies a local %o by extension when the browser type is missing", (file, fileType) => {
+ expect(classifyUpload({ ...file, size: 1 } as File).fileType).toBe(fileType);
+ });
+});
diff --git a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts
index da86cd31..e1892159 100644
--- a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts
+++ b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts
@@ -1,14 +1,20 @@
import { describe, expect, it } from "vitest";
import {
canDeleteWorkOrderMedia,
+ countWorkOrderMediaKinds,
formatMediaSizeLimit,
isWorkOrderDocumentsReadOnly,
isWorkOrderExtraDocTypeAllowed,
isWorkOrderFileTooLarge,
isWorkOrderMediaTypeAllowed,
+ MAX_PHOTOS_PER_WORK_ORDER,
+ MAX_VIDEO_DURATION_SECONDS,
+ MAX_VIDEOS_PER_WORK_ORDER,
MAX_WORK_ORDER_COMPLETION_DOC_BYTES,
- MAX_WORK_ORDER_MEDIA_BYTES,
+ MAX_WORK_ORDER_PHOTO_BYTES,
+ MAX_WORK_ORDER_VIDEO_BYTES,
workOrderExtraDocsAcceptAttribute,
+ workOrderFileSizeRejection,
workOrderMediaAcceptAttribute,
} from "@/domain/work-orders/utils/work-order-media-limits";
@@ -25,12 +31,53 @@ describe("work-order-media-limits", () => {
expect(canDeleteWorkOrderMedia({ id: "0" })).toBe(false);
});
- it("caps uploads at 200_000_000 bytes and formats decimal MB", () => {
- expect(MAX_WORK_ORDER_MEDIA_BYTES).toBe(200_000_000);
- expect(formatMediaSizeLimit()).toBe("200 MB");
- expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES } as File)).toBe(false);
- expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES + 1 } as File)).toBe(true);
- expect(isWorkOrderFileTooLarge({ size: 10 } as File)).toBe(false);
+ it("applies the SH-116 per-kind caps with stable generic messages", () => {
+ const file = (name: string, type: string, size: number) => ({ name, type, size }) as File;
+
+ expect(MAX_WORK_ORDER_PHOTO_BYTES).toBe(10_000_000);
+ expect(MAX_WORK_ORDER_VIDEO_BYTES).toBe(100_000_000);
+ expect(MAX_VIDEO_DURATION_SECONDS).toBe(90);
+ expect(MAX_PHOTOS_PER_WORK_ORDER).toBe(10);
+ expect(MAX_VIDEOS_PER_WORK_ORDER).toBe(3);
+
+ expect(workOrderFileSizeRejection(file("clip.mp4", "video/mp4", 60_000_000))).toBeNull();
+ expect(workOrderFileSizeRejection(file("IMG_1.MOV", "video/quicktime", 60_000_000))).toBeNull();
+ expect(workOrderFileSizeRejection(file("IMG_1.MOV", "", 60_000_000))).toBeNull();
+ expect(
+ workOrderFileSizeRejection(file("clip.mp4", "application/octet-stream", 60_000_000)),
+ ).toBeNull();
+ expect(workOrderFileSizeRejection(file("clip.mp4", "video/mp4", 101_000_000))).toBe(
+ "Videos must be 100 MB or smaller.",
+ );
+ expect(workOrderFileSizeRejection(file("IMG_2.jpg", "image/jpeg", 11_000_000))).toBe(
+ "Photos must be 10 MB or smaller.",
+ );
+ expect(workOrderFileSizeRejection(file("IMG_2.HEIC", "", 11_000_000))).toBe(
+ "Photos must be 10 MB or smaller.",
+ );
+ // A foreign declared type never widens the class: this .jpg is still sized as a photo.
+ expect(workOrderFileSizeRejection(file("IMG_3.jpg", "video/3gpp", 60_000_000))).toBe(
+ "Photos must be 10 MB or smaller.",
+ );
+ expect(workOrderFileSizeRejection(file("scope.pdf", "application/pdf", 51_000_000))).toBe(
+ "Documents must be 50 MB or smaller.",
+ );
+ expect(isWorkOrderFileTooLarge(file("clip.mp4", "video/mp4", MAX_WORK_ORDER_VIDEO_BYTES))).toBe(
+ false,
+ );
+ expect(formatMediaSizeLimit(MAX_WORK_ORDER_VIDEO_BYTES)).toBe("100 MB");
+ });
+
+ it("counts only non-legacy, non-failed photos and videos toward the per-work-order limits", () => {
+ expect(
+ countWorkOrderMediaKinds([
+ { fileType: "image" },
+ { fileType: "image", isLegacy: true },
+ { fileType: "video" },
+ { fileType: "document" },
+ { fileType: "video", uploadStatus: "failed" },
+ ]),
+ ).toEqual({ photos: 1, videos: 1 });
});
it("caps completion-document uploads at 50_000_000 bytes and formats the completion copy", () => {
diff --git a/src/test/lib/probe-video-duration.test.ts b/src/test/lib/probe-video-duration.test.ts
new file mode 100644
index 00000000..0e45a171
--- /dev/null
+++ b/src/test/lib/probe-video-duration.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from "vitest";
+import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
+
+function concat(...parts: Uint8Array[]): Uint8Array {
+ const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
+ let offset = 0;
+ for (const part of parts) {
+ out.set(part, offset);
+ offset += part.length;
+ }
+ return out;
+}
+
+function ascii(text: string): Uint8Array {
+ return Uint8Array.from(text, (char) => char.charCodeAt(0));
+}
+
+function box(type: string, ...children: Uint8Array[]): Uint8Array {
+ const body = concat(...children);
+ const out = new Uint8Array(8 + body.length);
+ new DataView(out.buffer).setUint32(0, out.length);
+ out.set(ascii(type), 4);
+ out.set(body, 8);
+ return out;
+}
+
+function movieHeader(timescale: number, duration: bigint, version1 = false): Uint8Array {
+ const body = new Uint8Array(version1 ? 32 : 20);
+ const view = new DataView(body.buffer);
+ body[0] = version1 ? 1 : 0;
+ if (version1) {
+ view.setUint32(20, timescale);
+ view.setBigUint64(24, duration);
+ } else {
+ view.setUint32(12, timescale);
+ view.setUint32(16, Number(duration));
+ }
+ return box("mvhd", body);
+}
+
+/** A phone-style file: ftyp, a large mdat, then moov at the end (not fast-start). */
+function mp4(timescale: number, duration: bigint, version1 = false): Uint8Array {
+ return concat(
+ box("ftyp", ascii("isom"), new Uint8Array(4)),
+ box("mdat", new Uint8Array(4096)),
+ box("moov", movieHeader(timescale, duration, version1)),
+ );
+}
+
+function asFile(bytes: Uint8Array, name = "IMG_0001.MOV"): File {
+ return new File([new Uint8Array(bytes)], name, { type: "video/quicktime" });
+}
+
+describe("probeVideoDurationSeconds", () => {
+ it("reads the duration from a moov box after the media data", async () => {
+ await expect(probeVideoDurationSeconds(asFile(mp4(600, 57_000n)))).resolves.toBe(95);
+ });
+
+ it("reads a version 1 movie header", async () => {
+ await expect(probeVideoDurationSeconds(asFile(mp4(1000, 30_500n, true)))).resolves.toBe(30.5);
+ });
+
+ it("follows 64-bit box sizes", async () => {
+ const mdat = new Uint8Array(16 + 512);
+ const view = new DataView(mdat.buffer);
+ view.setUint32(0, 1);
+ mdat.set(ascii("mdat"), 4);
+ view.setBigUint64(8, BigInt(mdat.length));
+ const file = concat(
+ box("ftyp", ascii("qt "), new Uint8Array(4)),
+ mdat,
+ box("moov", movieHeader(90_000, 90_000n * 120n)),
+ );
+
+ await expect(probeVideoDurationSeconds(asFile(file))).resolves.toBe(120);
+ });
+
+ it.each([
+ [
+ "no moov",
+ concat(box("ftyp", ascii("isom"), new Uint8Array(4)), box("mdat", new Uint8Array(64))),
+ ],
+ ["truncated", mp4(600, 57_000n).slice(0, -10)],
+ ["zero timescale", mp4(0, 57_000n)],
+ [
+ "oversized box",
+ concat(box("ftyp", new Uint8Array(8)), Uint8Array.of(0x7f, 0xff, 0xff, 0xff), ascii("moov")),
+ ],
+ ["not a video", ascii("%PDF-1.7 not a movie")],
+ ])("returns null for an unreadable file (%s)", async (_shape, bytes) => {
+ await expect(probeVideoDurationSeconds(asFile(bytes))).resolves.toBeNull();
+ });
+});