shoc-frontend-new/src/domain/work-orders/api/work-order-board-documents-api.ts
Alexandre Brandizzi 1a7a4ba58f fix(work-orders): open extra documents without credentialed CORS (SH-382)
Viewing a work-order extra document called the authorized content endpoint
with `credentials: "include"`. The API replies `Access-Control-Allow-Origin: *`,
and in credentialed mode the browser rejects a wildcard origin outright, so the
fetch threw, `openExtraDocContent` fell into its catch, closed the tab and
toasted "Unable to open this document." — QA CT-03.

The endpoint authenticates with the bearer token the ky beforeRequest hook
attaches, not cookies, so credentials mode was dead weight. List, upload and
delete never set it, which is why only viewing failed. Drop the option and
guard the request shape in a test (JSDOM cannot enforce CORS).
2026-09-18 16:35:01 -03:00

271 lines
8.6 KiB
TypeScript

import { API_PATHS } from "@/api/api-paths";
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
import { apiGet, apiPost, apiRequestRaw } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response";
import { uploadFormWithProgress } from "@/api/upload-form-with-progress";
import { traceHttpOperation } from "@/observability/http-transaction";
import {
BoardPatchConflictError,
BoardPatchValidationError,
} from "@/domain/work-orders/errors/board-patch-errors";
import {
buildCompletionDocFormData,
buildMediaFormData,
mapBoardDetailComment,
mapBoardDetailMediaItem,
mapCompletion,
type UploadableMediaCategory,
} from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type {
WorkOrderBoardCompletion,
WorkOrderBoardMediaItem,
} from "@/domain/work-orders/types/work-order-board-detail";
import type { WorkOrderComment } from "@/domain/work-orders/types/work-order";
export interface CreateWorkOrderCommentPayload {
text: string;
mentions?: string[];
}
async function readResponseJson(response: Response): Promise<unknown> {
try {
return await response.json();
} catch {
return undefined;
}
}
function throwDocumentHttpError(status: number, data: unknown): never {
const record = typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
if (status === 409) {
throw new BoardPatchConflictError(
typeof record.message === "string"
? record.message
: "The record was modified by another user. Refresh and retry.",
null,
);
}
if (status === 422) {
throw new BoardPatchValidationError(
typeof record.code === "string" ? record.code : "ValidationError",
typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data),
);
}
throw new ApiError(mapHttpStatusToMessage(status, data), status);
}
export const workOrderBoardDocumentsApi = {
listComments: async (workOrderId: string | number): Promise<WorkOrderComment[]> => {
const data = await apiGet<unknown>(API_PATHS.workOrder.comments(workOrderId));
const items = handleApiResponse<unknown[]>(data);
return Array.isArray(items) ? items.map(mapBoardDetailComment) : [];
},
createComment: async (
workOrderId: string | number,
text: string,
mentions?: string[],
): Promise<WorkOrderComment> => {
const payload: CreateWorkOrderCommentPayload = { text };
if (mentions && mentions.length > 0) {
payload.mentions = mentions.map(String);
}
const data = await apiPost<unknown>(API_PATHS.workOrder.comments(workOrderId), payload);
return mapBoardDetailComment(handleApiResponse(data));
},
updateComment: async (
workOrderId: string | number,
commentId: string | number,
text: string,
): Promise<WorkOrderComment> => {
const response = await apiRequestRaw(
"patch",
API_PATHS.workOrder.commentItem(workOrderId, commentId),
{ json: { text }, throwHttpErrors: false },
"workOrderBoardDocumentsApi.updateComment",
);
const data = await readResponseJson(response);
if (response.ok) {
if (response.status === 204 || data == null) {
return mapBoardDetailComment({ id: commentId, text });
}
return mapBoardDetailComment(handleApiResponse(data) ?? data);
}
throwDocumentHttpError(response.status, data);
},
/** @deprecated Prefer createComment — modern board endpoint. */
addComment: async (
workOrderId: string | number,
text: string,
_commentType?: string,
): Promise<WorkOrderComment> => {
return workOrderBoardDocumentsApi.createComment(workOrderId, text);
},
listMedia: async (workOrderId: string | number): Promise<WorkOrderBoardMediaItem[]> => {
const data = await apiGet<unknown>(API_PATHS.workOrder.media(workOrderId));
const items = handleApiResponse<unknown[]>(data);
return Array.isArray(items) ? items.map(mapBoardDetailMediaItem) : [];
},
uploadMedia: async (
workOrderId: string | number,
file: File,
category?: UploadableMediaCategory,
options?: { signal?: AbortSignal; onProgress?: (percent: number) => void },
): Promise<WorkOrderBoardMediaItem> => {
const formData = buildMediaFormData(file, category);
const { ok, status, data } = await traceHttpOperation(
{
method: "POST",
url: API_PATHS.workOrder.media(workOrderId),
entryPoint: "workOrderBoardDocumentsApi.uploadMedia",
},
async ({ reportStatus }) => {
const result = await uploadFormWithProgress(
API_PATHS.workOrder.media(workOrderId),
formData,
{ signal: options?.signal, onProgress: options?.onProgress },
);
reportStatus(result.status);
return result;
},
);
if (ok) {
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
}
throwDocumentHttpError(status, data);
},
updateMediaCategory: async (
workOrderId: string | number,
mediaId: string | number,
category: UploadableMediaCategory,
workOrderVersion: string,
): Promise<WorkOrderBoardMediaItem> => {
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 apiRequestRaw(
"patch",
API_PATHS.workOrder.mediaItem(workOrderId, mediaId),
{ body: formData, throwHttpErrors: false },
"workOrderBoardDocumentsApi.updateMediaCategory",
);
const data = await readResponseJson(response);
if (response.ok) {
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
}
throwDocumentHttpError(response.status, data);
},
deleteMedia: async (
workOrderId: string | number,
mediaId: string | number,
workOrderVersion: string,
): Promise<void> => {
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(
"InvalidMedia",
"Legacy media cannot be deleted via this endpoint.",
);
}
const response = await apiRequestRaw(
"delete",
API_PATHS.workOrder.mediaItem(workOrderId, numericId),
{ searchParams: { workOrderVersion }, throwHttpErrors: false },
"workOrderBoardDocumentsApi.deleteMedia",
);
if (response.status === 204) {
return;
}
const data = await readResponseJson(response);
if (response.ok) {
return;
}
throwDocumentHttpError(response.status, data);
},
getMediaContent: async (
workOrderId: string | number,
mediaId: string | number,
): Promise<Blob> => {
// No `credentials: "include"`: this endpoint authenticates with the bearer token the ky
// beforeRequest hook attaches, not cookies. In credentialed mode the browser rejects the
// API's `Access-Control-Allow-Origin: *` outright, so the fetch would throw and the document
// would never open.
const response = await apiRequestRaw(
"get",
API_PATHS.workOrder.mediaContent(workOrderId, mediaId),
{ throwHttpErrors: false },
"workOrderBoardDocumentsApi.getMediaContent",
);
if (response.ok) {
return response.blob();
}
if (response.status === 404) {
throw new ApiError(mapHttpStatusToMessage(404), 404);
}
const data = await readResponseJson(response);
throwDocumentHttpError(response.status, data);
},
uploadCompletionDoc: async (
workOrderId: string | number,
params: {
file: File;
workOrderVersion: string;
signOffName?: string;
signOffSignature?: string;
},
): Promise<WorkOrderBoardCompletion> => {
if (!params.workOrderVersion) {
throw new BoardPatchValidationError(
"WorkOrderVersionRequired",
"workOrderVersion is required to upload a completion document.",
);
}
const formData = buildCompletionDocFormData(params);
const response = await apiRequestRaw(
"post",
API_PATHS.workOrder.completionDoc(workOrderId),
{ body: formData, throwHttpErrors: false },
"workOrderBoardDocumentsApi.uploadCompletionDoc",
);
const data = await readResponseJson(response);
if (response.ok) {
return mapCompletion(handleApiResponse(data) ?? data);
}
throwDocumentHttpError(response.status, data);
},
};