fix(work-orders): harden CompDoc generate order and media abort ownership

Session owns AbortControllers for retry/cancel; refresh board state on Generate
prerequisite failures; remove dead EditWorkorder completedDate mapper.
This commit is contained in:
arthur.bassi 2026-08-04 16:32:40 -03:00
parent c0d5e556f2
commit 346760b973
11 changed files with 209 additions and 129 deletions

View file

@ -11,13 +11,10 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
import {
DOC_STATE_CFG,
docStateOf,
fmtDocNow,
} from "@/domain/work-orders/utils/completion-doc-state";
import { DOC_STATE_CFG, docStateOf } from "@/domain/work-orders/utils/completion-doc-state";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { useCompDocPdfActions } from "./use-comp-doc-pdf-actions";
import { useCompDocPersistFlow } from "./use-comp-doc-persist-flow";
type UseCompDocDialogArgs = {
row: WorkOrderTableRow;
@ -114,15 +111,21 @@ export function useCompDocDialog({
seaRep,
};
const persist = async (extra: Partial<CompDocData>, status: CompDocSaveStatus) => {
await onSave({ notes, generatedAt, ...extra }, status);
};
const syncDateIfChanged = async () => {
if (editDateCompleted !== (info.completedDate || "")) {
await onDateCompletedChange?.(editDateCompleted);
}
};
const { persist, syncDateIfChanged, refreshAuthoritativeState, handleSaveDraft, handleGenerate } =
useCompDocPersistFlow({
workOrderId: row.id,
notes,
generatedAt,
editDateCompleted,
currentCompletedDate: info.completedDate || "",
onSave,
onDateCompletedChange,
onGenerated: (ts) => {
setGeneratedAt(ts);
setDocState("generated");
},
onDraftSaved: notifyDraftSaved,
});
function handleMarkNotRequired() {
setDocState("not-required");
@ -134,28 +137,6 @@ export function useCompDocDialog({
void persist({}, "No");
}
async function handleSaveDraft() {
try {
await syncDateIfChanged();
await persist({}, "No");
notifyDraftSaved();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to save draft");
}
}
async function handleGenerate() {
const ts = fmtDocNow();
try {
await syncDateIfChanged();
await persist({ generatedAt: ts }, "Yes");
setGeneratedAt(ts);
setDocState("generated");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to generate document");
}
}
const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({
info,
signOffAttachment,
@ -168,6 +149,7 @@ export function useCompDocDialog({
syncDateIfChanged,
setGeneratedAt,
markGenerated: () => setDocState("generated"),
onPersistFailure: refreshAuthoritativeState,
});
return {

View file

@ -25,6 +25,7 @@ type UseCompDocPdfActionsArgs = {
syncDateIfChanged: () => Promise<void>;
setGeneratedAt: (value: string) => void;
markGenerated: () => void;
onPersistFailure?: () => void;
};
export function useCompDocPdfActions({
@ -39,6 +40,7 @@ export function useCompDocPdfActions({
syncDateIfChanged,
setGeneratedAt,
markGenerated,
onPersistFailure,
}: UseCompDocPdfActionsArgs) {
function handleDownloadPdf() {
if (signOffAttachment) {
@ -75,6 +77,7 @@ export function useCompDocPdfActions({
setGeneratedAt(ts);
markGenerated();
} catch (error) {
onPersistFailure?.();
toast.error(
error instanceof Error ? error.message : "Failed to finalize completion document",
);

View file

@ -0,0 +1,84 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
import { queryKeys } from "@/infra/query-key/query-key";
type UseCompDocPersistFlowArgs = {
workOrderId: string | number;
notes: string;
generatedAt: string;
editDateCompleted: string;
currentCompletedDate: string;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
onDateCompletedChange?: (date: string) => void | Promise<void>;
onGenerated: (generatedAt: string) => void;
onDraftSaved: () => void;
};
export function useCompDocPersistFlow({
workOrderId,
notes,
generatedAt,
editDateCompleted,
currentCompletedDate,
onSave,
onDateCompletedChange,
onGenerated,
onDraftSaved,
}: UseCompDocPersistFlowArgs) {
const queryClient = useQueryClient();
const refreshAuthoritativeState = () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
void queryClient.invalidateQueries({
queryKey: queryKeys.workOrders.boardDetail(workOrderId),
});
void queryClient.invalidateQueries({
queryKey: queryKeys.workOrders.detail(workOrderId),
});
};
const persist = async (extra: Partial<CompDocData>, status: CompDocSaveStatus) => {
await onSave({ notes, generatedAt, ...extra }, status);
};
const syncDateIfChanged = async () => {
if (editDateCompleted !== currentCompletedDate) {
await onDateCompletedChange?.(editDateCompleted);
}
};
async function handleSaveDraft() {
try {
// Prerequisites first; docStatus last.
await syncDateIfChanged();
await persist({}, "No");
onDraftSaved();
} catch (error) {
refreshAuthoritativeState();
toast.error(error instanceof Error ? error.message : "Failed to save draft");
}
}
async function handleGenerate() {
const ts = fmtDocNow();
try {
// Prerequisites first; only mark Generated after completedDate persists.
await syncDateIfChanged();
await persist({ generatedAt: ts }, "Yes");
onGenerated(ts);
} catch (error) {
refreshAuthoritativeState();
toast.error(error instanceof Error ? error.message : "Failed to generate document");
}
}
return {
persist,
syncDateIfChanged,
refreshAuthoritativeState,
handleSaveDraft,
handleGenerate,
};
}

View file

@ -56,7 +56,6 @@ export function useSlideOverMediaState({
for (const request of requests) {
await uploadMediaMutation.mutateAsync({
file: request.file,
signal: request.signal,
onProgress: request.onProgress,
});
}

View file

@ -29,10 +29,8 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
const deleteMutation = useDeleteWorkOrderMedia(row.id);
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
const { uploads, startUploads, retryUpload, removeLocal, isLocal } = useMediaUploadSession(
detail,
(input) => uploadMutation.mutateAsync(input),
);
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
const pending =
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
@ -85,7 +83,7 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
}}
onCategorize={(id, category) => categorizeMutation.mutate({ mediaId: id, category })}
onRetry={retryUpload}
onCancel={removeLocal}
onCancel={cancelUpload}
readOnly={isReadOnly}
pending={pending}
/>

View file

@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useRef } from "react";
import { toast } from "react-toastify";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
@ -15,7 +15,6 @@ import { MediaUploaderGrid } from "./media-uploader-grid";
export type MediaUploadRequest = {
localId: string;
file: File;
signal: AbortSignal;
onProgress: (percent: number) => void;
};
@ -25,7 +24,7 @@ type MediaUploaderProps = {
onUpload?: (requests: MediaUploadRequest[]) => void;
onRemove?: (id: string) => void;
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
onRetry?: (localId: string, signal: AbortSignal) => void;
onRetry?: (localId: string) => void;
onCancel?: (localId: string) => void;
/** Legacy local-only mode (table modal fallback). */
onChange?: (next: MediaFile[]) => void;
@ -45,7 +44,6 @@ export function MediaUploader({
pending = false,
}: MediaUploaderProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [controllers, setControllers] = useState<Record<string, AbortController>>({});
const addFiles = (files: FileList | null) => {
if (readOnly || pending || !files || files.length === 0) return;
@ -65,19 +63,11 @@ export function MediaUploader({
if (accepted.length === 0) return;
if (onUpload) {
const nextControllers: Record<string, AbortController> = { ...controllers };
const requests: MediaUploadRequest[] = accepted.map((file) => {
const localId = `local-${crypto.randomUUID()}`;
const controller = new AbortController();
nextControllers[localId] = controller;
return {
localId,
file,
signal: controller.signal,
onProgress: () => undefined,
};
});
setControllers(nextControllers);
const requests: MediaUploadRequest[] = accepted.map((file) => ({
localId: `local-${crypto.randomUUID()}`,
file,
onProgress: () => undefined,
}));
onUpload(requests);
return;
}
@ -98,18 +88,6 @@ export function MediaUploader({
onChange?.(uploads.filter((u) => u.id !== id));
};
const cancel = (id: string) => {
controllers[id]?.abort();
onCancel?.(id);
};
const retry = (id: string) => {
controllers[id]?.abort();
const controller = new AbortController();
setControllers((prev) => ({ ...prev, [id]: controller }));
onRetry?.(id, controller.signal);
};
return (
<div>
{!readOnly && (
@ -121,8 +99,8 @@ export function MediaUploader({
pending={pending}
onRemove={remove}
onCategorize={onCategorize}
onRetry={onRetry ? retry : undefined}
onCancel={cancel}
onRetry={onRetry}
onCancel={onCancel}
/>
</div>
);

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
@ -13,7 +13,13 @@ type UploadMutate = (input: {
onProgress?: (percent: number) => void;
}) => Promise<unknown>;
function toLocalUpload(request: MediaUploadRequest): LocalUpload {
type SessionUploadRequest = {
localId: string;
file: File;
signal: AbortSignal;
};
function toLocalUpload(request: SessionUploadRequest): LocalUpload {
const classified = classifyUpload(request.file);
return {
id: request.localId,
@ -29,7 +35,7 @@ function toLocalUpload(request: MediaUploadRequest): LocalUpload {
};
}
function isSameAttempt(item: LocalUpload, request: MediaUploadRequest) {
function isSameAttempt(item: LocalUpload, request: SessionUploadRequest) {
return item.id === request.localId && item.signal === request.signal;
}
@ -38,6 +44,7 @@ export function useMediaUploadSession(
uploadMutateAsync: UploadMutate,
) {
const [localUploads, setLocalUploads] = useState<LocalUpload[]>([]);
const controllersRef = useRef(new Map<string, AbortController>());
const serverUploads = useMemo(
() => boardMediaItemsToMediaFiles(detail?.media ?? []),
@ -52,10 +59,27 @@ export function useMediaUploadSession(
return [...serverUploads, ...pendingLocals.filter((item) => !serverIds.has(item.id))];
}, [serverUploads, localUploads]);
function clearController(localId: string) {
controllersRef.current.delete(localId);
}
function replaceController(localId: string): AbortController {
controllersRef.current.get(localId)?.abort();
const controller = new AbortController();
controllersRef.current.set(localId, controller);
return controller;
}
function startUploads(requests: MediaUploadRequest[]) {
const sessionRequests: SessionUploadRequest[] = requests.map((request) => ({
localId: request.localId,
file: request.file,
signal: replaceController(request.localId).signal,
}));
setLocalUploads((prev) => {
const next = [...prev];
for (const request of requests) {
for (const request of sessionRequests) {
const entry = toLocalUpload(request);
const index = next.findIndex((item) => item.id === request.localId);
if (index >= 0) {
@ -71,7 +95,7 @@ export function useMediaUploadSession(
return next;
});
for (const request of requests) {
for (const request of sessionRequests) {
void uploadMutateAsync({
file: request.file,
signal: request.signal,
@ -84,13 +108,16 @@ export function useMediaUploadSession(
},
})
.then(() => {
clearController(request.localId);
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
})
.catch((error: Error) => {
if (error.name === "AbortError") {
clearController(request.localId);
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
return;
}
// Keep controller entry only while retryable; replaceController on retry.
setLocalUploads((prev) =>
prev.map((item) =>
isSameAttempt(item, request)
@ -107,20 +134,32 @@ export function useMediaUploadSession(
}
}
function retryUpload(localId: string, signal: AbortSignal) {
function retryUpload(localId: string) {
const target = localUploads.find((item) => item.id === localId);
if (!target?.sourceFile) return;
startUploads([
{
localId,
file: target.sourceFile,
signal,
onProgress: () => undefined,
},
]);
}
function cancelUpload(localId: string) {
controllersRef.current.get(localId)?.abort();
clearController(localId);
setLocalUploads((prev) => {
const target = prev.find((item) => item.id === localId);
if (target?.url.startsWith("blob:")) {
URL.revokeObjectURL(target.url);
}
return prev.filter((item) => item.id !== localId);
});
}
function removeLocal(id: string) {
clearController(id);
setLocalUploads((prev) => prev.filter((item) => item.id !== id));
}
@ -128,5 +167,5 @@ export function useMediaUploadSession(
return localUploads.some((item) => item.id === id);
}
return { uploads, startUploads, retryUpload, removeLocal, isLocal };
return { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal };
}

View file

@ -397,17 +397,6 @@ export function mapWorkOrderFormToUpdateFormData(
return formData;
}
/** Partial EditWorkorder payload — only Id + CompletedDate (avoids legacy GetWorkorderById). */
export function mapCompletedDateUpdateFormData(
id: string | number,
completedDate: string,
): FormData {
const formData = new FormData();
formData.append("Id", String(id));
appendIfPresent(formData, "CompletedDate", completedDate);
return formData;
}
export function mapLocationOption(raw: unknown): LocationOption {
const item = asRecord(raw);
const name = readString(item, "name", "Name", "locationName", "LocationName");

View file

@ -170,4 +170,28 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
expect(onSave).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: /^generate$/i })).toBeInTheDocument();
});
it("Generate never writes docStatus Yes after a successful status-capable save when date fails first", async () => {
// Regression for the prior order (status then date): if date fails, Yes must not have been sent.
// With prerequisites-first ordering, date failure short-circuits before onSave("Yes").
const callOrder: string[] = [];
onDateCompletedChange.mockImplementation(async () => {
callOrder.push("date");
throw new Error("completedDate conflict");
});
onSave.mockImplementation(async () => {
callOrder.push("status");
});
const row = baseRow({ completedDate: "2026-07-01" });
renderDialog(row);
changeDateCompleted("2026-07-20");
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
await waitFor(() => {
expect(onDateCompletedChange).toHaveBeenCalled();
});
expect(callOrder).toEqual(["date"]);
expect(onSave).not.toHaveBeenCalled();
});
});

View file

@ -20,7 +20,6 @@ describe("useMediaUploadSession retry / cancel", () => {
.mockImplementation(() => new Promise(() => undefined));
const { result } = renderHook(() => useMediaUploadSession(undefined, uploadMutateAsync));
const firstController = new AbortController();
const localId = "local-1";
const file = makeFile();
@ -29,7 +28,6 @@ describe("useMediaUploadSession retry / cancel", () => {
{
localId,
file,
signal: firstController.signal,
onProgress: () => undefined,
},
]);
@ -39,6 +37,8 @@ describe("useMediaUploadSession retry / cancel", () => {
expect(result.current.uploads).toHaveLength(1);
});
const firstSignal = uploadMutateAsync.mock.calls[0]?.[0]?.signal as AbortSignal;
act(() => {
rejectFirst(new Error("network"));
});
@ -47,9 +47,8 @@ describe("useMediaUploadSession retry / cancel", () => {
expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
});
const retryController = new AbortController();
act(() => {
result.current.retryUpload(localId, retryController.signal);
result.current.retryUpload(localId);
});
await waitFor(() => {
@ -58,11 +57,15 @@ describe("useMediaUploadSession retry / cancel", () => {
expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
});
expect(uploadMutateAsync).toHaveBeenCalledTimes(2);
expect(uploadMutateAsync.mock.calls[1]?.[0]?.signal).toBe(retryController.signal);
const retrySignal = uploadMutateAsync.mock.calls[1]?.[0]?.signal as AbortSignal;
expect(retrySignal).toBeInstanceOf(AbortSignal);
expect(retrySignal).not.toBe(firstSignal);
expect(retrySignal.aborted).toBe(false);
});
it("cancel after retry aborts the replacement upload and removes the local row", async () => {
it("fail → retry → cancel aborts the replacement upload and records no success", async () => {
let rejectFirst!: (error: Error) => void;
let resolveRetry!: (value: unknown) => void;
const uploadMutateAsync = vi
.fn()
.mockImplementationOnce(
@ -73,7 +76,8 @@ describe("useMediaUploadSession retry / cancel", () => {
)
.mockImplementation(
({ signal }: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
new Promise((resolve, reject) => {
resolveRetry = resolve;
signal?.addEventListener("abort", () => {
const error = new Error("aborted");
error.name = "AbortError";
@ -91,7 +95,6 @@ describe("useMediaUploadSession retry / cancel", () => {
{
localId,
file,
signal: new AbortController().signal,
onProgress: () => undefined,
},
]);
@ -105,25 +108,28 @@ describe("useMediaUploadSession retry / cancel", () => {
expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
});
const retryController = new AbortController();
act(() => {
result.current.retryUpload(localId, retryController.signal);
result.current.retryUpload(localId);
});
await waitFor(() => {
expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
});
const retrySignal = uploadMutateAsync.mock.calls[1]?.[0]?.signal as AbortSignal;
act(() => {
// Mirrors MediaUploader.cancel: abort the mapped controller, then removeLocal.
retryController.abort();
result.current.removeLocal(localId);
result.current.cancelUpload(localId);
});
await waitFor(() => {
expect(result.current.uploads).toHaveLength(0);
});
expect(retryController.signal.aborted).toBe(true);
expect(uploadMutateAsync.mock.calls[1]?.[0]?.signal).toBe(retryController.signal);
expect(retrySignal.aborted).toBe(true);
// Cancelling must not leave a successful settle path.
expect(() => resolveRetry({})).not.toThrow();
await waitFor(() => {
expect(result.current.uploads).toHaveLength(0);
});
});
});

View file

@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import {
mapCompletedDateUpdateFormData,
mapWorkOrderFormToCreatePayload,
mapWorkOrderFormToUpdateFormData,
} from "@/domain/work-orders/mappers/work-order-mapper";
@ -137,24 +136,3 @@ describe("mapWorkOrderFormToUpdateFormData", () => {
expect(formData.getAll("CategoryIds")).toEqual([]);
});
});
describe("mapCompletedDateUpdateFormData", () => {
it("builds FormData with only Id and CompletedDate", () => {
const formData = mapCompletedDateUpdateFormData(42, "2026-07-30");
expect(formData.get("Id")).toBe("42");
expect(formData.get("CompletedDate")).toBe("2026-07-30");
expect(formData.get("Status")).toBeNull();
expect(formData.get("WorkerOrderTitle")).toBeNull();
expect(formData.get("Description")).toBeNull();
expect(formData.getAll("ContactIds")).toEqual([]);
expect(formData.getAll("CategoryIds")).toEqual([]);
});
it("omits empty CompletedDate", () => {
const formData = mapCompletedDateUpdateFormData(1, " ");
expect(formData.get("Id")).toBe("1");
expect(formData.get("CompletedDate")).toBeNull();
});
});