From 44e96cf2a3d6236414c1e9a9dcddc5aa9b628c6e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 20:59:26 -0300 Subject: [PATCH 01/12] fix(media): apply the SH-116 media contract to every upload surface Photos up to 10 MB (JPG/PNG/HEIC), videos up to 100 MB and 90 s (MP4/MOV), at most 10 photos and 3 videos per work order, pre-validated with stable generic messages on the Photos & Videos modal, the Completion Doc media tab, Extra Docs and the vendor portal. Video duration is read from metadata when the browser can; unreadable metadata never blocks. Mobile MIME variants (empty type, octet-stream with a video extension, QuickTime) stay accepted. The signed completion PDF keeps its 50 MB cap. --- .../detail/tabs/slide-over-extras-tab.tsx | 16 +- .../media/media-uploader-dropzone.tsx | 14 +- .../_components/media/media-uploader.tsx | 56 ++++++- .../_components/uplift-request-form.tsx | 12 +- .../v/_components/vendor-portal-documents.tsx | 12 +- src/app/v/_components/vendor-portal.css | 7 + .../vendor-portal/lib/document-validation.ts | 54 ++++++- .../utils/work-order-media-limits.ts | 95 +++++++++-- src/lib/probe-video-duration.ts | 36 +++++ .../media-upload-modal-api.test.tsx | 4 +- .../media-uploader-dropzone.test.tsx | 152 +++++++++++++++--- .../slide-over-extras-aveta.test.tsx | 6 +- .../app/v/vendor-portal-documents.test.tsx | 59 ++++++- .../utils/work-order-media-limits.test.ts | 56 ++++++- 14 files changed, 507 insertions(+), 72 deletions(-) create mode 100644 src/lib/probe-video-duration.ts diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx index a02ecb56..15a41b4e 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx @@ -20,10 +20,9 @@ import { } from "@/domain/work-orders/utils/extra-docs-aveta"; import { canDeleteWorkOrderMedia, - formatMediaSizeLimit, isWorkOrderExtraDocTypeAllowed, - isWorkOrderFileTooLarge, workOrderExtraDocsAcceptAttribute, + workOrderFileSizeRejection, } from "@/domain/work-orders/utils/work-order-media-limits"; const FILETYPE_ICON: Record = { @@ -188,11 +187,14 @@ export function SlideOverExtrasTab({ 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).`); + toast.error( + "Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV. Documents: PDF, DOC.", + ); continue; } - if (isWorkOrderFileTooLarge(file)) { - toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`); + const sizeRejection = workOrderFileSizeRejection(file); + if (sizeRejection) { + toast.error(sizeRejection); continue; } accepted.push(file); @@ -250,8 +252,8 @@ export function SlideOverExtrasTab({ {pending ? "Uploading…" : "Upload documents, photos or videos"} - Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max {formatMediaSizeLimit().replace(" ", "")}{" "} - per file + 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 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..c87f430e 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx @@ -4,11 +4,17 @@ import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work- import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; import { canDeleteWorkOrderMedia, - formatMediaSizeLimit, - isWorkOrderFileTooLarge, + countWorkOrderMediaKinds, + MAX_PHOTOS_PER_WORK_ORDER, + MAX_VIDEOS_PER_WORK_ORDER, + MAX_VIDEO_DURATION_SECONDS, + resolveWorkOrderUploadKind, + workOrderFileSizeRejection, isWorkOrderMediaTypeAllowed, + type WorkOrderUploadKind, } from "@/domain/work-orders/utils/work-order-media-limits"; import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils"; +import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; import { MediaUploaderDropzone } from "./media-uploader-dropzone"; import { MediaUploaderGrid } from "./media-uploader-grid"; @@ -32,6 +38,23 @@ type MediaUploaderProps = { pending?: boolean; }; +const UNSUPPORTED_TYPE_MESSAGE = "Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV."; +const PHOTO_COUNT_MESSAGE = `A work order can have at most ${MAX_PHOTOS_PER_WORK_ORDER} photos.`; +const VIDEO_COUNT_MESSAGE = `A work order can have at most ${MAX_VIDEOS_PER_WORK_ORDER} videos.`; +const VIDEO_DURATION_MESSAGE = `Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`; + +async function isWithinContract(file: File, kind: WorkOrderUploadKind): Promise { + if (kind === "video") { + // SH-116: duration is checked client-side only; unreadable metadata never blocks. + const duration = await probeVideoDurationSeconds(file); + if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) { + toast.error(VIDEO_DURATION_MESSAGE); + return false; + } + } + return true; +} + export function MediaUploader({ uploads, onUpload, @@ -45,19 +68,36 @@ export function MediaUploader({ }: MediaUploaderProps) { const inputRef = useRef(null); - const addFiles = (files: FileList | null) => { + const addFiles = async (files: FileList | null) => { if (readOnly || pending || !files || files.length === 0) return; + // 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); + const counts = countWorkOrderMediaKinds(uploads); const accepted: File[] = []; - for (const file of Array.from(files)) { + for (const file of selected) { if (!isWorkOrderMediaTypeAllowed(file)) { - toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`); + toast.error(UNSUPPORTED_TYPE_MESSAGE); continue; } - if (isWorkOrderFileTooLarge(file)) { - toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`); + const sizeRejection = workOrderFileSizeRejection(file); + if (sizeRejection) { + toast.error(sizeRejection); continue; } + const kind = resolveWorkOrderUploadKind(file); + if (kind === "photo" && counts.photos >= MAX_PHOTOS_PER_WORK_ORDER) { + toast.error(PHOTO_COUNT_MESSAGE); + continue; + } + if (kind === "video" && counts.videos >= MAX_VIDEOS_PER_WORK_ORDER) { + toast.error(VIDEO_COUNT_MESSAGE); + continue; + } + if (!(await isWithinContract(file, kind))) continue; + if (kind === "photo") counts.photos += 1; + if (kind === "video") counts.videos += 1; accepted.push(file); } if (accepted.length === 0) return; @@ -95,7 +135,7 @@ export function MediaUploader({ inputRef={inputRef} pending={pending} categorizeAfterUpload={Boolean(onCategorize)} - onFilesSelected={addFiles} + onFilesSelected={(files) => void addFiles(files)} /> )} (); const [formError, setFormError] = useState(""); + const latestSelection = useRef(0); const showApprovalFields = mode === "revise" || isApprovalRequired(requested, currentNte); @@ -54,8 +55,11 @@ 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; + const validationMessage = file ? await validateVendorDocument(file) : 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); }; @@ -119,7 +123,7 @@ export function UpliftRequestForm({ type="file" className="vp-input" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" - onChange={(event) => chooseFile(event.target.files?.[0])} + onChange={(event) => void chooseFile(event.target.files?.[0])} /> diff --git a/src/app/v/_components/vendor-portal-documents.tsx b/src/app/v/_components/vendor-portal-documents.tsx index 3055952d..02ab0248 100644 --- a/src/app/v/_components/vendor-portal-documents.tsx +++ b/src/app/v/_components/vendor-portal-documents.tsx @@ -57,12 +57,12 @@ export function VendorPortalDocuments({ inputRef.current?.click(); }; - const uploadSelectedFile = (file?: File) => { + const uploadSelectedFile = async (file?: File) => { if (!file) { return; } - const validationMessage = validateVendorDocument(file); + const validationMessage = await validateVendorDocument(file); if (validationMessage) { setValidationError(validationMessage); return; @@ -83,9 +83,9 @@ export function VendorPortalDocuments({ ref={inputRef} type="file" aria-label="Choose completion document" - accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" + accept=".pdf,.jpg,.jpeg,.png,.heic,.mp4,.mov,application/pdf,image/jpeg,image/png,image/heic,video/mp4,video/quicktime" hidden - onChange={(event) => uploadSelectedFile(event.target.files?.[0])} + onChange={(event) => void uploadSelectedFile(event.target.files?.[0])} /> {documents.length === 0 && ( @@ -155,6 +155,10 @@ export function VendorPortalDocuments({ > Upload document + + Accepted: PDF, JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB · Videos up to 100 MB and + 90 s + )} 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..fef1f766 100644 --- a/src/domain/vendor-portal/lib/document-validation.ts +++ b/src/domain/vendor-portal/lib/document-validation.ts @@ -1,12 +1,54 @@ -const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024; -const ALLOWED_DOCUMENT_TYPES = new Set(["application/pdf", "image/jpeg", "image/png"]); +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."; +/** + * SH-116 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."; + +type VendorUploadKind = "document" | "photo" | "video"; + +function resolveKind(file: File): VendorUploadKind | null { + const extension = file.name.toLowerCase().split(".").pop() ?? ""; + const mime = file.type.toLowerCase(); + if (mime.startsWith("video/") || extension === "mp4" || extension === "mov") return "video"; + if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "heic"].includes(extension)) { + return "photo"; + } + if (mime === "application/pdf" || extension === "pdf") return "document"; + return null; +} + +export async function validateVendorDocument(file: File): Promise { + const kind = resolveKind(file); + if (!kind) { + return UNSUPPORTED_TYPE_MESSAGE; } - if (file.size > MAX_DOCUMENT_BYTES) { + 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."; + } + // Duration is checked client-side only; 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/work-orders/utils/work-order-media-limits.ts b/src/domain/work-orders/utils/work-order-media-limits.ts index 00d9d261..17989068 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,37 @@ 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; +/** + * SH-116 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 + * checked client-side only (the server cannot probe it cheaply). 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 +42,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 +56,48 @@ 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; +export function resolveWorkOrderUploadKind(file: File): WorkOrderUploadKind { + const mime = file.type.toLowerCase(); + const extension = fileExtension(file); + if (mime.startsWith("video/") || extension === ".mp4" || extension === ".mov") { + return "video"; + } + if ( + mime.startsWith("image/") || + extension === ".jpg" || + extension === ".jpeg" || + extension === ".png" || + extension === ".heic" + ) { + return "photo"; + } + return "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 +109,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 }>, +): WorkOrderMediaKindCounts { + return uploads.reduce( + (counts, item) => { + if (item.isLegacy) 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/lib/probe-video-duration.ts b/src/lib/probe-video-duration.ts new file mode 100644 index 00000000..61f047f6 --- /dev/null +++ b/src/lib/probe-video-duration.ts @@ -0,0 +1,36 @@ +/** + * Best-effort client-side video duration probe. Resolves null when the browser + * cannot decode the container's metadata — callers must not block the upload in + * that case (SH-116: duration is a client-side check only). + */ +export const VIDEO_DURATION_PROBE_TIMEOUT_MS = 10_000; + +export function probeVideoDurationSeconds(file: File): Promise { + return new Promise((resolve) => { + const url = URL.createObjectURL(file); + const video = document.createElement("video"); + let settled = false; + + const done = (value: number | null) => { + if (settled) return; + settled = true; + video.removeAttribute("src"); + video.load(); + URL.revokeObjectURL(url); + resolve(value); + }; + + const timeout = window.setTimeout(() => done(null), VIDEO_DURATION_PROBE_TIMEOUT_MS); + + video.preload = "metadata"; + video.onloadedmetadata = () => { + window.clearTimeout(timeout); + done(Number.isFinite(video.duration) ? video.duration : null); + }; + video.onerror = () => { + window.clearTimeout(timeout); + done(null); + }; + video.src = url; + }); +} 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..20a71923 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,122 @@ 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 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/v/vendor-portal-documents.test.tsx b/src/test/app/v/vendor-portal-documents.test.tsx index e8a33e78..19507184 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,60 @@ describe("VendorPortalDocuments", () => { expect(screen.getByText("Processing")).toBeInTheDocument(); }); + 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("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(); + }); + }); + it("reports upload progress and refreshes the dispatch after success", async () => { const onChanged = vi.fn().mockResolvedValue(undefined); let finishUpload: (() => void) | undefined; 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..631b2319 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,48 @@ 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.", + ); + 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 photos and videos toward the per-work-order limits", () => { + expect( + countWorkOrderMediaKinds([ + { fileType: "image" }, + { fileType: "image", isLegacy: true }, + { fileType: "video" }, + { fileType: "document" }, + ]), + ).toEqual({ photos: 1, videos: 1 }); }); it("caps completion-document uploads at 50_000_000 bytes and formats the completion copy", () => { From f65a48077de3467e93177c6c199333dbfea9716a Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:01:36 -0300 Subject: [PATCH 02/12] fix(vendor-portal): validate uploads against the server allowlist Any image/* or video/* type passed the client check, so GIF, WebP and WebM were only rejected after upload. Resolve the kind from the same allowlist the server uses: an allowlisted browser type, otherwise the extension. --- .../vendor-portal/lib/document-validation.ts | 33 ++++++++++++++----- .../app/v/vendor-portal-documents.test.tsx | 26 +++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/domain/vendor-portal/lib/document-validation.ts b/src/domain/vendor-portal/lib/document-validation.ts index fef1f766..81444abc 100644 --- a/src/domain/vendor-portal/lib/document-validation.ts +++ b/src/domain/vendor-portal/lib/document-validation.ts @@ -14,15 +14,32 @@ const UNSUPPORTED_TYPE_MESSAGE = "Only PDF, JPG, PNG, HEIC, MP4, and MOV files a 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 extension = file.name.toLowerCase().split(".").pop() ?? ""; - const mime = file.type.toLowerCase(); - if (mime.startsWith("video/") || extension === "mp4" || extension === "mov") return "video"; - if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "heic"].includes(extension)) { - return "photo"; - } - if (mime === "application/pdf" || extension === "pdf") return "document"; - return 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; } export async function validateVendorDocument(file: File): Promise { diff --git a/src/test/app/v/vendor-portal-documents.test.tsx b/src/test/app/v/vendor-portal-documents.test.tsx index 19507184..d97b1134 100644 --- a/src/test/app/v/vendor-portal-documents.test.tsx +++ b/src/test/app/v/vendor-portal-documents.test.tsx @@ -65,6 +65,32 @@ 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 = () => From b5da537c931ddc1d720bf6c7d6f24213f506b1b3 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:31:03 -0300 Subject: [PATCH 03/12] fix(media): classify client uploads by the server's type rule A foreign declared type (e.g. video/3gpp on a .jpg) gave the file the video size allowance in the browser while the server sizes it as a photo. Use the same rule as the server: an allowlisted type decides, otherwise the extension. --- .../utils/work-order-media-limits.ts | 40 ++++++++++++------- .../utils/work-order-media-limits.test.ts | 4 ++ 2 files changed, 29 insertions(+), 15 deletions(-) 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 17989068..77429d75 100644 --- a/src/domain/work-orders/utils/work-order-media-limits.ts +++ b/src/domain/work-orders/utils/work-order-media-limits.ts @@ -56,22 +56,32 @@ function isTypeAllowed(file: File, mimes: Set, extensions: Set): return mimeOk || extOk; } +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 { - const mime = file.type.toLowerCase(); - const extension = fileExtension(file); - if (mime.startsWith("video/") || extension === ".mp4" || extension === ".mov") { - return "video"; - } - if ( - mime.startsWith("image/") || - extension === ".jpg" || - extension === ".jpeg" || - extension === ".png" || - extension === ".heic" - ) { - return "photo"; - } - return "document"; + return ( + KIND_BY_MIME[file.type.toLowerCase()] ?? KIND_BY_EXTENSION[fileExtension(file)] ?? "document" + ); } function workOrderUploadKindLimit(kind: WorkOrderUploadKind): number { 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 631b2319..02ace4bb 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 @@ -55,6 +55,10 @@ describe("work-order-media-limits", () => { 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.", ); From 11a753a2e9ad5409fafa7cdba898103b8b4cf371 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:39:07 -0300 Subject: [PATCH 04/12] docs(media): note the server now enforces the 90-second video limit --- .../workorders/_components/media/media-uploader.tsx | 2 +- src/domain/vendor-portal/lib/document-validation.ts | 2 +- src/domain/work-orders/utils/work-order-media-limits.ts | 3 ++- src/lib/probe-video-duration.ts | 3 ++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/(protected)/workorders/_components/media/media-uploader.tsx b/src/app/(protected)/workorders/_components/media/media-uploader.tsx index c87f430e..5364bd7b 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx @@ -45,7 +45,7 @@ const VIDEO_DURATION_MESSAGE = `Videos must be ${MAX_VIDEO_DURATION_SECONDS} sec async function isWithinContract(file: File, kind: WorkOrderUploadKind): Promise { if (kind === "video") { - // SH-116: duration is checked client-side only; unreadable metadata never blocks. + // SH-116: pre-check only; the server enforces it too. Unreadable metadata never blocks. const duration = await probeVideoDurationSeconds(file); if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) { toast.error(VIDEO_DURATION_MESSAGE); diff --git a/src/domain/vendor-portal/lib/document-validation.ts b/src/domain/vendor-portal/lib/document-validation.ts index 81444abc..bf059bca 100644 --- a/src/domain/vendor-portal/lib/document-validation.ts +++ b/src/domain/vendor-portal/lib/document-validation.ts @@ -56,7 +56,7 @@ export async function validateVendorDocument(file: File): Promise MAX_VIDEO_BYTES) { return "Videos must be 100 MB or smaller."; } - // Duration is checked client-side only; unreadable metadata never blocks. + // 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.`; 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 77429d75..e19640d5 100644 --- a/src/domain/work-orders/utils/work-order-media-limits.ts +++ b/src/domain/work-orders/utils/work-order-media-limits.ts @@ -5,7 +5,8 @@ import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils * (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 - * checked client-side only (the server cannot probe it cheaply). Documents + * 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; diff --git a/src/lib/probe-video-duration.ts b/src/lib/probe-video-duration.ts index 61f047f6..d09bec1c 100644 --- a/src/lib/probe-video-duration.ts +++ b/src/lib/probe-video-duration.ts @@ -1,7 +1,8 @@ /** * Best-effort client-side video duration probe. Resolves null when the browser * cannot decode the container's metadata — callers must not block the upload in - * that case (SH-116: duration is a client-side check only). + * that case (SH-116: the server enforces the limit from the MP4/MOV header and + * also lets unreadable metadata through). */ export const VIDEO_DURATION_PROBE_TIMEOUT_MS = 10_000; From c99e81d0d7ea537403edeeb54666f107d4b4dc82 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 22:01:22 -0300 Subject: [PATCH 05/12] fix(media): pre-check duration and per-work-order counts on Extra Docs Extra Docs advertised the 90-second limit but only checked type and size. Both dispatcher surfaces now share one screening step (type, size, count, duration), and the Completion Doc media tab and Extra Docs count the whole work order's photos and videos rather than only their own tab's share. Failed local uploads no longer count toward the limit. --- .../tabs/slide-over-completion-media.tsx | 4 + .../detail/tabs/slide-over-completion-tab.tsx | 4 + .../detail/tabs/slide-over-extras-tab.tsx | 39 ++++----- .../detail/use-slide-over-media-state.ts | 5 ++ .../detail/use-work-order-slide-over.ts | 2 + .../detail/work-order-slide-over-tabs.tsx | 2 + .../_components/media/media-uploader.tsx | 59 +++---------- .../utils/screen-work-order-uploads.ts | 64 +++++++++++++++ .../utils/work-order-media-limits.ts | 4 +- .../media-uploader-dropzone.test.tsx | 17 ++++ ...slide-over-extras-upload-contract.test.tsx | 82 +++++++++++++++++++ .../utils/work-order-media-limits.test.ts | 3 +- 12 files changed, 215 insertions(+), 70 deletions(-) create mode 100644 src/domain/work-orders/utils/screen-work-order-uploads.ts create mode 100644 src/test/app/(protected)/workorders/slide-over-extras-upload-contract.test.tsx 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({ = { @@ -52,11 +54,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) { @@ -175,6 +182,7 @@ export function SlideOverExtrasTab({ pending = false, avetaRequired = false, workOrderId, + mediaCounts, onUpload, onRemove, onCategorize, @@ -182,23 +190,16 @@ export function SlideOverExtrasTab({ const inputRef = useRef(null); const avetaAttached = hasExplicitAvetaFile(uploads); - const addFiles = (files: FileList | null) => { + const addFiles = async (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( - "Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV. Documents: PDF, DOC.", - ); - continue; - } - const sizeRejection = workOrderFileSizeRejection(file); - if (sizeRejection) { - toast.error(sizeRejection); - continue; - } - accepted.push(file); - } + // Snapshot synchronously: the input is reset right after this handler returns. + const selected = Array.from(files); + const { accepted, rejections } = await screenWorkOrderUploads(selected, { + existing: mediaCounts ?? countWorkOrderMediaKinds(uploads), + isTypeAllowed: isWorkOrderExtraDocTypeAllowed, + unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE, + }); + for (const message of rejections) toast.error(message); if (accepted.length > 0) { onUpload(accepted); } @@ -235,7 +236,7 @@ export function SlideOverExtrasTab({ onDragOver={(event) => event.preventDefault()} onDrop={(event) => { event.preventDefault(); - addFiles(event.dataTransfer.files); + void addFiles(event.dataTransfer.files); }} className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-(--color-bg-muted) disabled:opacity-60" style={{ @@ -264,7 +265,7 @@ export function SlideOverExtrasTab({ hidden disabled={pending} onChange={(event) => { - 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; 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."; -const PHOTO_COUNT_MESSAGE = `A work order can have at most ${MAX_PHOTOS_PER_WORK_ORDER} photos.`; -const VIDEO_COUNT_MESSAGE = `A work order can have at most ${MAX_VIDEOS_PER_WORK_ORDER} videos.`; -const VIDEO_DURATION_MESSAGE = `Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`; - -async function isWithinContract(file: File, kind: WorkOrderUploadKind): Promise { - if (kind === "video") { - // SH-116: pre-check only; the server enforces it too. Unreadable metadata never blocks. - const duration = await probeVideoDurationSeconds(file); - if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) { - toast.error(VIDEO_DURATION_MESSAGE); - return false; - } - } - return true; -} export function MediaUploader({ uploads, @@ -65,6 +47,7 @@ export function MediaUploader({ onChange, readOnly = false, pending = false, + mediaCounts, }: MediaUploaderProps) { const inputRef = useRef(null); @@ -74,32 +57,12 @@ export function MediaUploader({ // 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); - const counts = countWorkOrderMediaKinds(uploads); - const accepted: File[] = []; - for (const file of selected) { - if (!isWorkOrderMediaTypeAllowed(file)) { - toast.error(UNSUPPORTED_TYPE_MESSAGE); - continue; - } - const sizeRejection = workOrderFileSizeRejection(file); - if (sizeRejection) { - toast.error(sizeRejection); - continue; - } - const kind = resolveWorkOrderUploadKind(file); - if (kind === "photo" && counts.photos >= MAX_PHOTOS_PER_WORK_ORDER) { - toast.error(PHOTO_COUNT_MESSAGE); - continue; - } - if (kind === "video" && counts.videos >= MAX_VIDEOS_PER_WORK_ORDER) { - toast.error(VIDEO_COUNT_MESSAGE); - continue; - } - if (!(await isWithinContract(file, kind))) continue; - if (kind === "photo") counts.photos += 1; - if (kind === "video") counts.videos += 1; - accepted.push(file); - } + const { accepted, rejections } = await screenWorkOrderUploads(selected, { + existing: mediaCounts ?? countWorkOrderMediaKinds(uploads), + isTypeAllowed: isWorkOrderMediaTypeAllowed, + unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE, + }); + for (const message of rejections) toast.error(message); if (accepted.length === 0) return; if (onUpload) { 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..f91a48a1 --- /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[] }; + +/** + * SH-116 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 e19640d5..824477ee 100644 --- a/src/domain/work-orders/utils/work-order-media-limits.ts +++ b/src/domain/work-orders/utils/work-order-media-limits.ts @@ -135,11 +135,11 @@ 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 }>, + uploads: Array<{ fileType?: string; isLegacy?: boolean; uploadStatus?: string }>, ): WorkOrderMediaKindCounts { return uploads.reduce( (counts, item) => { - if (item.isLegacy) return counts; + if (item.isLegacy || item.uploadStatus === "failed") return counts; if (item.fileType === "image") counts.photos += 1; if (item.fileType === "video") counts.videos += 1; return counts; 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 20a71923..9aae2d73 100644 --- a/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx +++ b/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx @@ -126,6 +126,23 @@ describe("MediaUploader media contract (Completion Doc → Media, Photos & Video 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(); 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..d319d350 --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-extras-upload-contract.test.tsx @@ -0,0 +1,82 @@ +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(); + }); +}); 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 02ace4bb..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 @@ -68,13 +68,14 @@ describe("work-order-media-limits", () => { expect(formatMediaSizeLimit(MAX_WORK_ORDER_VIDEO_BYTES)).toBe("100 MB"); }); - it("counts only non-legacy photos and videos toward the per-work-order limits", () => { + 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 }); }); From cfe83fc4890978c6adf3cfd4ff82e0953fde3e06 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 22:14:42 -0300 Subject: [PATCH 06/12] fix(media): read video duration from the MP4/MOV header in the browser The browser pre-check decoded the picked file through a video element and an object URL, which CodeQL flags as DOM text reinterpreted as HTML. Parse the moov/mvhd movie header from file slices instead, the same way the server enforces the 90-second limit, so both sides read one duration. Unreadable headers still never block an upload. --- src/lib/probe-video-duration.ts | 102 +++++++++++++++------- src/test/lib/probe-video-duration.test.ts | 93 ++++++++++++++++++++ 2 files changed, 163 insertions(+), 32 deletions(-) create mode 100644 src/test/lib/probe-video-duration.test.ts diff --git a/src/lib/probe-video-duration.ts b/src/lib/probe-video-duration.ts index d09bec1c..29ae83cc 100644 --- a/src/lib/probe-video-duration.ts +++ b/src/lib/probe-video-duration.ts @@ -1,37 +1,75 @@ /** - * Best-effort client-side video duration probe. Resolves null when the browser - * cannot decode the container's metadata — callers must not block the upload in - * that case (SH-116: the server enforces the limit from the MP4/MOV header and - * also lets unreadable metadata through). + * 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 (SH-116: the server enforces the limit from the same + * header and also lets unreadable metadata through). */ -export const VIDEO_DURATION_PROBE_TIMEOUT_MS = 10_000; +const MAX_BOXES_PER_LEVEL = 1024; -export function probeVideoDurationSeconds(file: File): Promise { - return new Promise((resolve) => { - const url = URL.createObjectURL(file); - const video = document.createElement("video"); - let settled = false; +type Box = { bodyStart: number; end: number }; - const done = (value: number | null) => { - if (settled) return; - settled = true; - video.removeAttribute("src"); - video.load(); - URL.revokeObjectURL(url); - resolve(value); - }; - - const timeout = window.setTimeout(() => done(null), VIDEO_DURATION_PROBE_TIMEOUT_MS); - - video.preload = "metadata"; - video.onloadedmetadata = () => { - window.clearTimeout(timeout); - done(Number.isFinite(video.duration) ? video.duration : null); - }; - video.onerror = () => { - window.clearTimeout(timeout); - done(null); - }; - video.src = url; - }); +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/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(); + }); +}); From b31616893033f05b89be007c4e20c3b1277181d8 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 22:29:20 -0300 Subject: [PATCH 07/12] fix(vendor-portal): pre-check the per-work-order photo/video limit The vendor completion upload and uplift evidence checked type, size and duration but not the 10-photo / 3-video work-order limit. Use the counts the dispatch detail now reports (shoc-backend#173), on the same basis as the server: a new completion version does not count the document it replaces. When the backend does not report counts yet, the server check still applies. --- .../_components/dispatch-detail-sections.tsx | 2 + .../_components/uplift-request-card.tsx | 4 ++ .../_components/uplift-request-form.tsx | 10 ++- .../_components/uplift-requests-section.tsx | 7 ++ .../v/_components/vendor-portal-documents.tsx | 18 ++++- .../vendor-portal/lib/document-validation.ts | 56 +++++++++++++++- .../vendor-portal/types/vendor-portal.ts | 12 ++++ src/test/app/v/uplift-request-form.test.tsx | 10 +++ .../app/v/vendor-portal-documents.test.tsx | 67 +++++++++++++++++++ 9 files changed, 180 insertions(+), 6 deletions(-) diff --git a/src/app/v/[token]/dispatch/_components/dispatch-detail-sections.tsx b/src/app/v/[token]/dispatch/_components/dispatch-detail-sections.tsx index 1cac5391..c31fc93b 100644 --- a/src/app/v/[token]/dispatch/_components/dispatch-detail-sections.tsx +++ b/src/app/v/[token]/dispatch/_components/dispatch-detail-sections.tsx @@ -56,6 +56,7 @@ export function DispatchDetailSections({ token={token} dispatchId={dispatchId} documents={data.documents ?? []} + mediaCounts={data.mediaCounts} locked={locked} onChanged={refresh} /> @@ -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 f3ee9a48..63acfec8 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,9 @@ import { useRef, useState } from "react"; import { Text } from "@/components/ui/text"; -import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation"; +import { + validateVendorDocument, + type VendorMediaUsage, +} from "@/domain/vendor-portal/lib/document-validation"; import { isApprovalRequired, validateUpliftAmount, @@ -13,6 +16,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 +31,7 @@ export function UpliftRequestForm({ initialRequested = "", initialReason = "", hasExistingEvidence = false, + mediaUsage, submitting, uploadingPercent, errorMessage, @@ -57,7 +63,7 @@ export function UpliftRequestForm({ const chooseFile = async (file?: File) => { const selection = ++latestSelection.current; - const validationMessage = file ? await validateVendorDocument(file) : undefined; + 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 ?? ""); diff --git a/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx b/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx index 9fa98227..c8a10c01 100644 --- a/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx +++ b/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx @@ -4,10 +4,12 @@ import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; import { createUpliftRequestKey } from "@/domain/vendor-portal/lib/request-key"; import type { VendorPortalDocumentStatus, + VendorPortalMediaCounts, VendorPortalUpliftRequest, } from "@/domain/vendor-portal/types/vendor-portal"; import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form"; import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation"; +import { vendorMediaUsage } from "@/domain/vendor-portal/lib/document-validation"; import { UpliftRequestCard } from "@/app/v/[token]/dispatch/_components/uplift-request-card"; import { useVendorUpliftEvidence, @@ -24,6 +26,7 @@ type UpliftRequestsSectionProps = { locked: boolean; onChanged: () => Promise; pollDocument: (documentId: number) => Promise; + mediaCounts?: VendorPortalMediaCounts | null; }; type UseVendorUpliftActionsArgs = { @@ -168,7 +171,9 @@ export function UpliftRequestsSection({ locked, onChanged, pollDocument, + mediaCounts, }: UpliftRequestsSectionProps) { + const mediaUsage = vendorMediaUsage(mediaCounts, "evidence"); const evidence = useVendorUpliftEvidence({ pollDocument }); const { submitting, @@ -205,6 +210,7 @@ export function UpliftRequestsSection({ onWithdraw={(r) => void handleWithdraw(r)} onRevise={(r, values) => void handleRevise(r, values)} onCancelRevise={evidence.reset} + mediaUsage={mediaUsage} /> ))} @@ -212,6 +218,7 @@ export function UpliftRequestsSection({ Promise; }; @@ -24,6 +31,7 @@ export function VendorPortalDocuments({ dispatchId, documents, locked, + mediaCounts, onChanged, }: VendorPortalDocumentsProps) { const inputRef = useRef(null); @@ -62,7 +70,11 @@ export function VendorPortalDocuments({ return; } - const validationMessage = await validateVendorDocument(file); + const replaced = documents.find((document) => document.id === replacementId); + const validationMessage = await validateVendorDocument( + file, + vendorMediaUsage(mediaCounts, "completion", replaced?.contentType), + ); if (validationMessage) { setValidationError(validationMessage); return; diff --git a/src/domain/vendor-portal/lib/document-validation.ts b/src/domain/vendor-portal/lib/document-validation.ts index bf059bca..d62aa918 100644 --- a/src/domain/vendor-portal/lib/document-validation.ts +++ b/src/domain/vendor-portal/lib/document-validation.ts @@ -1,3 +1,4 @@ +import type { VendorPortalMediaCounts } from "@/domain/vendor-portal/types/vendor-portal"; import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; /** @@ -42,12 +43,65 @@ function resolveKind(file: File): VendorUploadKind | null { return KIND_BY_MIME[file.type.toLowerCase()] ?? KIND_BY_EXTENSION[extension] ?? null; } -export async function validateVendorDocument(file: File): Promise { +/** 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; } + 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."; } diff --git a/src/domain/vendor-portal/types/vendor-portal.ts b/src/domain/vendor-portal/types/vendor-portal.ts index d9853b07..75c3707a 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[]; + /** SH-116 per-work-order photo/video usage; absent until the backend reports it. */ + mediaCounts?: VendorPortalMediaCounts | null; } diff --git a/src/test/app/v/uplift-request-form.test.tsx b/src/test/app/v/uplift-request-form.test.tsx index 91cfc13e..84a612b6 100644 --- a/src/test/app/v/uplift-request-form.test.tsx +++ b/src/test/app/v/uplift-request-form.test.tsx @@ -38,6 +38,16 @@ 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("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 d97b1134..cee758e8 100644 --- a/src/test/app/v/vendor-portal-documents.test.tsx +++ b/src/test/app/v/vendor-portal-documents.test.tsx @@ -143,6 +143,73 @@ describe("VendorPortalDocuments", () => { 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 () => { From 6012ebabe72d75238d95d59fb537ecfc0793495f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 22:51:17 -0300 Subject: [PATCH 08/12] fix(media): count HEIC and untyped phone files, guard overlapping picks Persisted HEIC files and local files with an empty or octet-stream type were classified as "other", so they did not count toward the 10 photo / 3 video limit. Both classifiers now recognise them by extension. The Photos & Videos uploader and Extra Docs ignore a new selection while the previous one is still being screened, and the vendor portal drops an earlier pick whose video check finishes after a newer pick. Also removes ticket keys from source comments. --- .../detail/tabs/slide-over-extras-tab.tsx | 41 +++++++++++++------ .../_components/media/media-uploader.tsx | 34 +++++++++++---- .../v/_components/vendor-portal-documents.tsx | 7 ++++ .../vendor-portal/lib/document-validation.ts | 2 +- .../vendor-portal/types/vendor-portal.ts | 2 +- .../mappers/work-order-board-detail-mapper.ts | 2 +- .../utils/screen-work-order-uploads.ts | 2 +- .../utils/work-order-media-limits.ts | 2 +- .../utils/work-order-media-utils.ts | 5 +++ src/lib/probe-video-duration.ts | 2 +- ...slide-over-extras-upload-contract.test.tsx | 32 +++++++++++++++ .../app/v/vendor-portal-documents.test.tsx | 36 ++++++++++++++++ .../work-order-media-kind-counts.test.ts | 31 ++++++++++++++ 13 files changed, 171 insertions(+), 27 deletions(-) create mode 100644 src/test/domain/work-orders/utils/work-order-media-kind-counts.test.ts diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx index ec6963e0..a5d2e118 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx @@ -1,4 +1,4 @@ -import { useRef } from "react"; +import { useRef, useState } from "react"; import { File as FileIcon, FileText, @@ -18,7 +18,10 @@ import { hasExplicitAvetaFile, resolveExtraDocCategory, } from "@/domain/work-orders/utils/extra-docs-aveta"; -import { screenWorkOrderUploads } from "@/domain/work-orders/utils/screen-work-order-uploads"; +import { + screenWorkOrderUploads, + type WorkOrderUploadScreenResult, +} from "@/domain/work-orders/utils/screen-work-order-uploads"; import { canDeleteWorkOrderMedia, countWorkOrderMediaKinds, @@ -189,16 +192,30 @@ export function SlideOverExtrasTab({ }: 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 = async (files: FileList | null) => { - if (isReadOnly || pending || !files || files.length === 0) return; + 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); - const { accepted, rejections } = await screenWorkOrderUploads(selected, { - existing: mediaCounts ?? countWorkOrderMediaKinds(uploads), - isTypeAllowed: isWorkOrderExtraDocTypeAllowed, - unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE, - }); + 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); @@ -231,7 +248,7 @@ export function SlideOverExtrasTab({ <> + {!locked && ( + + )} +
+ + ); +} + type UploadRequest = { file: File; replacementId?: number; @@ -112,45 +170,14 @@ export function VendorPortalDocuments({ )} {documents.map((document) => ( -
-
- File - - {document.originalFileName} · v{document.version} - -
-
- Security scan - {document.scanStatus} -
-
- Review - {document.reviewStatus} -
- {Boolean(document.rejectionReason) && ( - {document.rejectionReason} - )} -
- - {!locked && ( - - )} -
-
+ void vendorPortalApi.downloadDocument(token, dispatchId, document)} + onReplace={() => chooseFile(document.id)} + /> ))} {progress !== undefined && ( From ef6cde64c897e2e2e24e524f1758d189af5a05a0 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 23:49:19 -0300 Subject: [PATCH 10/12] fix(vendor-portal): state the 10 MB PDF cap in the upload hint The vendor portal hint listed the photo and video limits but not the PDF one, so a vendor rejected for an 11 MB PDF saw a limit that was never stated. PDFs are the only accepted kind whose cap was missing from the copy; a test now pins the full hint. --- src/app/v/_components/vendor-portal-documents.tsx | 2 +- src/test/app/v/vendor-portal-documents.test.tsx | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/app/v/_components/vendor-portal-documents.tsx b/src/app/v/_components/vendor-portal-documents.tsx index 5ebc1538..d1881ba4 100644 --- a/src/app/v/_components/vendor-portal-documents.tsx +++ b/src/app/v/_components/vendor-portal-documents.tsx @@ -203,7 +203,7 @@ export function VendorPortalDocuments({ Accepted: PDF, JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB · Videos up to 100 MB and - 90 s + 90 s · PDFs up to 10 MB )} diff --git a/src/test/app/v/vendor-portal-documents.test.tsx b/src/test/app/v/vendor-portal-documents.test.tsx index 85d18ef8..a0085787 100644 --- a/src/test/app/v/vendor-portal-documents.test.tsx +++ b/src/test/app/v/vendor-portal-documents.test.tsx @@ -109,6 +109,16 @@ describe("VendorPortalDocuments", () => { 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 From 39ee98e09b75f0fea0b298010f4563a181991584 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 01:43:34 -0300 Subject: [PATCH 11/12] fix(vendor-portal): advertise phone photo and video types for uplift evidence The evidence picker filtered to PDF/JPG/PNG and its label and missing-file messages said the same, while the pre-check (and the server, which applies the SH-116 allowlist and work-order counts to UpliftEvidence uploads) accepts HEIC, MP4 and MOV. On a phone the picker hid exactly the photos and clips the validator would pass. The evidence input now uses the same accept filter as the completion upload, exported once from document-validation, and the label and both missing-evidence messages name all six types. --- e2e/vendors/vendor-uplift-workflow.spec.ts | 2 +- .../_components/uplift-request-form.tsx | 7 +++-- .../_components/uplift-requests-section.tsx | 2 +- .../v/_components/vendor-portal-documents.tsx | 3 ++- .../vendor-portal/lib/document-validation.ts | 4 +++ .../vendor-portal/lib/uplift-validation.ts | 2 +- src/test/app/v/uplift-request-form.test.tsx | 27 ++++++++++++++++++- 7 files changed, 40 insertions(+), 7 deletions(-) diff --git a/e2e/vendors/vendor-uplift-workflow.spec.ts b/e2e/vendors/vendor-uplift-workflow.spec.ts index 69205a2e..76dbe2cd 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/v/[token]/dispatch/_components/uplift-request-form.tsx b/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx index 63acfec8..fa50f2b9 100644 --- a/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx +++ b/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx @@ -2,6 +2,7 @@ import { useRef, useState } from "react"; import { Text } from "@/components/ui/text"; import { validateVendorDocument, + VENDOR_UPLOAD_ACCEPT, type VendorMediaUsage, } from "@/domain/vendor-portal/lib/document-validation"; import { @@ -122,13 +123,15 @@ export function UpliftRequestForm({
- + void chooseFile(event.target.files?.[0])} />
diff --git a/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx b/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx index c8a10c01..625bdda9 100644 --- a/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx +++ b/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx @@ -77,7 +77,7 @@ function useVendorUpliftActions({ if (requiresApproval) { if (!values.evidenceFile) { setFormError( - "Attach PDF, JPG, or PNG evidence — approval-required requests need evidence.", + "Attach PDF, JPG, PNG, HEIC, MP4, or MOV evidence — approval-required requests need evidence.", ); return; } diff --git a/src/app/v/_components/vendor-portal-documents.tsx b/src/app/v/_components/vendor-portal-documents.tsx index d1881ba4..97fed559 100644 --- a/src/app/v/_components/vendor-portal-documents.tsx +++ b/src/app/v/_components/vendor-portal-documents.tsx @@ -5,6 +5,7 @@ import { VendorPortalFeedback } from "@/app/v/_components/vendor-portal-feedback import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; import { validateVendorDocument, + VENDOR_UPLOAD_ACCEPT, vendorMediaUsage, } from "@/domain/vendor-portal/lib/document-validation"; import type { @@ -160,7 +161,7 @@ export function VendorPortalDocuments({ ref={inputRef} type="file" aria-label="Choose completion document" - accept=".pdf,.jpg,.jpeg,.png,.heic,.mp4,.mov,application/pdf,image/jpeg,image/png,image/heic,video/mp4,video/quicktime" + accept={VENDOR_UPLOAD_ACCEPT} hidden onChange={(event) => void uploadSelectedFile(event.target.files?.[0])} /> diff --git a/src/domain/vendor-portal/lib/document-validation.ts b/src/domain/vendor-portal/lib/document-validation.ts index 9d9ddc39..ee7969ab 100644 --- a/src/domain/vendor-portal/lib/document-validation.ts +++ b/src/domain/vendor-portal/lib/document-validation.ts @@ -13,6 +13,10 @@ 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 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/test/app/v/uplift-request-form.test.tsx b/src/test/app/v/uplift-request-form.test.tsx index 84a612b6..d39d6d47 100644 --- a/src/test/app/v/uplift-request-form.test.tsx +++ b/src/test/app/v/uplift-request-form.test.tsx @@ -1,4 +1,4 @@ -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"; @@ -48,6 +48,31 @@ describe("UpliftRequestForm revise mode", () => { 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("reports the current NTE threshold in the actionable error", () => { renderForm({ initialRequested: "900", currentNte: 1000 }); From d653ca00954c37da0824549ca6348496f23953c4 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 01:50:27 -0300 Subject: [PATCH 12/12] fix(vendor-portal): hold uplift submit until the evidence check settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking a video as uplift evidence reads its movie header before the file is stored in form state. A submit that landed inside that window validated the previous (usually empty) evidence, showed the missing-evidence error and dropped the request, forcing a second click. The submit button is now disabled and reads "Checking evidence…" while the latest pick is being checked, and the submit handler ignores submissions in that window. --- .../_components/uplift-request-form.tsx | 15 +++++++-- src/test/app/v/uplift-request-form.test.tsx | 32 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) 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 fa50f2b9..0ee1744a 100644 --- a/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx +++ b/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx @@ -43,12 +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, @@ -64,11 +67,13 @@ export function UpliftRequestForm({ 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; @@ -147,8 +152,14 @@ export function UpliftRequestForm({ {shownError}
- {mode === "revise" && (