fix: preserve retry and batch media limits

This commit is contained in:
Alexandre Brandizzi 2026-09-23 05:17:54 -03:00
parent f822c2cc9b
commit 340dd5252b
13 changed files with 207 additions and 9 deletions

View file

@ -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";
}

View file

@ -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}
/>
</div>

View file

@ -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}
/>
);

View file

@ -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<string | undefined>): boolean {
function getEmergencyUploaderOptions(
emergencyMedia: boolean,
limits: Parameters<typeof emergencyMediaSizeError>[1],
policy?: Parameters<typeof emergencyMediaBatchCountError>[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,

View file

@ -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) => {

View file

@ -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<typeof emergencyMediaSizeError>[1],
policy?: Parameters<typeof emergencyMediaBatchCountError>[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);

View file

@ -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<HTMLInputElement>(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;

View file

@ -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 (
<div>
<VendorPortalFeedback tone="error">{safeMediaUploadErrorMessage(error)}</VendorPortalFeedback>
{!locked && error instanceof MediaUploadNetworkError && (
{!locked && isRetryableMediaUploadError(error) && (
<button type="button" className="vp-btn vp-btn-ghost" onClick={onRetry}>
Retry upload
</button>

View file

@ -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<MediaUploadPolicy, "limits" | "remaining">,
): 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";
}

View file

@ -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.",
);
});
});

View file

@ -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(
<SlideOverCompletionMedia
isReadOnly={false}
media={[]}
mediaPending={false}
onUploadMedia={onUploadMedia}
batchFileError={result.current.emergencyMediaBatchFileError}
/>,
);
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()

View file

@ -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 }) =>

View file

@ -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"),