mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
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).
271 lines
8.6 KiB
TypeScript
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);
|
|
},
|
|
};
|