From 340dd5252b60b51042f8208fbe3083171f9744c9 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 05:17:54 -0300 Subject: [PATCH] fix: preserve retry and batch media limits --- src/api/media-upload-client.ts | 8 ++ .../tabs/slide-over-completion-media.tsx | 3 + .../detail/tabs/slide-over-completion-tab.tsx | 3 + .../detail/use-slide-over-media-state.ts | 10 ++- .../detail/work-order-slide-over-tabs.tsx | 1 + .../_components/media/media-upload-modal.tsx | 9 ++- .../_components/media/media-uploader.tsx | 7 ++ .../_components/vendor-media-section.tsx | 4 +- .../utils/emergency-media-limits.ts | 13 ++++ .../media-upload-modal-api.test.tsx | 31 ++++++++ .../use-slide-over-media-state.test.tsx | 77 ++++++++++++++++++- src/test/app/v/vendor-media-section.test.tsx | 36 +++++++++ .../utils/emergency-media-limits.test.ts | 14 ++++ 13 files changed, 207 insertions(+), 9 deletions(-) diff --git a/src/api/media-upload-client.ts b/src/api/media-upload-client.ts index ec5eb07c..5bc0ff63 100644 --- a/src/api/media-upload-client.ts +++ b/src/api/media-upload-client.ts @@ -33,6 +33,14 @@ export function isNetworkUploadError(error: unknown): boolean { return error instanceof MediaUploadNetworkError; } +/** Retryable HTTP failures keep the same client idempotency key/session. */ +export function isRetryableMediaUploadError(error: unknown): boolean { + if (isNetworkUploadError(error)) return true; + if (!(error instanceof Error) || error.name !== "MediaUploadHttpError") return false; + const status = (error as Error & { status?: unknown }).status; + return typeof status === "number" && status >= 500 && status <= 599; +} + export function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === "AbortError"; } 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 7908c1f8..9e5f1fdc 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 @@ -21,6 +21,7 @@ type SlideOverCompletionMediaProps = { typeErrorMessage?: string; acceptAttribute?: string; fileSizeError?: (file: File) => string | undefined; + batchFileError?: (file: File, acceptedFiles: readonly File[]) => string | undefined; uploadHint?: string; }; @@ -39,6 +40,7 @@ export function SlideOverCompletionMedia({ typeErrorMessage, acceptAttribute, fileSizeError, + batchFileError, uploadHint, }: SlideOverCompletionMediaProps) { return ( @@ -64,6 +66,7 @@ export function SlideOverCompletionMedia({ typeErrorMessage={typeErrorMessage} acceptAttribute={acceptAttribute} fileSizeError={fileSizeError} + batchFileError={batchFileError} uploadHint={uploadHint} /> diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx index ef631d45..e473bf9e 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx @@ -36,6 +36,7 @@ type SlideOverCompletionTabProps = { typeErrorMessage?: string; acceptAttribute?: string; fileSizeError?: (file: File) => string | undefined; + batchFileError?: (file: File, acceptedFiles: readonly File[]) => string | undefined; uploadHint?: string; onUploadCompletionPdf?: (file: File) => void; onOpenCompletionDoc: () => void; @@ -59,6 +60,7 @@ export function SlideOverCompletionTab({ typeErrorMessage, acceptAttribute, fileSizeError, + batchFileError, uploadHint, onUploadCompletionPdf, onOpenCompletionDoc, @@ -85,6 +87,7 @@ export function SlideOverCompletionTab({ typeErrorMessage={typeErrorMessage} acceptAttribute={acceptAttribute} fileSizeError={fileSizeError} + batchFileError={batchFileError} uploadHint={uploadHint} /> ); 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 227af08b..99373113 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 @@ -21,6 +21,7 @@ import { } from "@/domain/work-orders/utils/partition-slide-over-media"; import { classifyMediaKind, + emergencyMediaBatchCountError, emergencyMediaAcceptAttribute, emergencyMediaSizeError, isEmergencyReactiveWorkOrderType, @@ -254,7 +255,7 @@ function hasEmergencyMediaType(...types: Array): boolean { function getEmergencyUploaderOptions( emergencyMedia: boolean, - limits: Parameters[1], + policy?: Parameters[2], ) { if (!emergencyMedia) { return { @@ -262,6 +263,7 @@ function getEmergencyUploaderOptions( emergencyMediaTypeErrorMessage: undefined, emergencyMediaAcceptAttribute: undefined, emergencyMediaSizeError: undefined, + emergencyMediaBatchFileError: undefined, emergencyMediaUploadHint: undefined, }; } @@ -270,7 +272,9 @@ function getEmergencyUploaderOptions( emergencyMediaTypeErrorMessage: "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported.", emergencyMediaAcceptAttribute: emergencyMediaAcceptAttribute(), - emergencyMediaSizeError: (file: File) => emergencyMediaSizeError(file, limits), + emergencyMediaSizeError: (file: File) => emergencyMediaSizeError(file, policy?.limits), + emergencyMediaBatchFileError: (file: File, acceptedFiles: readonly File[]) => + emergencyMediaBatchCountError(file, acceptedFiles, policy), emergencyMediaUploadHint: "Photos up to 10 MB · Videos up to 100 MB and 90 seconds", }; } @@ -376,7 +380,7 @@ export function useSlideOverMediaState({ deleteMediaMutation.isPending, categorizeMediaMutation.isPending, ); - const emergencyUploaderOptions = getEmergencyUploaderOptions(emergencyMedia, mediaPolicy?.limits); + const emergencyUploaderOptions = getEmergencyUploaderOptions(emergencyMedia, mediaPolicy); return { completionMedia, 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 54513fc2..6f357870 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 @@ -132,6 +132,7 @@ export function WorkOrderSlideOverTabs({ typeErrorMessage={state.emergencyMediaTypeErrorMessage} acceptAttribute={state.emergencyMediaAcceptAttribute} fileSizeError={state.emergencyMediaSizeError} + batchFileError={state.emergencyMediaBatchFileError} uploadHint={state.emergencyMediaUploadHint} completionPending={state.completionPending} onUploadMedia={(requests) => { diff --git a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx index 7f824186..7c770571 100644 --- a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx +++ b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx @@ -18,6 +18,7 @@ import { } from "@/domain/work-orders/use-cases/use-work-order-mutations"; import { classifyMediaKind, + emergencyMediaBatchCountError, emergencyMediaAcceptAttribute, emergencyMediaSizeError, isEmergencyReactiveWorkOrderType, @@ -60,11 +61,13 @@ function emergencyPolicyMessage( function getEmergencyUploaderOptions( isEmergency: boolean, - limits: Parameters[1], + policy?: Parameters[2], ) { if (!isEmergency) return {}; return { - fileSizeError: (file: File) => emergencyMediaSizeError(file, limits), + fileSizeError: (file: File) => emergencyMediaSizeError(file, policy?.limits), + batchFileError: (file: File, acceptedFiles: readonly File[]) => + emergencyMediaBatchCountError(file, acceptedFiles, policy), uploadHint: "Photos up to 10 MB · Videos up to 100 MB and 90 seconds", typeAllowed: (file: File) => classifyMediaKind(file) !== null, typeErrorMessage: "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported.", @@ -95,7 +98,7 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr const pending = uploadMutation.isPending || deleteMutation.isPending; const handleClose = guardDialogClose(pending, () => onOpenChange(false)); - const emergencyUploaderOptions = getEmergencyUploaderOptions(isEmergency, policy?.limits); + const emergencyUploaderOptions = getEmergencyUploaderOptions(isEmergency, policy); const requireVersion = (): string | null => { const version = resolveWorkOrderVersion(detail?.info.rowVersion, row.rowVersion); diff --git a/src/app/(protected)/workorders/_components/media/media-uploader.tsx b/src/app/(protected)/workorders/_components/media/media-uploader.tsx index 187d464c..3854f5f8 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx @@ -37,6 +37,7 @@ type MediaUploaderProps = { typeErrorMessage?: string; acceptAttribute?: string; fileSizeError?: (file: File) => string | undefined; + batchFileError?: (file: File, acceptedFiles: readonly File[]) => string | undefined; uploadHint?: string; }; @@ -55,6 +56,7 @@ export function MediaUploader({ typeErrorMessage = "Unsupported file type.", acceptAttribute, fileSizeError, + batchFileError, uploadHint, }: MediaUploaderProps) { const inputRef = useRef(null); @@ -74,6 +76,11 @@ export function MediaUploader({ toast.error(sizeError); continue; } + const batchError = batchFileError?.(file, accepted); + if (batchError) { + toast.error(batchError); + continue; + } if (isWorkOrderFileTooLarge(file)) { toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`); continue; diff --git a/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx b/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx index 45392523..523e136e 100644 --- a/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx +++ b/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx @@ -7,7 +7,7 @@ import type { VendorMediaItem, VendorPortalMedia, } from "@/domain/vendor-portal/types/vendor-portal"; -import { MediaUploadNetworkError } from "@/api/media-upload-client"; +import { isRetryableMediaUploadError } from "@/api/media-upload-client"; import { uploadFileResumable } from "@/api/media-upload-engine"; import { validateMediaFileAgainstLimits } from "@/domain/work-orders/utils/emergency-media-limits"; import { @@ -112,7 +112,7 @@ function VendorUploadError({ return (
{safeMediaUploadErrorMessage(error)} - {!locked && error instanceof MediaUploadNetworkError && ( + {!locked && isRetryableMediaUploadError(error) && ( diff --git a/src/domain/work-orders/utils/emergency-media-limits.ts b/src/domain/work-orders/utils/emergency-media-limits.ts index d96bceef..dd0e4d26 100644 --- a/src/domain/work-orders/utils/emergency-media-limits.ts +++ b/src/domain/work-orders/utils/emergency-media-limits.ts @@ -59,6 +59,19 @@ export function mediaKindLimitReachedNotice(kind: MediaKind, limits: MediaUpload return `${kind} limit reached (${max} per work order). Remove a file to add another.`; } +export function emergencyMediaBatchCountError( + file: File, + acceptedFiles: readonly File[], + policy?: Pick, +): string | undefined { + const kind = classifyMediaKind(file); + if (!kind || !policy) return undefined; + const available = kind === "Photo" ? policy.remaining.photos : policy.remaining.videos; + const alreadyAccepted = acceptedFiles.filter((accepted) => classifyMediaKind(accepted) === kind); + if (alreadyAccepted.length < available) return undefined; + return mediaKindLimitReachedNotice(kind, defaultLimits(policy.limits)); +} + export function emergencyMediaAcceptAttribute(): string { return "image/jpeg,image/jpg,image/png,image/heic,video/mp4,video/quicktime,.jpg,.jpeg,.png,.heic,.mp4,.mov"; } 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 f992bf5a..c39317af 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 @@ -288,4 +288,35 @@ describe("MediaUploadModal API wiring", () => { expect(resumableUploadMutateAsync).not.toHaveBeenCalled(); expect(uploadMutateAsync).not.toHaveBeenCalled(); }); + + it("rejects excess Emergency files in a multi-select batch using remaining policy slots", async () => { + mediaQueryState.policy = { + workOrderType: "Emergency", + canUpload: true, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 1, videos: 1 }, + }; + resumableUploadMutateAsync.mockResolvedValue({ id: "uploaded" }); + renderModal(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const photoOne = new File(["one"], "one.jpg", { type: "image/jpeg" }); + const photoTwo = new File(["two"], "two.jpg", { type: "image/jpeg" }); + const video = new File(["clip"], "clip.mov", { type: "video/quicktime" }); + fireEvent.change(input, { target: { files: [photoOne, photoTwo, video] } }); + + await waitFor(() => expect(resumableUploadMutateAsync).toHaveBeenCalledTimes(2)); + expect(resumableUploadMutateAsync.mock.calls.map(([request]) => request.file)).toEqual([ + photoOne, + video, + ]); + expect(toast.error).toHaveBeenCalledWith( + "Photo limit reached (10 per work order). Remove a file to add another.", + ); + }); }); diff --git a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx index 90ae47e0..c511d72f 100644 --- a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx +++ b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx @@ -1,8 +1,9 @@ -import { act, renderHook, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useSlideOverMediaState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-media-state"; import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { SlideOverCompletionMedia } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media"; const policyState = vi.hoisted(() => ({ data: undefined as unknown, @@ -460,6 +461,80 @@ describe("useSlideOverMediaState completion filter", () => { ).toBe('"photo.jpg" exceeds the photo limit (10 MB).'); }); + it("exposes per-batch remaining-slot validation to the slide-over uploader", () => { + policyState.data = { + workOrderType: "Reactive", + canUpload: true, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 1, videos: 1 }, + }; + policyState.isLoading = false; + const { result } = renderHook(() => + useSlideOverMediaState({ + detailForRow: detailWithMedia([], { type: "Reactive" }), + uploadMediaMutation: idleMutation(), + deleteMediaMutation: idleMutation(), + categorizeMediaMutation: idleMutation(), + }), + ); + const photo = new File(["photo"], "photo.jpg", { type: "image/jpeg" }); + const video = new File(["video"], "video.mov", { type: "video/quicktime" }); + + expect(result.current.emergencyMediaBatchFileError?.(photo, [])).toBeUndefined(); + expect(result.current.emergencyMediaBatchFileError?.(video, [photo])).toBeUndefined(); + expect(result.current.emergencyMediaBatchFileError?.(photo, [photo])).toBe( + "Photo limit reached (10 per work order). Remove a file to add another.", + ); + }); + + it("keeps multi-select within Reactive remaining slots on the visible slide-over surface", () => { + policyState.data = { + workOrderType: "Reactive", + canUpload: true, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 1, videos: 3 }, + }; + policyState.isLoading = false; + const { result } = renderHook(() => + useSlideOverMediaState({ + detailForRow: detailWithMedia([], { type: "Reactive" }), + uploadMediaMutation: idleMutation(), + deleteMediaMutation: idleMutation(), + categorizeMediaMutation: idleMutation(), + }), + ); + const onUploadMedia = vi.fn(); + render( + , + ); + const photoOne = new File(["one"], "one.jpg", { type: "image/jpeg" }); + const photoTwo = new File(["two"], "two.jpg", { type: "image/jpeg" }); + fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, { + target: { files: [photoOne, photoTwo] }, + }); + + expect(onUploadMedia).toHaveBeenCalledTimes(1); + expect(onUploadMedia.mock.calls[0]?.[0].map((request) => request.file)).toEqual([photoOne]); + }); + it("reuses the same idempotency key when a failed media tile is retried", async () => { const mutateAsync = vi .fn() diff --git a/src/test/app/v/vendor-media-section.test.tsx b/src/test/app/v/vendor-media-section.test.tsx index 2fca289f..5e2c8948 100644 --- a/src/test/app/v/vendor-media-section.test.tsx +++ b/src/test/app/v/vendor-media-section.test.tsx @@ -79,6 +79,42 @@ describe("Vendor media controls", () => { expect(upload.mock.calls[1]?.[0].idempotencyKey).toBe(upload.mock.calls[0]?.[0].idempotencyKey); }); + it("offers same-session retry after a transient HTTP 503", async () => { + const unavailable = Object.assign(new Error("Upload request failed (HTTP 503)."), { + name: "MediaUploadHttpError", + status: 503, + }); + const upload = vi + .spyOn(mediaUploadEngine, "uploadFileResumable") + .mockRejectedValueOnce(unavailable) + .mockResolvedValue({ + uploadId: "upload-503", + state: "Completed", + chunkSizeBytes: 5_242_880, + sizeBytes: 5, + receivedChunks: [0], + expiresAt: "2026-09-24T00:00:00Z", + attachment: { id: 2 }, + }); + renderVendorMedia(); + + const file = new File(["video"], "clip.mov", { type: "video/quicktime" }); + await waitFor(() => + expect(screen.getByRole("button", { name: "Upload photo or video" })).toBeEnabled(), + ); + fireEvent.change(screen.getByLabelText("Choose photos or videos"), { + target: { files: [file] }, + }); + await waitFor(() => + expect(screen.getByRole("button", { name: "Retry upload" })).toBeInTheDocument(), + ); + fireEvent.click(screen.getByRole("button", { name: "Retry upload" })); + + await waitFor(() => expect(upload).toHaveBeenCalledTimes(2)); + expect(upload.mock.calls[1]?.[0].file).toBe(file); + expect(upload.mock.calls[1]?.[0].idempotencyKey).toBe(upload.mock.calls[0]?.[0].idempotencyKey); + }); + it("aborts the active vendor upload from the visible Cancel control", async () => { const upload = vi.spyOn(mediaUploadEngine, "uploadFileResumable").mockImplementation( ({ signal }) => diff --git a/src/test/domain/work-orders/utils/emergency-media-limits.test.ts b/src/test/domain/work-orders/utils/emergency-media-limits.test.ts index 1bd381bb..a896d4db 100644 --- a/src/test/domain/work-orders/utils/emergency-media-limits.test.ts +++ b/src/test/domain/work-orders/utils/emergency-media-limits.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { classifyMediaKind, + emergencyMediaBatchCountError, emergencyMediaAcceptAttribute, isEmergencyReactiveWorkOrderType, mediaKindLimitReachedNotice, @@ -81,6 +82,19 @@ describe("Emergency/Reactive media limits", () => { expect(result.ok).toBe(false); }); + it("counts already accepted files in the current batch against remaining slots", () => { + const policy = { limits: BASE_POLICY.limits, remaining: { photos: 1, videos: 1 } }; + const firstPhoto = fileOf(10, "first.jpg", "image/jpeg"); + const extraPhoto = fileOf(10, "extra.jpg", "image/jpeg"); + const firstVideo = fileOf(10, "first.mov", "video/quicktime"); + + expect(emergencyMediaBatchCountError(firstPhoto, [], policy)).toBeUndefined(); + expect(emergencyMediaBatchCountError(firstVideo, [firstPhoto], policy)).toBeUndefined(); + expect(emergencyMediaBatchCountError(extraPhoto, [firstPhoto], policy)).toBe( + "Photo limit reached (10 per work order). Remove a file to add another.", + ); + }); + it("renders a standard safe over-limit notice without raw payloads", async () => { const notice = mediaOverLimitNotice( fileOf(100_000_001, "big.mov", "video/quicktime"),