diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index d175e6c5..37db2b85 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -37,6 +37,8 @@ export const API_PATHS = { media: (id: string | number) => `workorders/${id}/media`, mediaItem: (id: string | number, mediaId: string | number) => `workorders/${id}/media/${mediaId}`, + mediaContent: (id: string | number, mediaId: string | number) => + `workorders/${id}/media/${mediaId}/content`, completionDoc: (id: string | number) => `workorders/${id}/completion-doc`, uplifts: (id: string | number) => `workorders/${id}/uplifts`, upliftCancel: (workOrderId: string | number, upliftId: string | number) => diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx index a6df25e5..eee8b3ec 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx @@ -11,6 +11,7 @@ import { } from "lucide-react"; import { toast } from "react-toastify"; import { Text } from "@/components/ui/text"; +import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media"; import { @@ -20,9 +21,9 @@ import { import { canDeleteWorkOrderMedia, formatMediaSizeLimit, + isWorkOrderExtraDocTypeAllowed, isWorkOrderFileTooLarge, - isWorkOrderMediaTypeAllowed, - workOrderMediaAcceptAttribute, + workOrderExtraDocsAcceptAttribute, } from "@/domain/work-orders/utils/work-order-media-limits"; const FILETYPE_ICON: Record = { @@ -43,21 +44,43 @@ type SlideOverExtrasTabProps = { isReadOnly: boolean; pending?: boolean; avetaRequired?: boolean; + workOrderId?: string | number; onUpload: (files: File[]) => void; onRemove: (id: string) => void; onCategorize?: (id: string, category: UploadableMediaCategory) => void; }; +async function openExtraDocContent(workOrderId: string | number, mediaId: string): Promise { + const tab = window.open("about:blank", "_blank"); + if (!tab) { + toast.error("Unable to open this document. Please allow pop-ups and try again."); + return; + } + + try { + const blob = await workOrdersApi.getMediaContent(workOrderId, mediaId); + const objectUrl = URL.createObjectURL(blob); + tab.opener = null; + tab.location.href = objectUrl; + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); + } catch { + tab.close(); + toast.error("Unable to open this document."); + } +} + function ExtraDocRow({ file, isReadOnly, pending, + workOrderId, onRemove, onCategorize, }: { file: MediaFile; isReadOnly: boolean; pending: boolean; + workOrderId?: string | number; onRemove: (id: string) => void; onCategorize?: (id: string, category: UploadableMediaCategory) => void; }) { @@ -75,15 +98,20 @@ function ExtraDocRow({ className="shrink-0" style={{ color: isAveta ? "var(--warning)" : "var(--color-text-muted)" }} /> - { + if (workOrderId == null) { + return; + } + void openExtraDocContent(workOrderId, file.id); + }} + className="min-w-0 flex-1 truncate text-left hover:underline disabled:cursor-not-allowed disabled:opacity-50" style={{ fontSize: 12, color: "var(--foreground)" }} > {file.name} - + {showCategory ? ( diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx index ebd41392..d43d94c1 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx @@ -147,6 +147,7 @@ export function WorkOrderSlideOverTabs({ > - Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file + Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit().replace(" ", "")} per file {categorizeAfterUpload ? " · categorize after upload" : ""} 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 3ee11286..ec0d9148 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 @@ -194,6 +194,27 @@ export const workOrderBoardDocumentsApi = { throwDocumentHttpError(response.status, data); }, + getMediaContent: async ( + workOrderId: string | number, + mediaId: string | number, + ): Promise => { + const response = await api.get(API_PATHS.workOrder.mediaContent(workOrderId, mediaId), { + credentials: "include", + throwHttpErrors: false, + }); + + 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: { diff --git a/src/domain/work-orders/utils/work-order-media-limits.ts b/src/domain/work-orders/utils/work-order-media-limits.ts index d93fb7cb..5d566ccd 100644 --- a/src/domain/work-orders/utils/work-order-media-limits.ts +++ b/src/domain/work-orders/utils/work-order-media-limits.ts @@ -1,7 +1,9 @@ -/** Backend RequestSizeLimit for media and completion-doc uploads. */ -export const MAX_WORK_ORDER_MEDIA_BYTES = 30 * 1024 * 1024; +import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock"; -const ALLOWED_MIME_TYPES = new Set([ +/** Backend RequestSizeLimit for media and completion-doc uploads. */ +export const MAX_WORK_ORDER_MEDIA_BYTES = 50_000_000; + +const MEDIA_MIME_TYPES = new Set([ "image/jpeg", "image/jpg", "image/png", @@ -9,7 +11,28 @@ const ALLOWED_MIME_TYPES = new Set([ "video/quicktime", ]); -const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]); +const MEDIA_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]); + +const EXTRA_DOC_MIME_TYPES = new Set([ + ...MEDIA_MIME_TYPES, + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", +]); + +const EXTRA_DOC_EXTENSIONS = new Set([...MEDIA_EXTENSIONS, ".pdf", ".doc", ".docx"]); + +function fileExtension(file: File): string { + const name = file.name.toLowerCase(); + const dot = name.lastIndexOf("."); + return dot >= 0 ? name.slice(dot) : ""; +} + +function isTypeAllowed(file: File, mimes: Set, extensions: Set): boolean { + const mimeOk = Boolean(file.type) && mimes.has(file.type.toLowerCase()); + const extOk = extensions.has(fileExtension(file)); + return mimeOk || extOk; +} export function isWorkOrderFileTooLarge( file: File, @@ -19,23 +42,24 @@ export function isWorkOrderFileTooLarge( } export function isWorkOrderMediaTypeAllowed(file: File): boolean { - const mimeOk = Boolean(file.type) && ALLOWED_MIME_TYPES.has(file.type.toLowerCase()); - const name = file.name.toLowerCase(); - const dot = name.lastIndexOf("."); - const ext = dot >= 0 ? name.slice(dot) : ""; - const extOk = ALLOWED_EXTENSIONS.has(ext); - return mimeOk || extOk; + return isTypeAllowed(file, MEDIA_MIME_TYPES, MEDIA_EXTENSIONS); +} + +export function isWorkOrderExtraDocTypeAllowed(file: File): boolean { + return isTypeAllowed(file, EXTRA_DOC_MIME_TYPES, EXTRA_DOC_EXTENSIONS); } export function workOrderMediaAcceptAttribute(): string { return "image/jpeg,image/jpg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov"; } -export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string { - return `${Math.round(maxBytes / (1024 * 1024))} MB`; +export function workOrderExtraDocsAcceptAttribute(): string { + return `${workOrderMediaAcceptAttribute()},.pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document`; } -import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock"; +export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string { + return `${Math.round(maxBytes / 1_000_000)} MB`; +} /** Completion-doc / completion-media lock: Canceled or Completed. Extra Docs use canMutateWorkOrderExtraDocs. */ export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean { 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 b1fb4451..7202587d 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 @@ -116,6 +116,10 @@ describe("MediaUploadModal API wiring", () => { expect(screen.getByText("before.jpg")).toBeInTheDocument(); }); expect(screen.getByText("Photos & Videos")).toBeInTheDocument(); + expect( + screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 50MB per file"), + ).toBeInTheDocument(); + expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument(); }); it("offers no purpose picker — the table modal only uploads and removes", async () => { diff --git a/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx b/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx new file mode 100644 index 00000000..35da0ef4 --- /dev/null +++ b/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx @@ -0,0 +1,28 @@ +import { createRef } from "react"; +import { screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { MediaUploaderDropzone } from "@/app/(protected)/workorders/_components/media/media-uploader-dropzone"; +import { workOrderMediaAcceptAttribute } from "@/domain/work-orders/utils/work-order-media-limits"; +import { renderWithProviders } from "@/test/test-utils"; + +describe("MediaUploaderDropzone", () => { + it("matches the AAP Photos accept copy and keeps it media-only", () => { + renderWithProviders( + ()} + pending={false} + onFilesSelected={vi.fn()} + />, + ); + + expect( + screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 50MB per file"), + ).toBeInTheDocument(); + expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/PDF/)).not.toBeInTheDocument(); + expect(screen.queryByText(/DOC/)).not.toBeInTheDocument(); + const input = document.querySelector('input[type="file"]'); + expect(input?.getAttribute("accept")).toBe(workOrderMediaAcceptAttribute()); + expect(input?.getAttribute("accept")).not.toContain(".pdf"); + }); +}); diff --git a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx index 75c36f00..f788e250 100644 --- a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx @@ -1,6 +1,8 @@ import { fireEvent, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { toast } from "react-toastify"; import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab"; +import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; import { renderWithProviders } from "@/test/test-utils"; @@ -21,6 +23,10 @@ const avetaDoc: MediaFile = { }; describe("SlideOverExtrasTab Aveta", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); it("does not label the first extra document as Aveta when none is explicitly Aveta", () => { renderWithProviders( { expect(onCategorize).toHaveBeenCalledWith("m1", "Aveta"); expect(screen.queryByText("safety-form.pdf")).toBeInTheDocument(); }); + + it("opens Extra Docs through authenticated content instead of a static href", async () => { + const blob = new Blob(["pdf"], { type: "application/pdf" }); + const getMediaContent = vi.spyOn(workOrdersApi, "getMediaContent").mockResolvedValue(blob); + const createObjectURL = vi.fn(() => "blob:authorized"); + vi.stubGlobal("URL", { ...URL, createObjectURL, revokeObjectURL: vi.fn() }); + const tab = { + opener: window, + location: { href: "about:blank" }, + close: vi.fn(), + }; + const open = vi.fn(() => tab); + vi.stubGlobal("open", open); + + renderWithProviders( + , + ); + + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "safety-form.pdf" })); + + await vi.waitFor(() => { + expect(getMediaContent).toHaveBeenCalledWith(10, "m1"); + }); + expect(createObjectURL).toHaveBeenCalledWith(blob); + expect(open).toHaveBeenCalledWith("about:blank", "_blank"); + expect(tab.opener).toBeNull(); + expect(tab.location.href).toBe("blob:authorized"); + }); + + it("shows an error when the browser blocks the document tab", async () => { + const getMediaContent = vi.spyOn(workOrdersApi, "getMediaContent"); + const toastError = vi.spyOn(toast, "error").mockImplementation(vi.fn()); + vi.stubGlobal( + "open", + vi.fn(() => null), + ); + + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "safety-form.pdf" })); + + expect(toastError).toHaveBeenCalledWith( + "Unable to open this document. Please allow pop-ups and try again.", + ); + expect(getMediaContent).not.toHaveBeenCalled(); + }); + + it("shows Extra Docs accept copy with PDF DOC and 50MB", () => { + renderWithProviders( + , + ); + + expect( + screen.getByText("Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max 50MB per file"), + ).toBeInTheDocument(); + const input = document.querySelector('input[type="file"]'); + expect(input?.getAttribute("accept")).toContain(".pdf"); + expect(input?.getAttribute("accept")).toContain(".doc"); + expect(input?.getAttribute("accept")).toContain(".docx"); + }); + + it("disables document opening until the work order id is available", () => { + renderWithProviders( + , + ); + + expect(screen.getByRole("button", { name: "safety-form.pdf" })).toBeDisabled(); + }); }); 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 7d7a3d1f..925a85e6 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 @@ -9,6 +9,7 @@ const apiPatch = vi.fn(); const apiPatchFn = vi.fn(); const apiPostFn = vi.fn(); const apiDeleteFn = vi.fn(); +const apiGetFn = vi.fn(); const uploadFormFn = vi.fn(); vi.mock("@/api/upload-form-with-progress", () => ({ @@ -27,6 +28,7 @@ vi.mock("@/api/api", () => ({ patch: (...args: unknown[]) => apiPatchFn(...args), post: (...args: unknown[]) => apiPostFn(...args), delete: (...args: unknown[]) => apiDeleteFn(...args), + get: (...args: unknown[]) => apiGetFn(...args), }, })); @@ -911,3 +913,46 @@ describe("workOrdersApi.fetchVendorsDropdown", () => { }); }); }); + +describe("API_PATHS.workOrder.mediaContent", () => { + it("builds the authorized media content path", () => { + expect(API_PATHS.workOrder.mediaContent(10, 12)).toBe("workorders/10/media/12/content"); + }); +}); + +describe("workOrdersApi.getMediaContent", () => { + beforeEach(() => { + apiGetFn.mockReset(); + }); + + it("returns the response blob on 200", async () => { + const blob = new Blob(["pdf-bytes"], { type: "application/pdf" }); + apiGetFn.mockResolvedValue({ + ok: true, + status: 200, + blob: async () => blob, + }); + + const result = await workOrdersApi.getMediaContent(10, 12); + + expect(result).toBe(blob); + expect(apiGetFn).toHaveBeenCalledWith( + API_PATHS.workOrder.mediaContent(10, 12), + expect.objectContaining({ credentials: "include", throwHttpErrors: false }), + ); + }); + + it("throws ApiError on 404 without inventing a filename", async () => { + apiGetFn.mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ fileName: "secret.pdf" }), + }); + + await expect(workOrdersApi.getMediaContent(10, 12)).rejects.toMatchObject({ + name: "ApiError", + status: 404, + message: "The requested resource was not found.", + }); + }); +}); diff --git a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts index 49af4e90..9cf3bc76 100644 --- a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts +++ b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts @@ -1,10 +1,14 @@ import { describe, expect, it } from "vitest"; import { canDeleteWorkOrderMedia, + formatMediaSizeLimit, isWorkOrderDocumentsReadOnly, + isWorkOrderExtraDocTypeAllowed, isWorkOrderFileTooLarge, isWorkOrderMediaTypeAllowed, MAX_WORK_ORDER_MEDIA_BYTES, + workOrderExtraDocsAcceptAttribute, + workOrderMediaAcceptAttribute, } from "@/domain/work-orders/utils/work-order-media-limits"; describe("work-order-media-limits", () => { @@ -20,7 +24,9 @@ describe("work-order-media-limits", () => { expect(canDeleteWorkOrderMedia({ id: "0" })).toBe(false); }); - it("flags files over 30 MB", () => { + it("caps uploads at 50_000_000 bytes and formats decimal MB", () => { + expect(MAX_WORK_ORDER_MEDIA_BYTES).toBe(50_000_000); + expect(formatMediaSizeLimit()).toBe("50 MB"); expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES + 1 } as File)).toBe(true); expect(isWorkOrderFileTooLarge({ size: 10 } as File)).toBe(false); }); @@ -37,5 +43,31 @@ describe("work-order-media-limits", () => { expect(isWorkOrderMediaTypeAllowed({ name: "a.pdf", type: "application/pdf" } as File)).toBe( false, ); + expect(workOrderMediaAcceptAttribute()).not.toContain(".pdf"); + }); + + it("allows Extra Docs PDF DOC DOCX plus media types", () => { + expect(isWorkOrderExtraDocTypeAllowed({ name: "a.pdf", type: "application/pdf" } as File)).toBe( + true, + ); + expect( + isWorkOrderExtraDocTypeAllowed({ name: "a.doc", type: "application/msword" } as File), + ).toBe(true); + expect( + isWorkOrderExtraDocTypeAllowed({ + name: "a.docx", + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + } as File), + ).toBe(true); + expect(isWorkOrderExtraDocTypeAllowed({ name: "a.jpg", type: "image/jpeg" } as File)).toBe( + true, + ); + expect(isWorkOrderExtraDocTypeAllowed({ name: "a.gif", type: "image/gif" } as File)).toBe( + false, + ); + const accept = workOrderExtraDocsAcceptAttribute(); + expect(accept).toContain(".pdf"); + expect(accept).toContain(".doc"); + expect(accept).toContain(".docx"); }); });