From 42d26b8a6bdc70c1b28e9c3b281d6e58f29f189e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 04:17:18 -0300 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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, + ); + }); +});