From 42d26b8a6bdc70c1b28e9c3b281d6e58f29f189e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 04:17:18 -0300 Subject: [PATCH 01/10] feat: add resumable SH-116 media uploads --- src/api/api-paths.ts | 2 + src/api/media-upload-client.ts | 160 ++++++ src/api/media-upload-contracts.ts | 130 +++++ src/api/media-upload-engine.ts | 309 +++++++++++ .../detail/use-slide-over-media-state.ts | 42 +- .../_components/media/media-upload-modal.tsx | 37 +- .../media/media-uploader-dropzone.tsx | 4 +- .../_components/media/media-uploader.tsx | 18 +- .../_hooks/use-media-upload-session.ts | 57 +- .../_components/dispatch-detail-sections.tsx | 3 + .../_components/vendor-media-section.tsx | 255 +++++++++ .../vendor-portal/api/vendor-portal-api.ts | 2 +- .../api/vendor-portal-media-api.ts | 49 ++ .../vendor-portal/types/vendor-portal.ts | 17 + .../api/work-order-media-upload-api.ts | 31 ++ .../use-resumable-work-order-media-upload.ts | 85 +++ .../use-cases/use-work-order-media-policy.ts | 24 + .../use-cases/use-work-order-mutations.ts | 1 + .../utils/emergency-media-limits.ts | 151 ++++++ src/infra/query-key/query-key.ts | 3 + src/test/api/media-upload-engine.test.ts | 511 ++++++++++++++++++ .../media-upload-modal-api.test.tsx | 33 ++ .../slide-over-signed-pdf-upload.test.tsx | 11 + .../use-slide-over-media-state.test.tsx | 76 ++- ...work-order-slide-over-failed-save.test.tsx | 11 + src/test/app/v/vendor-media-section.test.tsx | 113 ++++ .../api/vendor-portal-media-api.test.ts | 162 ++++++ .../api/work-order-media-upload-api.test.ts | 162 ++++++ .../utils/emergency-media-limits.test.ts | 136 +++++ 29 files changed, 2565 insertions(+), 30 deletions(-) create mode 100644 src/api/media-upload-client.ts create mode 100644 src/api/media-upload-contracts.ts create mode 100644 src/api/media-upload-engine.ts create mode 100644 src/app/v/[token]/dispatch/_components/vendor-media-section.tsx create mode 100644 src/domain/vendor-portal/api/vendor-portal-media-api.ts create mode 100644 src/domain/work-orders/api/work-order-media-upload-api.ts create mode 100644 src/domain/work-orders/use-cases/use-resumable-work-order-media-upload.ts create mode 100644 src/domain/work-orders/use-cases/use-work-order-media-policy.ts create mode 100644 src/domain/work-orders/utils/emergency-media-limits.ts create mode 100644 src/test/api/media-upload-engine.test.ts create mode 100644 src/test/app/v/vendor-media-section.test.tsx create mode 100644 src/test/domain/vendor-portal/api/vendor-portal-media-api.test.ts create mode 100644 src/test/domain/work-orders/api/work-order-media-upload-api.test.ts create mode 100644 src/test/domain/work-orders/utils/emergency-media-limits.test.ts diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index b5da6165..ccb94228 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -41,6 +41,8 @@ export const API_PATHS = { `workorders/${id}/media/${mediaId}`, mediaContent: (id: string | number, mediaId: string | number) => `workorders/${id}/media/${mediaId}/content`, + mediaPolicy: (id: string | number) => `workorders/${id}/media/policy`, + mediaUploads: (id: string | number) => `workorders/${id}/media/uploads`, completionDoc: (id: string | number) => `workorders/${id}/completion-doc`, uplifts: (id: string | number) => `workorders/${id}/uplifts`, upliftCancel: (workOrderId: string | number, upliftId: string | number) => diff --git a/src/api/media-upload-client.ts b/src/api/media-upload-client.ts new file mode 100644 index 00000000..ec5eb07c --- /dev/null +++ b/src/api/media-upload-client.ts @@ -0,0 +1,160 @@ +import { + type MediaChunkAckDto, + type MediaUploadCreateDto, + type MediaUploadSessionDto, +} from "@/api/media-upload-contracts"; + +export interface MediaUploadEndpointSet { + create(dto: MediaUploadCreateDto, signal?: AbortSignal): Promise; + putChunk(args: { + uploadId: string; + index: number; + blob: Blob; + start: number; + end: number; + totalBytes: number; + sha256: string; + signal?: AbortSignal; + }): Promise; + commit(uploadId: string, signal?: AbortSignal): Promise; + getStatus(uploadId: string, signal?: AbortSignal): Promise; + cancel(uploadId: string): Promise; +} + +/** Network-level failure (fetch rejected) — recoverable by re-reading session status. */ +export class MediaUploadNetworkError extends Error { + constructor() { + super("Upload failed. Check your connection and try again."); + this.name = "MediaUploadNetworkError"; + } +} + +export function isNetworkUploadError(error: unknown): boolean { + return error instanceof MediaUploadNetworkError; +} + +export function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; +} + +export async function computeSha256Hex(buffer: ArrayBuffer): Promise { + const digest = await crypto.subtle.digest("SHA-256", buffer); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +interface EndpointOptions { + resolveUrl: (suffix: string) => string; + headers: () => Record; +} + +function parseSessionPayload(payload: unknown): MediaUploadSessionDto { + if (typeof payload !== "object" || payload === null) { + throw new MediaUploadNetworkError(); + } + return payload as MediaUploadSessionDto; +} + +async function readJsonBody(response: Response): Promise { + const text = await response.text(); + if (!text.trim()) { + return {}; + } + try { + return JSON.parse(text) as unknown; + } catch { + throw new MediaUploadNetworkError(); + } +} + +async function requestJson( + url: string, + init: RequestInit, + authHeaders: Record, +): Promise { + let response: Response; + try { + response = await fetch(url, { + ...init, + headers: { Accept: "application/json", ...authHeaders, ...init.headers }, + }); + } catch (error) { + if (isAbortError(error)) { + throw error; + } + throw new MediaUploadNetworkError(); + } + + const payload = await readJsonBody(response); + if (!response.ok) { + const error = new Error(`Upload request failed (HTTP ${response.status}).`); + error.name = "MediaUploadHttpError"; + Object.assign(error, { status: response.status, payload }); + throw error; + } + return payload; +} + +export function createMediaUploadEndpoints({ + resolveUrl, + headers, +}: EndpointOptions): MediaUploadEndpointSet { + const sessionUrl = (uploadId: string, suffix = ""): string => + resolveUrl([uploadId, suffix].filter(Boolean).join("/")); + + return { + create: async (dto, signal) => + parseSessionPayload( + await requestJson( + resolveUrl(""), + { + method: "POST", + body: JSON.stringify(dto), + headers: { "Content-Type": "application/json" }, + signal, + }, + headers(), + ), + ), + + putChunk: async ({ uploadId, index, blob, start, end, totalBytes, sha256, signal }) => { + const payload = await requestJson( + sessionUrl(uploadId, `chunks/${index}`), + { + method: "PUT", + body: blob, + headers: { + "Content-Type": "application/octet-stream", + "Content-Range": `bytes ${start}-${end - 1}/${totalBytes}`, + "X-Chunk-SHA256": sha256.toLowerCase(), + }, + signal, + }, + headers(), + ); + return payload as MediaChunkAckDto; + }, + + commit: async (uploadId, signal) => + parseSessionPayload( + await requestJson(sessionUrl(uploadId, "commit"), { method: "POST", signal }, headers()), + ), + + getStatus: async (uploadId, signal) => + parseSessionPayload( + await requestJson(sessionUrl(uploadId), { method: "GET", signal }, headers()), + ), + + cancel: async (uploadId) => { + try { + await fetch(sessionUrl(uploadId), { + method: "DELETE", + headers: { Accept: "application/json", ...headers() }, + }); + } catch { + // Best-effort cancel — surface nothing when the session is already gone. + } + }, + }; +} diff --git a/src/api/media-upload-contracts.ts b/src/api/media-upload-contracts.ts new file mode 100644 index 00000000..baf1663a --- /dev/null +++ b/src/api/media-upload-contracts.ts @@ -0,0 +1,130 @@ +/** Shared SH-116 resumable media upload contract (dispatcher + vendor portal). */ + +export const MEDIA_UPLOAD_CHUNK_SIZE_BYTES = 5_242_880; + +export const MEDIA_UPLOAD_SCANNING_STATE = "Scanning" as const; + +export type UploadSurface = "WorkOrderTable" | "CompletionMedia" | "VendorPortal"; + +export type UploadCategory = "Before" | "After" | "Extra" | "Aveta"; + +export type MediaKind = "Photo" | "Video"; + +export type MediaUploadState = + "Created" | "Uploading" | "Scanning" | "Completed" | "Rejected" | "Canceled" | "Expired"; + +export interface MediaUploadLimits { + photoMaxBytes: number; + videoMaxBytes: number; + videoMaxSeconds: number; + maxPhotos: number; + maxVideos: number; +} + +export interface MediaUploadRemaining { + photos: number; + videos: number; +} + +export interface MediaUploadPolicy { + workOrderType: string; + canUpload: boolean; + limits: MediaUploadLimits; + remaining: MediaUploadRemaining; +} + +export interface MediaUploadCreateDto { + idempotencyKey: string; + fileName: string; + contentType: string; + sizeBytes: number; + category?: UploadCategory; + surface: UploadSurface; +} + +export interface MediaUploadSessionDto { + uploadId: string; + state: MediaUploadState; + chunkSizeBytes: number; + sizeBytes: number; + receivedChunks: number[]; + expiresAt: string; + attachment?: { id: string | number }; +} + +export interface MediaChunkAckDto { + index: number; + sha256: string; + sizeBytes: number; + receivedChunks: number[]; +} + +export const MEDIA_UPLOAD_TERMINAL_STATES: ReadonlySet = new Set([ + "Completed", + "Rejected", + "Canceled", + "Expired", +]); + +export function isTerminalMediaUploadState(state: MediaUploadState): boolean { + return MEDIA_UPLOAD_TERMINAL_STATES.has(state); +} + +export function isUploadCategory(value: string | undefined): value is UploadCategory { + return value === "Before" || value === "After" || value === "Extra" || value === "Aveta"; +} + +/** Safe, stable user-facing messages. Never render raw server payloads. */ +export function safeMediaUploadErrorMessage(error: Error): string { + if (error instanceof MediaUploadValidationError) { + return error.message; + } + if (error instanceof MediaUploadRejectedError) { + return "The file was rejected by the security scan and was not saved."; + } + if (error instanceof MediaUploadExpiredError) { + return "The upload session expired. Please try again."; + } + if (error instanceof MediaUploadScanTimeoutError) { + return "The upload is still processing. Check back shortly to see the result."; + } + if (error.name === "AbortError") { + return "Upload canceled."; + } + return "Upload failed. Check your connection and try again."; +} + +export class MediaUploadValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "MediaUploadValidationError"; + } +} + +export class MediaUploadRejectedError extends Error { + constructor() { + super("The file was rejected by the security scan."); + this.name = "MediaUploadRejectedError"; + } +} + +export class MediaUploadExpiredError extends Error { + constructor() { + super("The upload session expired."); + this.name = "MediaUploadExpiredError"; + } +} + +export class MediaUploadCanceledError extends Error { + constructor() { + super("The upload session was canceled."); + this.name = "MediaUploadCanceledError"; + } +} + +export class MediaUploadScanTimeoutError extends Error { + constructor() { + super("The upload is still processing."); + this.name = "MediaUploadScanTimeoutError"; + } +} diff --git a/src/api/media-upload-engine.ts b/src/api/media-upload-engine.ts new file mode 100644 index 00000000..aa42f9d0 --- /dev/null +++ b/src/api/media-upload-engine.ts @@ -0,0 +1,309 @@ +import { + type MediaChunkAckDto, + type MediaUploadCreateDto, + type MediaUploadSessionDto, + type UploadCategory, + type UploadSurface, + MEDIA_UPLOAD_CHUNK_SIZE_BYTES, + MediaUploadCanceledError, + MediaUploadExpiredError, + MediaUploadRejectedError, + MediaUploadScanTimeoutError, + isTerminalMediaUploadState, +} from "@/api/media-upload-contracts"; +import { + type MediaUploadEndpointSet, + computeSha256Hex, + isAbortError, + isNetworkUploadError, +} from "@/api/media-upload-client"; + +const MAX_CREATE_ATTEMPTS = 2; +const MAX_CHUNK_RECOVERY_ATTEMPTS = 3; +const CHUNK_RECOVERY_BACKOFF_MS = 200; +const MAX_COMMIT_ATTEMPTS = 2; +const MAX_POLL_ATTEMPTS = 120; +const DEFAULT_POLL_DELAY_MS = 1_500; + +export interface ResumableUploadArgs { + file: File; + endpoints: MediaUploadEndpointSet; + surface: UploadSurface; + category?: UploadCategory; + signal?: AbortSignal; + onProgress?: (percent: number) => void; + /** Defaults to a fresh random UUID per attempt. */ + idempotencyKey?: string; + digest?: (buffer: ArrayBuffer) => Promise; + pollDelayMs?: number; +} + +export function chunkCountFor(sizeBytes: number, chunkSizeBytes: number): number { + if (sizeBytes <= 0) return 1; + return Math.ceil(sizeBytes / chunkSizeBytes); +} + +export function missingChunkIndices(received: readonly number[], total: number): number[] { + const acked = new Set(received); + const missing: number[] = []; + for (let index = 0; index < total; index += 1) { + if (!acked.has(index)) { + missing.push(index); + } + } + return missing; +} + +function acknowledgedBytes( + received: readonly number[], + totalBytes: number, + chunkSize: number, +): number { + let bytes = 0; + for (const index of received) { + if (index >= 0 && index < chunkCountFor(totalBytes, chunkSize)) { + bytes += Math.min(chunkSize, totalBytes - index * chunkSize); + } + } + return bytes; +} + +function reportProgress( + args: ResumableUploadArgs, + received: readonly number[], + totalBytes: number, + chunkSize: number, +): void { + if (!args.onProgress || totalBytes <= 0) return; + const percent = Math.min( + 100, + Math.round((acknowledgedBytes(received, totalBytes, chunkSize) / totalBytes) * 100), + ); + args.onProgress(percent); +} + +function assertNotAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new DOMException("The upload was aborted.", "AbortError"); + } +} + +function throwForTerminalState(state: MediaUploadSessionDto["state"]): void { + if (state === "Rejected") throw new MediaUploadRejectedError(); + if (state === "Expired") throw new MediaUploadExpiredError(); + if (state === "Canceled") throw new MediaUploadCanceledError(); +} + +/** Idempotent create: a network failure retries the same idempotency key, never a new session. */ +async function createSessionIdempotent( + endpoints: MediaUploadEndpointSet, + dto: MediaUploadCreateDto, + signal: AbortSignal | undefined, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < MAX_CREATE_ATTEMPTS; attempt += 1) { + try { + return await endpoints.create(dto, signal); + } catch (error) { + if (!isNetworkUploadError(error)) throw error; + lastError = error; + } + } + throw lastError; +} + +/** + * Network failure recovery: re-read the session; terminal states settle here, + * otherwise the returned receivedChunks define what still needs sending. + */ +async function recoverSession( + endpoints: MediaUploadEndpointSet, + uploadId: string, + signal: AbortSignal | undefined, +): Promise { + let session: MediaUploadSessionDto; + try { + session = await endpoints.getStatus(uploadId, signal); + } catch (error) { + if (isAbortError(error)) throw error; + if (isNetworkUploadError(error)) { + // Still offline — bubble so the attempt surfaces as retryable failure. + throw error; + } + throw error; + } + if (isTerminalMediaUploadState(session.state)) { + throwForTerminalState(session.state); + return session; + } + return session; +} + +async function waitForScanTerminal( + args: ResumableUploadArgs, + uploadId: string, +): Promise { + const delayMs = args.pollDelayMs ?? DEFAULT_POLL_DELAY_MS; + for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) { + assertNotAborted(args.signal); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + assertNotAborted(args.signal); + let session: MediaUploadSessionDto; + try { + session = await args.endpoints.getStatus(uploadId, args.signal); + } catch (error) { + if (isAbortError(error)) throw error; + continue; // Transient poll failure — keep polling until the deadline. + } + if (isTerminalMediaUploadState(session.state)) { + throwForTerminalState(session.state); + return session; + } + } + throw new MediaUploadScanTimeoutError(); +} + +async function commitAndObserve( + args: ResumableUploadArgs, + uploadId: string, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < MAX_COMMIT_ATTEMPTS; attempt += 1) { + try { + const committed = await args.endpoints.commit(uploadId, args.signal); + return committed.state === "Scanning" ? waitForScanTerminal(args, uploadId) : committed; + } catch (error) { + if (!isNetworkUploadError(error)) throw error; + lastError = error; + const observed = await args.endpoints.getStatus(uploadId, args.signal); + if (observed.state === "Completed") return observed; + if (isTerminalMediaUploadState(observed.state)) { + throwForTerminalState(observed.state); + } + if (observed.state === "Scanning") return waitForScanTerminal(args, uploadId); + if (attempt + 1 < MAX_COMMIT_ATTEMPTS) { + continue; // Commit is idempotent for this uploadId. + } + } + } + throw lastError; +} + +async function sendChunk( + args: ResumableUploadArgs, + uploadId: string, + index: number, + chunkSize: number, +): Promise { + const { file } = args; + const start = index * chunkSize; + const end = Math.min(start + chunkSize, file.size); + const blob = file.slice(start, end); + const buffer = await blob.arrayBuffer(); + const digest = args.digest ?? computeSha256Hex; + const sha256 = await digest(buffer); + return args.endpoints.putChunk({ + uploadId, + index, + blob, + start, + end, + totalBytes: file.size, + sha256, + signal: args.signal, + }); +} + +/** + * Upload one file through the resumable session API: create → chunks → commit → poll. + * One in-flight chunk request per file; durable progress counts only acknowledged parts. + */ +export async function uploadFileResumable( + args: ResumableUploadArgs, +): Promise { + assertNotAborted(args.signal); + const createDto: MediaUploadCreateDto = { + idempotencyKey: args.idempotencyKey ?? crypto.randomUUID(), + fileName: args.file.name, + contentType: args.file.type, + sizeBytes: args.file.size, + surface: args.surface, + ...(args.category ? { category: args.category } : {}), + }; + + let session = await createSessionIdempotent(args.endpoints, createDto, args.signal); + const uploadId = session.uploadId; + const chunkSize = + Number.isFinite(session.chunkSizeBytes) && session.chunkSizeBytes > 0 + ? session.chunkSizeBytes + : MEDIA_UPLOAD_CHUNK_SIZE_BYTES; + const totalChunks = chunkCountFor(args.file.size, chunkSize); + reportProgress(args, session.receivedChunks, args.file.size, chunkSize); + + try { + const recoveryAttempts = new Map(); + for (;;) { + assertNotAborted(args.signal); + const missing = missingChunkIndices(session.receivedChunks, totalChunks); + if (missing.length === 0) break; + for (const index of missing) { + assertNotAborted(args.signal); + try { + const ack = await sendChunk(args, session.uploadId, index, chunkSize); + const acked = Array.isArray(ack?.receivedChunks) ? ack.receivedChunks : []; + if (!acked.includes(index)) { + // Treat a malformed ack as not-yet-acked so the loop cannot spin forever. + throw new Error("Chunk acknowledgement missing from server response."); + } + session = { ...session, receivedChunks: acked }; + } catch (error) { + if (!isNetworkUploadError(error)) throw error; + session = await recoverSession(args.endpoints, session.uploadId, args.signal); + if (session.receivedChunks.includes(index)) { + recoveryAttempts.delete(index); + } else { + const attempts = (recoveryAttempts.get(index) ?? 0) + 1; + recoveryAttempts.set(index, attempts); + if (attempts >= MAX_CHUNK_RECOVERY_ATTEMPTS) throw error; + await new Promise((resolve) => + setTimeout(resolve, CHUNK_RECOVERY_BACKOFF_MS * attempts), + ); + assertNotAborted(args.signal); + } + break; // Re-derive missing indices from the recovered server state. + } + reportProgress(args, session.receivedChunks, args.file.size, chunkSize); + } + } + + assertNotAborted(args.signal); + session = await commitAndObserve(args, uploadId); + } catch (error) { + // Cancellation aborts the in-flight request and DELETEs the known session. + if (isAbortError(error)) { + await args.endpoints.cancel(uploadId); + } + throw error; + } + + throwForTerminalState(session.state); + reportProgress(args, session.receivedChunks, args.file.size, chunkSize); + return session; +} + +/** Upload a queue of files with at most `maxConcurrent` files in flight. */ +export async function runMediaUploadQueue( + items: readonly T[], + runner: (item: T) => Promise, + maxConcurrent = 2, +): Promise { + let cursor = 0; + const workers = Array.from({ length: Math.min(maxConcurrent, items.length) }, async () => { + while (cursor < items.length) { + const item = items[cursor]; + cursor += 1; + await runner(item); + } + }); + await Promise.all(workers); +} 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..7352fefb 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 @@ -11,12 +11,15 @@ import type { useUploadWorkOrderMedia, UploadWorkOrderMediaInput, } from "@/domain/work-orders/use-cases/use-work-order-mutations"; +import { useResumableWorkOrderMediaUpload } from "@/domain/work-orders/use-cases/use-resumable-work-order-media-upload"; +import { useWorkOrderMediaPolicy } from "@/domain/work-orders/use-cases/use-work-order-media-policy"; import { hasCompletionMediaGallery, isCompletionSurfaceMedia, isExtrasSurfaceMedia, type MediaSurfaceOwnership, } from "@/domain/work-orders/utils/partition-slide-over-media"; +import { isEmergencyReactiveWorkOrderType } from "@/domain/work-orders/utils/emergency-media-limits"; type UseSlideOverMediaStateArgs = { detailForRow?: WorkOrderBoardDetail; @@ -82,6 +85,16 @@ export function useSlideOverMediaState({ }: UseSlideOverMediaStateArgs) { const workOrderId = detailForRow?.info.id; const completionHasGallery = hasCompletionMediaGallery(detailForRow?.info.type); + const { data: mediaPolicy } = useWorkOrderMediaPolicy(workOrderId, workOrderId != null); + const resumableCompletionUpload = useResumableWorkOrderMediaUpload( + workOrderId ?? "", + "CompletionMedia", + mediaPolicy ?? undefined, + ); + const policyCanUpload = mediaPolicy?.canUpload === true; + const emergencyMedia = + isEmergencyReactiveWorkOrderType(mediaPolicy?.workOrderType) || + isEmergencyReactiveWorkOrderType(detailForRow?.info.type); const [mediaBatchPending, setMediaBatchPending] = useState(false); const [ownership, setOwnershipState] = useState(() => loadOwnership(workOrderId), @@ -103,7 +116,25 @@ export function useSlideOverMediaState({ ); const persistCompletionUpload = useCallback( - async (input: UploadWorkOrderMediaInput) => { + async (input: UploadWorkOrderMediaInput): Promise<{ id: string }> => { + if (emergencyMedia) { + if (!policyCanUpload) { + throw new Error("Uploads are not available for this work order."); + } + const session = await resumableCompletionUpload.mutateAsync(input); + const attachmentId = session.attachment?.id; + if (attachmentId == null) { + throw new Error( + "Upload completed but no media item was returned. Refresh and try again.", + ); + } + const mediaId = String(attachmentId); + updateOwnership((current) => ({ + completionOwnedIds: addOwnedId(new Set(current.completionOwnedIds), mediaId), + extraOwnedIds: dropOwnedId(new Set(current.extraOwnedIds), mediaId), + })); + return { id: mediaId }; + } const item = await uploadMediaMutation.mutateAsync(input); updateOwnership((current) => ({ completionOwnedIds: addOwnedId(new Set(current.completionOwnedIds), item.id), @@ -111,7 +142,13 @@ export function useSlideOverMediaState({ })); return item; }, - [updateOwnership, uploadMediaMutation], + [ + emergencyMedia, + policyCanUpload, + resumableCompletionUpload, + updateOwnership, + uploadMediaMutation, + ], ); const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } = @@ -135,6 +172,7 @@ export function useSlideOverMediaState({ mediaBatchPending || hasLocalUploading || uploadMediaMutation.isPending || + resumableCompletionUpload.isPending || deleteMediaMutation.isPending || categorizeMediaMutation.isPending; 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 e3aa23ee..5dd4f65f 100644 --- a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx +++ b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx @@ -9,11 +9,18 @@ import { Text } from "@/components/ui/text"; import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader"; import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { useResumableWorkOrderMediaUpload } from "@/domain/work-orders/use-cases/use-resumable-work-order-media-upload"; +import { useWorkOrderMediaPolicy } from "@/domain/work-orders/use-cases/use-work-order-media-policy"; import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; import { useDeleteWorkOrderMedia, useUploadWorkOrderMedia, } from "@/domain/work-orders/use-cases/use-work-order-mutations"; +import { + classifyMediaKind, + emergencyMediaAcceptAttribute, + isEmergencyReactiveWorkOrderType, +} from "@/domain/work-orders/utils/emergency-media-limits"; import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits"; import { guardDialogClose } from "@/lib/dialog-guard"; @@ -33,8 +40,19 @@ function resolveWorkOrderVersion( export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) { const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open); - const uploadMutation = useUploadWorkOrderMedia(row.id); + const { data: policy } = useWorkOrderMediaPolicy(row.id, open); + const legacyUploadMutation = useUploadWorkOrderMedia(row.id); + const resumableUploadMutation = useResumableWorkOrderMediaUpload( + row.id, + "WorkOrderTable", + policy ?? undefined, + ); const deleteMutation = useDeleteWorkOrderMedia(row.id); + const isEmergency = + isEmergencyReactiveWorkOrderType(policy?.workOrderType) || + isEmergencyReactiveWorkOrderType(row.type); + const uploadMutation = isEmergency ? resumableUploadMutation : legacyUploadMutation; + const emergencyUploadUnavailable = isEmergency && policy?.canUpload !== true; const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status); const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } = useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input)); @@ -105,9 +123,26 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr onRetry={retryUpload} onCancel={cancelUpload} readOnly={isReadOnly} + uploadDisabled={emergencyUploadUnavailable} pending={pending} + typeAllowed={isEmergency ? (file) => classifyMediaKind(file) !== null : undefined} + typeErrorMessage={ + isEmergency + ? "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported." + : undefined + } + acceptAttribute={isEmergency ? emergencyMediaAcceptAttribute() : undefined} /> )} + {emergencyUploadUnavailable && !isReadOnly && ( +
+ + {policy + ? "Uploads are not available for this work order." + : "Loading upload permissions…"} + +
+ )}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx index 0f96ba9c..21c57a61 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx @@ -9,6 +9,7 @@ type MediaUploaderDropzoneProps = { inputRef: RefObject; pending: boolean; categorizeAfterUpload?: boolean; + acceptAttribute?: string; onFilesSelected: (files: FileList | null) => void; }; @@ -16,6 +17,7 @@ export function MediaUploaderDropzone({ inputRef, pending, categorizeAfterUpload = false, + acceptAttribute, onFilesSelected, }: MediaUploaderDropzoneProps) { return ( @@ -48,7 +50,7 @@ export function MediaUploaderDropzone({ void; + idempotencyKey?: string; }; type MediaUploaderProps = { @@ -30,6 +31,11 @@ type MediaUploaderProps = { onChange?: (next: MediaFile[]) => void; readOnly?: boolean; pending?: boolean; + uploadDisabled?: boolean; + /** Emergency/Reactive override: policy-driven type check incl. HEIC. */ + typeAllowed?: (file: File) => boolean; + typeErrorMessage?: string; + acceptAttribute?: string; }; export function MediaUploader({ @@ -42,6 +48,10 @@ export function MediaUploader({ onChange, readOnly = false, pending = false, + uploadDisabled = false, + typeAllowed, + typeErrorMessage = "Unsupported file type.", + acceptAttribute, }: MediaUploaderProps) { const inputRef = useRef(null); @@ -50,8 +60,9 @@ export function MediaUploader({ const accepted: File[] = []; for (const file of Array.from(files)) { - if (!isWorkOrderMediaTypeAllowed(file)) { - toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`); + const typeOk = typeAllowed ? typeAllowed(file) : isWorkOrderMediaTypeAllowed(file); + if (!typeOk) { + toast.error(`${file.name} — ${typeErrorMessage}`); continue; } if (isWorkOrderFileTooLarge(file)) { @@ -93,8 +104,9 @@ export function MediaUploader({ {!readOnly && ( )} diff --git a/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts b/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts index 3adf39ad..53fe731d 100644 --- a/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts +++ b/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts @@ -3,13 +3,19 @@ import type { MediaUploadRequest } from "@/app/(protected)/workorders/_component import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail"; +import { runMediaUploadQueue } from "@/api/media-upload-engine"; import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils"; -type LocalUpload = MediaFile & { sourceFile?: File; signal?: AbortSignal }; +type LocalUpload = MediaFile & { + sourceFile?: File; + signal?: AbortSignal; + idempotencyKey: string; +}; type UploadMutate = (input: { file: File; signal?: AbortSignal; + idempotencyKey: string; onProgress?: (percent: number) => void; }) => Promise; @@ -17,6 +23,7 @@ type SessionUploadRequest = { localId: string; file: File; signal: AbortSignal; + idempotencyKey: string; }; function revokeBlobUrl(url: string | undefined) { @@ -38,6 +45,7 @@ function toLocalUpload(request: SessionUploadRequest): LocalUpload { uploadStatus: "uploading" as const, sourceFile: request.file, signal: request.signal, + idempotencyKey: request.idempotencyKey, }; } @@ -148,32 +156,38 @@ export function useMediaUploadSession( localId: request.localId, file: request.file, signal: replaceController(request.localId).signal, + idempotencyKey: + request.idempotencyKey ?? + localUploads.find((item) => item.id === request.localId)?.idempotencyKey ?? + crypto.randomUUID(), })); const entries = sessionRequests.map(toLocalUpload); setLocalUploads((prev) => upsertUploads(prev, entries)); - for (const request of sessionRequests) { - void uploadMutateAsync({ - file: request.file, - signal: request.signal, - onProgress: (percent) => { - setLocalUploads((prev) => - prev.map((item) => - isSameAttempt(item, request) ? { ...item, progress: percent } : item, - ), - ); - }, - }) - .then(() => settleAttempt(request)) - .catch((error: Error) => { - if (error.name === "AbortError") { - settleAttempt(request); - return; - } - failAttempt(request, error); + void runMediaUploadQueue(sessionRequests, async (request) => { + try { + await uploadMutateAsync({ + file: request.file, + signal: request.signal, + idempotencyKey: request.idempotencyKey, + onProgress: (percent) => { + setLocalUploads((prev) => + prev.map((item) => + isSameAttempt(item, request) ? { ...item, progress: percent } : item, + ), + ); + }, }); - } + settleAttempt(request); + } catch (error) { + if ((error as Error).name === "AbortError") { + settleAttempt(request); + return; + } + failAttempt(request, error as Error); + } + }); } function retryUpload(localId: string) { @@ -184,6 +198,7 @@ export function useMediaUploadSession( localId, file: target.sourceFile, onProgress: () => undefined, + idempotencyKey: target.idempotencyKey, }, ]); } 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..5c6fe5ba 100644 --- a/src/app/v/[token]/dispatch/_components/dispatch-detail-sections.tsx +++ b/src/app/v/[token]/dispatch/_components/dispatch-detail-sections.tsx @@ -3,6 +3,7 @@ import { ChecklistSection } from "@/app/v/[token]/dispatch/_components/checklist import { CommentsSection } from "@/app/v/[token]/dispatch/_components/comments-section"; import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section"; import { UpliftRequestsSection } from "@/app/v/[token]/dispatch/_components/uplift-requests-section"; +import { VendorMediaCard } from "@/app/v/[token]/dispatch/_components/vendor-media-section"; import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents"; import type { VendorPortalDispatchDetail, @@ -61,6 +62,8 @@ export function DispatchDetailSections({ />
+ +
NTE Uplift Requests = 1_000_000) { + return `${Math.round(sizeBytes / 1_000_000)} MB`; + } + return `${Math.max(1, Math.round(sizeBytes / 1_000))} KB`; +} + +function viewMediaItem(item: VendorMediaItem, blob: Blob): void { + const url = URL.createObjectURL(blob); + window.open(url, "_blank", "noopener"); + window.setTimeout(() => URL.revokeObjectURL(url), 60_000); +} + +export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSectionProps) { + const inputRef = useRef(null); + const controllerRef = useRef(null); + const idempotencyKeyRef = useRef(undefined); + const failedFileRef = useRef(null); + const [loadState, setLoadState] = useState({ loading: true }); + const [progress, setProgress] = useState(); + const [uploadCanceled, setUploadCanceled] = useState(false); + + const load = useCallback(async () => { + setLoadState({ loading: true }); + try { + const media = await vendorPortalMediaApi.getMedia(token, dispatchId); + setLoadState({ loading: false, media }); + } catch { + setLoadState({ + loading: false, + error: "Unable to load media. Check your connection and try again.", + }); + } + }, [token, dispatchId]); + + useEffect(() => { + void load(); + }, [load]); + + const uploadMutation = useMutation({ + mutationFn: async (file: File) => { + const policy = loadState.media?.policy; + if (!policy?.canUpload) { + throw new MediaUploadValidationError("Uploading is not available for this dispatch."); + } + const check = await validateMediaFileAgainstLimits(file, policy); + if (!check.ok) { + throw new MediaUploadValidationError(check.reason); + } + return uploadFileResumable({ + file, + endpoints: vendorPortalMediaApi.endpoints(token, dispatchId), + surface: "VendorPortal", + idempotencyKey: idempotencyKeyRef.current, + signal: controllerRef.current?.signal, + onProgress: setProgress, + }); + }, + onSuccess: () => { + failedFileRef.current = null; + idempotencyKeyRef.current = undefined; + void load(); + }, + onError: (error: Error) => { + setUploadCanceled(error.name === "AbortError"); + }, + onSettled: () => { + setProgress(undefined); + controllerRef.current = null; + if (inputRef.current) { + inputRef.current.value = ""; + } + }, + }); + + const startUpload = (file: File | null, retry = false) => { + if (!file) return; + if (!retry || !idempotencyKeyRef.current) { + idempotencyKeyRef.current = crypto.randomUUID(); + } + failedFileRef.current = file; + controllerRef.current = new AbortController(); + setUploadCanceled(false); + uploadMutation.reset(); + uploadMutation.mutate(file); + }; + + const selectFile = (file: File | undefined) => startUpload(file ?? null); + + const handleView = async (item: VendorMediaItem) => { + try { + const blob = await vendorPortalMediaApi.fetchContent(token, dispatchId, item.id); + viewMediaItem(item, blob); + } catch { + setLoadState((current) => ({ + ...current, + error: "Unable to open the media file. Please try again.", + })); + } + }; + + const items = loadState.media?.items ?? []; + const canUpload = !locked && loadState.media?.policy.canUpload === true; + + return ( + <> + selectFile(event.target.files?.[0])} + /> + + {loadState.loading && Loading media…} + + {!loadState.loading && Boolean(loadState.error) && ( + <> + {loadState.error} +
+ +
+ + )} + + {!loadState.loading && !loadState.error && items.length === 0 && ( + No photos or videos uploaded yet. + )} + + {items.map((item) => ( +
+
+ File + + {item.originalFileName} · {item.kind} · {formatBytes(item.sizeBytes)} + +
+
+ +
+
+ ))} + + {uploadMutation.isPending && ( +
+ {progress !== undefined ? ( + <> + + Uploading… {progress}% + + ) : ( + Preparing upload… + )} + +
+ )} + + {uploadCanceled && Upload canceled.} + + {uploadMutation.isError && !uploadCanceled && ( +
+ + {safeMediaUploadErrorMessage(uploadMutation.error)} + + {!locked && uploadMutation.error instanceof MediaUploadNetworkError && ( + + )} +
+ )} + + {canUpload && ( +
+ +
+ )} + + {locked && ( + + Media is read-only while this dispatch is locked. + + )} + + ); +} + +export function VendorMediaCard(props: VendorMediaSectionProps) { + return ( +
+ Photos & Videos + +
+ ); +} diff --git a/src/domain/vendor-portal/api/vendor-portal-api.ts b/src/domain/vendor-portal/api/vendor-portal-api.ts index 5e16c3e0..faa9a1aa 100644 --- a/src/domain/vendor-portal/api/vendor-portal-api.ts +++ b/src/domain/vendor-portal/api/vendor-portal-api.ts @@ -113,7 +113,7 @@ async function downloadVendorDocument( ); } -async function vendorPortalCall( +export async function vendorPortalCall( token: string, path: string, options?: { method?: string; body?: unknown }, diff --git a/src/domain/vendor-portal/api/vendor-portal-media-api.ts b/src/domain/vendor-portal/api/vendor-portal-media-api.ts new file mode 100644 index 00000000..21908750 --- /dev/null +++ b/src/domain/vendor-portal/api/vendor-portal-media-api.ts @@ -0,0 +1,49 @@ +import { createMediaUploadEndpoints, type MediaUploadEndpointSet } from "@/api/media-upload-client"; +import { env } from "@/lib/env"; +import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url"; +import { vendorPortalCall } from "@/domain/vendor-portal/api/vendor-portal-api"; +import type { VendorPortalMedia } from "@/domain/vendor-portal/types/vendor-portal"; + +function vendorMediaUrl(dispatchId: string | number, suffix = ""): string { + const base = `${resolveApiBaseUrl(env.apiUrl)}/vendor-portal/dispatches/${dispatchId}/media`; + return suffix ? `${base}/${suffix}` : base; +} + +export const vendorPortalMediaApi = { + /** + * GET /api/vendor-portal/dispatches/{dispatchId}/media — policy plus scan-passed + * own-dispatch items. The server derives work-order type and own-dispatch scope. + */ + getMedia: (token: string, dispatchId: string | number): Promise => + vendorPortalCall(token, `/dispatches/${dispatchId}/media`), + + /** Content is fetched only on explicit user action; no object URLs are persisted. */ + fetchContent: async ( + token: string, + dispatchId: string | number, + mediaId: number, + ): Promise => { + const response = await fetch(vendorMediaUrl(dispatchId, `${mediaId}/content`), { + headers: { "X-Vendor-Token": token }, + }); + if (!response.ok) { + throw new Error( + response.status === 423 ? "File is still being scanned" : "Unable to load the media file.", + ); + } + return response.blob(); + }, + + /** + * Vendor transport bound to /api/vendor-portal/dispatches/{dispatchId}/media/uploads. + * X-Vendor-Token only — never the dispatcher Authorization header. + */ + endpoints: (token: string, dispatchId: string | number): MediaUploadEndpointSet => + createMediaUploadEndpoints({ + resolveUrl: (suffix) => { + const base = `${resolveApiBaseUrl(env.apiUrl)}/vendor-portal/dispatches/${dispatchId}/media/uploads`; + return suffix ? `${base}/${suffix}` : base; + }, + headers: () => ({ "X-Vendor-Token": token }), + }), +}; diff --git a/src/domain/vendor-portal/types/vendor-portal.ts b/src/domain/vendor-portal/types/vendor-portal.ts index d9853b07..38407b29 100644 --- a/src/domain/vendor-portal/types/vendor-portal.ts +++ b/src/domain/vendor-portal/types/vendor-portal.ts @@ -138,3 +138,20 @@ export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary signoffs: VendorPortalSignoff[]; documents: VendorPortalDocument[]; } + +export interface VendorMediaItem { + id: number; + originalFileName: string; + contentType: string; + sizeBytes: number; + kind: "Photo" | "Video"; + category: "Extra"; + scanStatus: "Passed"; + createdAt: string; + canView: boolean; +} + +export interface VendorPortalMedia { + policy: import("@/api/media-upload-contracts").MediaUploadPolicy; + items: VendorMediaItem[]; +} diff --git a/src/domain/work-orders/api/work-order-media-upload-api.ts b/src/domain/work-orders/api/work-order-media-upload-api.ts new file mode 100644 index 00000000..64be1ecc --- /dev/null +++ b/src/domain/work-orders/api/work-order-media-upload-api.ts @@ -0,0 +1,31 @@ +import { apiGet } from "@/api/api"; +import { API_PATHS } from "@/api/api-paths"; +import { createMediaUploadEndpoints, type MediaUploadEndpointSet } from "@/api/media-upload-client"; +import type { MediaUploadPolicy } from "@/api/media-upload-contracts"; +import { env } from "@/lib/env"; +import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url"; +import { getAccessToken } from "@/lib/auth/auth-storage"; + +function workOrderMediaUploadUrl(workOrderId: string | number, suffix: string): string { + const base = `${resolveApiBaseUrl(env.apiUrl)}/${API_PATHS.workOrder.mediaUploads(workOrderId)}`; + return suffix ? `${base}/${suffix}` : base; +} + +export const workOrdersMediaUploadApi = { + /** GET workorders/{woId}/media/policy — server-derived type, limits and remaining counts. */ + getMediaPolicy: async (workOrderId: string | number): Promise => + apiGet(API_PATHS.workOrder.mediaPolicy(workOrderId)), + + /** + * Dispatcher transport bound to /api/workorders/{woId}/media/uploads. + * Work-order binding comes from the URL; clients cannot repoint it. + */ + endpoints: (workOrderId: string | number): MediaUploadEndpointSet => + createMediaUploadEndpoints({ + resolveUrl: (suffix) => workOrderMediaUploadUrl(workOrderId, suffix), + headers: (): Record => { + const token = getAccessToken(); + return token ? { Authorization: token } : {}; + }, + }), +}; diff --git a/src/domain/work-orders/use-cases/use-resumable-work-order-media-upload.ts b/src/domain/work-orders/use-cases/use-resumable-work-order-media-upload.ts new file mode 100644 index 00000000..3e9686a8 --- /dev/null +++ b/src/domain/work-orders/use-cases/use-resumable-work-order-media-upload.ts @@ -0,0 +1,85 @@ +import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import { + type MediaUploadPolicy, + type MediaUploadSessionDto, + type UploadCategory, + type UploadSurface, + MediaUploadValidationError, + isUploadCategory, + safeMediaUploadErrorMessage, +} from "@/api/media-upload-contracts"; +import { uploadFileResumable } from "@/api/media-upload-engine"; +import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; +import type { UploadWorkOrderMediaInput } from "@/domain/work-orders/use-cases/use-work-order-mutations"; +import { workOrdersMediaUploadApi } from "@/domain/work-orders/api/work-order-media-upload-api"; +import { validateMediaFileAgainstLimits } from "@/domain/work-orders/utils/emergency-media-limits"; +import { queryKeys } from "@/infra/query-key/query-key"; + +function toUploadCategory( + category: UploadableMediaCategory | undefined, +): UploadCategory | undefined { + return typeof category === "string" && isUploadCategory(category) ? category : undefined; +} + +function invalidateMediaQueries( + queryClient: ReturnType, + workOrderId: string | number, +): void { + void queryClient.invalidateQueries({ + queryKey: queryKeys.workOrders.boardDetail(workOrderId), + }); + void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.boardMedia(workOrderId) }); + void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.mediaPolicy(workOrderId) }); + void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }); +} + +/** + * Emergency/Reactive resumable upload (5 MiB chunks, acknowledged-part progress, + * network-loss recovery by session status). Limits are enforced client-side from the + * server policy; the server re-checks everything at commit. Media surfaces render only + * Completed/scan-passed attachments. + */ +export function useResumableWorkOrderMediaUpload( + workOrderId: string | number, + surface: Extract, + policy?: MediaUploadPolicy, +): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + file, + category, + idempotencyKey, + signal, + onProgress, + }: UploadWorkOrderMediaInput) => { + if (!policy?.canUpload) { + throw new MediaUploadValidationError("Uploads are not available for this work order."); + } + const check = await validateMediaFileAgainstLimits(file, policy); + if (!check.ok) { + throw new MediaUploadValidationError(check.reason); + } + return uploadFileResumable({ + file, + endpoints: workOrdersMediaUploadApi.endpoints(workOrderId), + surface, + category: toUploadCategory(category), + idempotencyKey, + signal, + onProgress, + }); + }, + onSuccess: (session) => { + invalidateMediaQueries(queryClient, workOrderId); + if (session.state === "Completed") { + toast.success("File uploaded"); + } + }, + onError: (error: Error) => { + toast.error(safeMediaUploadErrorMessage(error)); + }, + }); +} diff --git a/src/domain/work-orders/use-cases/use-work-order-media-policy.ts b/src/domain/work-orders/use-cases/use-work-order-media-policy.ts new file mode 100644 index 00000000..7372d935 --- /dev/null +++ b/src/domain/work-orders/use-cases/use-work-order-media-policy.ts @@ -0,0 +1,24 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import type { MediaUploadPolicy } from "@/api/media-upload-contracts"; +import { workOrdersMediaUploadApi } from "@/domain/work-orders/api/work-order-media-upload-api"; +import { queryKeys } from "@/infra/query-key/query-key"; + +/** + * Server-derived Emergency/Reactive media policy: type, canUpload, limits and + * remaining counts (existing + pending reservations included server-side). + */ +export function useWorkOrderMediaPolicy( + workOrderId: string | number | undefined, + enabled: boolean, +): UseQueryResult { + return useQuery({ + queryKey: + workOrderId == null + ? [...queryKeys.workOrders.all, "mediaPolicy", "none"] + : queryKeys.workOrders.mediaPolicy(workOrderId), + queryFn: () => workOrdersMediaUploadApi.getMediaPolicy(workOrderId as string | number), + enabled: enabled && workOrderId != null, + staleTime: 15_000, + retry: false, + }); +} diff --git a/src/domain/work-orders/use-cases/use-work-order-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-mutations.ts index 156cdbde..c93f2307 100644 --- a/src/domain/work-orders/use-cases/use-work-order-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-mutations.ts @@ -52,6 +52,7 @@ export interface UpdateWorkOrderCommentInput { export interface UploadWorkOrderMediaInput { file: File; category?: UploadableMediaCategory; + idempotencyKey?: string; signal?: AbortSignal; onProgress?: (percent: number) => void; } diff --git a/src/domain/work-orders/utils/emergency-media-limits.ts b/src/domain/work-orders/utils/emergency-media-limits.ts new file mode 100644 index 00000000..b34a2cf3 --- /dev/null +++ b/src/domain/work-orders/utils/emergency-media-limits.ts @@ -0,0 +1,151 @@ +import type { MediaKind, MediaUploadLimits, MediaUploadPolicy } from "@/api/media-upload-contracts"; + +export const EMERGENCY_PHOTO_MAX_BYTES = 10_000_000; +export const EMERGENCY_VIDEO_MAX_BYTES = 100_000_000; +export const EMERGENCY_VIDEO_MAX_SECONDS = 90; + +const PHOTO_MIME_TYPES = new Set(["image/jpeg", "image/jpg", "image/png", "image/heic"]); + +const PHOTO_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".heic"]); + +const VIDEO_MIME_TYPES = new Set(["video/mp4", "video/quicktime"]); + +const VIDEO_EXTENSIONS = new Set([".mp4", ".mov"]); + +export type MediaValidationProbe = (file: File) => Promise; + +export type MediaValidationResult = { ok: true } | { ok: false; reason: string }; + +function fileExtension(file: File): string { + const name = file.name.toLowerCase(); + const dot = name.lastIndexOf("."); + return dot >= 0 ? name.slice(dot) : ""; +} + +export function classifyMediaKind(file: File): MediaKind | null { + const mime = file.type.toLowerCase(); + if (PHOTO_MIME_TYPES.has(mime) || PHOTO_EXTENSIONS.has(fileExtension(file))) { + return "Photo"; + } + if (VIDEO_MIME_TYPES.has(mime) || VIDEO_EXTENSIONS.has(fileExtension(file))) { + return "Video"; + } + return null; +} + +/** Inclusive limit: exactly at the cap passes, one byte over fails. */ +export function isMediaSizeWithinLimit( + file: File, + kind: MediaKind, + limits: MediaUploadLimits, +): boolean { + const maxBytes = kind === "Photo" ? limits.photoMaxBytes : limits.videoMaxBytes; + return file.size <= maxBytes; +} + +export function mediaOverLimitNotice( + file: File, + kind: MediaKind, + limits: MediaUploadLimits, +): string { + if (kind === "Video") { + return `"${file.name}" exceeds the video limit (${Math.round(limits.videoMaxBytes / 1_000_000)} MB, ${limits.videoMaxSeconds} seconds).`; + } + return `"${file.name}" exceeds the photo limit (${Math.round(limits.photoMaxBytes / 1_000_000)} MB).`; +} + +export function mediaKindLimitReachedNotice(kind: MediaKind, limits: MediaUploadLimits): string { + const max = kind === "Photo" ? limits.maxPhotos : limits.maxVideos; + return `${kind} limit reached (${max} per work order). Remove a file to add another.`; +} + +export function emergencyMediaAcceptAttribute(): string { + return "image/jpeg,image/jpg,image/png,image/heic,video/mp4,video/quicktime,.jpg,.jpeg,.png,.heic,.mp4,.mov"; +} + +/** + * Media limits apply only to Emergency/Reactive work orders; `canUpload` from the + * server policy is authoritative and work-order binding/category are fixed by route. + */ +export function isEmergencyReactiveWorkOrderType(workOrderType: string | undefined): boolean { + if (!workOrderType) return false; + const normalized = workOrderType.trim().toLowerCase(); + return normalized === "emergency" || normalized === "reactive"; +} + +/** + * Probe video duration from browser metadata. Returns null whenever probing or + * preview decoding fails (e.g. some MOV files) — unknown duration proceeds to the + * server, which enforces the cap at commit. Temporary preview URL is always revoked. + */ +export function probeVideoDurationSeconds(file: File): Promise { + return new Promise((resolve) => { + const url = URL.createObjectURL(file); + const video = document.createElement("video"); + let settled = false; + + const finish = (value: number | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + URL.revokeObjectURL(url); + video.removeAttribute("src"); + resolve(value); + }; + + const timer = setTimeout(() => finish(null), 4_000); + video.preload = "metadata"; + video.addEventListener("loadedmetadata", () => { + const duration = video.duration; + finish(Number.isFinite(duration) && duration > 0 ? duration : null); + }); + video.addEventListener("error", () => finish(null)); + video.src = url; + }); +} + +function defaultLimits(limits?: Partial): MediaUploadLimits { + return { + photoMaxBytes: limits?.photoMaxBytes ?? EMERGENCY_PHOTO_MAX_BYTES, + videoMaxBytes: limits?.videoMaxBytes ?? EMERGENCY_VIDEO_MAX_BYTES, + videoMaxSeconds: limits?.videoMaxSeconds ?? EMERGENCY_VIDEO_MAX_SECONDS, + maxPhotos: limits?.maxPhotos ?? 10, + maxVideos: limits?.maxVideos ?? 3, + }; +} + +/** + * Validate one media file against the policy limits: kind, inclusive size caps and + * duration when it can be probed. A failed duration probe keeps the file eligible. + */ +export async function validateMediaFileAgainstLimits( + file: File, + policy: Pick, + probe: MediaValidationProbe = probeVideoDurationSeconds, +): Promise { + const limits = defaultLimits(policy.limits); + const kind = classifyMediaKind(file); + if (!kind) { + return { + ok: false, + reason: "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported.", + }; + } + if (!isMediaSizeWithinLimit(file, kind, limits)) { + return { ok: false, reason: mediaOverLimitNotice(file, kind, limits) }; + } + const remaining = kind === "Photo" ? policy.remaining?.photos : policy.remaining?.videos; + if (typeof remaining === "number" && remaining <= 0) { + return { ok: false, reason: mediaKindLimitReachedNotice(kind, limits) }; + } + if (kind === "Video") { + const seconds = await probe(file); + if (seconds != null && seconds > limits.videoMaxSeconds) { + return { + ok: false, + reason: `"${file.name}" is longer than the ${limits.videoMaxSeconds} second video limit.`, + }; + } + } + return { ok: true }; +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index eb8a0664..2f4ef850 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -24,6 +24,7 @@ export const queryKeys = { detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const, boardDetail: (id: string | number) => [...queryKeys.workOrders.all, "boardDetail", id] as const, boardMedia: (id: string | number) => [...queryKeys.workOrders.all, "boardMedia", id] as const, + mediaPolicy: (id: string | number) => [...queryKeys.workOrders.all, "mediaPolicy", id] as const, dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const, taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const, dispatchers: () => [...queryKeys.workOrders.all, "dispatchers"] as const, @@ -159,5 +160,7 @@ export const queryKeys = { dispatches: (token: string) => [...queryKeys.vendorPortal.all, "dispatches", token] as const, dispatch: (token: string, id: string | number) => [...queryKeys.vendorPortal.all, "dispatch", token, id] as const, + media: (token: string, id: string | number) => + [...queryKeys.vendorPortal.all, "media", token, id] as const, }, } as const; diff --git a/src/test/api/media-upload-engine.test.ts b/src/test/api/media-upload-engine.test.ts new file mode 100644 index 00000000..e5245278 --- /dev/null +++ b/src/test/api/media-upload-engine.test.ts @@ -0,0 +1,511 @@ +// @vitest-environment node +// Node's fetch/Request/Blob follow WHATWG body-serialization rules; jsdom mangles raw +// chunk bodies (the same reason api-formdata-content-type.test.ts runs in node). +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + chunkCountFor, + missingChunkIndices, + runMediaUploadQueue, + uploadFileResumable, +} from "@/api/media-upload-engine"; +import { computeSha256Hex, createMediaUploadEndpoints } from "@/api/media-upload-client"; + +type CapturedRequest = { method: string; url: string; headers: Headers; body: unknown }; + +const BASE = "https://api.test/uploads"; + +function jsonResponse(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function fileOf(size: number, name = "clip.mp4", type = "video/mp4"): File { + return new File([new Uint8Array(size)], name, { type }); +} + +let captured: CapturedRequest[] = []; +let handler: (request: CapturedRequest) => Promise; + +function endpointsWith() { + return createMediaUploadEndpoints({ + resolveUrl: (suffix) => (suffix ? `${BASE}/${suffix}` : BASE), + headers: () => ({}), + }); +} + +beforeEach(() => { + captured = []; + handler = async () => jsonResponse({}); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + const body = + request.method === "PUT" + ? await request.arrayBuffer() + : await request.json().catch(() => ({})); + const capturedRequest: CapturedRequest = { + method: request.method, + url: request.url, + headers: request.headers, + body, + }; + captured.push(capturedRequest); + return handler(capturedRequest); + }), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function createdSession(sizeBytes: number, chunkSizeBytes: number) { + return { + uploadId: "u1", + state: "Uploading", + chunkSizeBytes, + sizeBytes, + receivedChunks: [], + expiresAt: "2026-09-24T00:00:00Z", + }; +} + +function completedSession(sizeBytes: number, chunkSizeBytes: number, received: number[]) { + return { + uploadId: "u1", + state: "Completed", + chunkSizeBytes, + sizeBytes, + receivedChunks: received, + expiresAt: "2026-09-24T00:00:00Z", + attachment: { id: 42 }, + }; +} + +describe("resumable media upload engine", () => { + it("sends raw chunk bytes with Content-Range, digest header, and acknowledged-only progress", async () => { + const file = fileOf(20); + handler = async (request) => { + if (request.method === "POST" && request.url === BASE) { + return jsonResponse(createdSession(20, 8)); + } + const match = /chunks\/(\d+)$/.exec(request.url); + if (request.method === "PUT" && match) { + const index = Number(match[1]); + const received = [0, 1, 2].filter((seen) => seen <= index); + return jsonResponse({ + index, + sha256: "server-digest", + sizeBytes: (request.body as ArrayBuffer).byteLength, + receivedChunks: received, + }); + } + if (request.method === "POST" && request.url.endsWith("/commit")) { + return jsonResponse(completedSession(20, 8, [0, 1, 2])); + } + return jsonResponse({}); + }; + + const percents: number[] = []; + const session = await uploadFileResumable({ + file, + endpoints: endpointsWith(), + surface: "WorkOrderTable", + onProgress: percents.push.bind(percents) as (percent: number) => void, + }); + + expect(session.state).toBe("Completed"); + expect(session.attachment?.id).toBe(42); + const puts = captured.filter((entry) => entry.method === "PUT"); + expect(puts).toHaveLength(3); + + const firstSlice = file.slice(0, 8); + const expectedDigest = await computeSha256Hex(await firstSlice.arrayBuffer()); + expect(puts[0]?.url).toBe(`${BASE}/u1/chunks/0`); + expect(puts[0]?.headers.get("content-range")).toBe("bytes 0-7/20"); + expect(puts[0]?.headers.get("x-chunk-sha256")).toBe(expectedDigest); + expect((puts[0]?.body as ArrayBuffer).byteLength).toBe(8); + expect(puts[2]?.headers.get("content-range")).toBe("bytes 16-19/20"); + expect((puts[2]?.body as ArrayBuffer).byteLength).toBe(4); + + // Progress derives from acknowledged chunks only (server receivedChunks), ending at 100. + expect(percents[percents.length - 1]).toBe(100); + expect(percents.every((percent, index) => index === 0 || percent >= percents[index - 1]!)).toBe( + true, + ); + }); + + it("recovers from network loss by re-reading session status and sending only missing chunks", async () => { + const file = fileOf(24); + let failedIndexOnce = false; + handler = async (request) => { + if (request.method === "POST" && request.url === BASE) { + return jsonResponse(createdSession(24, 8)); + } + const match = /chunks\/(\d+)$/.exec(request.url); + if (request.method === "PUT" && match) { + const index = Number(match[1]); + if (index === 1 && !failedIndexOnce) { + failedIndexOnce = true; + throw new TypeError("network down"); + } + const received = Array.from({ length: index + 1 }, (_, seen) => seen); + return jsonResponse({ index, sha256: "x", sizeBytes: 8, receivedChunks: received }); + } + if (request.method === "GET" && request.url === `${BASE}/u1`) { + return jsonResponse({ ...createdSession(24, 8), receivedChunks: [0] }); + } + if (request.method === "POST" && request.url.endsWith("/commit")) { + return jsonResponse(completedSession(24, 8, [0, 1, 2])); + } + return jsonResponse({}); + }; + + const session = await uploadFileResumable({ + file, + endpoints: endpointsWith(), + surface: "WorkOrderTable", + }); + + expect(session.state).toBe("Completed"); + const putIndices = captured + .filter((entry) => entry.method === "PUT") + .map((entry) => Number(/chunks\/(\d+)$/.exec(entry.url)?.[1])); + expect(putIndices).toEqual([0, 1, 1, 2]); + expect(captured.some((entry) => entry.method === "GET" && entry.url === `${BASE}/u1`)).toBe( + true, + ); + }); + + it("stops retrying a persistently failing chunk after bounded status recovery", async () => { + const file = fileOf(8); + let puts = 0; + let statusReads = 0; + handler = async (request) => { + if (request.method === "POST" && request.url === BASE) { + return jsonResponse(createdSession(8, 8)); + } + if (request.method === "PUT") { + puts += 1; + throw new TypeError("network down"); + } + if (request.method === "GET" && request.url === `${BASE}/u1`) { + statusReads += 1; + return jsonResponse(createdSession(8, 8)); + } + return jsonResponse({}); + }; + + await expect( + uploadFileResumable({ file, endpoints: endpointsWith(), surface: "WorkOrderTable" }), + ).rejects.toMatchObject({ name: "MediaUploadNetworkError" }); + expect(puts).toBe(3); + expect(statusReads).toBe(3); + }); + + it("recovers a lost commit response from Completed status without a duplicate commit", async () => { + const file = fileOf(8); + let commits = 0; + handler = async (request) => { + if (request.method === "POST" && request.url === BASE) { + return jsonResponse(createdSession(8, 8)); + } + if (request.method === "PUT") { + return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] }); + } + if (request.method === "POST" && request.url.endsWith("/commit")) { + commits += 1; + throw new TypeError("commit response lost"); + } + if (request.method === "GET" && request.url === `${BASE}/u1`) { + return jsonResponse(completedSession(8, 8, [0])); + } + return jsonResponse({}); + }; + + const result = await uploadFileResumable({ + file, + endpoints: endpointsWith(), + surface: "WorkOrderTable", + }); + expect(result.state).toBe("Completed"); + expect(result.attachment?.id).toBe(42); + expect(commits).toBe(1); + expect(captured.filter((entry) => entry.method === "GET")).toHaveLength(1); + }); + + it("retries the same idempotent session and sends only chunks missing after partial failure", async () => { + const file = fileOf(16); + const idempotencyKey = "11111111-2222-3333-4444-555555555555"; + let createCount = 0; + let chunkOneFailures = 0; + handler = async (request) => { + if (request.method === "POST" && request.url === BASE) { + createCount += 1; + return jsonResponse({ + ...createdSession(16, 8), + receivedChunks: createCount === 1 ? [] : [0], + }); + } + if (request.method === "PUT" && request.url.endsWith("/chunks/0")) { + return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] }); + } + if (request.method === "PUT" && request.url.endsWith("/chunks/1")) { + chunkOneFailures += 1; + if (chunkOneFailures <= 3) throw new TypeError("network down"); + return jsonResponse({ index: 1, sha256: "x", sizeBytes: 8, receivedChunks: [0, 1] }); + } + if (request.method === "GET" && request.url === `${BASE}/u1`) { + return jsonResponse({ ...createdSession(16, 8), receivedChunks: [0] }); + } + if (request.method === "POST" && request.url.endsWith("/commit")) { + return jsonResponse(completedSession(16, 8, [0, 1])); + } + return jsonResponse({}); + }; + + await expect( + uploadFileResumable({ + file, + endpoints: endpointsWith(), + surface: "WorkOrderTable", + idempotencyKey, + }), + ).rejects.toMatchObject({ name: "MediaUploadNetworkError" }); + + const resumed = await uploadFileResumable({ + file, + endpoints: endpointsWith(), + surface: "WorkOrderTable", + idempotencyKey, + }); + expect(resumed.state).toBe("Completed"); + const creates = captured.filter((entry) => entry.method === "POST" && entry.url === BASE); + expect(creates).toHaveLength(2); + expect( + creates.map((entry) => (entry.body as { idempotencyKey: string }).idempotencyKey), + ).toEqual([idempotencyKey, idempotencyKey]); + const putIndices = captured + .filter((entry) => entry.method === "PUT") + .map((entry) => Number(/chunks\/(\d+)$/.exec(entry.url)?.[1])); + expect(putIndices.filter((index) => index === 0)).toEqual([0]); + expect(putIndices.at(-1)).toBe(1); + }); + + it("retries a failed create with the same idempotency key and never re-creates after chunk loss", async () => { + const file = fileOf(8); + let createAttempts = 0; + handler = async (request) => { + if (request.method === "POST" && request.url === BASE) { + createAttempts += 1; + if (createAttempts === 1) { + throw new TypeError("network down"); + } + return jsonResponse(createdSession(8, 8)); + } + const match = /chunks\/(\d+)$/.exec(request.url); + if (request.method === "PUT" && match) { + return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] }); + } + if (request.method === "POST" && request.url.endsWith("/commit")) { + return jsonResponse(completedSession(8, 8, [0])); + } + return jsonResponse({}); + }; + + await uploadFileResumable({ file, endpoints: endpointsWith(), surface: "CompletionMedia" }); + + const creates = captured.filter((entry) => entry.method === "POST" && entry.url === BASE); + expect(creates).toHaveLength(2); + const keys = creates.map((entry) => (entry.body as { idempotencyKey: string }).idempotencyKey); + expect(keys[0]).toBe(keys[1]); + expect(keys[0]).toMatch(/^[0-9a-f-]{36}$/); + const firstBody = creates[0]?.body as Record; + expect(firstBody).toMatchObject({ + fileName: "clip.mp4", + contentType: "video/mp4", + sizeBytes: 8, + surface: "CompletionMedia", + }); + expect(captured.filter((entry) => entry.method === "POST" && entry.url === BASE)).toHaveLength( + 2, + ); + }); + + it("aborts the in-flight chunk and DELETEs the session on cancellation", async () => { + const file = fileOf(16); + const controller = new AbortController(); + handler = async (request) => { + if (request.method === "POST" && request.url === BASE) { + return jsonResponse(createdSession(16, 8)); + } + const match = /chunks\/(\d+)$/.exec(request.url); + if (request.method === "PUT" && match) { + if (Number(match[1]) === 0) { + controller.abort(); + } + return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] }); + } + return jsonResponse({}); + }; + + const pending = uploadFileResumable({ + file, + endpoints: endpointsWith(), + surface: "WorkOrderTable", + signal: controller.signal, + }); + + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(captured.some((entry) => entry.method === "DELETE" && entry.url === `${BASE}/u1`)).toBe( + true, + ); + }); + + it("uploads a queue with at most two files in flight", async () => { + let inFlight = 0; + let peak = 0; + handler = async (request) => { + if (request.method === "POST" && request.url === BASE) { + inFlight += 1; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight -= 1; + return jsonResponse(createdSession(8, 8)); + } + const match = /chunks\/(\d+)$/.exec(request.url); + if (request.method === "PUT" && match) { + return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] }); + } + if (request.method === "POST" && request.url.endsWith("/commit")) { + return jsonResponse(completedSession(8, 8, [0])); + } + return jsonResponse({}); + }; + + const files = [fileOf(8, "a.mp4"), fileOf(8, "b.mp4"), fileOf(8, "c.mp4")]; + await runMediaUploadQueue( + files, + (file) => + uploadFileResumable({ file, endpoints: endpointsWith(), surface: "WorkOrderTable" }).then( + () => undefined, + ), + 2, + ); + + expect(peak).toBe(2); + expect(captured.filter((entry) => entry.method === "POST" && entry.url === BASE)).toHaveLength( + 3, + ); + }); + + it("polls a 202 scanning commit until Completed and rejects terminal Rejected state", async () => { + const file = fileOf(8); + let statusCalls = 0; + handler = async (request) => { + if (request.method === "POST" && request.url === BASE) { + return jsonResponse(createdSession(8, 8)); + } + const match = /chunks\/(\d+)$/.exec(request.url); + if (request.method === "PUT" && match) { + return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] }); + } + if (request.method === "POST" && request.url.endsWith("/commit")) { + return jsonResponse( + { ...createdSession(8, 8), state: "Scanning", receivedChunks: [0] }, + 202, + ); + } + if (request.method === "GET" && request.url === `${BASE}/u1`) { + statusCalls += 1; + if (statusCalls === 1) { + return jsonResponse({ ...createdSession(8, 8), state: "Scanning", receivedChunks: [0] }); + } + return jsonResponse(completedSession(8, 8, [0])); + } + return jsonResponse({}); + }; + + const session = await uploadFileResumable({ + file, + endpoints: endpointsWith(), + surface: "WorkOrderTable", + pollDelayMs: 0, + }); + expect(session.state).toBe("Completed"); + expect(statusCalls).toBe(2); + + handler = async (request) => { + if (request.method === "POST" && request.url === BASE) { + return jsonResponse(createdSession(8, 8)); + } + if (request.method === "PUT" && /chunks\/0$/.test(request.url)) { + return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] }); + } + if (request.method === "POST" && request.url.endsWith("/commit")) { + return jsonResponse({ ...createdSession(8, 8), state: "Scanning" }, 202); + } + if (request.method === "GET" && request.url === `${BASE}/u1`) { + return jsonResponse({ ...createdSession(8, 8), state: "Rejected" }); + } + return jsonResponse({}); + }; + + await expect( + uploadFileResumable({ + file, + endpoints: endpointsWith(), + surface: "WorkOrderTable", + pollDelayMs: 0, + }), + ).rejects.toThrow("rejected by the security scan"); + }); + + it("DELETEs the session when cancellation aborts scan polling", async () => { + const file = fileOf(8); + const controller = new AbortController(); + handler = async (request) => { + if (request.method === "POST" && request.url === BASE) { + return jsonResponse(createdSession(8, 8)); + } + if (request.method === "PUT") { + return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] }); + } + if (request.method === "POST" && request.url.endsWith("/commit")) { + setTimeout(() => controller.abort(), 0); + return jsonResponse( + { ...createdSession(8, 8), state: "Scanning", receivedChunks: [0] }, + 202, + ); + } + return jsonResponse({}); + }; + + await expect( + uploadFileResumable({ + file, + endpoints: endpointsWith(), + surface: "WorkOrderTable", + signal: controller.signal, + pollDelayMs: 5, + }), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(captured.some((entry) => entry.method === "DELETE" && entry.url === `${BASE}/u1`)).toBe( + true, + ); + }); +}); + +describe("chunk math helpers", () => { + it("computes chunk counts and missing indices", () => { + expect(chunkCountFor(0, 5_242_880)).toBe(1); + expect(chunkCountFor(5_242_880, 5_242_880)).toBe(1); + expect(chunkCountFor(5_242_881, 5_242_880)).toBe(2); + expect(missingChunkIndices([0, 2], 4)).toEqual([1, 3]); + expect(missingChunkIndices([], 0)).toEqual([]); + }); +}); 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..01db34cd 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 @@ -39,6 +39,27 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({ useDeleteWorkOrderMedia: () => ({ mutate: deleteMutate, isPending: false }), })); +vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({ + useWorkOrderMediaPolicy: () => ({ + data: { + workOrderType: "Emergency", + canUpload: false, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 10, videos: 3 }, + }, + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({ + useResumableWorkOrderMediaUpload: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + function baseRow(): WorkOrderTableRow { return { id: 7, @@ -159,4 +180,16 @@ describe("MediaUploadModal API wiring", () => { workOrderVersion: "DETAIL-VER==", }); }); + + it("fails closed when Emergency policy says uploads are unavailable", async () => { + renderModal(); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + expect(input.disabled).toBe(true); + expect(screen.getByText("Uploads are not available for this work order.")).toBeInTheDocument(); + fireEvent.change(input, { + target: { files: [new File(["x"], "photo.jpg", { type: "image/jpeg" })] }, + }); + expect(uploadMutateAsync).not.toHaveBeenCalled(); + }); }); diff --git a/src/test/app/(protected)/workorders/slide-over-signed-pdf-upload.test.tsx b/src/test/app/(protected)/workorders/slide-over-signed-pdf-upload.test.tsx index a92bc22f..c182c3f0 100644 --- a/src/test/app/(protected)/workorders/slide-over-signed-pdf-upload.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-signed-pdf-upload.test.tsx @@ -19,6 +19,17 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ }), })); +vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({ + useWorkOrderMediaPolicy: () => ({ data: undefined }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({ + useResumableWorkOrderMediaUpload: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), +})); + // A completion-doc upload that settles successfully. The mock forwards any onSuccess the // caller passes; the fix means the slide-over passes none (no follow-up docStatus patch). vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({ 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 dab9e3bd..9b9669f7 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,9 +1,20 @@ -import { act, renderHook } from "@testing-library/react"; +import { act, 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"; +vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({ + useWorkOrderMediaPolicy: () => ({ data: undefined }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({ + useResumableWorkOrderMediaUpload: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), +})); + function baseInfo(overrides: Partial = {}): WorkOrderTableRow { return { id: 100, @@ -327,4 +338,67 @@ describe("useSlideOverMediaState completion filter", () => { "pdf", ]); }); + + it("blocks legacy completion upload while Emergency media policy is unavailable", async () => { + const legacyUpload = idleMutation(); + const { result } = renderHook(() => + useSlideOverMediaState({ + detailForRow: detailWithMedia([], { type: "Emergency" }), + uploadMediaMutation: legacyUpload, + deleteMediaMutation: idleMutation(), + categorizeMediaMutation: idleMutation(), + }), + ); + + act(() => { + result.current.uploadMediaRequests([ + { + localId: "local-emergency", + file: new File(["x"], "photo.jpg", { type: "image/jpeg" }), + onProgress: vi.fn(), + }, + ]); + }); + + await waitFor(() => { + expect(legacyUpload.mutateAsync).not.toHaveBeenCalled(); + expect(result.current.completionMedia.some((item) => item.uploadStatus === "failed")).toBe( + true, + ); + }); + }); + + it("reuses the same idempotency key when a failed media tile is retried", async () => { + const mutateAsync = vi + .fn() + .mockRejectedValueOnce(new Error("network unavailable")) + .mockResolvedValue({ id: "4" }); + const uploadMutation = { + mutate: vi.fn(), + mutateAsync, + isPending: false, + } as never; + const { result } = renderHook(() => + useSlideOverMediaState({ + detailForRow: detailWithMedia([], { type: "PM" }), + uploadMediaMutation: uploadMutation, + deleteMediaMutation: idleMutation(), + categorizeMediaMutation: idleMutation(), + }), + ); + const file = new File(["photo"], "photo.jpg", { type: "image/jpeg" }); + + act(() => { + result.current.uploadMediaRequests([{ localId: "local-retry", file, onProgress: vi.fn() }]); + }); + await waitFor(() => expect(result.current.completionMedia[0]?.uploadStatus).toBe("failed")); + + act(() => result.current.retryUpload("local-retry")); + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(2)); + expect(mutateAsync.mock.calls[0]?.[0].file).toBe(file); + expect(mutateAsync.mock.calls[1]?.[0].file).toBe(file); + expect(mutateAsync.mock.calls[1]?.[0].idempotencyKey).toBe( + mutateAsync.mock.calls[0]?.[0].idempotencyKey, + ); + }); }); diff --git a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx index 7e2ccec8..fa251c68 100644 --- a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx @@ -21,6 +21,17 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ }), })); +vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({ + useWorkOrderMediaPolicy: () => ({ data: undefined }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({ + useResumableWorkOrderMediaUpload: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), +})); + vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({ useAddWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }), useUpdateWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }), diff --git a/src/test/app/v/vendor-media-section.test.tsx b/src/test/app/v/vendor-media-section.test.tsx new file mode 100644 index 00000000..2fca289f --- /dev/null +++ b/src/test/app/v/vendor-media-section.test.tsx @@ -0,0 +1,113 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import * as mediaUploadEngine from "@/api/media-upload-engine"; +import { MediaUploadNetworkError } from "@/api/media-upload-client"; +import * as mediaLimits from "@/domain/work-orders/utils/emergency-media-limits"; +import { vendorPortalMediaApi } from "@/domain/vendor-portal/api/vendor-portal-media-api"; +import type { VendorPortalMedia } from "@/domain/vendor-portal/types/vendor-portal"; +import { VendorMediaSection } from "@/app/v/[token]/dispatch/_components/vendor-media-section"; + +const MEDIA: VendorPortalMedia = { + policy: { + workOrderType: "Emergency", + canUpload: true, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 9, videos: 3 }, + }, + items: [], +}; + +function renderVendorMedia() { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, + }); + return render( + + + , + ); +} + +describe("Vendor media controls", () => { + beforeEach(() => { + vi.spyOn(vendorPortalMediaApi, "getMedia").mockResolvedValue(MEDIA); + vi.spyOn(vendorPortalMediaApi, "endpoints").mockReturnValue({} as never); + vi.spyOn(mediaLimits, "validateMediaFileAgainstLimits").mockResolvedValue({ ok: true }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("retries the same file with the same idempotency key after a partial failure", async () => { + const upload = vi + .spyOn(mediaUploadEngine, "uploadFileResumable") + .mockRejectedValueOnce(new MediaUploadNetworkError()) + .mockResolvedValue({ + uploadId: "upload-1", + state: "Completed", + chunkSizeBytes: 5_242_880, + sizeBytes: 5, + receivedChunks: [0], + expiresAt: "2026-09-24T00:00:00Z", + attachment: { id: 1 }, + }); + 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[0]?.[0].file).toBe(file); + 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 }) => + new Promise((_resolve, reject) => { + signal?.addEventListener( + "abort", + () => reject(new DOMException("Aborted", "AbortError")), + { + once: true, + }, + ); + }), + ); + 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: "Cancel upload" })).toBeInTheDocument(), + ); + const signal = upload.mock.calls[0]?.[0].signal; + fireEvent.click(screen.getByRole("button", { name: "Cancel upload" })); + + await waitFor(() => expect(screen.getByText("Upload canceled.")).toBeInTheDocument()); + expect(signal?.aborted).toBe(true); + }); +}); diff --git a/src/test/domain/vendor-portal/api/vendor-portal-media-api.test.ts b/src/test/domain/vendor-portal/api/vendor-portal-media-api.test.ts new file mode 100644 index 00000000..a0c7bfd6 --- /dev/null +++ b/src/test/domain/vendor-portal/api/vendor-portal-media-api.test.ts @@ -0,0 +1,162 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const API_URL = "http://api.test/api"; +let captured: { method: string; url: string; headers: Headers; body: string }[] = []; + +const MEDIA_PAYLOAD = { + policy: { + workOrderType: "Emergency", + canUpload: true, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 9, videos: 3 }, + }, + items: [ + { + id: 5, + originalFileName: "before.jpg", + contentType: "image/jpeg", + sizeBytes: 2048, + kind: "Photo", + category: "Extra", + scanStatus: "Passed", + createdAt: "2026-09-23T10:00:00Z", + canView: true, + }, + ], +}; + +beforeEach(() => { + captured = []; + vi.resetModules(); + vi.doMock("@/lib/env", () => ({ env: { apiUrl: API_URL } })); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + captured.push({ + method: request.method, + url: request.url, + headers: request.headers, + body: await request.text(), + }); + return new Response(JSON.stringify({ data: MEDIA_PAYLOAD }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function lastRequest() { + const request = captured[captured.length - 1]; + if (!request) throw new Error("No request reached fetch"); + return request; +} + +describe("vendor portal media API", () => { + it("lists media with policy and scan-passed items using X-Vendor-Token only", async () => { + const { vendorPortalMediaApi } = + await import("@/domain/vendor-portal/api/vendor-portal-media-api"); + const media = await vendorPortalMediaApi.getMedia("portal-token", 9); + + const request = lastRequest(); + expect(request.url).toBe(`${API_URL}/vendor-portal/dispatches/9/media`); + expect(request.headers.get("x-vendor-token")).toBe("portal-token"); + expect(request.headers.get("authorization")).toBeNull(); + expect(media.policy.canUpload).toBe(true); + expect(media.items[0]).toMatchObject({ + id: 5, + kind: "Photo", + scanStatus: "Passed", + canView: true, + }); + }); + + it("creates chunked upload sessions on the vendor uploads base with the exact DTO", async () => { + const { vendorPortalMediaApi } = + await import("@/domain/vendor-portal/api/vendor-portal-media-api"); + await vendorPortalMediaApi.endpoints("portal-token", 9).create({ + idempotencyKey: "99999999-8888-7777-6666-555555555555", + fileName: "after.mp4", + contentType: "video/mp4", + sizeBytes: 2048, + surface: "VendorPortal", + }); + + const request = lastRequest(); + expect(request.url).toBe(`${API_URL}/vendor-portal/dispatches/9/media/uploads`); + expect(request.headers.get("x-vendor-token")).toBe("portal-token"); + expect(request.headers.get("authorization")).toBeNull(); + expect(JSON.parse(request.body)).toEqual({ + idempotencyKey: "99999999-8888-7777-6666-555555555555", + fileName: "after.mp4", + contentType: "video/mp4", + sizeBytes: 2048, + surface: "VendorPortal", + }); + }); + + it("sends chunk, commit, status and cancel calls with the vendor token", async () => { + const { vendorPortalMediaApi } = + await import("@/domain/vendor-portal/api/vendor-portal-media-api"); + const endpoints = vendorPortalMediaApi.endpoints("portal-token", 9); + + await endpoints.putChunk({ + uploadId: "u3", + index: 0, + blob: new Blob([new Uint8Array(2)]), + start: 0, + end: 2, + totalBytes: 2, + sha256: "ff00", + }); + expect(lastRequest().url).toBe( + `${API_URL}/vendor-portal/dispatches/9/media/uploads/u3/chunks/0`, + ); + + await endpoints.commit("u3"); + expect(lastRequest().url).toBe(`${API_URL}/vendor-portal/dispatches/9/media/uploads/u3/commit`); + + await endpoints.getStatus("u3"); + expect(lastRequest().url).toBe(`${API_URL}/vendor-portal/dispatches/9/media/uploads/u3`); + + await endpoints.cancel("u3"); + expect(lastRequest()).toMatchObject({ + method: "DELETE", + url: `${API_URL}/vendor-portal/dispatches/9/media/uploads/u3`, + }); + expect(lastRequest().headers.get("x-vendor-token")).toBe("portal-token"); + }); + + it("fetches media content only on demand from the content route", async () => { + const { vendorPortalMediaApi } = + await import("@/domain/vendor-portal/api/vendor-portal-media-api"); + const blob = await vendorPortalMediaApi.fetchContent("portal-token", 9, 5); + + const request = lastRequest(); + expect(request.url).toBe(`${API_URL}/vendor-portal/dispatches/9/media/5/content`); + expect(request.headers.get("x-vendor-token")).toBe("portal-token"); + expect(blob).toBeInstanceOf(Blob); + }); + + it("never exposes object URLs or storage endpoints in the media payload", async () => { + const { vendorPortalMediaApi } = + await import("@/domain/vendor-portal/api/vendor-portal-media-api"); + const media = await vendorPortalMediaApi.getMedia("portal-token", 9); + + const serialized = JSON.stringify(media); + expect(serialized).not.toContain("blob:"); + expect(serialized).not.toContain("https://storage"); + }); +}); diff --git a/src/test/domain/work-orders/api/work-order-media-upload-api.test.ts b/src/test/domain/work-orders/api/work-order-media-upload-api.test.ts new file mode 100644 index 00000000..1a471c92 --- /dev/null +++ b/src/test/domain/work-orders/api/work-order-media-upload-api.test.ts @@ -0,0 +1,162 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const API_URL = "http://api.test/api"; +let captured: { method: string; url: string; headers: Headers; body: string }[] = []; + +beforeEach(() => { + captured = []; + vi.resetModules(); + vi.doMock("@/lib/env", () => ({ env: { apiUrl: API_URL } })); + vi.doMock("@/lib/auth/auth-storage", () => ({ getAccessToken: () => "Bearer test-token" })); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + captured.push({ + method: request.method, + url: request.url, + headers: request.headers, + body: await request.text(), + }); + return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }); + }), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function lastRequest() { + const request = captured[captured.length - 1]; + if (!request) throw new Error("No request reached fetch"); + return request; +} + +describe("dispatcher resumable upload API", () => { + it("fetches the Emergency/Reactive media policy from the documented route", async () => { + const { workOrdersMediaUploadApi } = + await import("@/domain/work-orders/api/work-order-media-upload-api"); + await workOrdersMediaUploadApi.getMediaPolicy(7); + + const request = lastRequest(); + expect(request.url).toBe(`${API_URL}/workorders/7/media/policy`); + expect(request.method).toBe("GET"); + expect(request.headers.get("authorization")).toBe("Bearer test-token"); + }); + + it("creates sessions on the work-order-bound uploads base with the exact DTO shape", async () => { + const { workOrdersMediaUploadApi } = + await import("@/domain/work-orders/api/work-order-media-upload-api"); + await workOrdersMediaUploadApi.endpoints(10).create({ + idempotencyKey: "11111111-2222-3333-4444-555555555555", + fileName: "photo.jpg", + contentType: "image/jpeg", + sizeBytes: 12_345, + surface: "WorkOrderTable", + }); + + const request = lastRequest(); + expect(request.url).toBe(`${API_URL}/workorders/10/media/uploads`); + expect(request.method).toBe("POST"); + expect(request.headers.get("authorization")).toBe("Bearer test-token"); + expect(request.headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(request.body)).toEqual({ + idempotencyKey: "11111111-2222-3333-4444-555555555555", + fileName: "photo.jpg", + contentType: "image/jpeg", + sizeBytes: 12_345, + surface: "WorkOrderTable", + }); + }); + + it("omits category when not provided and sends it for explicit categories", async () => { + const { workOrdersMediaUploadApi } = + await import("@/domain/work-orders/api/work-order-media-upload-api"); + const endpoints = workOrdersMediaUploadApi.endpoints(10); + + await endpoints.create({ + idempotencyKey: "11111111-2222-3333-4444-555555555556", + fileName: "a.jpg", + contentType: "image/jpeg", + sizeBytes: 5, + category: "Aveta", + surface: "CompletionMedia", + }); + expect(JSON.parse(lastRequest().body)).toMatchObject({ + category: "Aveta", + surface: "CompletionMedia", + }); + }); + + it("PUTs raw chunk bytes with Content-Range and X-Chunk-SHA256 on the session route", async () => { + const { workOrdersMediaUploadApi } = + await import("@/domain/work-orders/api/work-order-media-upload-api"); + const blob = new Blob([new Uint8Array(4)]); + + await workOrdersMediaUploadApi.endpoints(10).putChunk({ + uploadId: "u9", + index: 2, + blob, + start: 10_485_760, + end: 10_485_764, + totalBytes: 10_485_764, + sha256: "ABCD", + signal: undefined, + }); + + const request = lastRequest(); + expect(request.url).toBe(`${API_URL}/workorders/10/media/uploads/u9/chunks/2`); + expect(request.method).toBe("PUT"); + expect(request.headers.get("content-range")).toBe("bytes 10485760-10485763/10485764"); + expect(request.headers.get("x-chunk-sha256")).toBe("abcd"); + expect(request.headers.get("content-type")).toBe("application/octet-stream"); + }); + + it("routes commit, status and cancel under the same session base", async () => { + const { workOrdersMediaUploadApi } = + await import("@/domain/work-orders/api/work-order-media-upload-api"); + const endpoints = workOrdersMediaUploadApi.endpoints(10); + + await endpoints.commit("u9"); + expect(lastRequest()).toMatchObject({ method: "POST" }); + expect(lastRequest().url).toBe(`${API_URL}/workorders/10/media/uploads/u9/commit`); + + await endpoints.getStatus("u9"); + expect(lastRequest()).toMatchObject({ + method: "GET", + url: `${API_URL}/workorders/10/media/uploads/u9`, + }); + + await endpoints.cancel("u9"); + expect(lastRequest()).toMatchObject({ + method: "DELETE", + url: `${API_URL}/workorders/10/media/uploads/u9`, + }); + }); +}); + +describe("protected legacy upload surfaces", () => { + it("keeps the signed completion-doc, media content and Extra Docs routes and limits untouched", async () => { + const { API_PATHS } = await import("@/api/api-paths"); + const limits = await import("@/domain/work-orders/utils/work-order-media-limits"); + const { validateVendorDocument } = + await import("@/domain/vendor-portal/lib/document-validation"); + + expect(API_PATHS.workOrder.mediaContent(10, 12)).toBe("workorders/10/media/12/content"); + expect(API_PATHS.workOrder.completionDoc(10)).toBe("workorders/10/completion-doc"); + expect(limits.MAX_WORK_ORDER_COMPLETION_DOC_BYTES).toBe(50_000_000); + expect(limits.MAX_WORK_ORDER_MEDIA_BYTES).toBe(200_000_000); + + // Vendor completion/uplift documents keep the 10,485,760-byte legacy limit. + const legacyPdf = { size: 10 * 1024 * 1024, type: "application/pdf", name: "doc.pdf" } as File; + const oversizedPdf = { + size: 10 * 1024 * 1024 + 1, + type: "application/pdf", + name: "doc.pdf", + } as File; + expect(validateVendorDocument(legacyPdf)).toBeUndefined(); + expect(validateVendorDocument(oversizedPdf)).toBeTruthy(); + }); +}); 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 new file mode 100644 index 00000000..1bd381bb --- /dev/null +++ b/src/test/domain/work-orders/utils/emergency-media-limits.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from "vitest"; +import { + classifyMediaKind, + emergencyMediaAcceptAttribute, + isEmergencyReactiveWorkOrderType, + mediaKindLimitReachedNotice, + mediaOverLimitNotice, + probeVideoDurationSeconds, + validateMediaFileAgainstLimits, +} from "@/domain/work-orders/utils/emergency-media-limits"; + +const BASE_POLICY = { + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 10, videos: 3 }, +}; + +function fileOf(size: number, name: string, type: string): File { + return new File([new Uint8Array(size)], name, { type }); +} + +const nullProbe = async () => null; + +describe("Emergency/Reactive media limits", () => { + it("classifies photos including HEIC and videos MP4/MOV, rejecting other kinds", () => { + expect(classifyMediaKind(fileOf(1, "a.heic", "image/heic"))).toBe("Photo"); + expect(classifyMediaKind(fileOf(1, "a.jpg", ""))).toBe("Photo"); + expect(classifyMediaKind(fileOf(1, "a.mov", "video/quicktime"))).toBe("Video"); + expect(classifyMediaKind(fileOf(1, "a.gif", "image/gif"))).toBeNull(); + expect(classifyMediaKind(fileOf(1, "b.txt", "text/plain"))).toBeNull(); + }); + + it("applies size limits inclusively — at the cap passes, one byte over fails", async () => { + const photoAtCap = fileOf(10_000_000, "a.jpg", "image/jpeg"); + const photoOver = fileOf(10_000_001, "a.jpg", "image/jpeg"); + expect((await validateMediaFileAgainstLimits(photoAtCap, BASE_POLICY, nullProbe)).ok).toBe( + true, + ); + const over = await validateMediaFileAgainstLimits(photoOver, BASE_POLICY, nullProbe); + expect(over).toMatchObject({ ok: false }); + + const videoAtCap = fileOf(100_000_000, "v.mp4", "video/mp4"); + const videoOver = fileOf(100_000_001, "v.mp4", "video/mp4"); + expect((await validateMediaFileAgainstLimits(videoAtCap, BASE_POLICY, nullProbe)).ok).toBe( + true, + ); + expect((await validateMediaFileAgainstLimits(videoOver, BASE_POLICY, nullProbe)).ok).toBe( + false, + ); + }); + + it("enforces the video duration cap only when the duration can be probed", async () => { + const longVideo = fileOf(1_000, "v.mp4", "video/mp4"); + expect((await validateMediaFileAgainstLimits(longVideo, BASE_POLICY, async () => 90)).ok).toBe( + true, + ); + expect( + (await validateMediaFileAgainstLimits(longVideo, BASE_POLICY, async () => 90.5)).ok, + ).toBe(false); + expect((await validateMediaFileAgainstLimits(longVideo, BASE_POLICY, async () => 91)).ok).toBe( + false, + ); + // MOV metadata probing fails — stays eligible, the server enforces at commit. + expect( + (await validateMediaFileAgainstLimits(longVideo, BASE_POLICY, async () => null)).ok, + ).toBe(true); + }); + + it("rejects files when the policy remaining count is exhausted", async () => { + const policy = { ...BASE_POLICY, remaining: { photos: 0, videos: 3 } }; + const result = await validateMediaFileAgainstLimits( + fileOf(10, "a.jpg", "image/jpeg"), + policy, + nullProbe, + ); + expect(result.ok).toBe(false); + }); + + it("renders a standard safe over-limit notice without raw payloads", async () => { + const notice = mediaOverLimitNotice( + fileOf(100_000_001, "big.mov", "video/quicktime"), + "Video", + BASE_POLICY.limits, + ); + expect(notice).toContain("big.mov"); + expect(notice).toContain("100 MB"); + expect(notice).toContain("90 seconds"); + expect(mediaKindLimitReachedNotice("Photo", BASE_POLICY.limits)).toContain("10"); + }); + + it("matches only Emergency and Reactive work order types", () => { + expect(isEmergencyReactiveWorkOrderType("Emergency")).toBe(true); + expect(isEmergencyReactiveWorkOrderType("reactive")).toBe(true); + expect(isEmergencyReactiveWorkOrderType("Preventive")).toBe(false); + expect(isEmergencyReactiveWorkOrderType(undefined)).toBe(false); + }); + + it("offers HEIC in the emergency accept attribute", () => { + const accept = emergencyMediaAcceptAttribute(); + expect(accept).toContain("image/heic"); + expect(accept).toContain(".mov"); + }); + + it("revokes the preview URL after probing duration, resolving null when the decode fails", async () => { + const createObjectURL = vi.fn(() => "blob:probe"); + const revokeObjectURL = vi.fn(); + vi.stubGlobal("URL", { ...URL, createObjectURL, revokeObjectURL }); + + const media: Record = {}; + const originalCreateElement = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation(((tag: string) => { + if (tag === "video") { + const element = originalCreateElement("video"); + Object.defineProperty(element, "duration", { value: NaN, configurable: true }); + media[element.src as string] = true; + setTimeout(() => { + element.dispatchEvent(new Event("error")); + }, 0); + return element; + } + return originalCreateElement(tag); + }) as typeof document.createElement); + + const duration = await probeVideoDurationSeconds(fileOf(10, "clip.mov", "video/quicktime")); + + expect(duration).toBeNull(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:probe"); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); +}); From 0c8a7f877fc174e9a50e079a12377269a9d0b7f8 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 04:31:53 -0300 Subject: [PATCH 02/10] fix: fail closed on emergency media policy --- .../tabs/slide-over-completion-media.tsx | 25 ++++ .../detail/tabs/slide-over-completion-tab.tsx | 21 +++ .../detail/use-slide-over-media-state.ts | 53 ++++++- .../detail/use-work-order-slide-over.ts | 15 ++ .../detail/work-order-slide-over-tabs.tsx | 7 + .../_components/media/media-upload-modal.tsx | 26 +++- .../media/media-uploader-dropzone.tsx | 11 +- .../_components/media/media-uploader.tsx | 13 +- .../utils/emergency-media-limits.ts | 12 ++ .../media-upload-modal-api.test.tsx | 140 +++++++++++++++--- ...lide-over-completion-service-gate.test.tsx | 71 +++++++++ .../use-slide-over-media-state.test.tsx | 92 +++++++++++- 12 files changed, 448 insertions(+), 38 deletions(-) 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..7908c1f8 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 @@ -15,6 +15,13 @@ type SlideOverCompletionMediaProps = { onRemoveMedia?: (id: string) => void; onRetryMedia?: (localId: string) => void; onCancelMedia?: (localId: string) => void; + uploadDisabled?: boolean; + uploadUnavailableMessage?: string; + typeAllowed?: (file: File) => boolean; + typeErrorMessage?: string; + acceptAttribute?: string; + fileSizeError?: (file: File) => string | undefined; + uploadHint?: string; }; export function SlideOverCompletionMedia({ @@ -26,6 +33,13 @@ export function SlideOverCompletionMedia({ onRemoveMedia, onRetryMedia, onCancelMedia, + uploadDisabled = false, + uploadUnavailableMessage, + typeAllowed, + typeErrorMessage, + acceptAttribute, + fileSizeError, + uploadHint, }: SlideOverCompletionMediaProps) { return (
@@ -45,8 +59,19 @@ export function SlideOverCompletionMedia({ onCancel={onCancelMedia} readOnly={isReadOnly} pending={mediaPending} + uploadDisabled={uploadDisabled} + typeAllowed={typeAllowed} + typeErrorMessage={typeErrorMessage} + acceptAttribute={acceptAttribute} + fileSizeError={fileSizeError} + uploadHint={uploadHint} />
+ {uploadDisabled && Boolean(uploadUnavailableMessage) && ( +
+ {uploadUnavailableMessage} +
+ )}
); } 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 f88e3867..ef631d45 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 @@ -30,6 +30,13 @@ type SlideOverCompletionTabProps = { onRemoveMedia?: (id: string) => void; onRetryMedia?: (localId: string) => void; onCancelMedia?: (localId: string) => void; + uploadDisabled?: boolean; + uploadUnavailableMessage?: string; + typeAllowed?: (file: File) => boolean; + typeErrorMessage?: string; + acceptAttribute?: string; + fileSizeError?: (file: File) => string | undefined; + uploadHint?: string; onUploadCompletionPdf?: (file: File) => void; onOpenCompletionDoc: () => void; }; @@ -46,6 +53,13 @@ export function SlideOverCompletionTab({ onRemoveMedia, onRetryMedia, onCancelMedia, + uploadDisabled, + uploadUnavailableMessage, + typeAllowed, + typeErrorMessage, + acceptAttribute, + fileSizeError, + uploadHint, onUploadCompletionPdf, onOpenCompletionDoc, }: SlideOverCompletionTabProps) { @@ -65,6 +79,13 @@ export function SlideOverCompletionTab({ onRemoveMedia={onRemoveMedia} onRetryMedia={onRetryMedia} onCancelMedia={onCancelMedia} + uploadDisabled={uploadDisabled} + uploadUnavailableMessage={uploadUnavailableMessage} + typeAllowed={typeAllowed} + typeErrorMessage={typeErrorMessage} + acceptAttribute={acceptAttribute} + fileSizeError={fileSizeError} + 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 7352fefb..de94ee7d 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 @@ -5,6 +5,7 @@ import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-m import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; 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 type { useDeleteWorkOrderMedia, useUpdateWorkOrderMediaCategory, @@ -19,10 +20,16 @@ import { isExtrasSurfaceMedia, type MediaSurfaceOwnership, } from "@/domain/work-orders/utils/partition-slide-over-media"; -import { isEmergencyReactiveWorkOrderType } from "@/domain/work-orders/utils/emergency-media-limits"; +import { + classifyMediaKind, + emergencyMediaAcceptAttribute, + emergencyMediaSizeError, + isEmergencyReactiveWorkOrderType, +} from "@/domain/work-orders/utils/emergency-media-limits"; type UseSlideOverMediaStateArgs = { detailForRow?: WorkOrderBoardDetail; + row?: WorkOrderTableRow | null; uploadMediaMutation: ReturnType; deleteMediaMutation: ReturnType; categorizeMediaMutation: ReturnType; @@ -79,22 +86,40 @@ function saveOwnership( export function useSlideOverMediaState({ detailForRow, + row, uploadMediaMutation, deleteMediaMutation, categorizeMediaMutation, }: UseSlideOverMediaStateArgs) { - const workOrderId = detailForRow?.info.id; - const completionHasGallery = hasCompletionMediaGallery(detailForRow?.info.type); - const { data: mediaPolicy } = useWorkOrderMediaPolicy(workOrderId, workOrderId != null); + const workOrderId = detailForRow?.info.id ?? row?.id; + const workOrderType = detailForRow?.info.type ?? row?.type; + const { + data: mediaPolicy, + isError: mediaPolicyError, + isLoading: mediaPolicyLoading, + } = useWorkOrderMediaPolicy(workOrderId, workOrderId != null); const resumableCompletionUpload = useResumableWorkOrderMediaUpload( workOrderId ?? "", "CompletionMedia", mediaPolicy ?? undefined, ); - const policyCanUpload = mediaPolicy?.canUpload === true; + const policyCanUpload = mediaPolicy?.canUpload === true && !mediaPolicyError; + const completionHasGallery = + hasCompletionMediaGallery(detailForRow?.info.type) || + hasCompletionMediaGallery(mediaPolicy?.workOrderType) || + hasCompletionMediaGallery(row?.type); const emergencyMedia = isEmergencyReactiveWorkOrderType(mediaPolicy?.workOrderType) || - isEmergencyReactiveWorkOrderType(detailForRow?.info.type); + isEmergencyReactiveWorkOrderType(workOrderType); + const emergencyMediaUnavailable = emergencyMedia && !policyCanUpload; + const emergencyMediaUnavailableMessage = + mediaPolicy?.canUpload === false + ? "Uploads are not available for this work order." + : mediaPolicyError + ? "Upload permissions could not be loaded. Refresh and try again." + : mediaPolicyLoading || !mediaPolicy + ? "Loading upload permissions…" + : "Uploads are not available for this work order."; const [mediaBatchPending, setMediaBatchPending] = useState(false); const [ownership, setOwnershipState] = useState(() => loadOwnership(workOrderId), @@ -235,6 +260,22 @@ export function useSlideOverMediaState({ completionMedia, extrasUploads, mediaPending, + emergencyMedia, + emergencyMediaUnavailable, + emergencyMediaUnavailableMessage, + emergencyMediaTypeAllowed: emergencyMedia + ? (file: File) => classifyMediaKind(file) !== null + : undefined, + emergencyMediaTypeErrorMessage: emergencyMedia + ? "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported." + : undefined, + emergencyMediaAcceptAttribute: emergencyMedia ? emergencyMediaAcceptAttribute() : undefined, + emergencyMediaSizeError: emergencyMedia + ? (file: File) => emergencyMediaSizeError(file, mediaPolicy?.limits) + : undefined, + emergencyMediaUploadHint: emergencyMedia + ? "Photos up to 10 MB · Videos up to 100 MB and 90 seconds" + : undefined, uploadMediaRequests, uploadExtraFiles, categorizeMedia, 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..37792f32 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 @@ -119,6 +119,13 @@ export function useWorkOrderSlideOver({ completionMedia, extrasUploads, mediaPending, + emergencyMediaUnavailable, + emergencyMediaUnavailableMessage, + emergencyMediaTypeAllowed, + emergencyMediaTypeErrorMessage, + emergencyMediaAcceptAttribute, + emergencyMediaSizeError, + emergencyMediaUploadHint, uploadMediaRequests, uploadExtraFiles, categorizeMedia, @@ -127,6 +134,7 @@ export function useWorkOrderSlideOver({ removeMedia, } = useSlideOverMediaState({ detailForRow, + row, uploadMediaMutation, deleteMediaMutation, categorizeMediaMutation, @@ -175,6 +183,13 @@ export function useWorkOrderSlideOver({ completionMedia, extrasUploads, mediaPending, + emergencyMediaUnavailable, + emergencyMediaUnavailableMessage, + emergencyMediaTypeAllowed, + emergencyMediaTypeErrorMessage, + emergencyMediaAcceptAttribute, + emergencyMediaSizeError, + emergencyMediaUploadHint, completionPending, closeDisabled, uploadMediaRequests, 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..54513fc2 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 @@ -126,6 +126,13 @@ export function WorkOrderSlideOverTabs({ completion={state.detailForRow?.completion} media={state.completionMedia} mediaPending={state.mediaPending} + uploadDisabled={state.emergencyMediaUnavailable} + uploadUnavailableMessage={state.emergencyMediaUnavailableMessage} + typeAllowed={state.emergencyMediaTypeAllowed} + typeErrorMessage={state.emergencyMediaTypeErrorMessage} + acceptAttribute={state.emergencyMediaAcceptAttribute} + fileSizeError={state.emergencyMediaSizeError} + uploadHint={state.emergencyMediaUploadHint} completionPending={state.completionPending} onUploadMedia={(requests) => { state.uploadMediaRequests(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 5dd4f65f..58e1e722 100644 --- a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx +++ b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx @@ -19,6 +19,7 @@ import { import { classifyMediaKind, emergencyMediaAcceptAttribute, + emergencyMediaSizeError, isEmergencyReactiveWorkOrderType, } from "@/domain/work-orders/utils/emergency-media-limits"; import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits"; @@ -40,19 +41,24 @@ function resolveWorkOrderVersion( export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) { const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open); - const { data: policy } = useWorkOrderMediaPolicy(row.id, open); + const { + data: policy, + isError: policyError, + isLoading: policyLoading, + } = useWorkOrderMediaPolicy(row.id, open); const legacyUploadMutation = useUploadWorkOrderMedia(row.id); const resumableUploadMutation = useResumableWorkOrderMediaUpload( row.id, "WorkOrderTable", - policy ?? undefined, + policyError ? undefined : (policy ?? undefined), ); const deleteMutation = useDeleteWorkOrderMedia(row.id); const isEmergency = + isEmergencyReactiveWorkOrderType(detail?.info.type) || isEmergencyReactiveWorkOrderType(policy?.workOrderType) || isEmergencyReactiveWorkOrderType(row.type); const uploadMutation = isEmergency ? resumableUploadMutation : legacyUploadMutation; - const emergencyUploadUnavailable = isEmergency && policy?.canUpload !== true; + const emergencyUploadUnavailable = isEmergency && (policy?.canUpload !== true || policyError); const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status); const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } = useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input)); @@ -124,6 +130,12 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr onCancel={cancelUpload} readOnly={isReadOnly} uploadDisabled={emergencyUploadUnavailable} + fileSizeError={ + isEmergency ? (file) => emergencyMediaSizeError(file, policy?.limits) : undefined + } + uploadHint={ + isEmergency ? "Photos up to 10 MB · Videos up to 100 MB and 90 seconds" : undefined + } pending={pending} typeAllowed={isEmergency ? (file) => classifyMediaKind(file) !== null : undefined} typeErrorMessage={ @@ -137,9 +149,13 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr {emergencyUploadUnavailable && !isReadOnly && (
- {policy + {policy?.canUpload === false ? "Uploads are not available for this work order." - : "Loading upload permissions…"} + : policyError + ? "Upload permissions could not be loaded. Refresh and try again." + : policyLoading || !policy + ? "Loading upload permissions…" + : "Uploads are not available for this work order."}
)} diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx index 21c57a61..1827d2b1 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx @@ -8,6 +8,8 @@ import { type MediaUploaderDropzoneProps = { inputRef: RefObject; pending: boolean; + disabled?: boolean; + helperText?: string; categorizeAfterUpload?: boolean; acceptAttribute?: string; onFilesSelected: (files: FileList | null) => void; @@ -16,6 +18,8 @@ type MediaUploaderDropzoneProps = { export function MediaUploaderDropzone({ inputRef, pending, + disabled = false, + helperText, categorizeAfterUpload = false, acceptAttribute, onFilesSelected, @@ -24,7 +28,7 @@ export function MediaUploaderDropzone({ <> @@ -53,7 +58,7 @@ export function MediaUploaderDropzone({ accept={acceptAttribute ?? workOrderMediaAcceptAttribute()} multiple hidden - disabled={pending} + disabled={pending || disabled} onChange={(e) => { onFilesSelected(e.target.files); e.target.value = ""; diff --git a/src/app/(protected)/workorders/_components/media/media-uploader.tsx b/src/app/(protected)/workorders/_components/media/media-uploader.tsx index 3221a2a0..187d464c 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx @@ -36,6 +36,8 @@ type MediaUploaderProps = { typeAllowed?: (file: File) => boolean; typeErrorMessage?: string; acceptAttribute?: string; + fileSizeError?: (file: File) => string | undefined; + uploadHint?: string; }; export function MediaUploader({ @@ -52,6 +54,8 @@ export function MediaUploader({ typeAllowed, typeErrorMessage = "Unsupported file type.", acceptAttribute, + fileSizeError, + uploadHint, }: MediaUploaderProps) { const inputRef = useRef(null); @@ -65,6 +69,11 @@ export function MediaUploader({ toast.error(`${file.name} — ${typeErrorMessage}`); continue; } + const sizeError = fileSizeError?.(file); + if (sizeError) { + toast.error(sizeError); + continue; + } if (isWorkOrderFileTooLarge(file)) { toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`); continue; @@ -104,7 +113,9 @@ export function MediaUploader({ {!readOnly && ( ): MediaUploadLimits { }; } +export function emergencyMediaSizeError( + file: File, + limits?: Partial, +): string | undefined { + const kind = classifyMediaKind(file); + if (!kind) return undefined; + const resolvedLimits = defaultLimits(limits); + return isMediaSizeWithinLimit(file, kind, resolvedLimits) + ? undefined + : mediaOverLimitNotice(file, kind, resolvedLimits); +} + /** * Validate one media file against the policy limits: kind, inclusive size caps and * duration when it can be probed. A failed duration probe keeps the file eligible. 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 01db34cd..c9d7b4d4 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 @@ -1,17 +1,41 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { toast } from "react-toastify"; import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; const uploadMutateAsync = vi.fn(); +const resumableUploadMutateAsync = vi.fn(); +const resumableUploadHookSpy = vi.hoisted(() => vi.fn()); const deleteMutate = vi.fn(); const categorizeMutate = vi.fn(); +const mediaQueryState = vi.hoisted(() => ({ + detailType: "Emergency" as string | undefined, + policy: { + workOrderType: "Emergency", + canUpload: false, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 10, videos: 3 }, + } as unknown, + isError: false, + isLoading: false, +})); + +vi.mock("react-toastify", () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ useWorkOrderBoardDetail: () => ({ data: { - info: { status: "Scheduled", rowVersion: "DETAIL-VER==" }, + info: { status: "Scheduled", rowVersion: "DETAIL-VER==", type: mediaQueryState.detailType }, media: [ { id: "11", @@ -41,31 +65,25 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({ vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({ useWorkOrderMediaPolicy: () => ({ - data: { - workOrderType: "Emergency", - canUpload: false, - limits: { - photoMaxBytes: 10_000_000, - videoMaxBytes: 100_000_000, - videoMaxSeconds: 90, - maxPhotos: 10, - maxVideos: 3, - }, - remaining: { photos: 10, videos: 3 }, - }, + data: mediaQueryState.policy, + isError: mediaQueryState.isError, + isLoading: mediaQueryState.isLoading, }), })); vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({ - useResumableWorkOrderMediaUpload: () => ({ mutateAsync: vi.fn(), isPending: false }), + useResumableWorkOrderMediaUpload: (...args: unknown[]) => { + resumableUploadHookSpy(...args); + return { mutateAsync: resumableUploadMutateAsync, isPending: false }; + }, })); -function baseRow(): WorkOrderTableRow { +function baseRow(type = "Emergency"): WorkOrderTableRow { return { id: 7, woNumber: "WO-7", site: "SEA1", - type: "Emergency", + type, severity: 1, dispatcherId: "u1", dispatcherName: "Dispatcher", @@ -111,14 +129,14 @@ function baseRow(): WorkOrderTableRow { }; } -function renderModal() { +function renderModal(rowType = "Emergency") { const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); return render( - + , ); } @@ -126,8 +144,26 @@ function renderModal() { describe("MediaUploadModal API wiring", () => { beforeEach(() => { uploadMutateAsync.mockReset(); + resumableUploadMutateAsync.mockReset(); + resumableUploadHookSpy.mockClear(); deleteMutate.mockReset(); categorizeMutate.mockReset(); + mediaQueryState.detailType = "Emergency"; + mediaQueryState.policy = { + workOrderType: "Emergency", + canUpload: false, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 10, videos: 3 }, + }; + mediaQueryState.isError = false; + mediaQueryState.isLoading = false; + vi.mocked(toast.error).mockClear(); }); it("renders persisted media from board detail (refresh-safe)", async () => { @@ -138,7 +174,7 @@ describe("MediaUploadModal API wiring", () => { }); expect(screen.getByText("Photos & Videos")).toBeInTheDocument(); expect( - screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"), + screen.getByText("Photos up to 10 MB · Videos up to 100 MB and 90 seconds"), ).toBeInTheDocument(); expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument(); }); @@ -184,12 +220,72 @@ describe("MediaUploadModal API wiring", () => { it("fails closed when Emergency policy says uploads are unavailable", async () => { renderModal(); - const input = document.querySelector('input[type="file"]') as HTMLInputElement; - expect(input.disabled).toBe(true); + expect((document.querySelector('input[type="file"]') as HTMLInputElement).disabled).toBe(true); expect(screen.getByText("Uploads are not available for this work order.")).toBeInTheDocument(); + expect(uploadMutateAsync).not.toHaveBeenCalled(); + expect(resumableUploadMutateAsync).not.toHaveBeenCalled(); + expect(resumableUploadHookSpy).toHaveBeenCalledWith(7, "WorkOrderTable", expect.anything()); + }); + + it("routes by the detailed work-order type when policy denies uploads", () => { + mediaQueryState.detailType = "Emergency"; + mediaQueryState.policy = { + workOrderType: "Emergency", + canUpload: false, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 10, videos: 3 }, + }; + renderModal("PM"); + + expect((document.querySelector('input[type="file"]') as HTMLInputElement).disabled).toBe(true); + expect(screen.getByText("Uploads are not available for this work order.")).toBeInTheDocument(); + expect(resumableUploadHookSpy).toHaveBeenCalledWith(7, "WorkOrderTable", expect.anything()); + expect(uploadMutateAsync).not.toHaveBeenCalled(); + expect(resumableUploadMutateAsync).not.toHaveBeenCalled(); + }); + + it("fails closed with a visible error when the Emergency policy request fails", () => { + mediaQueryState.policy = undefined; + mediaQueryState.isError = true; + renderModal("PM"); + + expect((document.querySelector('input[type="file"]') as HTMLInputElement).disabled).toBe(true); + expect( + screen.getByText("Upload permissions could not be loaded. Refresh and try again."), + ).toBeInTheDocument(); + expect(uploadMutateAsync).not.toHaveBeenCalled(); + expect(resumableUploadMutateAsync).not.toHaveBeenCalled(); + }); + + it("shows the Emergency photo cap notice before starting an upload", () => { + mediaQueryState.policy = { + workOrderType: "Emergency", + canUpload: true, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 10, videos: 3 }, + }; + renderModal(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; fireEvent.change(input, { - target: { files: [new File(["x"], "photo.jpg", { type: "image/jpeg" })] }, + target: { + files: [new File([new Uint8Array(10_000_001)], "photo.jpg", { type: "image/jpeg" })], + }, }); + + expect(toast.error).toHaveBeenCalledWith('"photo.jpg" exceeds the photo limit (10 MB).'); + expect(resumableUploadMutateAsync).not.toHaveBeenCalled(); expect(uploadMutateAsync).not.toHaveBeenCalled(); }); }); diff --git a/src/test/app/(protected)/workorders/slide-over-completion-service-gate.test.tsx b/src/test/app/(protected)/workorders/slide-over-completion-service-gate.test.tsx index d87162fe..4f91f046 100644 --- a/src/test/app/(protected)/workorders/slide-over-completion-service-gate.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-completion-service-gate.test.tsx @@ -1,8 +1,13 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import { toast } from "react-toastify"; import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +vi.mock("react-toastify", () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + function renderTab(pm: string, onOpenCompletionDoc = vi.fn()) { const row = { id: "1", type: "PM", pm, docStatus: "No", rowVersion: "1" } as WorkOrderTableRow; render( @@ -43,4 +48,70 @@ describe("SlideOverCompletionTab service gate", () => { fireEvent.click(button); expect(onOpen).toHaveBeenCalledTimes(1); }); + + it("renders disabled Emergency media upload with the policy failure message", () => { + const onUpload = vi.fn(); + const row = { + id: "1", + type: "Emergency", + docStatus: "No", + rowVersion: "1", + } as WorkOrderTableRow; + render( + , + ); + + expect(screen.getByRole("status")).toHaveTextContent( + "Upload permissions could not be loaded. Refresh and try again.", + ); + expect(document.querySelector('input[type="file"]')).toBeDisabled(); + expect( + screen.getByRole("button", { name: /drag files here or click to browse/i }), + ).toBeDisabled(); + expect(onUpload).not.toHaveBeenCalled(); + }); + + it("shows per-kind Emergency limits before starting an oversized photo upload", () => { + const onUpload = vi.fn(); + const row = { + id: "1", + type: "Emergency", + docStatus: "No", + rowVersion: "1", + } as WorkOrderTableRow; + render( + true} + fileSizeError={(file) => + file.size > 10_000_000 ? `"${file.name}" exceeds the photo limit (10 MB).` : undefined + } + uploadHint="Photos up to 10 MB · Videos up to 100 MB and 90 seconds" + onUploadMedia={onUpload} + onOpenCompletionDoc={vi.fn()} + />, + ); + + expect( + screen.getByText("Photos up to 10 MB · Videos up to 100 MB and 90 seconds"), + ).toBeInTheDocument(); + fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, { + target: { + files: [new File([new Uint8Array(10_000_001)], "photo.jpg", { type: "image/jpeg" })], + }, + }); + + expect(toast.error).toHaveBeenCalledWith('"photo.jpg" exceeds the photo limit (10 MB).'); + expect(onUpload).not.toHaveBeenCalled(); + }); }); 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 9b9669f7..eb2a34b4 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 @@ -4,8 +4,14 @@ import { useSlideOverMediaState } from "@/app/(protected)/workorders/_components import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +const policyState = vi.hoisted(() => ({ + data: undefined as unknown, + isError: false, + isLoading: true, +})); + vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({ - useWorkOrderMediaPolicy: () => ({ data: undefined }), + useWorkOrderMediaPolicy: () => policyState, })); vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({ @@ -98,6 +104,9 @@ function idleMutation() { describe("useSlideOverMediaState completion filter", () => { beforeEach(() => { sessionStorage.clear(); + policyState.data = undefined; + policyState.isError = false; + policyState.isLoading = true; }); it("keeps Before/After/Completion after refetch mapped purpose (not file category)", () => { @@ -368,6 +377,87 @@ describe("useSlideOverMediaState completion filter", () => { }); }); + it("keeps the Emergency completion surface disabled with a visible policy error", async () => { + policyState.isError = true; + policyState.isLoading = false; + const legacyUpload = idleMutation(); + const { result } = renderHook(() => + useSlideOverMediaState({ + detailForRow: detailWithMedia([], { type: "Emergency" }), + uploadMediaMutation: legacyUpload, + deleteMediaMutation: idleMutation(), + categorizeMediaMutation: idleMutation(), + }), + ); + + expect(result.current.emergencyMediaUnavailable).toBe(true); + expect(result.current.emergencyMediaUnavailableMessage).toBe( + "Upload permissions could not be loaded. Refresh and try again.", + ); + act(() => { + result.current.uploadMediaRequests([ + { + localId: "local-policy-error", + file: new File(["x"], "photo.jpg", { type: "image/jpeg" }), + onProgress: vi.fn(), + }, + ]); + }); + + await waitFor(() => { + expect(legacyUpload.mutateAsync).not.toHaveBeenCalled(); + expect(result.current.completionMedia[0]?.uploadStatus).toBe("failed"); + }); + }); + + it("uses the table row type to fail closed while work-order detail is loading", () => { + const { result } = renderHook(() => + useSlideOverMediaState({ + row: baseInfo({ type: "Reactive" }), + uploadMediaMutation: idleMutation(), + deleteMediaMutation: idleMutation(), + categorizeMediaMutation: idleMutation(), + }), + ); + + expect(result.current.emergencyMediaUnavailable).toBe(true); + expect( + result.current.emergencyMediaTypeAllowed?.( + new File(["x"], "clip.mov", { type: "video/quicktime" }), + ), + ).toBe(true); + }); + + it("exposes server-derived Emergency size caps 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: 10, videos: 3 }, + }; + policyState.isLoading = false; + const { result } = renderHook(() => + useSlideOverMediaState({ + detailForRow: detailWithMedia([], { type: "Reactive" }), + uploadMediaMutation: idleMutation(), + deleteMediaMutation: idleMutation(), + categorizeMediaMutation: idleMutation(), + }), + ); + + expect( + result.current.emergencyMediaSizeError?.( + new File([new Uint8Array(10_000_001)], "photo.jpg", { type: "image/jpeg" }), + ), + ).toBe('"photo.jpg" exceeds the photo limit (10 MB).'); + }); + it("reuses the same idempotency key when a failed media tile is retried", async () => { const mutateAsync = vi .fn() From d6252552ca6c91e482e7890650551d9130e41c1a Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 04:43:58 -0300 Subject: [PATCH 03/10] fix: satisfy SH-116 frontend gates --- .../detail/use-slide-over-media-state.ts | 340 ++++++++++++------ .../_components/media/media-upload-modal.tsx | 61 ++-- .../_components/vendor-media-section.tsx | 195 ++++++---- src/test/api/media-upload-engine.test.ts | 2 +- .../media-upload-modal-api.test.tsx | 2 +- .../use-slide-over-media-state.test.tsx | 8 +- 6 files changed, 389 insertions(+), 219 deletions(-) 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 de94ee7d..227af08b 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 @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "react-toastify"; -import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader"; import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session"; import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; @@ -84,43 +83,7 @@ function saveOwnership( } } -export function useSlideOverMediaState({ - detailForRow, - row, - uploadMediaMutation, - deleteMediaMutation, - categorizeMediaMutation, -}: UseSlideOverMediaStateArgs) { - const workOrderId = detailForRow?.info.id ?? row?.id; - const workOrderType = detailForRow?.info.type ?? row?.type; - const { - data: mediaPolicy, - isError: mediaPolicyError, - isLoading: mediaPolicyLoading, - } = useWorkOrderMediaPolicy(workOrderId, workOrderId != null); - const resumableCompletionUpload = useResumableWorkOrderMediaUpload( - workOrderId ?? "", - "CompletionMedia", - mediaPolicy ?? undefined, - ); - const policyCanUpload = mediaPolicy?.canUpload === true && !mediaPolicyError; - const completionHasGallery = - hasCompletionMediaGallery(detailForRow?.info.type) || - hasCompletionMediaGallery(mediaPolicy?.workOrderType) || - hasCompletionMediaGallery(row?.type); - const emergencyMedia = - isEmergencyReactiveWorkOrderType(mediaPolicy?.workOrderType) || - isEmergencyReactiveWorkOrderType(workOrderType); - const emergencyMediaUnavailable = emergencyMedia && !policyCanUpload; - const emergencyMediaUnavailableMessage = - mediaPolicy?.canUpload === false - ? "Uploads are not available for this work order." - : mediaPolicyError - ? "Upload permissions could not be loaded. Refresh and try again." - : mediaPolicyLoading || !mediaPolicy - ? "Loading upload permissions…" - : "Uploads are not available for this work order."; - const [mediaBatchPending, setMediaBatchPending] = useState(false); +function useMediaOwnership(workOrderId: string | number | undefined) { const [ownership, setOwnershipState] = useState(() => loadOwnership(workOrderId), ); @@ -140,7 +103,27 @@ export function useSlideOverMediaState({ [workOrderId], ); - const persistCompletionUpload = useCallback( + return { ownership, updateOwnership }; +} + +type OwnershipUpdater = (update: (current: MediaSurfaceOwnership) => MediaSurfaceOwnership) => void; + +type CompletionUploadArgs = { + emergencyMedia: boolean; + policyCanUpload: boolean; + resumableCompletionUpload: ReturnType; + uploadMediaMutation: ReturnType; + updateOwnership: OwnershipUpdater; +}; + +function usePersistCompletionUpload({ + emergencyMedia, + policyCanUpload, + resumableCompletionUpload, + uploadMediaMutation, + updateOwnership, +}: CompletionUploadArgs) { + return useCallback( async (input: UploadWorkOrderMediaInput): Promise<{ id: string }> => { if (emergencyMedia) { if (!policyCanUpload) { @@ -175,9 +158,201 @@ export function useSlideOverMediaState({ uploadMediaMutation, ], ); +} + +type SlideOverActionArgs = { + detailForRow: WorkOrderBoardDetail | undefined; + isLocal: (id: string) => boolean; + removeLocal: (id: string) => void; + updateOwnership: OwnershipUpdater; + uploadMediaMutation: ReturnType; + deleteMediaMutation: ReturnType; + categorizeMediaMutation: ReturnType; +}; + +function useSlideOverMutationActions({ + detailForRow, + isLocal, + removeLocal, + updateOwnership, + uploadMediaMutation, + deleteMediaMutation, + categorizeMediaMutation, +}: SlideOverActionArgs) { + const [mediaBatchPending, setMediaBatchPending] = useState(false); + + const uploadExtraFiles = useCallback( + async (files: File[]) => { + setMediaBatchPending(true); + try { + for (const file of files) { + // Extra Docs stay on the legacy route and change category only by explicit PATCH. + const item = await uploadMediaMutation.mutateAsync({ file, category: "Extra" }); + updateOwnership((current) => ({ + completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), item.id), + extraOwnedIds: addOwnedId(new Set(current.extraOwnedIds), item.id), + })); + } + } finally { + setMediaBatchPending(false); + } + }, + [uploadMediaMutation, updateOwnership], + ); + + const categorizeMedia = useCallback( + (mediaId: string, category: UploadableMediaCategory) => { + const workOrderVersion = detailForRow?.info.rowVersion; + if (!workOrderVersion) { + toast.error("Missing work order version. Refresh and try again."); + return; + } + categorizeMediaMutation.mutate( + { mediaId, category, workOrderVersion }, + { + onSuccess: () => { + updateOwnership((current) => ({ + completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), mediaId), + extraOwnedIds: + category === "Extra" + ? addOwnedId(new Set(current.extraOwnedIds), mediaId) + : dropOwnedId(new Set(current.extraOwnedIds), mediaId), + })); + }, + }, + ); + }, + [detailForRow?.info.rowVersion, categorizeMediaMutation, updateOwnership], + ); + + const removeMedia = useCallback( + (id: string) => { + if (isLocal(id)) { + removeLocal(id); + return; + } + const workOrderVersion = detailForRow?.info.rowVersion; + if (!workOrderVersion) { + toast.error("Missing work order version. Refresh and try again."); + return; + } + deleteMediaMutation.mutate({ mediaId: id, workOrderVersion }); + }, + [detailForRow?.info.rowVersion, isLocal, removeLocal, deleteMediaMutation], + ); + + return { mediaBatchPending, uploadExtraFiles, categorizeMedia, removeMedia }; +} + +function hasAnyCompletionGallery(...types: Array): boolean { + return types.some(hasCompletionMediaGallery); +} + +function hasEmergencyMediaType(...types: Array): boolean { + return types.some(isEmergencyReactiveWorkOrderType); +} + +function getEmergencyUploaderOptions( + emergencyMedia: boolean, + limits: Parameters[1], +) { + if (!emergencyMedia) { + return { + emergencyMediaTypeAllowed: undefined, + emergencyMediaTypeErrorMessage: undefined, + emergencyMediaAcceptAttribute: undefined, + emergencyMediaSizeError: undefined, + emergencyMediaUploadHint: undefined, + }; + } + return { + emergencyMediaTypeAllowed: (file: File) => classifyMediaKind(file) !== null, + emergencyMediaTypeErrorMessage: + "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported.", + emergencyMediaAcceptAttribute: emergencyMediaAcceptAttribute(), + emergencyMediaSizeError: (file: File) => emergencyMediaSizeError(file, limits), + emergencyMediaUploadHint: "Photos up to 10 MB · Videos up to 100 MB and 90 seconds", + }; +} + +function isAnyMediaMutationPending(...states: boolean[]): boolean { + return states.some(Boolean); +} + +function getEmergencyMediaUnavailableMessage( + canUpload: boolean | undefined, + policyError: boolean, + policyLoading: boolean, + hasPolicy: boolean, +): string { + if (canUpload === false) return "Uploads are not available for this work order."; + if (policyError) return "Upload permissions could not be loaded. Refresh and try again."; + if (policyLoading || !hasPolicy) return "Loading upload permissions…"; + return "Uploads are not available for this work order."; +} + +function isPolicyUploadAllowed(canUpload: boolean | undefined, policyError: boolean): boolean { + return canUpload === true && !policyError; +} + +function isEmergencyUploadUnavailable(emergencyMedia: boolean, policyCanUpload: boolean): boolean { + return emergencyMedia && !policyCanUpload; +} + +export function useSlideOverMediaState({ + detailForRow, + row, + uploadMediaMutation, + deleteMediaMutation, + categorizeMediaMutation, +}: UseSlideOverMediaStateArgs) { + const workOrderId = detailForRow?.info.id ?? row?.id; + const workOrderType = detailForRow?.info.type ?? row?.type; + const { + data: mediaPolicy, + isError: mediaPolicyError, + isLoading: mediaPolicyLoading, + } = useWorkOrderMediaPolicy(workOrderId, workOrderId != null); + const resumableCompletionUpload = useResumableWorkOrderMediaUpload( + workOrderId ?? "", + "CompletionMedia", + mediaPolicy ?? undefined, + ); + const policyCanUpload = isPolicyUploadAllowed(mediaPolicy?.canUpload, mediaPolicyError); + const completionHasGallery = hasAnyCompletionGallery( + detailForRow?.info.type, + mediaPolicy?.workOrderType, + row?.type, + ); + const emergencyMedia = hasEmergencyMediaType(mediaPolicy?.workOrderType, workOrderType); + const emergencyMediaUnavailable = isEmergencyUploadUnavailable(emergencyMedia, policyCanUpload); + const emergencyMediaUnavailableMessage = getEmergencyMediaUnavailableMessage( + mediaPolicy?.canUpload, + mediaPolicyError, + mediaPolicyLoading, + Boolean(mediaPolicy), + ); + const { ownership, updateOwnership } = useMediaOwnership(workOrderId); + const persistCompletionUpload = usePersistCompletionUpload({ + emergencyMedia, + policyCanUpload, + resumableCompletionUpload, + uploadMediaMutation, + updateOwnership, + }); const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } = useMediaUploadSession(detailForRow, persistCompletionUpload); + const { mediaBatchPending, uploadExtraFiles, categorizeMedia, removeMedia } = + useSlideOverMutationActions({ + detailForRow, + isLocal, + removeLocal, + updateOwnership, + uploadMediaMutation, + deleteMediaMutation, + categorizeMediaMutation, + }); const completionMedia = useMemo( () => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership, completionHasGallery)), @@ -193,68 +368,15 @@ export function useSlideOverMediaState({ ); const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading"); - const mediaPending = - mediaBatchPending || - hasLocalUploading || - uploadMediaMutation.isPending || - resumableCompletionUpload.isPending || - deleteMediaMutation.isPending || - categorizeMediaMutation.isPending; - - const uploadMediaRequests = (requests: MediaUploadRequest[]) => { - startUploads(requests); - }; - - const uploadExtraFiles = async (files: File[]) => { - setMediaBatchPending(true); - try { - for (const file of files) { - // Upload stays Extra; Aveta is applied only by an explicit Extra Docs PATCH. - const item = await uploadMediaMutation.mutateAsync({ file, category: "Extra" }); - updateOwnership((current) => ({ - completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), item.id), - extraOwnedIds: addOwnedId(new Set(current.extraOwnedIds), item.id), - })); - } - } finally { - setMediaBatchPending(false); - } - }; - - const categorizeMedia = (mediaId: string, category: UploadableMediaCategory) => { - const workOrderVersion = detailForRow?.info.rowVersion; - if (!workOrderVersion) { - toast.error("Missing work order version. Refresh and try again."); - return; - } - categorizeMediaMutation.mutate( - { mediaId, category, workOrderVersion }, - { - onSuccess: () => { - updateOwnership((current) => ({ - completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), mediaId), - extraOwnedIds: - category === "Extra" - ? addOwnedId(new Set(current.extraOwnedIds), mediaId) - : dropOwnedId(new Set(current.extraOwnedIds), mediaId), - })); - }, - }, - ); - }; - - const removeMedia = (id: string) => { - if (isLocal(id)) { - removeLocal(id); - return; - } - const workOrderVersion = detailForRow?.info.rowVersion; - if (!workOrderVersion) { - toast.error("Missing work order version. Refresh and try again."); - return; - } - deleteMediaMutation.mutate({ mediaId: id, workOrderVersion }); - }; + const mediaPending = isAnyMediaMutationPending( + mediaBatchPending, + hasLocalUploading, + uploadMediaMutation.isPending, + resumableCompletionUpload.isPending, + deleteMediaMutation.isPending, + categorizeMediaMutation.isPending, + ); + const emergencyUploaderOptions = getEmergencyUploaderOptions(emergencyMedia, mediaPolicy?.limits); return { completionMedia, @@ -263,20 +385,8 @@ export function useSlideOverMediaState({ emergencyMedia, emergencyMediaUnavailable, emergencyMediaUnavailableMessage, - emergencyMediaTypeAllowed: emergencyMedia - ? (file: File) => classifyMediaKind(file) !== null - : undefined, - emergencyMediaTypeErrorMessage: emergencyMedia - ? "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported." - : undefined, - emergencyMediaAcceptAttribute: emergencyMedia ? emergencyMediaAcceptAttribute() : undefined, - emergencyMediaSizeError: emergencyMedia - ? (file: File) => emergencyMediaSizeError(file, mediaPolicy?.limits) - : undefined, - emergencyMediaUploadHint: emergencyMedia - ? "Photos up to 10 MB · Videos up to 100 MB and 90 seconds" - : undefined, - uploadMediaRequests, + ...emergencyUploaderOptions, + uploadMediaRequests: startUploads, uploadExtraFiles, categorizeMedia, retryUpload, 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 58e1e722..7f824186 100644 --- a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx +++ b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx @@ -39,6 +39,39 @@ function resolveWorkOrderVersion( return version || null; } +function isEmergencyReactive(row: WorkOrderTableRow, detailType?: string, policyType?: string) { + return ( + isEmergencyReactiveWorkOrderType(detailType) || + isEmergencyReactiveWorkOrderType(policyType) || + isEmergencyReactiveWorkOrderType(row.type) + ); +} + +function emergencyPolicyMessage( + canUpload: boolean | undefined, + isError: boolean, + isLoading: boolean, +): string { + if (canUpload === false) return "Uploads are not available for this work order."; + if (isError) return "Upload permissions could not be loaded. Refresh and try again."; + if (isLoading) return "Loading upload permissions…"; + return "Uploads are not available for this work order."; +} + +function getEmergencyUploaderOptions( + isEmergency: boolean, + limits: Parameters[1], +) { + if (!isEmergency) return {}; + return { + fileSizeError: (file: File) => emergencyMediaSizeError(file, limits), + 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.", + acceptAttribute: emergencyMediaAcceptAttribute(), + }; +} + export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) { const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open); const { @@ -53,10 +86,7 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr policyError ? undefined : (policy ?? undefined), ); const deleteMutation = useDeleteWorkOrderMedia(row.id); - const isEmergency = - isEmergencyReactiveWorkOrderType(detail?.info.type) || - isEmergencyReactiveWorkOrderType(policy?.workOrderType) || - isEmergencyReactiveWorkOrderType(row.type); + const isEmergency = isEmergencyReactive(row, detail?.info.type, policy?.workOrderType); const uploadMutation = isEmergency ? resumableUploadMutation : legacyUploadMutation; const emergencyUploadUnavailable = isEmergency && (policy?.canUpload !== true || policyError); const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status); @@ -65,6 +95,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 requireVersion = (): string | null => { const version = resolveWorkOrderVersion(detail?.info.rowVersion, row.rowVersion); @@ -130,32 +161,14 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr onCancel={cancelUpload} readOnly={isReadOnly} uploadDisabled={emergencyUploadUnavailable} - fileSizeError={ - isEmergency ? (file) => emergencyMediaSizeError(file, policy?.limits) : undefined - } - uploadHint={ - isEmergency ? "Photos up to 10 MB · Videos up to 100 MB and 90 seconds" : undefined - } + {...emergencyUploaderOptions} pending={pending} - typeAllowed={isEmergency ? (file) => classifyMediaKind(file) !== null : undefined} - typeErrorMessage={ - isEmergency - ? "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported." - : undefined - } - acceptAttribute={isEmergency ? emergencyMediaAcceptAttribute() : undefined} /> )} {emergencyUploadUnavailable && !isReadOnly && (
- {policy?.canUpload === false - ? "Uploads are not available for this work order." - : policyError - ? "Upload permissions could not be loaded. Refresh and try again." - : policyLoading || !policy - ? "Loading upload permissions…" - : "Uploads are not available for this work order."} + {emergencyPolicyMessage(policy?.canUpload, policyError, policyLoading || !policy)}
)} 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 240af621..45392523 100644 --- a/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx +++ b/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx @@ -40,6 +40,106 @@ function viewMediaItem(item: VendorMediaItem, blob: Blob): void { window.setTimeout(() => URL.revokeObjectURL(url), 60_000); } +function VendorMediaItems({ + items, + onView, +}: { + items: VendorMediaItem[]; + onView: (item: VendorMediaItem) => void; +}) { + return items.map((item) => ( +
+
+ File + + {item.originalFileName} · {item.kind} · {formatBytes(item.sizeBytes)} + +
+
+ +
+
+ )); +} + +function VendorUploadProgress({ + pending, + progress, + onCancel, +}: { + pending: boolean; + progress: number | undefined; + onCancel: () => void; +}) { + if (!pending) return null; + return ( +
+ {progress !== undefined ? ( + <> + + Uploading… {progress}% + + ) : ( + Preparing upload… + )} + +
+ ); +} + +function VendorUploadError({ + error, + canceled, + locked, + onRetry, +}: { + error: Error | null; + canceled: boolean; + locked: boolean; + onRetry: () => void; +}) { + if (canceled) return Upload canceled.; + if (!error) return null; + return ( +
+ {safeMediaUploadErrorMessage(error)} + {!locked && error instanceof MediaUploadNetworkError && ( + + )} +
+ ); +} + +function VendorUploadButton({ + canUpload, + pending, + onClick, +}: { + canUpload: boolean; + pending: boolean; + onClick: () => void; +}) { + if (!canUpload) return null; + return ( +
+ +
+ ); +} + export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSectionProps) { const inputRef = useRef(null); const controllerRef = useRef(null); @@ -159,82 +259,27 @@ export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSec No photos or videos uploaded yet. )} - {items.map((item) => ( -
-
- File - - {item.originalFileName} · {item.kind} · {formatBytes(item.sizeBytes)} - -
-
- -
-
- ))} + void handleView(item)} /> + controllerRef.current?.abort()} + /> + startUpload(failedFileRef.current, true)} + /> - {uploadMutation.isPending && ( -
- {progress !== undefined ? ( - <> - - Uploading… {progress}% - - ) : ( - Preparing upload… - )} - -
- )} - - {uploadCanceled && Upload canceled.} - - {uploadMutation.isError && !uploadCanceled && ( -
- - {safeMediaUploadErrorMessage(uploadMutation.error)} - - {!locked && uploadMutation.error instanceof MediaUploadNetworkError && ( - - )} -
- )} - - {canUpload && ( -
- -
- )} + { + uploadMutation.reset(); + inputRef.current?.click(); + }} + /> {locked && ( diff --git a/src/test/api/media-upload-engine.test.ts b/src/test/api/media-upload-engine.test.ts index e5245278..e06b9264 100644 --- a/src/test/api/media-upload-engine.test.ts +++ b/src/test/api/media-upload-engine.test.ts @@ -292,7 +292,7 @@ describe("resumable media upload engine", () => { .filter((entry) => entry.method === "PUT") .map((entry) => Number(/chunks\/(\d+)$/.exec(entry.url)?.[1])); expect(putIndices.filter((index) => index === 0)).toEqual([0]); - expect(putIndices.at(-1)).toBe(1); + expect(putIndices[putIndices.length - 1]).toBe(1); }); it("retries a failed create with the same idempotency key and never re-creates after chunk loss", async () => { 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 c9d7b4d4..6fe189a3 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 @@ -78,7 +78,7 @@ vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", }, })); -function baseRow(type = "Emergency"): WorkOrderTableRow { +function baseRow(type: WorkOrderTableRow["type"] = "Emergency"): WorkOrderTableRow { return { id: 7, woNumber: "WO-7", 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 eb2a34b4..90ae47e0 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 @@ -9,6 +9,7 @@ const policyState = vi.hoisted(() => ({ isError: false, isLoading: true, })); +const legacyUploadMutateAsync = vi.fn().mockResolvedValue({ id: "legacy" }); vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({ useWorkOrderMediaPolicy: () => policyState, @@ -96,13 +97,14 @@ function detailWithMedia( function idleMutation() { return { mutate: vi.fn(), - mutateAsync: vi.fn().mockResolvedValue({}), + mutateAsync: legacyUploadMutateAsync, isPending: false, } as never; } describe("useSlideOverMediaState completion filter", () => { beforeEach(() => { + legacyUploadMutateAsync.mockClear(); sessionStorage.clear(); policyState.data = undefined; policyState.isError = false; @@ -370,7 +372,7 @@ describe("useSlideOverMediaState completion filter", () => { }); await waitFor(() => { - expect(legacyUpload.mutateAsync).not.toHaveBeenCalled(); + expect(legacyUploadMutateAsync).not.toHaveBeenCalled(); expect(result.current.completionMedia.some((item) => item.uploadStatus === "failed")).toBe( true, ); @@ -405,7 +407,7 @@ describe("useSlideOverMediaState completion filter", () => { }); await waitFor(() => { - expect(legacyUpload.mutateAsync).not.toHaveBeenCalled(); + expect(legacyUploadMutateAsync).not.toHaveBeenCalled(); expect(result.current.completionMedia[0]?.uploadStatus).toBe("failed"); }); }); From 6321f176aa7cd521160308e20aaabb263d30ac94 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 04:49:16 -0300 Subject: [PATCH 04/10] fix: resolve SH-116 governance and type errors --- src/api/media-upload-engine.ts | 75 +++++++++++++------ .../media-upload-modal-api.test.tsx | 2 +- 2 files changed, 52 insertions(+), 25 deletions(-) diff --git a/src/api/media-upload-engine.ts b/src/api/media-upload-engine.ts index aa42f9d0..6e8b0055 100644 --- a/src/api/media-upload-engine.ts +++ b/src/api/media-upload-engine.ts @@ -214,6 +214,48 @@ async function sendChunk( }); } +type ChunkRecoveryContext = { + args: ResumableUploadArgs; + session: MediaUploadSessionDto; + index: number; + chunkSize: number; + recoveryAttempts: Map; +}; + +async function sendChunkWithRecovery({ + args, + session, + index, + chunkSize, + recoveryAttempts, +}: ChunkRecoveryContext): Promise<{ + session: MediaUploadSessionDto; + recovered: boolean; +}> { + try { + const ack = await sendChunk(args, session.uploadId, index, chunkSize); + const receivedChunks = Array.isArray(ack?.receivedChunks) ? ack.receivedChunks : []; + if (!receivedChunks.includes(index)) { + throw new Error("Chunk acknowledgement missing from server response."); + } + return { session: { ...session, receivedChunks }, recovered: false }; + } catch (error) { + if (!isNetworkUploadError(error)) throw error; + const recovered = await recoverSession(args.endpoints, session.uploadId, args.signal); + if (recovered.receivedChunks.includes(index)) { + recoveryAttempts.delete(index); + return { session: recovered, recovered: true }; + } + + const attempts = (recoveryAttempts.get(index) ?? 0) + 1; + recoveryAttempts.set(index, attempts); + if (attempts >= MAX_CHUNK_RECOVERY_ATTEMPTS) throw error; + await new Promise((resolve) => setTimeout(resolve, CHUNK_RECOVERY_BACKOFF_MS * attempts)); + assertNotAborted(args.signal); + return { session: recovered, recovered: true }; + } +} + /** * Upload one file through the resumable session API: create → chunks → commit → poll. * One in-flight chunk request per file; durable progress counts only acknowledged parts. @@ -248,30 +290,15 @@ export async function uploadFileResumable( if (missing.length === 0) break; for (const index of missing) { assertNotAborted(args.signal); - try { - const ack = await sendChunk(args, session.uploadId, index, chunkSize); - const acked = Array.isArray(ack?.receivedChunks) ? ack.receivedChunks : []; - if (!acked.includes(index)) { - // Treat a malformed ack as not-yet-acked so the loop cannot spin forever. - throw new Error("Chunk acknowledgement missing from server response."); - } - session = { ...session, receivedChunks: acked }; - } catch (error) { - if (!isNetworkUploadError(error)) throw error; - session = await recoverSession(args.endpoints, session.uploadId, args.signal); - if (session.receivedChunks.includes(index)) { - recoveryAttempts.delete(index); - } else { - const attempts = (recoveryAttempts.get(index) ?? 0) + 1; - recoveryAttempts.set(index, attempts); - if (attempts >= MAX_CHUNK_RECOVERY_ATTEMPTS) throw error; - await new Promise((resolve) => - setTimeout(resolve, CHUNK_RECOVERY_BACKOFF_MS * attempts), - ); - assertNotAborted(args.signal); - } - break; // Re-derive missing indices from the recovered server state. - } + const result = await sendChunkWithRecovery({ + args, + session, + index, + chunkSize, + recoveryAttempts, + }); + session = result.session; + if (result.recovered) break; reportProgress(args, session.receivedChunks, args.file.size, chunkSize); } } 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 6fe189a3..f992bf5a 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 @@ -129,7 +129,7 @@ function baseRow(type: WorkOrderTableRow["type"] = "Emergency"): WorkOrderTableR }; } -function renderModal(rowType = "Emergency") { +function renderModal(rowType: WorkOrderTableRow["type"] = "Emergency") { const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); From f822c2cc9b18d8bfd8eb9fb5c1a93f3819c8b241 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 05:06:58 -0300 Subject: [PATCH 05/10] fix: align browser fixtures with media upload contract --- e2e/vendor-portal/portal-lifecycle.spec.ts | 20 +++ e2e/vendors/vendor-uplift-workflow.spec.ts | 32 +++++ .../completion-uploads-mobile.spec.ts | 136 +++++++++++++----- 3 files changed, 152 insertions(+), 36 deletions(-) diff --git a/e2e/vendor-portal/portal-lifecycle.spec.ts b/e2e/vendor-portal/portal-lifecycle.spec.ts index 294051a6..ed573ad9 100644 --- a/e2e/vendor-portal/portal-lifecycle.spec.ts +++ b/e2e/vendor-portal/portal-lifecycle.spec.ts @@ -136,6 +136,26 @@ async function mockPortalApi(page: Page, options: { sessionError?: boolean } = { fulfill(route, { data: detailState.current }), ); + await page.route(/\/api\/vendor-portal\/dispatches\/\d+\/media$/, (route) => + fulfill(route, { + data: { + policy: { + workOrderType: "Emergency", + canUpload: true, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 10, videos: 3 }, + }, + items: [], + }, + }), + ); + await page.route(/\/api\/vendor-portal\/dispatches(?:\?.*)?$/, (route) => fulfill(route, { data: listDispatches }), ); diff --git a/e2e/vendors/vendor-uplift-workflow.spec.ts b/e2e/vendors/vendor-uplift-workflow.spec.ts index 69205a2e..c826dd1c 100644 --- a/e2e/vendors/vendor-uplift-workflow.spec.ts +++ b/e2e/vendors/vendor-uplift-workflow.spec.ts @@ -1,5 +1,21 @@ import { expect, test, type Route } from "@playwright/test"; +const vendorMedia = { + policy: { + workOrderType: "Reactive", + canUpload: true, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 10, videos: 3 }, + }, + items: [], +}; + const INTERNAL_USER = { token: "uplift-approver-token", expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), @@ -26,6 +42,10 @@ test("vendor uploads scanned evidence and submits a dispatch-scoped uplift", asy const request = route.request(); const path = new URL(request.url()).pathname; + if (path.endsWith("/media") && request.method() === "GET") { + await fulfillJson(route, vendorMedia); + return; + } if (path.endsWith("/session")) { await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" }); return; @@ -122,6 +142,10 @@ test("vendor can withdraw a pending uplift from the dispatch", async ({ page }) await page.route("**/api/vendor-portal/**", async (route) => { const request = route.request(); const path = new URL(request.url()).pathname; + if (path.endsWith("/media") && request.method() === "GET") { + await fulfillJson(route, vendorMedia); + return; + } if (path.endsWith("/session")) { await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" }); return; @@ -172,6 +196,10 @@ test("vendor receives the no-approval result for a request within the current NT await page.route("**/api/vendor-portal/**", async (route) => { const request = route.request(); const path = new URL(request.url()).pathname; + if (path.endsWith("/media") && request.method() === "GET") { + await fulfillJson(route, vendorMedia); + return; + } if (path.endsWith("/session")) { await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" }); return; @@ -216,6 +244,10 @@ test("vendor revises a changes-requested uplift using its existing evidence", as await page.route("**/api/vendor-portal/**", async (route) => { const request = route.request(); const path = new URL(request.url()).pathname; + if (path.endsWith("/media") && request.method() === "GET") { + await fulfillJson(route, vendorMedia); + return; + } if (path.endsWith("/session")) { await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" }); return; diff --git a/e2e/work-orders/completion-uploads-mobile.spec.ts b/e2e/work-orders/completion-uploads-mobile.spec.ts index f778567b..86c40555 100644 --- a/e2e/work-orders/completion-uploads-mobile.spec.ts +++ b/e2e/work-orders/completion-uploads-mobile.spec.ts @@ -1,7 +1,6 @@ import { devices, expect, test, type Page, type Request, type Route } from "@playwright/test"; -// Completion uploads from a mobile-sized viewport must reach the API as -// multipart/form-data with a boundary, or ASP.NET cannot bind the [FromForm] file/category. +// Signed completion PDFs remain multipart; Emergency/Reactive photos and videos use resumable chunks. test.use({ ...devices["Pixel 7"] }); const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; @@ -45,15 +44,21 @@ async function fulfillJson(route: Route, body: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); } +type UploadSessionFixture = { + uploadId: string; + state: "Created" | "Uploading" | "Completed"; + chunkSizeBytes: number; + sizeBytes: number; + receivedChunks: number[]; + expiresAt: string; + attachment?: { id: number }; +}; + function multipartBoundary(request: Request): string | undefined { const contentType = request.headers()["content-type"] ?? ""; return /^multipart\/form-data; boundary=(.+)$/.exec(contentType)?.[1]; } -function multipartFileName(body: string): string | undefined { - return /filename="([^"]+)"/.exec(body)?.[1]; -} - async function hideQueryDevtools(page: Page) { await page.addStyleTag({ content: @@ -133,28 +138,84 @@ test.describe("Completion uploads on a mobile viewport", () => { expect(body).toMatch(new RegExp(`name="workOrderVersion"\\r\\n\\r\\n${ROW_VERSION}`)); }); - test("uploads JPG, MP4 and MOV media and categorizes as multipart", async ({ page }) => { + test("uploads JPG, MP4 and MOV through acknowledged resumable chunks", async ({ page }) => { const row = boardRow({ id: 72, woNumber: "RX-MEDIA-72", workOrderType: 6 }); - const media: { id: number; category: number; url: string; fileName: string }[] = []; await mockBoard(page, row); await page.route("**/api/workorders/72/detail", (route) => - fulfillJson(route, { info: row, completion: { docStatus: "No" }, media }), + fulfillJson(route, { info: row, completion: { docStatus: "No" }, media: [] }), ); - const uploads: { contentType: string; body: string }[] = []; - await page.route("**/api/workorders/72/media", (route) => { + const sessions = new Map(); + const creates: Record[] = []; + const chunks: { body: Buffer; headers: Record }[] = []; + const commits: string[] = []; + await page.route("**/api/workorders/72/media/policy", (route) => + fulfillJson(route, { + workOrderType: "Reactive", + canUpload: true, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 10, videos: 3 }, + }), + ); + await page.route("**/api/workorders/72/media/uploads", async (route) => { const request = route.request(); - const body = request.postDataBuffer()?.toString("latin1") ?? ""; - uploads.push({ contentType: request.headers()["content-type"] ?? "", body }); - const fileName = multipartFileName(body) ?? "file"; - const item = { id: 900 + media.length, category: 3, url: `/Assets/${fileName}`, fileName }; - media.push(item); - return fulfillJson(route, item); + const dto = request.postDataJSON() as Record; + creates.push(dto); + const uploadId = `media-${creates.length}`; + const session: UploadSessionFixture = { + uploadId, + state: "Created", + chunkSizeBytes: 5_242_880, + sizeBytes: Number(dto.sizeBytes), + receivedChunks: [], + expiresAt: "2026-08-20T15:00:00.000Z", + }; + sessions.set(uploadId, session); + await fulfillJson(route, session); }); - let categorize: Request | undefined; - await page.route("**/api/workorders/72/media/*", (route) => { - categorize = route.request(); - const item = media[0]!; - return fulfillJson(route, { ...item, category: 1 }); + await page.route("**/api/workorders/72/media/uploads/**", async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + const match = /\/media\/uploads\/(media-\d+)(?:\/chunks\/(\d+)|\/commit)?$/.exec(path); + if (!match) return route.fallback(); + const uploadId = match[1]!; + const index = match[2]; + const session = sessions.get(uploadId)!; + + if (request.method() === "PUT" && index !== undefined) { + const body = request.postDataBuffer() ?? Buffer.alloc(0); + chunks.push({ body, headers: request.headers() }); + session.receivedChunks = [...new Set([...session.receivedChunks, Number(index)])]; + session.state = "Uploading"; + await fulfillJson(route, { + index: Number(index), + sha256: request.headers()["x-chunk-sha256"], + sizeBytes: body.length, + receivedChunks: session.receivedChunks, + }); + return; + } + if (request.method() === "POST" && path.endsWith("/commit")) { + commits.push(uploadId); + session.state = "Completed"; + session.attachment = { id: 900 + commits.length }; + await fulfillJson(route, session); + return; + } + if (request.method() === "GET") { + await fulfillJson(route, session); + return; + } + if (request.method() === "DELETE") { + await fulfillJson(route, {}); + return; + } + await route.fallback(); }); const panel = await openCompletionTab(page, row.woNumber); @@ -167,19 +228,22 @@ test.describe("Completion uploads on a mobile viewport", () => { ]; await (await chooser).setFiles(files); - await expect.poll(() => uploads.length).toBe(files.length); - for (const { name } of files) { - const upload = uploads.find((item) => multipartFileName(item.body) === name); - expect(upload, `multipart upload for ${name}`).toBeTruthy(); - expect(upload!.contentType).toMatch(/^multipart\/form-data; boundary=/); - } - - await panel.getByRole("combobox", { name: "Category for IMG_0001.jpg" }).selectOption("Before"); - await expect.poll(() => categorize?.method()).toBe("PATCH"); - const boundary = multipartBoundary(categorize!); - expect(boundary, categorize!.headers()["content-type"]).toBeTruthy(); - const body = categorize!.postDataBuffer()?.toString("latin1") ?? ""; - expect(body).toMatch(/name="category"\r\n\r\nBefore/); - expect(body).toMatch(new RegExp(`name="workOrderVersion"\\r\\n\\r\\n${ROW_VERSION}`)); + await expect.poll(() => creates.length).toBe(files.length); + await expect.poll(() => chunks.length).toBe(files.length); + await expect.poll(() => commits.length).toBe(files.length); + expect(creates.map((dto) => dto.surface)).toEqual( + Array.from({ length: files.length }, () => "CompletionMedia"), + ); + expect(creates.map((dto) => dto.sizeBytes).sort()).toEqual( + files.map((file) => file.buffer.length).sort(), + ); + expect(chunks.map((chunk) => chunk.headers["content-type"])).toEqual( + Array.from({ length: files.length }, () => "application/octet-stream"), + ); + expect(chunks.map((chunk) => chunk.body.toString("hex")).sort()).toEqual( + files.map((file) => file.buffer.toString("hex")).sort(), + ); + expect([...sessions.values()].every((session) => session.state === "Completed")).toBe(true); + await expect(page.getByText("File uploaded").first()).toBeVisible(); }); }); From 340dd5252b60b51042f8208fbe3083171f9744c9 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 05:17:54 -0300 Subject: [PATCH 06/10] 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"), From 0e8328f91d65afb7548ec2f1bbf4c789e25e6e2d Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 05:20:52 -0300 Subject: [PATCH 07/10] fix: expose slide-over media batch guard --- .../_components/detail/use-work-order-slide-over.ts | 2 ++ .../workorders/use-slide-over-media-state.test.tsx | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) 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 37792f32..a2596b08 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 @@ -125,6 +125,7 @@ export function useWorkOrderSlideOver({ emergencyMediaTypeErrorMessage, emergencyMediaAcceptAttribute, emergencyMediaSizeError, + emergencyMediaBatchFileError, emergencyMediaUploadHint, uploadMediaRequests, uploadExtraFiles, @@ -189,6 +190,7 @@ export function useWorkOrderSlideOver({ emergencyMediaTypeErrorMessage, emergencyMediaAcceptAttribute, emergencyMediaSizeError, + emergencyMediaBatchFileError, emergencyMediaUploadHint, completionPending, closeDisabled, 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 c511d72f..68238fee 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,6 +1,7 @@ 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 { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader"; 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"; @@ -532,7 +533,9 @@ describe("useSlideOverMediaState completion filter", () => { }); expect(onUploadMedia).toHaveBeenCalledTimes(1); - expect(onUploadMedia.mock.calls[0]?.[0].map((request) => request.file)).toEqual([photoOne]); + expect( + onUploadMedia.mock.calls[0]?.[0].map((request: MediaUploadRequest) => request.file), + ).toEqual([photoOne]); }); it("reuses the same idempotency key when a failed media tile is retried", async () => { From 9d310afa7549ce8538208b32b9e799f38545a643 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 05:32:18 -0300 Subject: [PATCH 08/10] fix: complete vendor media cancel and preview flow --- src/api/media-upload-client.ts | 4 +- .../_components/vendor-media-section.tsx | 28 ++++-- src/test/app/v/vendor-media-section.test.tsx | 97 ++++++++++++++++--- 3 files changed, 105 insertions(+), 24 deletions(-) diff --git a/src/api/media-upload-client.ts b/src/api/media-upload-client.ts index 5bc0ff63..edc268b4 100644 --- a/src/api/media-upload-client.ts +++ b/src/api/media-upload-client.ts @@ -42,7 +42,9 @@ export function isRetryableMediaUploadError(error: unknown): boolean { } export function isAbortError(error: unknown): boolean { - return error instanceof Error && error.name === "AbortError"; + return ( + typeof error === "object" && error !== null && "name" in error && error.name === "AbortError" + ); } export async function computeSha256Hex(buffer: ArrayBuffer): Promise { 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 523e136e..b364cc4f 100644 --- a/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx +++ b/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx @@ -34,10 +34,11 @@ function formatBytes(sizeBytes: number): string { return `${Math.max(1, Math.round(sizeBytes / 1_000))} KB`; } -function viewMediaItem(item: VendorMediaItem, blob: Blob): void { +function viewMediaItem(item: VendorMediaItem, blob: Blob, previewWindow: Window): void { const url = URL.createObjectURL(blob); - window.open(url, "_blank", "noopener"); - window.setTimeout(() => URL.revokeObjectURL(url), 60_000); + previewWindow.document.title = item.originalFileName; + previewWindow.addEventListener("pagehide", () => URL.revokeObjectURL(url), { once: true }); + previewWindow.location.replace(url); } function VendorMediaItems({ @@ -148,6 +149,7 @@ export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSec const [loadState, setLoadState] = useState({ loading: true }); const [progress, setProgress] = useState(); const [uploadCanceled, setUploadCanceled] = useState(false); + const [mediaActionError, setMediaActionError] = useState(); const load = useCallback(async () => { setLoadState({ loading: true }); @@ -217,14 +219,21 @@ export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSec const selectFile = (file: File | undefined) => startUpload(file ?? null); const handleView = async (item: VendorMediaItem) => { + const previewWindow = window.open("about:blank", "_blank"); + if (!previewWindow) { + setMediaActionError("Allow pop-ups to view this media file."); + return; + } + previewWindow.opener = null; + previewWindow.document.title = item.originalFileName; + previewWindow.document.body.textContent = `Loading ${item.originalFileName}…`; + setMediaActionError(undefined); try { const blob = await vendorPortalMediaApi.fetchContent(token, dispatchId, item.id); - viewMediaItem(item, blob); + viewMediaItem(item, blob, previewWindow); } catch { - setLoadState((current) => ({ - ...current, - error: "Unable to open the media file. Please try again.", - })); + previewWindow.close(); + setMediaActionError("Unable to open the media file. Check your connection and try again."); } }; @@ -260,6 +269,9 @@ export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSec )} void handleView(item)} /> + {Boolean(mediaActionError) && ( + {mediaActionError} + )} { }); it("aborts the active vendor upload from the visible Cancel control", async () => { - const upload = vi.spyOn(mediaUploadEngine, "uploadFileResumable").mockImplementation( - ({ signal }) => - new Promise((_resolve, reject) => { - signal?.addEventListener( - "abort", - () => reject(new DOMException("Aborted", "AbortError")), - { - once: true, - }, - ); - }), - ); + const cancel = vi.fn().mockResolvedValue(undefined); + const endpoints: MediaUploadEndpointSet = { + create: vi.fn().mockResolvedValue({ + uploadId: "upload-cancel", + state: "Created", + chunkSizeBytes: 5_242_880, + sizeBytes: 5, + receivedChunks: [], + expiresAt: "2026-09-24T00:00:00Z", + }), + putChunk: vi.fn( + ({ signal }) => + new Promise((_resolve, reject) => { + const abort = () => reject(new DOMException("Aborted", "AbortError")); + if (signal?.aborted) abort(); + else signal?.addEventListener("abort", abort, { once: true }); + }), + ), + commit: vi.fn(), + getStatus: vi.fn(), + cancel, + }; + vi.mocked(vendorPortalMediaApi.endpoints).mockReturnValue(endpoints); renderVendorMedia(); const file = new File(["video"], "clip.mov", { type: "video/quicktime" }); @@ -140,10 +155,62 @@ describe("Vendor media controls", () => { await waitFor(() => expect(screen.getByRole("button", { name: "Cancel upload" })).toBeInTheDocument(), ); - const signal = upload.mock.calls[0]?.[0].signal; fireEvent.click(screen.getByRole("button", { name: "Cancel upload" })); await waitFor(() => expect(screen.getByText("Upload canceled.")).toBeInTheDocument()); - expect(signal?.aborted).toBe(true); + expect(endpoints.putChunk).toHaveBeenCalledWith( + expect.objectContaining({ + signal: expect.objectContaining({ aborted: true }), + }), + ); + await waitFor(() => expect(cancel).toHaveBeenCalledWith("upload-cancel")); + }); + + it("opens media from the View click before fetching and revokes its object URL on close", async () => { + const item: VendorMediaItem = { + id: 14, + originalFileName: "photo.jpg", + contentType: "image/jpeg", + sizeBytes: 5, + kind: "Photo", + category: "Extra", + scanStatus: "Passed", + createdAt: "2026-09-23T00:00:00Z", + canView: true, + }; + vi.spyOn(vendorPortalMediaApi, "getMedia").mockResolvedValue({ + ...MEDIA, + items: [item], + }); + const addEventListener = vi.fn(); + const previewWindow = { + opener: window, + closed: false, + document: { title: "", body: { textContent: "" } }, + location: { replace: vi.fn() }, + addEventListener, + close: vi.fn(), + } as unknown as Window; + const open = vi.spyOn(window, "open").mockReturnValue(previewWindow); + const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:vendor-media"); + const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + const fetchContent = vi + .spyOn(vendorPortalMediaApi, "fetchContent") + .mockImplementation(async (_token, _dispatchId, mediaId) => { + expect(open).toHaveBeenCalledWith("about:blank", "_blank"); + expect(mediaId).toBe(item.id); + return new Blob(["image"], { type: item.contentType }); + }); + renderVendorMedia(); + + fireEvent.click(await screen.findByRole("button", { name: "View" })); + + await waitFor(() => expect(fetchContent).toHaveBeenCalledWith("vendor-token", 9, item.id)); + expect(createObjectURL).toHaveBeenCalledOnce(); + expect(previewWindow.document.title).toBe(item.originalFileName); + expect(previewWindow.location.replace).toHaveBeenCalledWith("blob:vendor-media"); + const pageHide = addEventListener.mock.calls[0]?.[1] as EventListener | undefined; + pageHide?.(new Event("pagehide")); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:vendor-media"); }); }); From 7d9d8ed47bcbcb5152fa51aec52ff8a03c60ddcd Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 05:40:40 -0300 Subject: [PATCH 09/10] fix: await media policy refresh before retrying uploads --- .../_components/vendor-media-section.tsx | 55 +++++++----- .../use-resumable-work-order-media-upload.ts | 16 ++-- src/test/app/v/vendor-media-section.test.tsx | 5 +- ...resumable-work-order-media-upload.test.tsx | 89 +++++++++++++++++++ 4 files changed, 132 insertions(+), 33 deletions(-) create mode 100644 src/test/domain/work-orders/use-cases/use-resumable-work-order-media-upload.test.tsx 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 b364cc4f..8cbea431 100644 --- a/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx +++ b/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx @@ -41,6 +41,35 @@ function viewMediaItem(item: VendorMediaItem, blob: Blob, previewWindow: Window) previewWindow.location.replace(url); } +async function openVendorMediaPreview({ + item, + token, + dispatchId, + onError, +}: { + item: VendorMediaItem; + token: string; + dispatchId: number; + onError: (message: string | undefined) => void; +}): Promise { + const previewWindow = window.open("about:blank", "_blank"); + if (!previewWindow) { + onError("Allow pop-ups to view this media file."); + return; + } + previewWindow.opener = null; + previewWindow.document.title = item.originalFileName; + previewWindow.document.body.textContent = `Loading ${item.originalFileName}…`; + onError(undefined); + try { + const blob = await vendorPortalMediaApi.fetchContent(token, dispatchId, item.id); + viewMediaItem(item, blob, previewWindow); + } catch { + previewWindow.close(); + onError("Unable to open the media file. Check your connection and try again."); + } +} + function VendorMediaItems({ items, onView, @@ -218,25 +247,6 @@ export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSec const selectFile = (file: File | undefined) => startUpload(file ?? null); - const handleView = async (item: VendorMediaItem) => { - const previewWindow = window.open("about:blank", "_blank"); - if (!previewWindow) { - setMediaActionError("Allow pop-ups to view this media file."); - return; - } - previewWindow.opener = null; - previewWindow.document.title = item.originalFileName; - previewWindow.document.body.textContent = `Loading ${item.originalFileName}…`; - setMediaActionError(undefined); - try { - const blob = await vendorPortalMediaApi.fetchContent(token, dispatchId, item.id); - viewMediaItem(item, blob, previewWindow); - } catch { - previewWindow.close(); - setMediaActionError("Unable to open the media file. Check your connection and try again."); - } - }; - const items = loadState.media?.items ?? []; const canUpload = !locked && loadState.media?.policy.canUpload === true; @@ -268,7 +278,12 @@ export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSec No photos or videos uploaded yet. )} - void handleView(item)} /> + + void openVendorMediaPreview({ item, token, dispatchId, onError: setMediaActionError }) + } + /> {Boolean(mediaActionError) && ( {mediaActionError} )} diff --git a/src/domain/work-orders/use-cases/use-resumable-work-order-media-upload.ts b/src/domain/work-orders/use-cases/use-resumable-work-order-media-upload.ts index 3e9686a8..0982cd33 100644 --- a/src/domain/work-orders/use-cases/use-resumable-work-order-media-upload.ts +++ b/src/domain/work-orders/use-cases/use-resumable-work-order-media-upload.ts @@ -22,16 +22,10 @@ function toUploadCategory( return typeof category === "string" && isUploadCategory(category) ? category : undefined; } -function invalidateMediaQueries( +async function invalidateMediaQueries( queryClient: ReturnType, - workOrderId: string | number, -): void { - void queryClient.invalidateQueries({ - queryKey: queryKeys.workOrders.boardDetail(workOrderId), - }); - void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.boardMedia(workOrderId) }); - void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.mediaPolicy(workOrderId) }); - void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }); +): Promise { + await queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }); } /** @@ -72,8 +66,8 @@ export function useResumableWorkOrderMediaUpload( onProgress, }); }, - onSuccess: (session) => { - invalidateMediaQueries(queryClient, workOrderId); + onSuccess: async (session) => { + await invalidateMediaQueries(queryClient); if (session.state === "Completed") { toast.success("File uploaded"); } diff --git a/src/test/app/v/vendor-media-section.test.tsx b/src/test/app/v/vendor-media-section.test.tsx index ae842060..1f6fb9b4 100644 --- a/src/test/app/v/vendor-media-section.test.tsx +++ b/src/test/app/v/vendor-media-section.test.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import * as mediaUploadEngine from "@/api/media-upload-engine"; import type { MediaUploadEndpointSet } from "@/api/media-upload-client"; import { MediaUploadNetworkError } from "@/api/media-upload-client"; +import type { MediaChunkAckDto } from "@/api/media-upload-contracts"; import * as mediaLimits from "@/domain/work-orders/utils/emergency-media-limits"; import { vendorPortalMediaApi } from "@/domain/vendor-portal/api/vendor-portal-media-api"; import type { @@ -131,8 +132,8 @@ describe("Vendor media controls", () => { expiresAt: "2026-09-24T00:00:00Z", }), putChunk: vi.fn( - ({ signal }) => - new Promise((_resolve, reject) => { + ({ signal }: Parameters[0]) => + new Promise((_resolve, reject) => { const abort = () => reject(new DOMException("Aborted", "AbortError")); if (signal?.aborted) abort(); else signal?.addEventListener("abort", abort, { once: true }); diff --git a/src/test/domain/work-orders/use-cases/use-resumable-work-order-media-upload.test.tsx b/src/test/domain/work-orders/use-cases/use-resumable-work-order-media-upload.test.tsx new file mode 100644 index 00000000..757f15f8 --- /dev/null +++ b/src/test/domain/work-orders/use-cases/use-resumable-work-order-media-upload.test.tsx @@ -0,0 +1,89 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { MediaUploadPolicy, MediaUploadSessionDto } from "@/api/media-upload-contracts"; +import * as mediaUploadEngine from "@/api/media-upload-engine"; +import { workOrdersMediaUploadApi } from "@/domain/work-orders/api/work-order-media-upload-api"; +import * as mediaLimits from "@/domain/work-orders/utils/emergency-media-limits"; +import { useResumableWorkOrderMediaUpload } from "@/domain/work-orders/use-cases/use-resumable-work-order-media-upload"; +import { useWorkOrderMediaPolicy } from "@/domain/work-orders/use-cases/use-work-order-media-policy"; + +const POLICY: MediaUploadPolicy = { + workOrderType: "Emergency", + canUpload: true, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 2, videos: 3 }, +}; + +const COMPLETED: MediaUploadSessionDto = { + uploadId: "upload-1", + state: "Completed", + chunkSizeBytes: 5_242_880, + sizeBytes: 5, + receivedChunks: [0], + expiresAt: "2026-09-24T00:00:00Z", + attachment: { id: 1 }, +}; + +describe("Emergency media upload policy refresh", () => { + afterEach(() => vi.restoreAllMocks()); + + it("keeps the upload mutation pending until fresh remaining slots arrive", async () => { + let resolvePolicyRefresh: ((policy: MediaUploadPolicy) => void) | undefined; + const policyRefresh = new Promise((resolve) => { + resolvePolicyRefresh = resolve; + }); + const getPolicy = vi + .spyOn(workOrdersMediaUploadApi, "getMediaPolicy") + .mockResolvedValueOnce(POLICY) + .mockReturnValueOnce(policyRefresh); + vi.spyOn(workOrdersMediaUploadApi, "endpoints").mockReturnValue({} as never); + vi.spyOn(mediaUploadEngine, "uploadFileResumable").mockResolvedValue(COMPLETED); + vi.spyOn(mediaLimits, "validateMediaFileAgainstLimits").mockResolvedValue({ ok: true }); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook( + () => { + const policyQuery = useWorkOrderMediaPolicy(42, true); + const upload = useResumableWorkOrderMediaUpload(42, "WorkOrderTable", policyQuery.data); + return { policyQuery, upload }; + }, + { wrapper }, + ); + + await waitFor(() => expect(result.current.policyQuery.data).toEqual(POLICY)); + const uploadPromise = result.current.upload.mutateAsync({ + file: new File(["photo"], "photo.jpg", { type: "image/jpeg" }), + }); + await waitFor(() => expect(getPolicy).toHaveBeenCalledTimes(2)); + + expect(result.current.upload.isPending).toBe(true); + expect(result.current.policyQuery.data?.remaining.photos).toBe(2); + + await act(async () => { + resolvePolicyRefresh?.({ ...POLICY, remaining: { photos: 1, videos: 3 } }); + await policyRefresh; + await uploadPromise; + }); + + await waitFor(() => expect(result.current.policyQuery.data?.remaining.photos).toBe(1)); + expect(result.current.upload.isPending).toBe(false); + expect(mediaUploadEngine.uploadFileResumable).toHaveBeenCalledOnce(); + expect(mediaLimits.validateMediaFileAgainstLimits).toHaveBeenCalledWith( + expect.objectContaining({ name: "photo.jpg" }), + POLICY, + ); + }); +}); From f4d22e2b3bf5661b172dd9ed18462e5a836b4949 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:45:27 -0300 Subject: [PATCH 10/10] fix: show server-canceled upload copy instead of connection error A session the server moves to Canceled throws MediaUploadCanceledError, which safeMediaUploadErrorMessage did not map, so it fell through to the generic 'Check your connection and try again' copy and blamed the network for a server-side cancellation. Map it to 'The upload session was canceled.' ahead of the user-abort branch, and cover the Canceled terminal state end to end through the scan-polling path. --- src/api/media-upload-contracts.ts | 3 ++ src/test/api/media-upload-engine.test.ts | 38 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/api/media-upload-contracts.ts b/src/api/media-upload-contracts.ts index baf1663a..7a4644d1 100644 --- a/src/api/media-upload-contracts.ts +++ b/src/api/media-upload-contracts.ts @@ -88,6 +88,9 @@ export function safeMediaUploadErrorMessage(error: Error): string { if (error instanceof MediaUploadScanTimeoutError) { return "The upload is still processing. Check back shortly to see the result."; } + if (error instanceof MediaUploadCanceledError) { + return "The upload session was canceled."; + } if (error.name === "AbortError") { return "Upload canceled."; } diff --git a/src/test/api/media-upload-engine.test.ts b/src/test/api/media-upload-engine.test.ts index e06b9264..3f4bbc0a 100644 --- a/src/test/api/media-upload-engine.test.ts +++ b/src/test/api/media-upload-engine.test.ts @@ -9,6 +9,10 @@ import { uploadFileResumable, } from "@/api/media-upload-engine"; import { computeSha256Hex, createMediaUploadEndpoints } from "@/api/media-upload-client"; +import { + MediaUploadCanceledError, + safeMediaUploadErrorMessage, +} from "@/api/media-upload-contracts"; type CapturedRequest = { method: string; url: string; headers: Headers; body: unknown }; @@ -500,6 +504,40 @@ describe("resumable media upload engine", () => { }); }); +describe("safeMediaUploadErrorMessage", () => { + it("maps a server-canceled session to its own copy, distinct from a user abort", async () => { + const file = fileOf(8); + handler = async (request) => { + if (request.method === "POST" && request.url === BASE) { + return jsonResponse(createdSession(8, 8)); + } + if (request.method === "PUT" && /chunks\/0$/.test(request.url)) { + return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] }); + } + if (request.method === "POST" && request.url.endsWith("/commit")) { + return jsonResponse({ ...createdSession(8, 8), state: "Scanning" }, 202); + } + if (request.method === "GET" && request.url === `${BASE}/u1`) { + return jsonResponse({ ...createdSession(8, 8), state: "Canceled" }); + } + return jsonResponse({}); + }; + + const error = await uploadFileResumable({ + file, + endpoints: endpointsWith(), + surface: "WorkOrderTable", + pollDelayMs: 0, + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(MediaUploadCanceledError); + expect(safeMediaUploadErrorMessage(error as Error)).toBe("The upload session was canceled."); + expect(safeMediaUploadErrorMessage(new DOMException("aborted", "AbortError"))).toBe( + "Upload canceled.", + ); + }); +}); + describe("chunk math helpers", () => { it("computes chunk counts and missing indices", () => { expect(chunkCountFor(0, 5_242_880)).toBe(1);