From c99e81d0d7ea537403edeeb54666f107d4b4dc82 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 22:01:22 -0300 Subject: [PATCH] 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 }); });