From 8d50c6f75e1959f026d090ba2e425eaea7259a09 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 18 Sep 2026 16:57:42 -0300 Subject: [PATCH] fix(work-orders): stop the stale-version docStatus patch after a signed PDF upload (SH-380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The completion-doc upload endpoint sets DocStatus=Yes and bumps the work-order row version in one transaction. Both signed-PDF upload paths then re-ran a "docStatus=Yes" work-order patch using the pre-upload row version (the comp-doc dialog re-ran the whole Generate flow; the slide-over posted docStatus directly). That patch always 409'd — and in the dialog it also demanded a Date Work Completed — so a successful upload surfaced an error to the user. Both paths now stop after the upload settles: the mutation's onSuccess refreshes the board and the UI reflects the generated state locally, with no second docStatus patch. --- .../_components/comp-doc/comp-doc-dialog.tsx | 7 +- .../detail/use-work-order-slide-over.ts | 35 +--- .../_hooks/create-comp-doc-dialog-actions.ts | 11 ++ .../comp-doc-signed-pdf-upload.test.tsx | 153 ++++++++++++++++++ .../slide-over-signed-pdf-upload.test.tsx | 145 +++++++++++++++++ 5 files changed, 319 insertions(+), 32 deletions(-) create mode 100644 src/test/app/(protected)/workorders/comp-doc-signed-pdf-upload.test.tsx create mode 100644 src/test/app/(protected)/workorders/slide-over-signed-pdf-upload.test.tsx diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx index 030e63df..74d9c75e 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx @@ -97,12 +97,13 @@ export function CompDocDialog({ const rowVersion = boardDetail?.info.rowVersion || row.rowVersion; const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status); - // Uploading a signed PDF finalizes through the same Generate flow, so the - // completedDate/address prerequisites still persist before docStatus Yes. + // The upload endpoint finalizes the document (DocStatus=Yes) and bumps the row version in + // one transaction, so uploading a signed PDF only refreshes state and the UI — a follow-up + // Generate patch would carry the pre-upload version and 409 after a successful upload. const upload = useCompDocPdfUpload({ workOrderId: row.id, rowVersion, - onUploaded: dialog.handleGenerate, + onUploaded: dialog.markSignedPdfUploaded, }); // A signed PDF on file means the document exists even if docState has not caught up. 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 dee476ec..1491613d 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 @@ -20,7 +20,6 @@ import { isWorkOrderFullyLocked, } from "@/domain/work-orders/utils/work-order-lock"; import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits"; -import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; import { useAuthContext } from "@/providers/auth-context"; import { useSlideOverEditState } from "./use-slide-over-edit-state"; import { useSlideOverMediaState } from "./use-slide-over-media-state"; @@ -37,22 +36,17 @@ type UseWorkOrderSlideOverArgs = { saving?: boolean; }; -type CompletionUploadMutate = ( - input: { file: File; workOrderVersion: string }, - options: { onSuccess: () => Promise | void }, -) => void; +type CompletionUploadMutate = (input: { file: File; workOrderVersion: string }) => void; function uploadSlideOverCompletionPdf({ file, row, infoSource, - onSave, mutate, }: { file: File; row: WorkOrderTableRow | null; infoSource: WorkOrderTableRow | null | undefined; - onSave: UseWorkOrderSlideOverArgs["onSave"]; mutate: CompletionUploadMutate; }) { const version = infoSource?.rowVersion; @@ -60,27 +54,11 @@ function uploadSlideOverCompletionPdf({ toast.error("Missing work order version. Refresh and try again."); return; } - mutate( - { file, workOrderVersion: version }, - { - onSuccess: async () => { - const patch: WorkOrderTablePatch = { docStatus: "Yes" }; - if (!infoSource.completedDate) { - patch.completedDate = todayIso(); - } - try { - await new Promise((resolve, reject) => { - onSave(row.id, patch, { - onSuccess: () => resolve(), - onError: (error) => reject(error), - }); - }); - } catch { - toast.error("Uploaded PDF but failed to update document status. Refresh and retry."); - } - }, - }, - ); + // The upload transitions the completion document to Yes and bumps the work-order row + // version in the same server transaction; the mutation's onSuccess refreshes the board. + // A client docStatus patch here would carry the pre-upload version and 409 after a + // successful upload, so it is intentionally omitted. + mutate({ file, workOrderVersion: version }); } function resolveSlideOverSource( @@ -167,7 +145,6 @@ export function useWorkOrderSlideOver({ file, row, infoSource, - onSave, mutate: uploadCompletionMutation.mutate, }); }; diff --git a/src/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions.ts b/src/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions.ts index 4fb049dd..98c9edab 100644 --- a/src/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions.ts +++ b/src/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions.ts @@ -162,11 +162,22 @@ export function createCompDocDialogActions({ printDoc(buildDocHtml(row, { notes, generatedAt }, docFields)); } + // Uploading a signed PDF finalizes the document server-side: the endpoint sets DocStatus=Yes + // and bumps the work-order row version in one transaction. Reflect that in the UI and refresh + // authoritative state, but do not re-run the Generate persist — that patch would carry the + // pre-upload row version and 409, surfacing an error right after a successful upload. + function markSignedPdfUploaded() { + setGeneratedAt(fmtDocNow()); + setDocState("generated"); + refreshAuthoritativeState(); + } + return { handleMarkNotRequired, handleUndo, handleSaveDraft, handleGenerate, handleDownloadPdf, + markSignedPdfUploaded, }; } diff --git a/src/test/app/(protected)/workorders/comp-doc-signed-pdf-upload.test.tsx b/src/test/app/(protected)/workorders/comp-doc-signed-pdf-upload.test.tsx new file mode 100644 index 00000000..3f22dc25 --- /dev/null +++ b/src/test/app/(protected)/workorders/comp-doc-signed-pdf-upload.test.tsx @@ -0,0 +1,153 @@ +import { fireEvent, render, 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 { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog"; +import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +type SaveHandler = (data: CompDocData, status: CompDocSaveStatus) => void | Promise; + +vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({ + useLocationDetail: () => ({ data: undefined }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderBoardDetail: () => ({ data: undefined, isLoading: false }), +})); + +// A completion-doc upload that settles successfully. The backend has already set +// DocStatus=Yes and bumped the work-order row version inside the same transaction, +// so any client follow-up patch would carry a stale version. +vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({ + useUploadCompletionDoc: () => ({ + mutate: (_vars: unknown, options?: { onSuccess?: () => void | Promise }) => { + void options?.onSuccess?.(); + }, + isPending: false, + }), +})); + +vi.mock("react-toastify", () => ({ + toast: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() }), +})); + +vi.mock("@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast", () => ({ + showCompDocDraftSavedToast: vi.fn(), +})); + +function baseRow(overrides: Partial = {}): WorkOrderTableRow { + const defaults: WorkOrderTableRow = { + id: 42, + woNumber: "WO-42", + site: "SEA1", + type: "PM", + severity: null, + dispatcherId: "u1", + dispatcherName: "Dispatcher", + scheduledOn: "2026-06-01", + completedDate: "2026-07-01", + targetWeek: "", + dayGroup: "", + dayLabel: "", + location: "Seattle", + locationId: "mock-loc-1", + pm: "HVAC", + serviceNotes: "", + extraServices: [], + vendorId: "", + company: "Vendor Co", + tech: "Tech One", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Scheduled", + docStatus: "No", + pocName: "Amazon Rep", + pocPhone: "", + pocNotes: "", + additionalContacts: [], + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + title: "PM — SEA1", + priority: "Sev 3", + primaryDispatchId: null, + rowVersion: "v1", + dispatchRowVersion: "", + flagColor: null, + isAddOn: false, + upliftSummary: { hasUplift: false, pendingCount: 0 }, + }; + return { ...defaults, ...overrides }; +} + +describe("CompDocDialog signed PDF upload (SH-380)", () => { + beforeEach(() => { + vi.mocked(toast.error).mockReset(); + }); + + function uploadSignedPdf(onSave: SaveHandler) { + const onDateCompletedChange = vi.fn().mockResolvedValue(undefined); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + render( + + + , + ); + + const fileInput = document.querySelector('input[type="file"]'); + expect(fileInput).not.toBeNull(); + fireEvent.change(fileInput!, { + target: { + files: [ + new File([new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d])], "signed.pdf", { + type: "application/pdf", + }), + ], + }, + }); + + return { onSave, onDateCompletedChange }; + } + + it("does not re-run the docStatus persist after a successful upload", async () => { + // The server already marked the document generated; a client docStatus patch here would + // carry the pre-upload row version and 409. It must not run. + const onSave: SaveHandler = vi.fn(() => + Promise.reject(new Error("The record was modified by another user. Refresh and retry.")), + ); + + uploadSignedPdf(onSave); + + await waitFor(() => { + expect(vi.mocked(toast.error)).not.toHaveBeenCalled(); + }); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("surfaces no error toast to the user when the signed PDF upload settles", async () => { + const onSave: SaveHandler = vi.fn(() => Promise.reject(new Error("conflict"))); + + const { onDateCompletedChange } = uploadSignedPdf(onSave); + + await waitFor(() => { + expect(vi.mocked(toast.error)).not.toHaveBeenCalled(); + }); + expect(onDateCompletedChange).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 new file mode 100644 index 00000000..a92bc22f --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-signed-pdf-upload.test.tsx @@ -0,0 +1,145 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { toast } from "react-toastify"; +import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +vi.mock("@/providers/auth-context", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useAuthContext: () => ({ user: { id: "u1" } }) }; +}); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderBoardDetail: () => ({ + data: undefined, + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + }), +})); + +// 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", () => ({ + useAddWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }), + useUpdateWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }), + useUploadWorkOrderMedia: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }), + useUpdateWorkOrderMediaCategory: () => ({ mutate: vi.fn(), isPending: false }), + useDeleteWorkOrderMedia: () => ({ mutate: vi.fn(), isPending: false }), + useUploadCompletionDoc: () => ({ + mutate: (_vars: unknown, options?: { onSuccess?: () => void | Promise }) => { + void options?.onSuccess?.(); + }, + isPending: false, + }), +})); + +vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } })); + +function baseRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: 42, + woNumber: "WO-42", + site: "SEA1", + type: "PM", + severity: null, + dispatcherId: "u1", + dispatcherName: "Dispatcher", + scheduledOn: "2026-07-20", + completedDate: "", + targetWeek: "", + dayGroup: "07/20", + dayLabel: "Mon", + location: "Seattle", + locationId: "loc-1", + pm: "HVAC", + serviceNotes: "", + extraServices: [], + company: "Vendor Co", + tech: "Tech", + techPhone: "", + vendorId: "v1", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Scheduled", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + additionalContacts: [], + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + title: "PM — SEA1", + priority: "Sev 3", + primaryDispatchId: null, + rowVersion: "v1", + dispatchRowVersion: "", + flagColor: null, + isAddOn: false, + upliftSummary: { hasUplift: false, pendingCount: 0 }, + ...overrides, + }; +} + +describe("WorkOrderSlideOver signed PDF upload (SH-380)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not issue a docStatus patch or surface an error after a successful upload", async () => { + const row = baseRow(); + // A follow-up docStatus patch would carry the pre-upload version and reject; assert none runs. + const onSave = vi.fn( + ( + _id: string | number, + _patch: Partial, + options?: { onSuccess?: () => void; onError?: (error: Error) => void }, + ) => { + options?.onError?.(new Error("The record was modified by another user.")); + }, + ); + + render( + , + ); + + fireEvent.click(screen.getByRole("tab", { name: /completion doc/i })); + + const pdfInput = document.querySelector('input[accept="application/pdf,.pdf"]'); + expect(pdfInput).not.toBeNull(); + fireEvent.change(pdfInput!, { + target: { + files: [ + new File([new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d])], "signed.pdf", { + type: "application/pdf", + }), + ], + }, + }); + + await waitFor(() => { + expect(vi.mocked(toast.error)).not.toHaveBeenCalled(); + }); + expect(onSave).not.toHaveBeenCalled(); + }); +});