From 969f99a57604199a236d1fd24ba7aca61ba9d05c Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 11 Aug 2026 14:52:41 -0300 Subject: [PATCH] fix(work-orders): send workOrderVersion on media categorize/delete Align CompDoc media mutations with backend PR #47 so categorize and delete no longer return 422 WorkOrderVersionRequired. --- .../_components/media/media-upload-modal.tsx | 28 ++++++++- .../api/work-order-board-documents-api.ts | 23 ++++++- .../use-cases/use-work-order-mutations.ts | 15 +++-- .../media-upload-modal-api.test.tsx | 32 +++++++++- .../work-orders/api/work-orders-api.test.ts | 62 ++++++++++++++++++- 5 files changed, 148 insertions(+), 12 deletions(-) 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 4b6c10c1..79254c34 100644 --- a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx +++ b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx @@ -1,6 +1,7 @@ import { type MouseEvent } from "react"; import { Dialog, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material"; import { Image as ImageIcon } from "lucide-react"; +import { toast } from "react-toastify"; import { Button } from "@/components/ui/button"; import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; import { LoadingRegion } from "@/components/ui/loading-region"; @@ -23,6 +24,14 @@ type MediaUploadModalProps = { onOpenChange: (open: boolean) => void; }; +function resolveWorkOrderVersion( + detailRowVersion: string | null | undefined, + rowVersion: string | null | undefined, +): string | null { + const version = detailRowVersion?.trim() || rowVersion?.trim() || ""; + return version || null; +} + export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) { const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open); const uploadMutation = useUploadWorkOrderMedia(row.id); @@ -38,6 +47,15 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending; const handleClose = guardDialogClose(pending, () => onOpenChange(false)); + const requireVersion = (): string | null => { + const version = resolveWorkOrderVersion(detail?.info.rowVersion, row.rowVersion); + if (!version) { + toast.error("Missing work order version. Refresh and try again."); + return null; + } + return version; + }; + return ( { + const workOrderVersion = requireVersion(); + if (!workOrderVersion) return; + categorizeMutation.mutate({ mediaId: id, category, workOrderVersion }); }} - onCategorize={(id, category) => categorizeMutation.mutate({ mediaId: id, category })} onRetry={retryUpload} onCancel={removeLocal} readOnly={isReadOnly} diff --git a/src/domain/work-orders/api/work-order-board-documents-api.ts b/src/domain/work-orders/api/work-order-board-documents-api.ts index 85cf8e7c..cba8bf8d 100644 --- a/src/domain/work-orders/api/work-order-board-documents-api.ts +++ b/src/domain/work-orders/api/work-order-board-documents-api.ts @@ -126,9 +126,18 @@ export const workOrderBoardDocumentsApi = { workOrderId: string | number, mediaId: string | number, category: UploadableMediaCategory, + workOrderVersion: string, ): Promise => { + if (!workOrderVersion) { + throw new BoardPatchValidationError( + "WorkOrderVersionRequired", + "workOrderVersion is required to update media category.", + ); + } + const formData = new FormData(); formData.append("category", category); + formData.append("workOrderVersion", workOrderVersion); const response = await api.patch(API_PATHS.workOrder.mediaItem(workOrderId, mediaId), { body: formData, throwHttpErrors: false, @@ -140,7 +149,18 @@ export const workOrderBoardDocumentsApi = { throwDocumentHttpError(response, data); }, - deleteMedia: async (workOrderId: string | number, mediaId: string | number): Promise => { + deleteMedia: async ( + workOrderId: string | number, + mediaId: string | number, + workOrderVersion: string, + ): Promise => { + if (!workOrderVersion) { + throw new BoardPatchValidationError( + "WorkOrderVersionRequired", + "workOrderVersion is required to delete media.", + ); + } + const numericId = typeof mediaId === "number" ? mediaId : Number(mediaId); if (!Number.isFinite(numericId) || numericId <= 0) { throw new BoardPatchValidationError( @@ -150,6 +170,7 @@ export const workOrderBoardDocumentsApi = { } const response = await api.delete(API_PATHS.workOrder.mediaItem(workOrderId, numericId), { + searchParams: { workOrderVersion }, throwHttpErrors: 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 5b70c93a..ae2fec6f 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 @@ -58,6 +58,12 @@ export interface UploadWorkOrderMediaInput { export interface UpdateWorkOrderMediaCategoryInput { mediaId: string | number; category: UploadableMediaCategory; + workOrderVersion: string; +} + +export interface DeleteWorkOrderMediaInput { + mediaId: string | number; + workOrderVersion: string; } export interface UploadCompletionDocInput { @@ -217,8 +223,8 @@ export function useUpdateWorkOrderMediaCategory( const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ mediaId, category }: UpdateWorkOrderMediaCategoryInput) => - workOrdersApi.updateMediaCategory(workOrderId, mediaId, category), + mutationFn: ({ mediaId, category, workOrderVersion }: UpdateWorkOrderMediaCategoryInput) => + workOrdersApi.updateMediaCategory(workOrderId, mediaId, category, workOrderVersion), onSuccess: () => { invalidateBoardDetail(queryClient, workOrderId); invalidateWorkOrders(queryClient); @@ -232,11 +238,12 @@ export function useUpdateWorkOrderMediaCategory( export function useDeleteWorkOrderMedia( workOrderId: string | number, -): UseMutationResult { +): UseMutationResult { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (mediaId: string | number) => workOrdersApi.deleteMedia(workOrderId, mediaId), + mutationFn: ({ mediaId, workOrderVersion }: DeleteWorkOrderMediaInput) => + workOrdersApi.deleteMedia(workOrderId, mediaId, workOrderVersion), onSuccess: () => { invalidateBoardDetail(queryClient, workOrderId); invalidateWorkOrders(queryClient); 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 402afa30..d74bfca8 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 @@ -11,7 +11,7 @@ const categorizeMutate = vi.fn(); vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ useWorkOrderBoardDetail: () => ({ data: { - info: { status: "Scheduled" }, + info: { status: "Scheduled", rowVersion: "DETAIL-VER==" }, media: [ { id: "11", @@ -80,7 +80,7 @@ function baseRow(): WorkOrderTableRow { title: "Emergency", priority: "Sev 1", primaryDispatchId: null, - rowVersion: "", + rowVersion: "ROW-VER==", dispatchRowVersion: "", flagColor: null, }; @@ -111,4 +111,32 @@ describe("MediaUploadModal API wiring", () => { expect(screen.getByText(/categorize after upload/i)).toBeInTheDocument(); expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument(); }); + + it("categorizes with workOrderVersion from detail.info.rowVersion", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + + const { fireEvent } = await import("@testing-library/react"); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByLabelText(/category for before\.jpg/i), { + target: { value: "After" }, + }); + + expect(categorizeMutate).toHaveBeenCalledWith({ + mediaId: "11", + category: "After", + workOrderVersion: "DETAIL-VER==", + }); + }); }); diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index 3df69544..90b0bd7f 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -494,12 +494,55 @@ describe("workOrdersApi.uploadMedia", () => { }); }); +describe("workOrdersApi.updateMediaCategory", () => { + beforeEach(() => { + apiPatchFn.mockReset(); + }); + + it("PATCHes FormData with category and workOrderVersion", async () => { + apiPatchFn.mockResolvedValue({ + ok: true, + json: async () => ({ + id: 12, + category: 1, + url: "https://cdn.example.com/before.jpg", + fileName: "before.jpg", + uploadedAt: "2026-07-16T12:00:00Z", + isLegacy: false, + }), + }); + + const item = await workOrdersApi.updateMediaCategory(10, 12, "Before", "AAAAAAA="); + + expect(apiPatchFn).toHaveBeenCalledWith( + API_PATHS.workOrder.mediaItem(10, 12), + expect.objectContaining({ throwHttpErrors: false }), + ); + const body = apiPatchFn.mock.calls[0]?.[1]?.body as FormData; + expect(body.get("category")).toBe("Before"); + expect(body.get("workOrderVersion")).toBe("AAAAAAA="); + expect(item.category).toBe("Before"); + }); + + it("rejects missing workOrderVersion client-side", async () => { + const { BoardPatchValidationError } = + await import("@/domain/work-orders/errors/board-patch-errors"); + + await expect(workOrdersApi.updateMediaCategory(10, 12, "After", "")).rejects.toMatchObject({ + name: "BoardPatchValidationError", + code: "WorkOrderVersionRequired", + }); + expect(apiPatchFn).not.toHaveBeenCalled(); + expect(BoardPatchValidationError).toBeTruthy(); + }); +}); + describe("workOrdersApi.deleteMedia", () => { beforeEach(() => { apiDeleteFn.mockReset(); }); - it("DELETEs media item and accepts 204", async () => { + it("DELETEs media item with workOrderVersion query and accepts 204", async () => { apiDeleteFn.mockResolvedValue({ ok: true, status: 204, @@ -508,18 +551,31 @@ describe("workOrdersApi.deleteMedia", () => { }, }); - await workOrdersApi.deleteMedia(10, 12); + await workOrdersApi.deleteMedia(10, 12, "AAAAAAA="); expect(apiDeleteFn).toHaveBeenCalledWith(API_PATHS.workOrder.mediaItem(10, 12), { + searchParams: { workOrderVersion: "AAAAAAA=" }, throwHttpErrors: false, }); }); + it("rejects missing workOrderVersion client-side", async () => { + const { BoardPatchValidationError } = + await import("@/domain/work-orders/errors/board-patch-errors"); + + await expect(workOrdersApi.deleteMedia(10, 12, "")).rejects.toMatchObject({ + name: "BoardPatchValidationError", + code: "WorkOrderVersionRequired", + }); + expect(apiDeleteFn).not.toHaveBeenCalled(); + expect(BoardPatchValidationError).toBeTruthy(); + }); + it("rejects legacy media ids client-side", async () => { const { BoardPatchValidationError } = await import("@/domain/work-orders/errors/board-patch-errors"); - await expect(workOrdersApi.deleteMedia(10, -1)).rejects.toBeInstanceOf( + await expect(workOrdersApi.deleteMedia(10, -1, "AAAAAAA=")).rejects.toBeInstanceOf( BoardPatchValidationError, ); expect(apiDeleteFn).not.toHaveBeenCalled();