mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Merge 7d9d8ed47b into 6367dc2a7a
This commit is contained in:
commit
bd602f1d25
38 changed files with 3789 additions and 148 deletions
|
|
@ -136,6 +136,26 @@ async function mockPortalApi(page: Page, options: { sessionError?: boolean } = {
|
|||
fulfill(route, { data: detailState.current }),
|
||||
);
|
||||
|
||||
await page.route(/\/api\/vendor-portal\/dispatches\/\d+\/media$/, (route) =>
|
||||
fulfill(route, {
|
||||
data: {
|
||||
policy: {
|
||||
workOrderType: "Emergency",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 10, videos: 3 },
|
||||
},
|
||||
items: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route(/\/api\/vendor-portal\/dispatches(?:\?.*)?$/, (route) =>
|
||||
fulfill(route, { data: listDispatches }),
|
||||
);
|
||||
|
|
|
|||
32
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
32
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
|
|
@ -1,5 +1,21 @@
|
|||
import { expect, test, type Route } from "@playwright/test";
|
||||
|
||||
const vendorMedia = {
|
||||
policy: {
|
||||
workOrderType: "Reactive",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 10, videos: 3 },
|
||||
},
|
||||
items: [],
|
||||
};
|
||||
|
||||
const INTERNAL_USER = {
|
||||
token: "uplift-approver-token",
|
||||
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
|
|
@ -26,6 +42,10 @@ test("vendor uploads scanned evidence and submits a dispatch-scoped uplift", asy
|
|||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
|
||||
if (path.endsWith("/media") && request.method() === "GET") {
|
||||
await fulfillJson(route, vendorMedia);
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/session")) {
|
||||
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
||||
return;
|
||||
|
|
@ -122,6 +142,10 @@ test("vendor can withdraw a pending uplift from the dispatch", async ({ page })
|
|||
await page.route("**/api/vendor-portal/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (path.endsWith("/media") && request.method() === "GET") {
|
||||
await fulfillJson(route, vendorMedia);
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/session")) {
|
||||
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
||||
return;
|
||||
|
|
@ -172,6 +196,10 @@ test("vendor receives the no-approval result for a request within the current NT
|
|||
await page.route("**/api/vendor-portal/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (path.endsWith("/media") && request.method() === "GET") {
|
||||
await fulfillJson(route, vendorMedia);
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/session")) {
|
||||
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
||||
return;
|
||||
|
|
@ -216,6 +244,10 @@ test("vendor revises a changes-requested uplift using its existing evidence", as
|
|||
await page.route("**/api/vendor-portal/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (path.endsWith("/media") && request.method() === "GET") {
|
||||
await fulfillJson(route, vendorMedia);
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/session")) {
|
||||
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { devices, expect, test, type Page, type Request, type Route } from "@playwright/test";
|
||||
|
||||
// Completion uploads from a mobile-sized viewport must reach the API as
|
||||
// multipart/form-data with a boundary, or ASP.NET cannot bind the [FromForm] file/category.
|
||||
// Signed completion PDFs remain multipart; Emergency/Reactive photos and videos use resumable chunks.
|
||||
test.use({ ...devices["Pixel 7"] });
|
||||
|
||||
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
|
||||
|
|
@ -45,15 +44,21 @@ async function fulfillJson(route: Route, body: unknown, status = 200) {
|
|||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
type UploadSessionFixture = {
|
||||
uploadId: string;
|
||||
state: "Created" | "Uploading" | "Completed";
|
||||
chunkSizeBytes: number;
|
||||
sizeBytes: number;
|
||||
receivedChunks: number[];
|
||||
expiresAt: string;
|
||||
attachment?: { id: number };
|
||||
};
|
||||
|
||||
function multipartBoundary(request: Request): string | undefined {
|
||||
const contentType = request.headers()["content-type"] ?? "";
|
||||
return /^multipart\/form-data; boundary=(.+)$/.exec(contentType)?.[1];
|
||||
}
|
||||
|
||||
function multipartFileName(body: string): string | undefined {
|
||||
return /filename="([^"]+)"/.exec(body)?.[1];
|
||||
}
|
||||
|
||||
async function hideQueryDevtools(page: Page) {
|
||||
await page.addStyleTag({
|
||||
content:
|
||||
|
|
@ -133,28 +138,84 @@ test.describe("Completion uploads on a mobile viewport", () => {
|
|||
expect(body).toMatch(new RegExp(`name="workOrderVersion"\\r\\n\\r\\n${ROW_VERSION}`));
|
||||
});
|
||||
|
||||
test("uploads JPG, MP4 and MOV media and categorizes as multipart", async ({ page }) => {
|
||||
test("uploads JPG, MP4 and MOV through acknowledged resumable chunks", async ({ page }) => {
|
||||
const row = boardRow({ id: 72, woNumber: "RX-MEDIA-72", workOrderType: 6 });
|
||||
const media: { id: number; category: number; url: string; fileName: string }[] = [];
|
||||
await mockBoard(page, row);
|
||||
await page.route("**/api/workorders/72/detail", (route) =>
|
||||
fulfillJson(route, { info: row, completion: { docStatus: "No" }, media }),
|
||||
fulfillJson(route, { info: row, completion: { docStatus: "No" }, media: [] }),
|
||||
);
|
||||
const uploads: { contentType: string; body: string }[] = [];
|
||||
await page.route("**/api/workorders/72/media", (route) => {
|
||||
const sessions = new Map<string, UploadSessionFixture>();
|
||||
const creates: Record<string, unknown>[] = [];
|
||||
const chunks: { body: Buffer; headers: Record<string, string> }[] = [];
|
||||
const commits: string[] = [];
|
||||
await page.route("**/api/workorders/72/media/policy", (route) =>
|
||||
fulfillJson(route, {
|
||||
workOrderType: "Reactive",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 10, videos: 3 },
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/workorders/72/media/uploads", async (route) => {
|
||||
const request = route.request();
|
||||
const body = request.postDataBuffer()?.toString("latin1") ?? "";
|
||||
uploads.push({ contentType: request.headers()["content-type"] ?? "", body });
|
||||
const fileName = multipartFileName(body) ?? "file";
|
||||
const item = { id: 900 + media.length, category: 3, url: `/Assets/${fileName}`, fileName };
|
||||
media.push(item);
|
||||
return fulfillJson(route, item);
|
||||
const dto = request.postDataJSON() as Record<string, unknown>;
|
||||
creates.push(dto);
|
||||
const uploadId = `media-${creates.length}`;
|
||||
const session: UploadSessionFixture = {
|
||||
uploadId,
|
||||
state: "Created",
|
||||
chunkSizeBytes: 5_242_880,
|
||||
sizeBytes: Number(dto.sizeBytes),
|
||||
receivedChunks: [],
|
||||
expiresAt: "2026-08-20T15:00:00.000Z",
|
||||
};
|
||||
sessions.set(uploadId, session);
|
||||
await fulfillJson(route, session);
|
||||
});
|
||||
let categorize: Request | undefined;
|
||||
await page.route("**/api/workorders/72/media/*", (route) => {
|
||||
categorize = route.request();
|
||||
const item = media[0]!;
|
||||
return fulfillJson(route, { ...item, category: 1 });
|
||||
await page.route("**/api/workorders/72/media/uploads/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
const match = /\/media\/uploads\/(media-\d+)(?:\/chunks\/(\d+)|\/commit)?$/.exec(path);
|
||||
if (!match) return route.fallback();
|
||||
const uploadId = match[1]!;
|
||||
const index = match[2];
|
||||
const session = sessions.get(uploadId)!;
|
||||
|
||||
if (request.method() === "PUT" && index !== undefined) {
|
||||
const body = request.postDataBuffer() ?? Buffer.alloc(0);
|
||||
chunks.push({ body, headers: request.headers() });
|
||||
session.receivedChunks = [...new Set([...session.receivedChunks, Number(index)])];
|
||||
session.state = "Uploading";
|
||||
await fulfillJson(route, {
|
||||
index: Number(index),
|
||||
sha256: request.headers()["x-chunk-sha256"],
|
||||
sizeBytes: body.length,
|
||||
receivedChunks: session.receivedChunks,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === "POST" && path.endsWith("/commit")) {
|
||||
commits.push(uploadId);
|
||||
session.state = "Completed";
|
||||
session.attachment = { id: 900 + commits.length };
|
||||
await fulfillJson(route, session);
|
||||
return;
|
||||
}
|
||||
if (request.method() === "GET") {
|
||||
await fulfillJson(route, session);
|
||||
return;
|
||||
}
|
||||
if (request.method() === "DELETE") {
|
||||
await fulfillJson(route, {});
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
const panel = await openCompletionTab(page, row.woNumber);
|
||||
|
|
@ -167,19 +228,22 @@ test.describe("Completion uploads on a mobile viewport", () => {
|
|||
];
|
||||
await (await chooser).setFiles(files);
|
||||
|
||||
await expect.poll(() => uploads.length).toBe(files.length);
|
||||
for (const { name } of files) {
|
||||
const upload = uploads.find((item) => multipartFileName(item.body) === name);
|
||||
expect(upload, `multipart upload for ${name}`).toBeTruthy();
|
||||
expect(upload!.contentType).toMatch(/^multipart\/form-data; boundary=/);
|
||||
}
|
||||
|
||||
await panel.getByRole("combobox", { name: "Category for IMG_0001.jpg" }).selectOption("Before");
|
||||
await expect.poll(() => categorize?.method()).toBe("PATCH");
|
||||
const boundary = multipartBoundary(categorize!);
|
||||
expect(boundary, categorize!.headers()["content-type"]).toBeTruthy();
|
||||
const body = categorize!.postDataBuffer()?.toString("latin1") ?? "";
|
||||
expect(body).toMatch(/name="category"\r\n\r\nBefore/);
|
||||
expect(body).toMatch(new RegExp(`name="workOrderVersion"\\r\\n\\r\\n${ROW_VERSION}`));
|
||||
await expect.poll(() => creates.length).toBe(files.length);
|
||||
await expect.poll(() => chunks.length).toBe(files.length);
|
||||
await expect.poll(() => commits.length).toBe(files.length);
|
||||
expect(creates.map((dto) => dto.surface)).toEqual(
|
||||
Array.from({ length: files.length }, () => "CompletionMedia"),
|
||||
);
|
||||
expect(creates.map((dto) => dto.sizeBytes).sort()).toEqual(
|
||||
files.map((file) => file.buffer.length).sort(),
|
||||
);
|
||||
expect(chunks.map((chunk) => chunk.headers["content-type"])).toEqual(
|
||||
Array.from({ length: files.length }, () => "application/octet-stream"),
|
||||
);
|
||||
expect(chunks.map((chunk) => chunk.body.toString("hex")).sort()).toEqual(
|
||||
files.map((file) => file.buffer.toString("hex")).sort(),
|
||||
);
|
||||
expect([...sessions.values()].every((session) => session.state === "Completed")).toBe(true);
|
||||
await expect(page.getByText("File uploaded").first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ export const API_PATHS = {
|
|||
`workorders/${id}/media/${mediaId}`,
|
||||
mediaContent: (id: string | number, mediaId: string | number) =>
|
||||
`workorders/${id}/media/${mediaId}/content`,
|
||||
mediaPolicy: (id: string | number) => `workorders/${id}/media/policy`,
|
||||
mediaUploads: (id: string | number) => `workorders/${id}/media/uploads`,
|
||||
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
|
||||
uplifts: (id: string | number) => `workorders/${id}/uplifts`,
|
||||
upliftCancel: (workOrderId: string | number, upliftId: string | number) =>
|
||||
|
|
|
|||
170
src/api/media-upload-client.ts
Normal file
170
src/api/media-upload-client.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import {
|
||||
type MediaChunkAckDto,
|
||||
type MediaUploadCreateDto,
|
||||
type MediaUploadSessionDto,
|
||||
} from "@/api/media-upload-contracts";
|
||||
|
||||
export interface MediaUploadEndpointSet {
|
||||
create(dto: MediaUploadCreateDto, signal?: AbortSignal): Promise<MediaUploadSessionDto>;
|
||||
putChunk(args: {
|
||||
uploadId: string;
|
||||
index: number;
|
||||
blob: Blob;
|
||||
start: number;
|
||||
end: number;
|
||||
totalBytes: number;
|
||||
sha256: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<MediaChunkAckDto>;
|
||||
commit(uploadId: string, signal?: AbortSignal): Promise<MediaUploadSessionDto>;
|
||||
getStatus(uploadId: string, signal?: AbortSignal): Promise<MediaUploadSessionDto>;
|
||||
cancel(uploadId: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Network-level failure (fetch rejected) — recoverable by re-reading session status. */
|
||||
export class MediaUploadNetworkError extends Error {
|
||||
constructor() {
|
||||
super("Upload failed. Check your connection and try again.");
|
||||
this.name = "MediaUploadNetworkError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isNetworkUploadError(error: unknown): boolean {
|
||||
return error instanceof MediaUploadNetworkError;
|
||||
}
|
||||
|
||||
/** Retryable HTTP failures keep the same client idempotency key/session. */
|
||||
export function isRetryableMediaUploadError(error: unknown): boolean {
|
||||
if (isNetworkUploadError(error)) return true;
|
||||
if (!(error instanceof Error) || error.name !== "MediaUploadHttpError") return false;
|
||||
const status = (error as Error & { status?: unknown }).status;
|
||||
return typeof status === "number" && status >= 500 && status <= 599;
|
||||
}
|
||||
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" && error !== null && "name" in error && error.name === "AbortError"
|
||||
);
|
||||
}
|
||||
|
||||
export async function computeSha256Hex(buffer: ArrayBuffer): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
interface EndpointOptions {
|
||||
resolveUrl: (suffix: string) => string;
|
||||
headers: () => Record<string, string>;
|
||||
}
|
||||
|
||||
function parseSessionPayload(payload: unknown): MediaUploadSessionDto {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
throw new MediaUploadNetworkError();
|
||||
}
|
||||
return payload as MediaUploadSessionDto;
|
||||
}
|
||||
|
||||
async function readJsonBody(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
throw new MediaUploadNetworkError();
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
authHeaders: Record<string, string>,
|
||||
): Promise<unknown> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...init,
|
||||
headers: { Accept: "application/json", ...authHeaders, ...init.headers },
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw new MediaUploadNetworkError();
|
||||
}
|
||||
|
||||
const payload = await readJsonBody(response);
|
||||
if (!response.ok) {
|
||||
const error = new Error(`Upload request failed (HTTP ${response.status}).`);
|
||||
error.name = "MediaUploadHttpError";
|
||||
Object.assign(error, { status: response.status, payload });
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function createMediaUploadEndpoints({
|
||||
resolveUrl,
|
||||
headers,
|
||||
}: EndpointOptions): MediaUploadEndpointSet {
|
||||
const sessionUrl = (uploadId: string, suffix = ""): string =>
|
||||
resolveUrl([uploadId, suffix].filter(Boolean).join("/"));
|
||||
|
||||
return {
|
||||
create: async (dto, signal) =>
|
||||
parseSessionPayload(
|
||||
await requestJson(
|
||||
resolveUrl(""),
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(dto),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal,
|
||||
},
|
||||
headers(),
|
||||
),
|
||||
),
|
||||
|
||||
putChunk: async ({ uploadId, index, blob, start, end, totalBytes, sha256, signal }) => {
|
||||
const payload = await requestJson(
|
||||
sessionUrl(uploadId, `chunks/${index}`),
|
||||
{
|
||||
method: "PUT",
|
||||
body: blob,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Range": `bytes ${start}-${end - 1}/${totalBytes}`,
|
||||
"X-Chunk-SHA256": sha256.toLowerCase(),
|
||||
},
|
||||
signal,
|
||||
},
|
||||
headers(),
|
||||
);
|
||||
return payload as MediaChunkAckDto;
|
||||
},
|
||||
|
||||
commit: async (uploadId, signal) =>
|
||||
parseSessionPayload(
|
||||
await requestJson(sessionUrl(uploadId, "commit"), { method: "POST", signal }, headers()),
|
||||
),
|
||||
|
||||
getStatus: async (uploadId, signal) =>
|
||||
parseSessionPayload(
|
||||
await requestJson(sessionUrl(uploadId), { method: "GET", signal }, headers()),
|
||||
),
|
||||
|
||||
cancel: async (uploadId) => {
|
||||
try {
|
||||
await fetch(sessionUrl(uploadId), {
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json", ...headers() },
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cancel — surface nothing when the session is already gone.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
130
src/api/media-upload-contracts.ts
Normal file
130
src/api/media-upload-contracts.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/** Shared SH-116 resumable media upload contract (dispatcher + vendor portal). */
|
||||
|
||||
export const MEDIA_UPLOAD_CHUNK_SIZE_BYTES = 5_242_880;
|
||||
|
||||
export const MEDIA_UPLOAD_SCANNING_STATE = "Scanning" as const;
|
||||
|
||||
export type UploadSurface = "WorkOrderTable" | "CompletionMedia" | "VendorPortal";
|
||||
|
||||
export type UploadCategory = "Before" | "After" | "Extra" | "Aveta";
|
||||
|
||||
export type MediaKind = "Photo" | "Video";
|
||||
|
||||
export type MediaUploadState =
|
||||
"Created" | "Uploading" | "Scanning" | "Completed" | "Rejected" | "Canceled" | "Expired";
|
||||
|
||||
export interface MediaUploadLimits {
|
||||
photoMaxBytes: number;
|
||||
videoMaxBytes: number;
|
||||
videoMaxSeconds: number;
|
||||
maxPhotos: number;
|
||||
maxVideos: number;
|
||||
}
|
||||
|
||||
export interface MediaUploadRemaining {
|
||||
photos: number;
|
||||
videos: number;
|
||||
}
|
||||
|
||||
export interface MediaUploadPolicy {
|
||||
workOrderType: string;
|
||||
canUpload: boolean;
|
||||
limits: MediaUploadLimits;
|
||||
remaining: MediaUploadRemaining;
|
||||
}
|
||||
|
||||
export interface MediaUploadCreateDto {
|
||||
idempotencyKey: string;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
category?: UploadCategory;
|
||||
surface: UploadSurface;
|
||||
}
|
||||
|
||||
export interface MediaUploadSessionDto {
|
||||
uploadId: string;
|
||||
state: MediaUploadState;
|
||||
chunkSizeBytes: number;
|
||||
sizeBytes: number;
|
||||
receivedChunks: number[];
|
||||
expiresAt: string;
|
||||
attachment?: { id: string | number };
|
||||
}
|
||||
|
||||
export interface MediaChunkAckDto {
|
||||
index: number;
|
||||
sha256: string;
|
||||
sizeBytes: number;
|
||||
receivedChunks: number[];
|
||||
}
|
||||
|
||||
export const MEDIA_UPLOAD_TERMINAL_STATES: ReadonlySet<MediaUploadState> = new Set([
|
||||
"Completed",
|
||||
"Rejected",
|
||||
"Canceled",
|
||||
"Expired",
|
||||
]);
|
||||
|
||||
export function isTerminalMediaUploadState(state: MediaUploadState): boolean {
|
||||
return MEDIA_UPLOAD_TERMINAL_STATES.has(state);
|
||||
}
|
||||
|
||||
export function isUploadCategory(value: string | undefined): value is UploadCategory {
|
||||
return value === "Before" || value === "After" || value === "Extra" || value === "Aveta";
|
||||
}
|
||||
|
||||
/** Safe, stable user-facing messages. Never render raw server payloads. */
|
||||
export function safeMediaUploadErrorMessage(error: Error): string {
|
||||
if (error instanceof MediaUploadValidationError) {
|
||||
return error.message;
|
||||
}
|
||||
if (error instanceof MediaUploadRejectedError) {
|
||||
return "The file was rejected by the security scan and was not saved.";
|
||||
}
|
||||
if (error instanceof MediaUploadExpiredError) {
|
||||
return "The upload session expired. Please try again.";
|
||||
}
|
||||
if (error instanceof MediaUploadScanTimeoutError) {
|
||||
return "The upload is still processing. Check back shortly to see the result.";
|
||||
}
|
||||
if (error.name === "AbortError") {
|
||||
return "Upload canceled.";
|
||||
}
|
||||
return "Upload failed. Check your connection and try again.";
|
||||
}
|
||||
|
||||
export class MediaUploadValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "MediaUploadValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaUploadRejectedError extends Error {
|
||||
constructor() {
|
||||
super("The file was rejected by the security scan.");
|
||||
this.name = "MediaUploadRejectedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaUploadExpiredError extends Error {
|
||||
constructor() {
|
||||
super("The upload session expired.");
|
||||
this.name = "MediaUploadExpiredError";
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaUploadCanceledError extends Error {
|
||||
constructor() {
|
||||
super("The upload session was canceled.");
|
||||
this.name = "MediaUploadCanceledError";
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaUploadScanTimeoutError extends Error {
|
||||
constructor() {
|
||||
super("The upload is still processing.");
|
||||
this.name = "MediaUploadScanTimeoutError";
|
||||
}
|
||||
}
|
||||
336
src/api/media-upload-engine.ts
Normal file
336
src/api/media-upload-engine.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
import {
|
||||
type MediaChunkAckDto,
|
||||
type MediaUploadCreateDto,
|
||||
type MediaUploadSessionDto,
|
||||
type UploadCategory,
|
||||
type UploadSurface,
|
||||
MEDIA_UPLOAD_CHUNK_SIZE_BYTES,
|
||||
MediaUploadCanceledError,
|
||||
MediaUploadExpiredError,
|
||||
MediaUploadRejectedError,
|
||||
MediaUploadScanTimeoutError,
|
||||
isTerminalMediaUploadState,
|
||||
} from "@/api/media-upload-contracts";
|
||||
import {
|
||||
type MediaUploadEndpointSet,
|
||||
computeSha256Hex,
|
||||
isAbortError,
|
||||
isNetworkUploadError,
|
||||
} from "@/api/media-upload-client";
|
||||
|
||||
const MAX_CREATE_ATTEMPTS = 2;
|
||||
const MAX_CHUNK_RECOVERY_ATTEMPTS = 3;
|
||||
const CHUNK_RECOVERY_BACKOFF_MS = 200;
|
||||
const MAX_COMMIT_ATTEMPTS = 2;
|
||||
const MAX_POLL_ATTEMPTS = 120;
|
||||
const DEFAULT_POLL_DELAY_MS = 1_500;
|
||||
|
||||
export interface ResumableUploadArgs {
|
||||
file: File;
|
||||
endpoints: MediaUploadEndpointSet;
|
||||
surface: UploadSurface;
|
||||
category?: UploadCategory;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (percent: number) => void;
|
||||
/** Defaults to a fresh random UUID per attempt. */
|
||||
idempotencyKey?: string;
|
||||
digest?: (buffer: ArrayBuffer) => Promise<string>;
|
||||
pollDelayMs?: number;
|
||||
}
|
||||
|
||||
export function chunkCountFor(sizeBytes: number, chunkSizeBytes: number): number {
|
||||
if (sizeBytes <= 0) return 1;
|
||||
return Math.ceil(sizeBytes / chunkSizeBytes);
|
||||
}
|
||||
|
||||
export function missingChunkIndices(received: readonly number[], total: number): number[] {
|
||||
const acked = new Set(received);
|
||||
const missing: number[] = [];
|
||||
for (let index = 0; index < total; index += 1) {
|
||||
if (!acked.has(index)) {
|
||||
missing.push(index);
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
function acknowledgedBytes(
|
||||
received: readonly number[],
|
||||
totalBytes: number,
|
||||
chunkSize: number,
|
||||
): number {
|
||||
let bytes = 0;
|
||||
for (const index of received) {
|
||||
if (index >= 0 && index < chunkCountFor(totalBytes, chunkSize)) {
|
||||
bytes += Math.min(chunkSize, totalBytes - index * chunkSize);
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function reportProgress(
|
||||
args: ResumableUploadArgs,
|
||||
received: readonly number[],
|
||||
totalBytes: number,
|
||||
chunkSize: number,
|
||||
): void {
|
||||
if (!args.onProgress || totalBytes <= 0) return;
|
||||
const percent = Math.min(
|
||||
100,
|
||||
Math.round((acknowledgedBytes(received, totalBytes, chunkSize) / totalBytes) * 100),
|
||||
);
|
||||
args.onProgress(percent);
|
||||
}
|
||||
|
||||
function assertNotAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException("The upload was aborted.", "AbortError");
|
||||
}
|
||||
}
|
||||
|
||||
function throwForTerminalState(state: MediaUploadSessionDto["state"]): void {
|
||||
if (state === "Rejected") throw new MediaUploadRejectedError();
|
||||
if (state === "Expired") throw new MediaUploadExpiredError();
|
||||
if (state === "Canceled") throw new MediaUploadCanceledError();
|
||||
}
|
||||
|
||||
/** Idempotent create: a network failure retries the same idempotency key, never a new session. */
|
||||
async function createSessionIdempotent(
|
||||
endpoints: MediaUploadEndpointSet,
|
||||
dto: MediaUploadCreateDto,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<MediaUploadSessionDto> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < MAX_CREATE_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
return await endpoints.create(dto, signal);
|
||||
} catch (error) {
|
||||
if (!isNetworkUploadError(error)) throw error;
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Network failure recovery: re-read the session; terminal states settle here,
|
||||
* otherwise the returned receivedChunks define what still needs sending.
|
||||
*/
|
||||
async function recoverSession(
|
||||
endpoints: MediaUploadEndpointSet,
|
||||
uploadId: string,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<MediaUploadSessionDto> {
|
||||
let session: MediaUploadSessionDto;
|
||||
try {
|
||||
session = await endpoints.getStatus(uploadId, signal);
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) throw error;
|
||||
if (isNetworkUploadError(error)) {
|
||||
// Still offline — bubble so the attempt surfaces as retryable failure.
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (isTerminalMediaUploadState(session.state)) {
|
||||
throwForTerminalState(session.state);
|
||||
return session;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
async function waitForScanTerminal(
|
||||
args: ResumableUploadArgs,
|
||||
uploadId: string,
|
||||
): Promise<MediaUploadSessionDto> {
|
||||
const delayMs = args.pollDelayMs ?? DEFAULT_POLL_DELAY_MS;
|
||||
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
|
||||
assertNotAborted(args.signal);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
assertNotAborted(args.signal);
|
||||
let session: MediaUploadSessionDto;
|
||||
try {
|
||||
session = await args.endpoints.getStatus(uploadId, args.signal);
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) throw error;
|
||||
continue; // Transient poll failure — keep polling until the deadline.
|
||||
}
|
||||
if (isTerminalMediaUploadState(session.state)) {
|
||||
throwForTerminalState(session.state);
|
||||
return session;
|
||||
}
|
||||
}
|
||||
throw new MediaUploadScanTimeoutError();
|
||||
}
|
||||
|
||||
async function commitAndObserve(
|
||||
args: ResumableUploadArgs,
|
||||
uploadId: string,
|
||||
): Promise<MediaUploadSessionDto> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < MAX_COMMIT_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const committed = await args.endpoints.commit(uploadId, args.signal);
|
||||
return committed.state === "Scanning" ? waitForScanTerminal(args, uploadId) : committed;
|
||||
} catch (error) {
|
||||
if (!isNetworkUploadError(error)) throw error;
|
||||
lastError = error;
|
||||
const observed = await args.endpoints.getStatus(uploadId, args.signal);
|
||||
if (observed.state === "Completed") return observed;
|
||||
if (isTerminalMediaUploadState(observed.state)) {
|
||||
throwForTerminalState(observed.state);
|
||||
}
|
||||
if (observed.state === "Scanning") return waitForScanTerminal(args, uploadId);
|
||||
if (attempt + 1 < MAX_COMMIT_ATTEMPTS) {
|
||||
continue; // Commit is idempotent for this uploadId.
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function sendChunk(
|
||||
args: ResumableUploadArgs,
|
||||
uploadId: string,
|
||||
index: number,
|
||||
chunkSize: number,
|
||||
): Promise<MediaChunkAckDto> {
|
||||
const { file } = args;
|
||||
const start = index * chunkSize;
|
||||
const end = Math.min(start + chunkSize, file.size);
|
||||
const blob = file.slice(start, end);
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const digest = args.digest ?? computeSha256Hex;
|
||||
const sha256 = await digest(buffer);
|
||||
return args.endpoints.putChunk({
|
||||
uploadId,
|
||||
index,
|
||||
blob,
|
||||
start,
|
||||
end,
|
||||
totalBytes: file.size,
|
||||
sha256,
|
||||
signal: args.signal,
|
||||
});
|
||||
}
|
||||
|
||||
type ChunkRecoveryContext = {
|
||||
args: ResumableUploadArgs;
|
||||
session: MediaUploadSessionDto;
|
||||
index: number;
|
||||
chunkSize: number;
|
||||
recoveryAttempts: Map<number, number>;
|
||||
};
|
||||
|
||||
async function sendChunkWithRecovery({
|
||||
args,
|
||||
session,
|
||||
index,
|
||||
chunkSize,
|
||||
recoveryAttempts,
|
||||
}: ChunkRecoveryContext): Promise<{
|
||||
session: MediaUploadSessionDto;
|
||||
recovered: boolean;
|
||||
}> {
|
||||
try {
|
||||
const ack = await sendChunk(args, session.uploadId, index, chunkSize);
|
||||
const receivedChunks = Array.isArray(ack?.receivedChunks) ? ack.receivedChunks : [];
|
||||
if (!receivedChunks.includes(index)) {
|
||||
throw new Error("Chunk acknowledgement missing from server response.");
|
||||
}
|
||||
return { session: { ...session, receivedChunks }, recovered: false };
|
||||
} catch (error) {
|
||||
if (!isNetworkUploadError(error)) throw error;
|
||||
const recovered = await recoverSession(args.endpoints, session.uploadId, args.signal);
|
||||
if (recovered.receivedChunks.includes(index)) {
|
||||
recoveryAttempts.delete(index);
|
||||
return { session: recovered, recovered: true };
|
||||
}
|
||||
|
||||
const attempts = (recoveryAttempts.get(index) ?? 0) + 1;
|
||||
recoveryAttempts.set(index, attempts);
|
||||
if (attempts >= MAX_CHUNK_RECOVERY_ATTEMPTS) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, CHUNK_RECOVERY_BACKOFF_MS * attempts));
|
||||
assertNotAborted(args.signal);
|
||||
return { session: recovered, recovered: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload one file through the resumable session API: create → chunks → commit → poll.
|
||||
* One in-flight chunk request per file; durable progress counts only acknowledged parts.
|
||||
*/
|
||||
export async function uploadFileResumable(
|
||||
args: ResumableUploadArgs,
|
||||
): Promise<MediaUploadSessionDto> {
|
||||
assertNotAborted(args.signal);
|
||||
const createDto: MediaUploadCreateDto = {
|
||||
idempotencyKey: args.idempotencyKey ?? crypto.randomUUID(),
|
||||
fileName: args.file.name,
|
||||
contentType: args.file.type,
|
||||
sizeBytes: args.file.size,
|
||||
surface: args.surface,
|
||||
...(args.category ? { category: args.category } : {}),
|
||||
};
|
||||
|
||||
let session = await createSessionIdempotent(args.endpoints, createDto, args.signal);
|
||||
const uploadId = session.uploadId;
|
||||
const chunkSize =
|
||||
Number.isFinite(session.chunkSizeBytes) && session.chunkSizeBytes > 0
|
||||
? session.chunkSizeBytes
|
||||
: MEDIA_UPLOAD_CHUNK_SIZE_BYTES;
|
||||
const totalChunks = chunkCountFor(args.file.size, chunkSize);
|
||||
reportProgress(args, session.receivedChunks, args.file.size, chunkSize);
|
||||
|
||||
try {
|
||||
const recoveryAttempts = new Map<number, number>();
|
||||
for (;;) {
|
||||
assertNotAborted(args.signal);
|
||||
const missing = missingChunkIndices(session.receivedChunks, totalChunks);
|
||||
if (missing.length === 0) break;
|
||||
for (const index of missing) {
|
||||
assertNotAborted(args.signal);
|
||||
const result = await sendChunkWithRecovery({
|
||||
args,
|
||||
session,
|
||||
index,
|
||||
chunkSize,
|
||||
recoveryAttempts,
|
||||
});
|
||||
session = result.session;
|
||||
if (result.recovered) break;
|
||||
reportProgress(args, session.receivedChunks, args.file.size, chunkSize);
|
||||
}
|
||||
}
|
||||
|
||||
assertNotAborted(args.signal);
|
||||
session = await commitAndObserve(args, uploadId);
|
||||
} catch (error) {
|
||||
// Cancellation aborts the in-flight request and DELETEs the known session.
|
||||
if (isAbortError(error)) {
|
||||
await args.endpoints.cancel(uploadId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
throwForTerminalState(session.state);
|
||||
reportProgress(args, session.receivedChunks, args.file.size, chunkSize);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Upload a queue of files with at most `maxConcurrent` files in flight. */
|
||||
export async function runMediaUploadQueue<T>(
|
||||
items: readonly T[],
|
||||
runner: (item: T) => Promise<void>,
|
||||
maxConcurrent = 2,
|
||||
): Promise<void> {
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: Math.min(maxConcurrent, items.length) }, async () => {
|
||||
while (cursor < items.length) {
|
||||
const item = items[cursor];
|
||||
cursor += 1;
|
||||
await runner(item);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
|
@ -15,6 +15,14 @@ type SlideOverCompletionMediaProps = {
|
|||
onRemoveMedia?: (id: string) => void;
|
||||
onRetryMedia?: (localId: string) => void;
|
||||
onCancelMedia?: (localId: string) => void;
|
||||
uploadDisabled?: boolean;
|
||||
uploadUnavailableMessage?: string;
|
||||
typeAllowed?: (file: File) => boolean;
|
||||
typeErrorMessage?: string;
|
||||
acceptAttribute?: string;
|
||||
fileSizeError?: (file: File) => string | undefined;
|
||||
batchFileError?: (file: File, acceptedFiles: readonly File[]) => string | undefined;
|
||||
uploadHint?: string;
|
||||
};
|
||||
|
||||
export function SlideOverCompletionMedia({
|
||||
|
|
@ -26,6 +34,14 @@ export function SlideOverCompletionMedia({
|
|||
onRemoveMedia,
|
||||
onRetryMedia,
|
||||
onCancelMedia,
|
||||
uploadDisabled = false,
|
||||
uploadUnavailableMessage,
|
||||
typeAllowed,
|
||||
typeErrorMessage,
|
||||
acceptAttribute,
|
||||
fileSizeError,
|
||||
batchFileError,
|
||||
uploadHint,
|
||||
}: SlideOverCompletionMediaProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -45,8 +61,20 @@ export function SlideOverCompletionMedia({
|
|||
onCancel={onCancelMedia}
|
||||
readOnly={isReadOnly}
|
||||
pending={mediaPending}
|
||||
uploadDisabled={uploadDisabled}
|
||||
typeAllowed={typeAllowed}
|
||||
typeErrorMessage={typeErrorMessage}
|
||||
acceptAttribute={acceptAttribute}
|
||||
fileSizeError={fileSizeError}
|
||||
batchFileError={batchFileError}
|
||||
uploadHint={uploadHint}
|
||||
/>
|
||||
</div>
|
||||
{uploadDisabled && Boolean(uploadUnavailableMessage) && (
|
||||
<div role="status" className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{uploadUnavailableMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,14 @@ type SlideOverCompletionTabProps = {
|
|||
onRemoveMedia?: (id: string) => void;
|
||||
onRetryMedia?: (localId: string) => void;
|
||||
onCancelMedia?: (localId: string) => void;
|
||||
uploadDisabled?: boolean;
|
||||
uploadUnavailableMessage?: string;
|
||||
typeAllowed?: (file: File) => boolean;
|
||||
typeErrorMessage?: string;
|
||||
acceptAttribute?: string;
|
||||
fileSizeError?: (file: File) => string | undefined;
|
||||
batchFileError?: (file: File, acceptedFiles: readonly File[]) => string | undefined;
|
||||
uploadHint?: string;
|
||||
onUploadCompletionPdf?: (file: File) => void;
|
||||
onOpenCompletionDoc: () => void;
|
||||
};
|
||||
|
|
@ -46,6 +54,14 @@ export function SlideOverCompletionTab({
|
|||
onRemoveMedia,
|
||||
onRetryMedia,
|
||||
onCancelMedia,
|
||||
uploadDisabled,
|
||||
uploadUnavailableMessage,
|
||||
typeAllowed,
|
||||
typeErrorMessage,
|
||||
acceptAttribute,
|
||||
fileSizeError,
|
||||
batchFileError,
|
||||
uploadHint,
|
||||
onUploadCompletionPdf,
|
||||
onOpenCompletionDoc,
|
||||
}: SlideOverCompletionTabProps) {
|
||||
|
|
@ -65,6 +81,14 @@ export function SlideOverCompletionTab({
|
|||
onRemoveMedia={onRemoveMedia}
|
||||
onRetryMedia={onRetryMedia}
|
||||
onCancelMedia={onCancelMedia}
|
||||
uploadDisabled={uploadDisabled}
|
||||
uploadUnavailableMessage={uploadUnavailableMessage}
|
||||
typeAllowed={typeAllowed}
|
||||
typeErrorMessage={typeErrorMessage}
|
||||
acceptAttribute={acceptAttribute}
|
||||
fileSizeError={fileSizeError}
|
||||
batchFileError={batchFileError}
|
||||
uploadHint={uploadHint}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,35 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type {
|
||||
useDeleteWorkOrderMedia,
|
||||
useUpdateWorkOrderMediaCategory,
|
||||
useUploadWorkOrderMedia,
|
||||
UploadWorkOrderMediaInput,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { useResumableWorkOrderMediaUpload } from "@/domain/work-orders/use-cases/use-resumable-work-order-media-upload";
|
||||
import { useWorkOrderMediaPolicy } from "@/domain/work-orders/use-cases/use-work-order-media-policy";
|
||||
import {
|
||||
hasCompletionMediaGallery,
|
||||
isCompletionSurfaceMedia,
|
||||
isExtrasSurfaceMedia,
|
||||
type MediaSurfaceOwnership,
|
||||
} from "@/domain/work-orders/utils/partition-slide-over-media";
|
||||
import {
|
||||
classifyMediaKind,
|
||||
emergencyMediaBatchCountError,
|
||||
emergencyMediaAcceptAttribute,
|
||||
emergencyMediaSizeError,
|
||||
isEmergencyReactiveWorkOrderType,
|
||||
} from "@/domain/work-orders/utils/emergency-media-limits";
|
||||
|
||||
type UseSlideOverMediaStateArgs = {
|
||||
detailForRow?: WorkOrderBoardDetail;
|
||||
row?: WorkOrderTableRow | null;
|
||||
uploadMediaMutation: ReturnType<typeof useUploadWorkOrderMedia>;
|
||||
deleteMediaMutation: ReturnType<typeof useDeleteWorkOrderMedia>;
|
||||
categorizeMediaMutation: ReturnType<typeof useUpdateWorkOrderMediaCategory>;
|
||||
|
|
@ -74,15 +84,7 @@ function saveOwnership(
|
|||
}
|
||||
}
|
||||
|
||||
export function useSlideOverMediaState({
|
||||
detailForRow,
|
||||
uploadMediaMutation,
|
||||
deleteMediaMutation,
|
||||
categorizeMediaMutation,
|
||||
}: UseSlideOverMediaStateArgs) {
|
||||
const workOrderId = detailForRow?.info.id;
|
||||
const completionHasGallery = hasCompletionMediaGallery(detailForRow?.info.type);
|
||||
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
||||
function useMediaOwnership(workOrderId: string | number | undefined) {
|
||||
const [ownership, setOwnershipState] = useState<MediaSurfaceOwnership>(() =>
|
||||
loadOwnership(workOrderId),
|
||||
);
|
||||
|
|
@ -102,8 +104,46 @@ export function useSlideOverMediaState({
|
|||
[workOrderId],
|
||||
);
|
||||
|
||||
const persistCompletionUpload = useCallback(
|
||||
async (input: UploadWorkOrderMediaInput) => {
|
||||
return { ownership, updateOwnership };
|
||||
}
|
||||
|
||||
type OwnershipUpdater = (update: (current: MediaSurfaceOwnership) => MediaSurfaceOwnership) => void;
|
||||
|
||||
type CompletionUploadArgs = {
|
||||
emergencyMedia: boolean;
|
||||
policyCanUpload: boolean;
|
||||
resumableCompletionUpload: ReturnType<typeof useResumableWorkOrderMediaUpload>;
|
||||
uploadMediaMutation: ReturnType<typeof useUploadWorkOrderMedia>;
|
||||
updateOwnership: OwnershipUpdater;
|
||||
};
|
||||
|
||||
function usePersistCompletionUpload({
|
||||
emergencyMedia,
|
||||
policyCanUpload,
|
||||
resumableCompletionUpload,
|
||||
uploadMediaMutation,
|
||||
updateOwnership,
|
||||
}: CompletionUploadArgs) {
|
||||
return useCallback(
|
||||
async (input: UploadWorkOrderMediaInput): Promise<{ id: string }> => {
|
||||
if (emergencyMedia) {
|
||||
if (!policyCanUpload) {
|
||||
throw new Error("Uploads are not available for this work order.");
|
||||
}
|
||||
const session = await resumableCompletionUpload.mutateAsync(input);
|
||||
const attachmentId = session.attachment?.id;
|
||||
if (attachmentId == null) {
|
||||
throw new Error(
|
||||
"Upload completed but no media item was returned. Refresh and try again.",
|
||||
);
|
||||
}
|
||||
const mediaId = String(attachmentId);
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: addOwnedId(new Set(current.completionOwnedIds), mediaId),
|
||||
extraOwnedIds: dropOwnedId(new Set(current.extraOwnedIds), mediaId),
|
||||
}));
|
||||
return { id: mediaId };
|
||||
}
|
||||
const item = await uploadMediaMutation.mutateAsync(input);
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: addOwnedId(new Set(current.completionOwnedIds), item.id),
|
||||
|
|
@ -111,11 +151,212 @@ export function useSlideOverMediaState({
|
|||
}));
|
||||
return item;
|
||||
},
|
||||
[updateOwnership, uploadMediaMutation],
|
||||
[
|
||||
emergencyMedia,
|
||||
policyCanUpload,
|
||||
resumableCompletionUpload,
|
||||
updateOwnership,
|
||||
uploadMediaMutation,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
type SlideOverActionArgs = {
|
||||
detailForRow: WorkOrderBoardDetail | undefined;
|
||||
isLocal: (id: string) => boolean;
|
||||
removeLocal: (id: string) => void;
|
||||
updateOwnership: OwnershipUpdater;
|
||||
uploadMediaMutation: ReturnType<typeof useUploadWorkOrderMedia>;
|
||||
deleteMediaMutation: ReturnType<typeof useDeleteWorkOrderMedia>;
|
||||
categorizeMediaMutation: ReturnType<typeof useUpdateWorkOrderMediaCategory>;
|
||||
};
|
||||
|
||||
function useSlideOverMutationActions({
|
||||
detailForRow,
|
||||
isLocal,
|
||||
removeLocal,
|
||||
updateOwnership,
|
||||
uploadMediaMutation,
|
||||
deleteMediaMutation,
|
||||
categorizeMediaMutation,
|
||||
}: SlideOverActionArgs) {
|
||||
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
||||
|
||||
const uploadExtraFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
setMediaBatchPending(true);
|
||||
try {
|
||||
for (const file of files) {
|
||||
// Extra Docs stay on the legacy route and change category only by explicit PATCH.
|
||||
const item = await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), item.id),
|
||||
extraOwnedIds: addOwnedId(new Set(current.extraOwnedIds), item.id),
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
setMediaBatchPending(false);
|
||||
}
|
||||
},
|
||||
[uploadMediaMutation, updateOwnership],
|
||||
);
|
||||
|
||||
const categorizeMedia = useCallback(
|
||||
(mediaId: string, category: UploadableMediaCategory) => {
|
||||
const workOrderVersion = detailForRow?.info.rowVersion;
|
||||
if (!workOrderVersion) {
|
||||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
categorizeMediaMutation.mutate(
|
||||
{ mediaId, category, workOrderVersion },
|
||||
{
|
||||
onSuccess: () => {
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), mediaId),
|
||||
extraOwnedIds:
|
||||
category === "Extra"
|
||||
? addOwnedId(new Set(current.extraOwnedIds), mediaId)
|
||||
: dropOwnedId(new Set(current.extraOwnedIds), mediaId),
|
||||
}));
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[detailForRow?.info.rowVersion, categorizeMediaMutation, updateOwnership],
|
||||
);
|
||||
|
||||
const removeMedia = useCallback(
|
||||
(id: string) => {
|
||||
if (isLocal(id)) {
|
||||
removeLocal(id);
|
||||
return;
|
||||
}
|
||||
const workOrderVersion = detailForRow?.info.rowVersion;
|
||||
if (!workOrderVersion) {
|
||||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
deleteMediaMutation.mutate({ mediaId: id, workOrderVersion });
|
||||
},
|
||||
[detailForRow?.info.rowVersion, isLocal, removeLocal, deleteMediaMutation],
|
||||
);
|
||||
|
||||
return { mediaBatchPending, uploadExtraFiles, categorizeMedia, removeMedia };
|
||||
}
|
||||
|
||||
function hasAnyCompletionGallery(...types: Array<string | undefined>): boolean {
|
||||
return types.some(hasCompletionMediaGallery);
|
||||
}
|
||||
|
||||
function hasEmergencyMediaType(...types: Array<string | undefined>): boolean {
|
||||
return types.some(isEmergencyReactiveWorkOrderType);
|
||||
}
|
||||
|
||||
function getEmergencyUploaderOptions(
|
||||
emergencyMedia: boolean,
|
||||
policy?: Parameters<typeof emergencyMediaBatchCountError>[2],
|
||||
) {
|
||||
if (!emergencyMedia) {
|
||||
return {
|
||||
emergencyMediaTypeAllowed: undefined,
|
||||
emergencyMediaTypeErrorMessage: undefined,
|
||||
emergencyMediaAcceptAttribute: undefined,
|
||||
emergencyMediaSizeError: undefined,
|
||||
emergencyMediaBatchFileError: undefined,
|
||||
emergencyMediaUploadHint: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
emergencyMediaTypeAllowed: (file: File) => classifyMediaKind(file) !== null,
|
||||
emergencyMediaTypeErrorMessage:
|
||||
"Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported.",
|
||||
emergencyMediaAcceptAttribute: emergencyMediaAcceptAttribute(),
|
||||
emergencyMediaSizeError: (file: File) => emergencyMediaSizeError(file, policy?.limits),
|
||||
emergencyMediaBatchFileError: (file: File, acceptedFiles: readonly File[]) =>
|
||||
emergencyMediaBatchCountError(file, acceptedFiles, policy),
|
||||
emergencyMediaUploadHint: "Photos up to 10 MB · Videos up to 100 MB and 90 seconds",
|
||||
};
|
||||
}
|
||||
|
||||
function isAnyMediaMutationPending(...states: boolean[]): boolean {
|
||||
return states.some(Boolean);
|
||||
}
|
||||
|
||||
function getEmergencyMediaUnavailableMessage(
|
||||
canUpload: boolean | undefined,
|
||||
policyError: boolean,
|
||||
policyLoading: boolean,
|
||||
hasPolicy: boolean,
|
||||
): string {
|
||||
if (canUpload === false) return "Uploads are not available for this work order.";
|
||||
if (policyError) return "Upload permissions could not be loaded. Refresh and try again.";
|
||||
if (policyLoading || !hasPolicy) return "Loading upload permissions…";
|
||||
return "Uploads are not available for this work order.";
|
||||
}
|
||||
|
||||
function isPolicyUploadAllowed(canUpload: boolean | undefined, policyError: boolean): boolean {
|
||||
return canUpload === true && !policyError;
|
||||
}
|
||||
|
||||
function isEmergencyUploadUnavailable(emergencyMedia: boolean, policyCanUpload: boolean): boolean {
|
||||
return emergencyMedia && !policyCanUpload;
|
||||
}
|
||||
|
||||
export function useSlideOverMediaState({
|
||||
detailForRow,
|
||||
row,
|
||||
uploadMediaMutation,
|
||||
deleteMediaMutation,
|
||||
categorizeMediaMutation,
|
||||
}: UseSlideOverMediaStateArgs) {
|
||||
const workOrderId = detailForRow?.info.id ?? row?.id;
|
||||
const workOrderType = detailForRow?.info.type ?? row?.type;
|
||||
const {
|
||||
data: mediaPolicy,
|
||||
isError: mediaPolicyError,
|
||||
isLoading: mediaPolicyLoading,
|
||||
} = useWorkOrderMediaPolicy(workOrderId, workOrderId != null);
|
||||
const resumableCompletionUpload = useResumableWorkOrderMediaUpload(
|
||||
workOrderId ?? "",
|
||||
"CompletionMedia",
|
||||
mediaPolicy ?? undefined,
|
||||
);
|
||||
const policyCanUpload = isPolicyUploadAllowed(mediaPolicy?.canUpload, mediaPolicyError);
|
||||
const completionHasGallery = hasAnyCompletionGallery(
|
||||
detailForRow?.info.type,
|
||||
mediaPolicy?.workOrderType,
|
||||
row?.type,
|
||||
);
|
||||
const emergencyMedia = hasEmergencyMediaType(mediaPolicy?.workOrderType, workOrderType);
|
||||
const emergencyMediaUnavailable = isEmergencyUploadUnavailable(emergencyMedia, policyCanUpload);
|
||||
const emergencyMediaUnavailableMessage = getEmergencyMediaUnavailableMessage(
|
||||
mediaPolicy?.canUpload,
|
||||
mediaPolicyError,
|
||||
mediaPolicyLoading,
|
||||
Boolean(mediaPolicy),
|
||||
);
|
||||
const { ownership, updateOwnership } = useMediaOwnership(workOrderId);
|
||||
const persistCompletionUpload = usePersistCompletionUpload({
|
||||
emergencyMedia,
|
||||
policyCanUpload,
|
||||
resumableCompletionUpload,
|
||||
uploadMediaMutation,
|
||||
updateOwnership,
|
||||
});
|
||||
|
||||
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||
useMediaUploadSession(detailForRow, persistCompletionUpload);
|
||||
const { mediaBatchPending, uploadExtraFiles, categorizeMedia, removeMedia } =
|
||||
useSlideOverMutationActions({
|
||||
detailForRow,
|
||||
isLocal,
|
||||
removeLocal,
|
||||
updateOwnership,
|
||||
uploadMediaMutation,
|
||||
deleteMediaMutation,
|
||||
categorizeMediaMutation,
|
||||
});
|
||||
|
||||
const completionMedia = useMemo(
|
||||
() => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership, completionHasGallery)),
|
||||
|
|
@ -131,73 +372,25 @@ export function useSlideOverMediaState({
|
|||
);
|
||||
|
||||
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
|
||||
const mediaPending =
|
||||
mediaBatchPending ||
|
||||
hasLocalUploading ||
|
||||
uploadMediaMutation.isPending ||
|
||||
deleteMediaMutation.isPending ||
|
||||
categorizeMediaMutation.isPending;
|
||||
|
||||
const uploadMediaRequests = (requests: MediaUploadRequest[]) => {
|
||||
startUploads(requests);
|
||||
};
|
||||
|
||||
const uploadExtraFiles = async (files: File[]) => {
|
||||
setMediaBatchPending(true);
|
||||
try {
|
||||
for (const file of files) {
|
||||
// Upload stays Extra; Aveta is applied only by an explicit Extra Docs PATCH.
|
||||
const item = await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), item.id),
|
||||
extraOwnedIds: addOwnedId(new Set(current.extraOwnedIds), item.id),
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
setMediaBatchPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const categorizeMedia = (mediaId: string, category: UploadableMediaCategory) => {
|
||||
const workOrderVersion = detailForRow?.info.rowVersion;
|
||||
if (!workOrderVersion) {
|
||||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
categorizeMediaMutation.mutate(
|
||||
{ mediaId, category, workOrderVersion },
|
||||
{
|
||||
onSuccess: () => {
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), mediaId),
|
||||
extraOwnedIds:
|
||||
category === "Extra"
|
||||
? addOwnedId(new Set(current.extraOwnedIds), mediaId)
|
||||
: dropOwnedId(new Set(current.extraOwnedIds), mediaId),
|
||||
}));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const removeMedia = (id: string) => {
|
||||
if (isLocal(id)) {
|
||||
removeLocal(id);
|
||||
return;
|
||||
}
|
||||
const workOrderVersion = detailForRow?.info.rowVersion;
|
||||
if (!workOrderVersion) {
|
||||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
deleteMediaMutation.mutate({ mediaId: id, workOrderVersion });
|
||||
};
|
||||
const mediaPending = isAnyMediaMutationPending(
|
||||
mediaBatchPending,
|
||||
hasLocalUploading,
|
||||
uploadMediaMutation.isPending,
|
||||
resumableCompletionUpload.isPending,
|
||||
deleteMediaMutation.isPending,
|
||||
categorizeMediaMutation.isPending,
|
||||
);
|
||||
const emergencyUploaderOptions = getEmergencyUploaderOptions(emergencyMedia, mediaPolicy);
|
||||
|
||||
return {
|
||||
completionMedia,
|
||||
extrasUploads,
|
||||
mediaPending,
|
||||
uploadMediaRequests,
|
||||
emergencyMedia,
|
||||
emergencyMediaUnavailable,
|
||||
emergencyMediaUnavailableMessage,
|
||||
...emergencyUploaderOptions,
|
||||
uploadMediaRequests: startUploads,
|
||||
uploadExtraFiles,
|
||||
categorizeMedia,
|
||||
retryUpload,
|
||||
|
|
|
|||
|
|
@ -119,6 +119,14 @@ export function useWorkOrderSlideOver({
|
|||
completionMedia,
|
||||
extrasUploads,
|
||||
mediaPending,
|
||||
emergencyMediaUnavailable,
|
||||
emergencyMediaUnavailableMessage,
|
||||
emergencyMediaTypeAllowed,
|
||||
emergencyMediaTypeErrorMessage,
|
||||
emergencyMediaAcceptAttribute,
|
||||
emergencyMediaSizeError,
|
||||
emergencyMediaBatchFileError,
|
||||
emergencyMediaUploadHint,
|
||||
uploadMediaRequests,
|
||||
uploadExtraFiles,
|
||||
categorizeMedia,
|
||||
|
|
@ -127,6 +135,7 @@ export function useWorkOrderSlideOver({
|
|||
removeMedia,
|
||||
} = useSlideOverMediaState({
|
||||
detailForRow,
|
||||
row,
|
||||
uploadMediaMutation,
|
||||
deleteMediaMutation,
|
||||
categorizeMediaMutation,
|
||||
|
|
@ -175,6 +184,14 @@ export function useWorkOrderSlideOver({
|
|||
completionMedia,
|
||||
extrasUploads,
|
||||
mediaPending,
|
||||
emergencyMediaUnavailable,
|
||||
emergencyMediaUnavailableMessage,
|
||||
emergencyMediaTypeAllowed,
|
||||
emergencyMediaTypeErrorMessage,
|
||||
emergencyMediaAcceptAttribute,
|
||||
emergencyMediaSizeError,
|
||||
emergencyMediaBatchFileError,
|
||||
emergencyMediaUploadHint,
|
||||
completionPending,
|
||||
closeDisabled,
|
||||
uploadMediaRequests,
|
||||
|
|
|
|||
|
|
@ -126,6 +126,14 @@ export function WorkOrderSlideOverTabs({
|
|||
completion={state.detailForRow?.completion}
|
||||
media={state.completionMedia}
|
||||
mediaPending={state.mediaPending}
|
||||
uploadDisabled={state.emergencyMediaUnavailable}
|
||||
uploadUnavailableMessage={state.emergencyMediaUnavailableMessage}
|
||||
typeAllowed={state.emergencyMediaTypeAllowed}
|
||||
typeErrorMessage={state.emergencyMediaTypeErrorMessage}
|
||||
acceptAttribute={state.emergencyMediaAcceptAttribute}
|
||||
fileSizeError={state.emergencyMediaSizeError}
|
||||
batchFileError={state.emergencyMediaBatchFileError}
|
||||
uploadHint={state.emergencyMediaUploadHint}
|
||||
completionPending={state.completionPending}
|
||||
onUploadMedia={(requests) => {
|
||||
state.uploadMediaRequests(requests);
|
||||
|
|
|
|||
|
|
@ -9,11 +9,20 @@ import { Text } from "@/components/ui/text";
|
|||
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { useResumableWorkOrderMediaUpload } from "@/domain/work-orders/use-cases/use-resumable-work-order-media-upload";
|
||||
import { useWorkOrderMediaPolicy } from "@/domain/work-orders/use-cases/use-work-order-media-policy";
|
||||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useDeleteWorkOrderMedia,
|
||||
useUploadWorkOrderMedia,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import {
|
||||
classifyMediaKind,
|
||||
emergencyMediaBatchCountError,
|
||||
emergencyMediaAcceptAttribute,
|
||||
emergencyMediaSizeError,
|
||||
isEmergencyReactiveWorkOrderType,
|
||||
} from "@/domain/work-orders/utils/emergency-media-limits";
|
||||
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||
|
||||
|
|
@ -31,16 +40,65 @@ function resolveWorkOrderVersion(
|
|||
return version || null;
|
||||
}
|
||||
|
||||
function isEmergencyReactive(row: WorkOrderTableRow, detailType?: string, policyType?: string) {
|
||||
return (
|
||||
isEmergencyReactiveWorkOrderType(detailType) ||
|
||||
isEmergencyReactiveWorkOrderType(policyType) ||
|
||||
isEmergencyReactiveWorkOrderType(row.type)
|
||||
);
|
||||
}
|
||||
|
||||
function emergencyPolicyMessage(
|
||||
canUpload: boolean | undefined,
|
||||
isError: boolean,
|
||||
isLoading: boolean,
|
||||
): string {
|
||||
if (canUpload === false) return "Uploads are not available for this work order.";
|
||||
if (isError) return "Upload permissions could not be loaded. Refresh and try again.";
|
||||
if (isLoading) return "Loading upload permissions…";
|
||||
return "Uploads are not available for this work order.";
|
||||
}
|
||||
|
||||
function getEmergencyUploaderOptions(
|
||||
isEmergency: boolean,
|
||||
policy?: Parameters<typeof emergencyMediaBatchCountError>[2],
|
||||
) {
|
||||
if (!isEmergency) return {};
|
||||
return {
|
||||
fileSizeError: (file: File) => emergencyMediaSizeError(file, policy?.limits),
|
||||
batchFileError: (file: File, acceptedFiles: readonly File[]) =>
|
||||
emergencyMediaBatchCountError(file, acceptedFiles, policy),
|
||||
uploadHint: "Photos up to 10 MB · Videos up to 100 MB and 90 seconds",
|
||||
typeAllowed: (file: File) => classifyMediaKind(file) !== null,
|
||||
typeErrorMessage: "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported.",
|
||||
acceptAttribute: emergencyMediaAcceptAttribute(),
|
||||
};
|
||||
}
|
||||
|
||||
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
|
||||
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
|
||||
const uploadMutation = useUploadWorkOrderMedia(row.id);
|
||||
const {
|
||||
data: policy,
|
||||
isError: policyError,
|
||||
isLoading: policyLoading,
|
||||
} = useWorkOrderMediaPolicy(row.id, open);
|
||||
const legacyUploadMutation = useUploadWorkOrderMedia(row.id);
|
||||
const resumableUploadMutation = useResumableWorkOrderMediaUpload(
|
||||
row.id,
|
||||
"WorkOrderTable",
|
||||
policyError ? undefined : (policy ?? undefined),
|
||||
);
|
||||
const deleteMutation = useDeleteWorkOrderMedia(row.id);
|
||||
const isEmergency = isEmergencyReactive(row, detail?.info.type, policy?.workOrderType);
|
||||
const uploadMutation = isEmergency ? resumableUploadMutation : legacyUploadMutation;
|
||||
const emergencyUploadUnavailable = isEmergency && (policy?.canUpload !== true || policyError);
|
||||
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
|
||||
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||
useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
|
||||
|
||||
const pending = uploadMutation.isPending || deleteMutation.isPending;
|
||||
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
|
||||
const emergencyUploaderOptions = getEmergencyUploaderOptions(isEmergency, policy);
|
||||
|
||||
const requireVersion = (): string | null => {
|
||||
const version = resolveWorkOrderVersion(detail?.info.rowVersion, row.rowVersion);
|
||||
|
|
@ -105,9 +163,18 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
onRetry={retryUpload}
|
||||
onCancel={cancelUpload}
|
||||
readOnly={isReadOnly}
|
||||
uploadDisabled={emergencyUploadUnavailable}
|
||||
{...emergencyUploaderOptions}
|
||||
pending={pending}
|
||||
/>
|
||||
)}
|
||||
{emergencyUploadUnavailable && !isReadOnly && (
|
||||
<div role="status" className="mt-2">
|
||||
<Text variant="description">
|
||||
{emergencyPolicyMessage(policy?.canUpload, policyError, policyLoading || !policy)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
|
|
|
|||
|
|
@ -8,21 +8,27 @@ import {
|
|||
type MediaUploaderDropzoneProps = {
|
||||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
pending: boolean;
|
||||
disabled?: boolean;
|
||||
helperText?: string;
|
||||
categorizeAfterUpload?: boolean;
|
||||
acceptAttribute?: string;
|
||||
onFilesSelected: (files: FileList | null) => void;
|
||||
};
|
||||
|
||||
export function MediaUploaderDropzone({
|
||||
inputRef,
|
||||
pending,
|
||||
disabled = false,
|
||||
helperText,
|
||||
categorizeAfterUpload = false,
|
||||
acceptAttribute,
|
||||
onFilesSelected,
|
||||
}: MediaUploaderDropzoneProps) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
disabled={pending || disabled}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
|
|
@ -41,17 +47,18 @@ export function MediaUploaderDropzone({
|
|||
{pending ? "Uploading…" : "Drag files here or click to browse"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit().replace(" ", "")} per file
|
||||
{helperText ??
|
||||
`Accepted: JPG, PNG, MP4, MOV · Max ${formatMediaSizeLimit().replace(" ", "")} per file`}
|
||||
{categorizeAfterUpload ? " · categorize after upload" : ""}
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={workOrderMediaAcceptAttribute()}
|
||||
accept={acceptAttribute ?? workOrderMediaAcceptAttribute()}
|
||||
multiple
|
||||
hidden
|
||||
disabled={pending}
|
||||
disabled={pending || disabled}
|
||||
onChange={(e) => {
|
||||
onFilesSelected(e.target.files);
|
||||
e.target.value = "";
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export type MediaUploadRequest = {
|
|||
localId: string;
|
||||
file: File;
|
||||
onProgress: (percent: number) => void;
|
||||
idempotencyKey?: string;
|
||||
};
|
||||
|
||||
type MediaUploaderProps = {
|
||||
|
|
@ -30,6 +31,14 @@ type MediaUploaderProps = {
|
|||
onChange?: (next: MediaFile[]) => void;
|
||||
readOnly?: boolean;
|
||||
pending?: boolean;
|
||||
uploadDisabled?: boolean;
|
||||
/** Emergency/Reactive override: policy-driven type check incl. HEIC. */
|
||||
typeAllowed?: (file: File) => boolean;
|
||||
typeErrorMessage?: string;
|
||||
acceptAttribute?: string;
|
||||
fileSizeError?: (file: File) => string | undefined;
|
||||
batchFileError?: (file: File, acceptedFiles: readonly File[]) => string | undefined;
|
||||
uploadHint?: string;
|
||||
};
|
||||
|
||||
export function MediaUploader({
|
||||
|
|
@ -42,6 +51,13 @@ export function MediaUploader({
|
|||
onChange,
|
||||
readOnly = false,
|
||||
pending = false,
|
||||
uploadDisabled = false,
|
||||
typeAllowed,
|
||||
typeErrorMessage = "Unsupported file type.",
|
||||
acceptAttribute,
|
||||
fileSizeError,
|
||||
batchFileError,
|
||||
uploadHint,
|
||||
}: MediaUploaderProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
|
|
@ -50,8 +66,19 @@ export function MediaUploader({
|
|||
|
||||
const accepted: File[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
if (!isWorkOrderMediaTypeAllowed(file)) {
|
||||
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
|
||||
const typeOk = typeAllowed ? typeAllowed(file) : isWorkOrderMediaTypeAllowed(file);
|
||||
if (!typeOk) {
|
||||
toast.error(`${file.name} — ${typeErrorMessage}`);
|
||||
continue;
|
||||
}
|
||||
const sizeError = fileSizeError?.(file);
|
||||
if (sizeError) {
|
||||
toast.error(sizeError);
|
||||
continue;
|
||||
}
|
||||
const batchError = batchFileError?.(file, accepted);
|
||||
if (batchError) {
|
||||
toast.error(batchError);
|
||||
continue;
|
||||
}
|
||||
if (isWorkOrderFileTooLarge(file)) {
|
||||
|
|
@ -94,7 +121,10 @@ export function MediaUploader({
|
|||
<MediaUploaderDropzone
|
||||
inputRef={inputRef}
|
||||
pending={pending}
|
||||
disabled={uploadDisabled}
|
||||
helperText={uploadHint}
|
||||
categorizeAfterUpload={Boolean(onCategorize)}
|
||||
acceptAttribute={acceptAttribute}
|
||||
onFilesSelected={addFiles}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,19 @@ import type { MediaUploadRequest } from "@/app/(protected)/workorders/_component
|
|||
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import { runMediaUploadQueue } from "@/api/media-upload-engine";
|
||||
import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils";
|
||||
|
||||
type LocalUpload = MediaFile & { sourceFile?: File; signal?: AbortSignal };
|
||||
type LocalUpload = MediaFile & {
|
||||
sourceFile?: File;
|
||||
signal?: AbortSignal;
|
||||
idempotencyKey: string;
|
||||
};
|
||||
|
||||
type UploadMutate = (input: {
|
||||
file: File;
|
||||
signal?: AbortSignal;
|
||||
idempotencyKey: string;
|
||||
onProgress?: (percent: number) => void;
|
||||
}) => Promise<unknown>;
|
||||
|
||||
|
|
@ -17,6 +23,7 @@ type SessionUploadRequest = {
|
|||
localId: string;
|
||||
file: File;
|
||||
signal: AbortSignal;
|
||||
idempotencyKey: string;
|
||||
};
|
||||
|
||||
function revokeBlobUrl(url: string | undefined) {
|
||||
|
|
@ -38,6 +45,7 @@ function toLocalUpload(request: SessionUploadRequest): LocalUpload {
|
|||
uploadStatus: "uploading" as const,
|
||||
sourceFile: request.file,
|
||||
signal: request.signal,
|
||||
idempotencyKey: request.idempotencyKey,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -148,32 +156,38 @@ export function useMediaUploadSession(
|
|||
localId: request.localId,
|
||||
file: request.file,
|
||||
signal: replaceController(request.localId).signal,
|
||||
idempotencyKey:
|
||||
request.idempotencyKey ??
|
||||
localUploads.find((item) => item.id === request.localId)?.idempotencyKey ??
|
||||
crypto.randomUUID(),
|
||||
}));
|
||||
|
||||
const entries = sessionRequests.map(toLocalUpload);
|
||||
setLocalUploads((prev) => upsertUploads(prev, entries));
|
||||
|
||||
for (const request of sessionRequests) {
|
||||
void uploadMutateAsync({
|
||||
file: request.file,
|
||||
signal: request.signal,
|
||||
onProgress: (percent) => {
|
||||
setLocalUploads((prev) =>
|
||||
prev.map((item) =>
|
||||
isSameAttempt(item, request) ? { ...item, progress: percent } : item,
|
||||
),
|
||||
);
|
||||
},
|
||||
})
|
||||
.then(() => settleAttempt(request))
|
||||
.catch((error: Error) => {
|
||||
if (error.name === "AbortError") {
|
||||
settleAttempt(request);
|
||||
return;
|
||||
}
|
||||
failAttempt(request, error);
|
||||
void runMediaUploadQueue(sessionRequests, async (request) => {
|
||||
try {
|
||||
await uploadMutateAsync({
|
||||
file: request.file,
|
||||
signal: request.signal,
|
||||
idempotencyKey: request.idempotencyKey,
|
||||
onProgress: (percent) => {
|
||||
setLocalUploads((prev) =>
|
||||
prev.map((item) =>
|
||||
isSameAttempt(item, request) ? { ...item, progress: percent } : item,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
settleAttempt(request);
|
||||
} catch (error) {
|
||||
if ((error as Error).name === "AbortError") {
|
||||
settleAttempt(request);
|
||||
return;
|
||||
}
|
||||
failAttempt(request, error as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function retryUpload(localId: string) {
|
||||
|
|
@ -184,6 +198,7 @@ export function useMediaUploadSession(
|
|||
localId,
|
||||
file: target.sourceFile,
|
||||
onProgress: () => undefined,
|
||||
idempotencyKey: target.idempotencyKey,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { ChecklistSection } from "@/app/v/[token]/dispatch/_components/checklist
|
|||
import { CommentsSection } from "@/app/v/[token]/dispatch/_components/comments-section";
|
||||
import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section";
|
||||
import { UpliftRequestsSection } from "@/app/v/[token]/dispatch/_components/uplift-requests-section";
|
||||
import { VendorMediaCard } from "@/app/v/[token]/dispatch/_components/vendor-media-section";
|
||||
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
|
||||
import type {
|
||||
VendorPortalDispatchDetail,
|
||||
|
|
@ -61,6 +62,8 @@ export function DispatchDetailSections({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<VendorMediaCard token={token} dispatchId={dispatchId} locked={locked} />
|
||||
|
||||
<div className="vp-card">
|
||||
<Text variant="heading">NTE Uplift Requests</Text>
|
||||
<UpliftRequestsSection
|
||||
|
|
|
|||
327
src/app/v/[token]/dispatch/_components/vendor-media-section.tsx
Normal file
327
src/app/v/[token]/dispatch/_components/vendor-media-section.tsx
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { VendorPortalFeedback } from "@/app/v/_components/vendor-portal-feedback";
|
||||
import { vendorPortalMediaApi } from "@/domain/vendor-portal/api/vendor-portal-media-api";
|
||||
import type {
|
||||
VendorMediaItem,
|
||||
VendorPortalMedia,
|
||||
} from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { isRetryableMediaUploadError } from "@/api/media-upload-client";
|
||||
import { uploadFileResumable } from "@/api/media-upload-engine";
|
||||
import { validateMediaFileAgainstLimits } from "@/domain/work-orders/utils/emergency-media-limits";
|
||||
import {
|
||||
MediaUploadValidationError,
|
||||
safeMediaUploadErrorMessage,
|
||||
} from "@/api/media-upload-contracts";
|
||||
|
||||
type VendorMediaSectionProps = {
|
||||
token: string;
|
||||
dispatchId: number;
|
||||
locked: boolean;
|
||||
};
|
||||
|
||||
type MediaLoadState = {
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
media?: VendorPortalMedia;
|
||||
};
|
||||
|
||||
function formatBytes(sizeBytes: number): string {
|
||||
if (sizeBytes >= 1_000_000) {
|
||||
return `${Math.round(sizeBytes / 1_000_000)} MB`;
|
||||
}
|
||||
return `${Math.max(1, Math.round(sizeBytes / 1_000))} KB`;
|
||||
}
|
||||
|
||||
function viewMediaItem(item: VendorMediaItem, blob: Blob, previewWindow: Window): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
previewWindow.document.title = item.originalFileName;
|
||||
previewWindow.addEventListener("pagehide", () => URL.revokeObjectURL(url), { once: true });
|
||||
previewWindow.location.replace(url);
|
||||
}
|
||||
|
||||
async function openVendorMediaPreview({
|
||||
item,
|
||||
token,
|
||||
dispatchId,
|
||||
onError,
|
||||
}: {
|
||||
item: VendorMediaItem;
|
||||
token: string;
|
||||
dispatchId: number;
|
||||
onError: (message: string | undefined) => void;
|
||||
}): Promise<void> {
|
||||
const previewWindow = window.open("about:blank", "_blank");
|
||||
if (!previewWindow) {
|
||||
onError("Allow pop-ups to view this media file.");
|
||||
return;
|
||||
}
|
||||
previewWindow.opener = null;
|
||||
previewWindow.document.title = item.originalFileName;
|
||||
previewWindow.document.body.textContent = `Loading ${item.originalFileName}…`;
|
||||
onError(undefined);
|
||||
try {
|
||||
const blob = await vendorPortalMediaApi.fetchContent(token, dispatchId, item.id);
|
||||
viewMediaItem(item, blob, previewWindow);
|
||||
} catch {
|
||||
previewWindow.close();
|
||||
onError("Unable to open the media file. Check your connection and try again.");
|
||||
}
|
||||
}
|
||||
|
||||
function VendorMediaItems({
|
||||
items,
|
||||
onView,
|
||||
}: {
|
||||
items: VendorMediaItem[];
|
||||
onView: (item: VendorMediaItem) => void;
|
||||
}) {
|
||||
return items.map((item) => (
|
||||
<article key={item.id} className="vp-uplift-row">
|
||||
<div className="vp-row">
|
||||
<strong>File</strong>
|
||||
<span>
|
||||
{item.originalFileName} · {item.kind} · {formatBytes(item.sizeBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vp-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
disabled={!item.canView}
|
||||
onClick={() => onView(item)}
|
||||
>
|
||||
{item.canView ? "View" : "Unavailable"}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
));
|
||||
}
|
||||
|
||||
function VendorUploadProgress({
|
||||
pending,
|
||||
progress,
|
||||
onCancel,
|
||||
}: {
|
||||
pending: boolean;
|
||||
progress: number | undefined;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
if (!pending) return null;
|
||||
return (
|
||||
<div role="status" aria-live="polite">
|
||||
{progress !== undefined ? (
|
||||
<>
|
||||
<progress max={100} value={progress} style={{ width: "100%" }} />
|
||||
<VendorPortalFeedback>Uploading… {progress}%</VendorPortalFeedback>
|
||||
</>
|
||||
) : (
|
||||
<VendorPortalFeedback>Preparing upload…</VendorPortalFeedback>
|
||||
)}
|
||||
<button type="button" className="vp-btn vp-btn-ghost" onClick={onCancel}>
|
||||
Cancel upload
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VendorUploadError({
|
||||
error,
|
||||
canceled,
|
||||
locked,
|
||||
onRetry,
|
||||
}: {
|
||||
error: Error | null;
|
||||
canceled: boolean;
|
||||
locked: boolean;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
if (canceled) return <VendorPortalFeedback>Upload canceled.</VendorPortalFeedback>;
|
||||
if (!error) return null;
|
||||
return (
|
||||
<div>
|
||||
<VendorPortalFeedback tone="error">{safeMediaUploadErrorMessage(error)}</VendorPortalFeedback>
|
||||
{!locked && isRetryableMediaUploadError(error) && (
|
||||
<button type="button" className="vp-btn vp-btn-ghost" onClick={onRetry}>
|
||||
Retry upload
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VendorUploadButton({
|
||||
canUpload,
|
||||
pending,
|
||||
onClick,
|
||||
}: {
|
||||
canUpload: boolean;
|
||||
pending: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
if (!canUpload) return null;
|
||||
return (
|
||||
<div className="vp-actions">
|
||||
<button type="button" className="vp-btn vp-btn-primary" disabled={pending} onClick={onClick}>
|
||||
Upload photo or video
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSectionProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
const idempotencyKeyRef = useRef<string | undefined>(undefined);
|
||||
const failedFileRef = useRef<File | null>(null);
|
||||
const [loadState, setLoadState] = useState<MediaLoadState>({ loading: true });
|
||||
const [progress, setProgress] = useState<number>();
|
||||
const [uploadCanceled, setUploadCanceled] = useState(false);
|
||||
const [mediaActionError, setMediaActionError] = useState<string>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoadState({ loading: true });
|
||||
try {
|
||||
const media = await vendorPortalMediaApi.getMedia(token, dispatchId);
|
||||
setLoadState({ loading: false, media });
|
||||
} catch {
|
||||
setLoadState({
|
||||
loading: false,
|
||||
error: "Unable to load media. Check your connection and try again.",
|
||||
});
|
||||
}
|
||||
}, [token, dispatchId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const policy = loadState.media?.policy;
|
||||
if (!policy?.canUpload) {
|
||||
throw new MediaUploadValidationError("Uploading is not available for this dispatch.");
|
||||
}
|
||||
const check = await validateMediaFileAgainstLimits(file, policy);
|
||||
if (!check.ok) {
|
||||
throw new MediaUploadValidationError(check.reason);
|
||||
}
|
||||
return uploadFileResumable({
|
||||
file,
|
||||
endpoints: vendorPortalMediaApi.endpoints(token, dispatchId),
|
||||
surface: "VendorPortal",
|
||||
idempotencyKey: idempotencyKeyRef.current,
|
||||
signal: controllerRef.current?.signal,
|
||||
onProgress: setProgress,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
failedFileRef.current = null;
|
||||
idempotencyKeyRef.current = undefined;
|
||||
void load();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setUploadCanceled(error.name === "AbortError");
|
||||
},
|
||||
onSettled: () => {
|
||||
setProgress(undefined);
|
||||
controllerRef.current = null;
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const startUpload = (file: File | null, retry = false) => {
|
||||
if (!file) return;
|
||||
if (!retry || !idempotencyKeyRef.current) {
|
||||
idempotencyKeyRef.current = crypto.randomUUID();
|
||||
}
|
||||
failedFileRef.current = file;
|
||||
controllerRef.current = new AbortController();
|
||||
setUploadCanceled(false);
|
||||
uploadMutation.reset();
|
||||
uploadMutation.mutate(file);
|
||||
};
|
||||
|
||||
const selectFile = (file: File | undefined) => startUpload(file ?? null);
|
||||
|
||||
const items = loadState.media?.items ?? [];
|
||||
const canUpload = !locked && loadState.media?.policy.canUpload === true;
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
aria-label="Choose photos or videos"
|
||||
accept="image/jpeg,image/jpg,image/png,image/heic,video/mp4,video/quicktime,.jpg,.jpeg,.png,.heic,.mp4,.mov"
|
||||
hidden
|
||||
onChange={(event) => selectFile(event.target.files?.[0])}
|
||||
/>
|
||||
|
||||
{loadState.loading && <VendorPortalFeedback>Loading media…</VendorPortalFeedback>}
|
||||
|
||||
{!loadState.loading && Boolean(loadState.error) && (
|
||||
<>
|
||||
<VendorPortalFeedback tone="error">{loadState.error}</VendorPortalFeedback>
|
||||
<div className="vp-actions">
|
||||
<button type="button" className="vp-btn vp-btn-ghost" onClick={() => void load()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loadState.loading && !loadState.error && items.length === 0 && (
|
||||
<VendorPortalFeedback>No photos or videos uploaded yet.</VendorPortalFeedback>
|
||||
)}
|
||||
|
||||
<VendorMediaItems
|
||||
items={items}
|
||||
onView={(item) =>
|
||||
void openVendorMediaPreview({ item, token, dispatchId, onError: setMediaActionError })
|
||||
}
|
||||
/>
|
||||
{Boolean(mediaActionError) && (
|
||||
<VendorPortalFeedback tone="error">{mediaActionError}</VendorPortalFeedback>
|
||||
)}
|
||||
<VendorUploadProgress
|
||||
pending={uploadMutation.isPending}
|
||||
progress={progress}
|
||||
onCancel={() => controllerRef.current?.abort()}
|
||||
/>
|
||||
<VendorUploadError
|
||||
error={uploadMutation.error}
|
||||
canceled={uploadCanceled}
|
||||
locked={locked}
|
||||
onRetry={() => startUpload(failedFileRef.current, true)}
|
||||
/>
|
||||
|
||||
<VendorUploadButton
|
||||
canUpload={canUpload}
|
||||
pending={uploadMutation.isPending}
|
||||
onClick={() => {
|
||||
uploadMutation.reset();
|
||||
inputRef.current?.click();
|
||||
}}
|
||||
/>
|
||||
|
||||
{locked && (
|
||||
<VendorPortalFeedback>
|
||||
Media is read-only while this dispatch is locked.
|
||||
</VendorPortalFeedback>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function VendorMediaCard(props: VendorMediaSectionProps) {
|
||||
return (
|
||||
<div className="vp-card">
|
||||
<Text variant="heading">Photos & Videos</Text>
|
||||
<VendorMediaSection {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -113,7 +113,7 @@ async function downloadVendorDocument(
|
|||
);
|
||||
}
|
||||
|
||||
async function vendorPortalCall<T>(
|
||||
export async function vendorPortalCall<T>(
|
||||
token: string,
|
||||
path: string,
|
||||
options?: { method?: string; body?: unknown },
|
||||
|
|
|
|||
49
src/domain/vendor-portal/api/vendor-portal-media-api.ts
Normal file
49
src/domain/vendor-portal/api/vendor-portal-media-api.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { createMediaUploadEndpoints, type MediaUploadEndpointSet } from "@/api/media-upload-client";
|
||||
import { env } from "@/lib/env";
|
||||
import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url";
|
||||
import { vendorPortalCall } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
import type { VendorPortalMedia } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
|
||||
function vendorMediaUrl(dispatchId: string | number, suffix = ""): string {
|
||||
const base = `${resolveApiBaseUrl(env.apiUrl)}/vendor-portal/dispatches/${dispatchId}/media`;
|
||||
return suffix ? `${base}/${suffix}` : base;
|
||||
}
|
||||
|
||||
export const vendorPortalMediaApi = {
|
||||
/**
|
||||
* GET /api/vendor-portal/dispatches/{dispatchId}/media — policy plus scan-passed
|
||||
* own-dispatch items. The server derives work-order type and own-dispatch scope.
|
||||
*/
|
||||
getMedia: (token: string, dispatchId: string | number): Promise<VendorPortalMedia> =>
|
||||
vendorPortalCall<VendorPortalMedia>(token, `/dispatches/${dispatchId}/media`),
|
||||
|
||||
/** Content is fetched only on explicit user action; no object URLs are persisted. */
|
||||
fetchContent: async (
|
||||
token: string,
|
||||
dispatchId: string | number,
|
||||
mediaId: number,
|
||||
): Promise<Blob> => {
|
||||
const response = await fetch(vendorMediaUrl(dispatchId, `${mediaId}/content`), {
|
||||
headers: { "X-Vendor-Token": token },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
response.status === 423 ? "File is still being scanned" : "Unable to load the media file.",
|
||||
);
|
||||
}
|
||||
return response.blob();
|
||||
},
|
||||
|
||||
/**
|
||||
* Vendor transport bound to /api/vendor-portal/dispatches/{dispatchId}/media/uploads.
|
||||
* X-Vendor-Token only — never the dispatcher Authorization header.
|
||||
*/
|
||||
endpoints: (token: string, dispatchId: string | number): MediaUploadEndpointSet =>
|
||||
createMediaUploadEndpoints({
|
||||
resolveUrl: (suffix) => {
|
||||
const base = `${resolveApiBaseUrl(env.apiUrl)}/vendor-portal/dispatches/${dispatchId}/media/uploads`;
|
||||
return suffix ? `${base}/${suffix}` : base;
|
||||
},
|
||||
headers: () => ({ "X-Vendor-Token": token }),
|
||||
}),
|
||||
};
|
||||
|
|
@ -138,3 +138,20 @@ export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary
|
|||
signoffs: VendorPortalSignoff[];
|
||||
documents: VendorPortalDocument[];
|
||||
}
|
||||
|
||||
export interface VendorMediaItem {
|
||||
id: number;
|
||||
originalFileName: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
kind: "Photo" | "Video";
|
||||
category: "Extra";
|
||||
scanStatus: "Passed";
|
||||
createdAt: string;
|
||||
canView: boolean;
|
||||
}
|
||||
|
||||
export interface VendorPortalMedia {
|
||||
policy: import("@/api/media-upload-contracts").MediaUploadPolicy;
|
||||
items: VendorMediaItem[];
|
||||
}
|
||||
|
|
|
|||
31
src/domain/work-orders/api/work-order-media-upload-api.ts
Normal file
31
src/domain/work-orders/api/work-order-media-upload-api.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { apiGet } from "@/api/api";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { createMediaUploadEndpoints, type MediaUploadEndpointSet } from "@/api/media-upload-client";
|
||||
import type { MediaUploadPolicy } from "@/api/media-upload-contracts";
|
||||
import { env } from "@/lib/env";
|
||||
import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url";
|
||||
import { getAccessToken } from "@/lib/auth/auth-storage";
|
||||
|
||||
function workOrderMediaUploadUrl(workOrderId: string | number, suffix: string): string {
|
||||
const base = `${resolveApiBaseUrl(env.apiUrl)}/${API_PATHS.workOrder.mediaUploads(workOrderId)}`;
|
||||
return suffix ? `${base}/${suffix}` : base;
|
||||
}
|
||||
|
||||
export const workOrdersMediaUploadApi = {
|
||||
/** GET workorders/{woId}/media/policy — server-derived type, limits and remaining counts. */
|
||||
getMediaPolicy: async (workOrderId: string | number): Promise<MediaUploadPolicy> =>
|
||||
apiGet<MediaUploadPolicy>(API_PATHS.workOrder.mediaPolicy(workOrderId)),
|
||||
|
||||
/**
|
||||
* Dispatcher transport bound to /api/workorders/{woId}/media/uploads.
|
||||
* Work-order binding comes from the URL; clients cannot repoint it.
|
||||
*/
|
||||
endpoints: (workOrderId: string | number): MediaUploadEndpointSet =>
|
||||
createMediaUploadEndpoints({
|
||||
resolveUrl: (suffix) => workOrderMediaUploadUrl(workOrderId, suffix),
|
||||
headers: (): Record<string, string> => {
|
||||
const token = getAccessToken();
|
||||
return token ? { Authorization: token } : {};
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
type MediaUploadPolicy,
|
||||
type MediaUploadSessionDto,
|
||||
type UploadCategory,
|
||||
type UploadSurface,
|
||||
MediaUploadValidationError,
|
||||
isUploadCategory,
|
||||
safeMediaUploadErrorMessage,
|
||||
} from "@/api/media-upload-contracts";
|
||||
import { uploadFileResumable } from "@/api/media-upload-engine";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { UploadWorkOrderMediaInput } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { workOrdersMediaUploadApi } from "@/domain/work-orders/api/work-order-media-upload-api";
|
||||
import { validateMediaFileAgainstLimits } from "@/domain/work-orders/utils/emergency-media-limits";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
function toUploadCategory(
|
||||
category: UploadableMediaCategory | undefined,
|
||||
): UploadCategory | undefined {
|
||||
return typeof category === "string" && isUploadCategory(category) ? category : undefined;
|
||||
}
|
||||
|
||||
async function invalidateMediaQueries(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
): Promise<void> {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||
}
|
||||
|
||||
/**
|
||||
* Emergency/Reactive resumable upload (5 MiB chunks, acknowledged-part progress,
|
||||
* network-loss recovery by session status). Limits are enforced client-side from the
|
||||
* server policy; the server re-checks everything at commit. Media surfaces render only
|
||||
* Completed/scan-passed attachments.
|
||||
*/
|
||||
export function useResumableWorkOrderMediaUpload(
|
||||
workOrderId: string | number,
|
||||
surface: Extract<UploadSurface, "WorkOrderTable" | "CompletionMedia">,
|
||||
policy?: MediaUploadPolicy,
|
||||
): UseMutationResult<MediaUploadSessionDto, Error, UploadWorkOrderMediaInput> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
file,
|
||||
category,
|
||||
idempotencyKey,
|
||||
signal,
|
||||
onProgress,
|
||||
}: UploadWorkOrderMediaInput) => {
|
||||
if (!policy?.canUpload) {
|
||||
throw new MediaUploadValidationError("Uploads are not available for this work order.");
|
||||
}
|
||||
const check = await validateMediaFileAgainstLimits(file, policy);
|
||||
if (!check.ok) {
|
||||
throw new MediaUploadValidationError(check.reason);
|
||||
}
|
||||
return uploadFileResumable({
|
||||
file,
|
||||
endpoints: workOrdersMediaUploadApi.endpoints(workOrderId),
|
||||
surface,
|
||||
category: toUploadCategory(category),
|
||||
idempotencyKey,
|
||||
signal,
|
||||
onProgress,
|
||||
});
|
||||
},
|
||||
onSuccess: async (session) => {
|
||||
await invalidateMediaQueries(queryClient);
|
||||
if (session.state === "Completed") {
|
||||
toast.success("File uploaded");
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(safeMediaUploadErrorMessage(error));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import type { MediaUploadPolicy } from "@/api/media-upload-contracts";
|
||||
import { workOrdersMediaUploadApi } from "@/domain/work-orders/api/work-order-media-upload-api";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
/**
|
||||
* Server-derived Emergency/Reactive media policy: type, canUpload, limits and
|
||||
* remaining counts (existing + pending reservations included server-side).
|
||||
*/
|
||||
export function useWorkOrderMediaPolicy(
|
||||
workOrderId: string | number | undefined,
|
||||
enabled: boolean,
|
||||
): UseQueryResult<MediaUploadPolicy, Error> {
|
||||
return useQuery({
|
||||
queryKey:
|
||||
workOrderId == null
|
||||
? [...queryKeys.workOrders.all, "mediaPolicy", "none"]
|
||||
: queryKeys.workOrders.mediaPolicy(workOrderId),
|
||||
queryFn: () => workOrdersMediaUploadApi.getMediaPolicy(workOrderId as string | number),
|
||||
enabled: enabled && workOrderId != null,
|
||||
staleTime: 15_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
|
@ -52,6 +52,7 @@ export interface UpdateWorkOrderCommentInput {
|
|||
export interface UploadWorkOrderMediaInput {
|
||||
file: File;
|
||||
category?: UploadableMediaCategory;
|
||||
idempotencyKey?: string;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (percent: number) => void;
|
||||
}
|
||||
|
|
|
|||
176
src/domain/work-orders/utils/emergency-media-limits.ts
Normal file
176
src/domain/work-orders/utils/emergency-media-limits.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import type { MediaKind, MediaUploadLimits, MediaUploadPolicy } from "@/api/media-upload-contracts";
|
||||
|
||||
export const EMERGENCY_PHOTO_MAX_BYTES = 10_000_000;
|
||||
export const EMERGENCY_VIDEO_MAX_BYTES = 100_000_000;
|
||||
export const EMERGENCY_VIDEO_MAX_SECONDS = 90;
|
||||
|
||||
const PHOTO_MIME_TYPES = new Set(["image/jpeg", "image/jpg", "image/png", "image/heic"]);
|
||||
|
||||
const PHOTO_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".heic"]);
|
||||
|
||||
const VIDEO_MIME_TYPES = new Set(["video/mp4", "video/quicktime"]);
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov"]);
|
||||
|
||||
export type MediaValidationProbe = (file: File) => Promise<number | null>;
|
||||
|
||||
export type MediaValidationResult = { ok: true } | { ok: false; reason: string };
|
||||
|
||||
function fileExtension(file: File): string {
|
||||
const name = file.name.toLowerCase();
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot >= 0 ? name.slice(dot) : "";
|
||||
}
|
||||
|
||||
export function classifyMediaKind(file: File): MediaKind | null {
|
||||
const mime = file.type.toLowerCase();
|
||||
if (PHOTO_MIME_TYPES.has(mime) || PHOTO_EXTENSIONS.has(fileExtension(file))) {
|
||||
return "Photo";
|
||||
}
|
||||
if (VIDEO_MIME_TYPES.has(mime) || VIDEO_EXTENSIONS.has(fileExtension(file))) {
|
||||
return "Video";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Inclusive limit: exactly at the cap passes, one byte over fails. */
|
||||
export function isMediaSizeWithinLimit(
|
||||
file: File,
|
||||
kind: MediaKind,
|
||||
limits: MediaUploadLimits,
|
||||
): boolean {
|
||||
const maxBytes = kind === "Photo" ? limits.photoMaxBytes : limits.videoMaxBytes;
|
||||
return file.size <= maxBytes;
|
||||
}
|
||||
|
||||
export function mediaOverLimitNotice(
|
||||
file: File,
|
||||
kind: MediaKind,
|
||||
limits: MediaUploadLimits,
|
||||
): string {
|
||||
if (kind === "Video") {
|
||||
return `"${file.name}" exceeds the video limit (${Math.round(limits.videoMaxBytes / 1_000_000)} MB, ${limits.videoMaxSeconds} seconds).`;
|
||||
}
|
||||
return `"${file.name}" exceeds the photo limit (${Math.round(limits.photoMaxBytes / 1_000_000)} MB).`;
|
||||
}
|
||||
|
||||
export function mediaKindLimitReachedNotice(kind: MediaKind, limits: MediaUploadLimits): string {
|
||||
const max = kind === "Photo" ? limits.maxPhotos : limits.maxVideos;
|
||||
return `${kind} limit reached (${max} per work order). Remove a file to add another.`;
|
||||
}
|
||||
|
||||
export function emergencyMediaBatchCountError(
|
||||
file: File,
|
||||
acceptedFiles: readonly File[],
|
||||
policy?: Pick<MediaUploadPolicy, "limits" | "remaining">,
|
||||
): string | undefined {
|
||||
const kind = classifyMediaKind(file);
|
||||
if (!kind || !policy) return undefined;
|
||||
const available = kind === "Photo" ? policy.remaining.photos : policy.remaining.videos;
|
||||
const alreadyAccepted = acceptedFiles.filter((accepted) => classifyMediaKind(accepted) === kind);
|
||||
if (alreadyAccepted.length < available) return undefined;
|
||||
return mediaKindLimitReachedNotice(kind, defaultLimits(policy.limits));
|
||||
}
|
||||
|
||||
export function emergencyMediaAcceptAttribute(): string {
|
||||
return "image/jpeg,image/jpg,image/png,image/heic,video/mp4,video/quicktime,.jpg,.jpeg,.png,.heic,.mp4,.mov";
|
||||
}
|
||||
|
||||
/**
|
||||
* Media limits apply only to Emergency/Reactive work orders; `canUpload` from the
|
||||
* server policy is authoritative and work-order binding/category are fixed by route.
|
||||
*/
|
||||
export function isEmergencyReactiveWorkOrderType(workOrderType: string | undefined): boolean {
|
||||
if (!workOrderType) return false;
|
||||
const normalized = workOrderType.trim().toLowerCase();
|
||||
return normalized === "emergency" || normalized === "reactive";
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe video duration from browser metadata. Returns null whenever probing or
|
||||
* preview decoding fails (e.g. some MOV files) — unknown duration proceeds to the
|
||||
* server, which enforces the cap at commit. Temporary preview URL is always revoked.
|
||||
*/
|
||||
export function probeVideoDurationSeconds(file: File): Promise<number | null> {
|
||||
return new Promise((resolve) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const video = document.createElement("video");
|
||||
let settled = false;
|
||||
|
||||
const finish = (value: number | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
URL.revokeObjectURL(url);
|
||||
video.removeAttribute("src");
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => finish(null), 4_000);
|
||||
video.preload = "metadata";
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
const duration = video.duration;
|
||||
finish(Number.isFinite(duration) && duration > 0 ? duration : null);
|
||||
});
|
||||
video.addEventListener("error", () => finish(null));
|
||||
video.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function defaultLimits(limits?: Partial<MediaUploadLimits>): MediaUploadLimits {
|
||||
return {
|
||||
photoMaxBytes: limits?.photoMaxBytes ?? EMERGENCY_PHOTO_MAX_BYTES,
|
||||
videoMaxBytes: limits?.videoMaxBytes ?? EMERGENCY_VIDEO_MAX_BYTES,
|
||||
videoMaxSeconds: limits?.videoMaxSeconds ?? EMERGENCY_VIDEO_MAX_SECONDS,
|
||||
maxPhotos: limits?.maxPhotos ?? 10,
|
||||
maxVideos: limits?.maxVideos ?? 3,
|
||||
};
|
||||
}
|
||||
|
||||
export function emergencyMediaSizeError(
|
||||
file: File,
|
||||
limits?: Partial<MediaUploadLimits>,
|
||||
): string | undefined {
|
||||
const kind = classifyMediaKind(file);
|
||||
if (!kind) return undefined;
|
||||
const resolvedLimits = defaultLimits(limits);
|
||||
return isMediaSizeWithinLimit(file, kind, resolvedLimits)
|
||||
? undefined
|
||||
: mediaOverLimitNotice(file, kind, resolvedLimits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one media file against the policy limits: kind, inclusive size caps and
|
||||
* duration when it can be probed. A failed duration probe keeps the file eligible.
|
||||
*/
|
||||
export async function validateMediaFileAgainstLimits(
|
||||
file: File,
|
||||
policy: Pick<MediaUploadPolicy, "limits" | "remaining">,
|
||||
probe: MediaValidationProbe = probeVideoDurationSeconds,
|
||||
): Promise<MediaValidationResult> {
|
||||
const limits = defaultLimits(policy.limits);
|
||||
const kind = classifyMediaKind(file);
|
||||
if (!kind) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported.",
|
||||
};
|
||||
}
|
||||
if (!isMediaSizeWithinLimit(file, kind, limits)) {
|
||||
return { ok: false, reason: mediaOverLimitNotice(file, kind, limits) };
|
||||
}
|
||||
const remaining = kind === "Photo" ? policy.remaining?.photos : policy.remaining?.videos;
|
||||
if (typeof remaining === "number" && remaining <= 0) {
|
||||
return { ok: false, reason: mediaKindLimitReachedNotice(kind, limits) };
|
||||
}
|
||||
if (kind === "Video") {
|
||||
const seconds = await probe(file);
|
||||
if (seconds != null && seconds > limits.videoMaxSeconds) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `"${file.name}" is longer than the ${limits.videoMaxSeconds} second video limit.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ export const queryKeys = {
|
|||
detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const,
|
||||
boardDetail: (id: string | number) => [...queryKeys.workOrders.all, "boardDetail", id] as const,
|
||||
boardMedia: (id: string | number) => [...queryKeys.workOrders.all, "boardMedia", id] as const,
|
||||
mediaPolicy: (id: string | number) => [...queryKeys.workOrders.all, "mediaPolicy", id] as const,
|
||||
dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const,
|
||||
taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const,
|
||||
dispatchers: () => [...queryKeys.workOrders.all, "dispatchers"] as const,
|
||||
|
|
@ -159,5 +160,7 @@ export const queryKeys = {
|
|||
dispatches: (token: string) => [...queryKeys.vendorPortal.all, "dispatches", token] as const,
|
||||
dispatch: (token: string, id: string | number) =>
|
||||
[...queryKeys.vendorPortal.all, "dispatch", token, id] as const,
|
||||
media: (token: string, id: string | number) =>
|
||||
[...queryKeys.vendorPortal.all, "media", token, id] as const,
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
511
src/test/api/media-upload-engine.test.ts
Normal file
511
src/test/api/media-upload-engine.test.ts
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
// @vitest-environment node
|
||||
// Node's fetch/Request/Blob follow WHATWG body-serialization rules; jsdom mangles raw
|
||||
// chunk bodies (the same reason api-formdata-content-type.test.ts runs in node).
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
chunkCountFor,
|
||||
missingChunkIndices,
|
||||
runMediaUploadQueue,
|
||||
uploadFileResumable,
|
||||
} from "@/api/media-upload-engine";
|
||||
import { computeSha256Hex, createMediaUploadEndpoints } from "@/api/media-upload-client";
|
||||
|
||||
type CapturedRequest = { method: string; url: string; headers: Headers; body: unknown };
|
||||
|
||||
const BASE = "https://api.test/uploads";
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function fileOf(size: number, name = "clip.mp4", type = "video/mp4"): File {
|
||||
return new File([new Uint8Array(size)], name, { type });
|
||||
}
|
||||
|
||||
let captured: CapturedRequest[] = [];
|
||||
let handler: (request: CapturedRequest) => Promise<Response>;
|
||||
|
||||
function endpointsWith() {
|
||||
return createMediaUploadEndpoints({
|
||||
resolveUrl: (suffix) => (suffix ? `${BASE}/${suffix}` : BASE),
|
||||
headers: () => ({}),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
captured = [];
|
||||
handler = async () => jsonResponse({});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init);
|
||||
const body =
|
||||
request.method === "PUT"
|
||||
? await request.arrayBuffer()
|
||||
: await request.json().catch(() => ({}));
|
||||
const capturedRequest: CapturedRequest = {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body,
|
||||
};
|
||||
captured.push(capturedRequest);
|
||||
return handler(capturedRequest);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function createdSession(sizeBytes: number, chunkSizeBytes: number) {
|
||||
return {
|
||||
uploadId: "u1",
|
||||
state: "Uploading",
|
||||
chunkSizeBytes,
|
||||
sizeBytes,
|
||||
receivedChunks: [],
|
||||
expiresAt: "2026-09-24T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function completedSession(sizeBytes: number, chunkSizeBytes: number, received: number[]) {
|
||||
return {
|
||||
uploadId: "u1",
|
||||
state: "Completed",
|
||||
chunkSizeBytes,
|
||||
sizeBytes,
|
||||
receivedChunks: received,
|
||||
expiresAt: "2026-09-24T00:00:00Z",
|
||||
attachment: { id: 42 },
|
||||
};
|
||||
}
|
||||
|
||||
describe("resumable media upload engine", () => {
|
||||
it("sends raw chunk bytes with Content-Range, digest header, and acknowledged-only progress", async () => {
|
||||
const file = fileOf(20);
|
||||
handler = async (request) => {
|
||||
if (request.method === "POST" && request.url === BASE) {
|
||||
return jsonResponse(createdSession(20, 8));
|
||||
}
|
||||
const match = /chunks\/(\d+)$/.exec(request.url);
|
||||
if (request.method === "PUT" && match) {
|
||||
const index = Number(match[1]);
|
||||
const received = [0, 1, 2].filter((seen) => seen <= index);
|
||||
return jsonResponse({
|
||||
index,
|
||||
sha256: "server-digest",
|
||||
sizeBytes: (request.body as ArrayBuffer).byteLength,
|
||||
receivedChunks: received,
|
||||
});
|
||||
}
|
||||
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||
return jsonResponse(completedSession(20, 8, [0, 1, 2]));
|
||||
}
|
||||
return jsonResponse({});
|
||||
};
|
||||
|
||||
const percents: number[] = [];
|
||||
const session = await uploadFileResumable({
|
||||
file,
|
||||
endpoints: endpointsWith(),
|
||||
surface: "WorkOrderTable",
|
||||
onProgress: percents.push.bind(percents) as (percent: number) => void,
|
||||
});
|
||||
|
||||
expect(session.state).toBe("Completed");
|
||||
expect(session.attachment?.id).toBe(42);
|
||||
const puts = captured.filter((entry) => entry.method === "PUT");
|
||||
expect(puts).toHaveLength(3);
|
||||
|
||||
const firstSlice = file.slice(0, 8);
|
||||
const expectedDigest = await computeSha256Hex(await firstSlice.arrayBuffer());
|
||||
expect(puts[0]?.url).toBe(`${BASE}/u1/chunks/0`);
|
||||
expect(puts[0]?.headers.get("content-range")).toBe("bytes 0-7/20");
|
||||
expect(puts[0]?.headers.get("x-chunk-sha256")).toBe(expectedDigest);
|
||||
expect((puts[0]?.body as ArrayBuffer).byteLength).toBe(8);
|
||||
expect(puts[2]?.headers.get("content-range")).toBe("bytes 16-19/20");
|
||||
expect((puts[2]?.body as ArrayBuffer).byteLength).toBe(4);
|
||||
|
||||
// Progress derives from acknowledged chunks only (server receivedChunks), ending at 100.
|
||||
expect(percents[percents.length - 1]).toBe(100);
|
||||
expect(percents.every((percent, index) => index === 0 || percent >= percents[index - 1]!)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("recovers from network loss by re-reading session status and sending only missing chunks", async () => {
|
||||
const file = fileOf(24);
|
||||
let failedIndexOnce = false;
|
||||
handler = async (request) => {
|
||||
if (request.method === "POST" && request.url === BASE) {
|
||||
return jsonResponse(createdSession(24, 8));
|
||||
}
|
||||
const match = /chunks\/(\d+)$/.exec(request.url);
|
||||
if (request.method === "PUT" && match) {
|
||||
const index = Number(match[1]);
|
||||
if (index === 1 && !failedIndexOnce) {
|
||||
failedIndexOnce = true;
|
||||
throw new TypeError("network down");
|
||||
}
|
||||
const received = Array.from({ length: index + 1 }, (_, seen) => seen);
|
||||
return jsonResponse({ index, sha256: "x", sizeBytes: 8, receivedChunks: received });
|
||||
}
|
||||
if (request.method === "GET" && request.url === `${BASE}/u1`) {
|
||||
return jsonResponse({ ...createdSession(24, 8), receivedChunks: [0] });
|
||||
}
|
||||
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||
return jsonResponse(completedSession(24, 8, [0, 1, 2]));
|
||||
}
|
||||
return jsonResponse({});
|
||||
};
|
||||
|
||||
const session = await uploadFileResumable({
|
||||
file,
|
||||
endpoints: endpointsWith(),
|
||||
surface: "WorkOrderTable",
|
||||
});
|
||||
|
||||
expect(session.state).toBe("Completed");
|
||||
const putIndices = captured
|
||||
.filter((entry) => entry.method === "PUT")
|
||||
.map((entry) => Number(/chunks\/(\d+)$/.exec(entry.url)?.[1]));
|
||||
expect(putIndices).toEqual([0, 1, 1, 2]);
|
||||
expect(captured.some((entry) => entry.method === "GET" && entry.url === `${BASE}/u1`)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("stops retrying a persistently failing chunk after bounded status recovery", async () => {
|
||||
const file = fileOf(8);
|
||||
let puts = 0;
|
||||
let statusReads = 0;
|
||||
handler = async (request) => {
|
||||
if (request.method === "POST" && request.url === BASE) {
|
||||
return jsonResponse(createdSession(8, 8));
|
||||
}
|
||||
if (request.method === "PUT") {
|
||||
puts += 1;
|
||||
throw new TypeError("network down");
|
||||
}
|
||||
if (request.method === "GET" && request.url === `${BASE}/u1`) {
|
||||
statusReads += 1;
|
||||
return jsonResponse(createdSession(8, 8));
|
||||
}
|
||||
return jsonResponse({});
|
||||
};
|
||||
|
||||
await expect(
|
||||
uploadFileResumable({ file, endpoints: endpointsWith(), surface: "WorkOrderTable" }),
|
||||
).rejects.toMatchObject({ name: "MediaUploadNetworkError" });
|
||||
expect(puts).toBe(3);
|
||||
expect(statusReads).toBe(3);
|
||||
});
|
||||
|
||||
it("recovers a lost commit response from Completed status without a duplicate commit", async () => {
|
||||
const file = fileOf(8);
|
||||
let commits = 0;
|
||||
handler = async (request) => {
|
||||
if (request.method === "POST" && request.url === BASE) {
|
||||
return jsonResponse(createdSession(8, 8));
|
||||
}
|
||||
if (request.method === "PUT") {
|
||||
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
|
||||
}
|
||||
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||
commits += 1;
|
||||
throw new TypeError("commit response lost");
|
||||
}
|
||||
if (request.method === "GET" && request.url === `${BASE}/u1`) {
|
||||
return jsonResponse(completedSession(8, 8, [0]));
|
||||
}
|
||||
return jsonResponse({});
|
||||
};
|
||||
|
||||
const result = await uploadFileResumable({
|
||||
file,
|
||||
endpoints: endpointsWith(),
|
||||
surface: "WorkOrderTable",
|
||||
});
|
||||
expect(result.state).toBe("Completed");
|
||||
expect(result.attachment?.id).toBe(42);
|
||||
expect(commits).toBe(1);
|
||||
expect(captured.filter((entry) => entry.method === "GET")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("retries the same idempotent session and sends only chunks missing after partial failure", async () => {
|
||||
const file = fileOf(16);
|
||||
const idempotencyKey = "11111111-2222-3333-4444-555555555555";
|
||||
let createCount = 0;
|
||||
let chunkOneFailures = 0;
|
||||
handler = async (request) => {
|
||||
if (request.method === "POST" && request.url === BASE) {
|
||||
createCount += 1;
|
||||
return jsonResponse({
|
||||
...createdSession(16, 8),
|
||||
receivedChunks: createCount === 1 ? [] : [0],
|
||||
});
|
||||
}
|
||||
if (request.method === "PUT" && request.url.endsWith("/chunks/0")) {
|
||||
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
|
||||
}
|
||||
if (request.method === "PUT" && request.url.endsWith("/chunks/1")) {
|
||||
chunkOneFailures += 1;
|
||||
if (chunkOneFailures <= 3) throw new TypeError("network down");
|
||||
return jsonResponse({ index: 1, sha256: "x", sizeBytes: 8, receivedChunks: [0, 1] });
|
||||
}
|
||||
if (request.method === "GET" && request.url === `${BASE}/u1`) {
|
||||
return jsonResponse({ ...createdSession(16, 8), receivedChunks: [0] });
|
||||
}
|
||||
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||
return jsonResponse(completedSession(16, 8, [0, 1]));
|
||||
}
|
||||
return jsonResponse({});
|
||||
};
|
||||
|
||||
await expect(
|
||||
uploadFileResumable({
|
||||
file,
|
||||
endpoints: endpointsWith(),
|
||||
surface: "WorkOrderTable",
|
||||
idempotencyKey,
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "MediaUploadNetworkError" });
|
||||
|
||||
const resumed = await uploadFileResumable({
|
||||
file,
|
||||
endpoints: endpointsWith(),
|
||||
surface: "WorkOrderTable",
|
||||
idempotencyKey,
|
||||
});
|
||||
expect(resumed.state).toBe("Completed");
|
||||
const creates = captured.filter((entry) => entry.method === "POST" && entry.url === BASE);
|
||||
expect(creates).toHaveLength(2);
|
||||
expect(
|
||||
creates.map((entry) => (entry.body as { idempotencyKey: string }).idempotencyKey),
|
||||
).toEqual([idempotencyKey, idempotencyKey]);
|
||||
const putIndices = captured
|
||||
.filter((entry) => entry.method === "PUT")
|
||||
.map((entry) => Number(/chunks\/(\d+)$/.exec(entry.url)?.[1]));
|
||||
expect(putIndices.filter((index) => index === 0)).toEqual([0]);
|
||||
expect(putIndices[putIndices.length - 1]).toBe(1);
|
||||
});
|
||||
|
||||
it("retries a failed create with the same idempotency key and never re-creates after chunk loss", async () => {
|
||||
const file = fileOf(8);
|
||||
let createAttempts = 0;
|
||||
handler = async (request) => {
|
||||
if (request.method === "POST" && request.url === BASE) {
|
||||
createAttempts += 1;
|
||||
if (createAttempts === 1) {
|
||||
throw new TypeError("network down");
|
||||
}
|
||||
return jsonResponse(createdSession(8, 8));
|
||||
}
|
||||
const match = /chunks\/(\d+)$/.exec(request.url);
|
||||
if (request.method === "PUT" && match) {
|
||||
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
|
||||
}
|
||||
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||
return jsonResponse(completedSession(8, 8, [0]));
|
||||
}
|
||||
return jsonResponse({});
|
||||
};
|
||||
|
||||
await uploadFileResumable({ file, endpoints: endpointsWith(), surface: "CompletionMedia" });
|
||||
|
||||
const creates = captured.filter((entry) => entry.method === "POST" && entry.url === BASE);
|
||||
expect(creates).toHaveLength(2);
|
||||
const keys = creates.map((entry) => (entry.body as { idempotencyKey: string }).idempotencyKey);
|
||||
expect(keys[0]).toBe(keys[1]);
|
||||
expect(keys[0]).toMatch(/^[0-9a-f-]{36}$/);
|
||||
const firstBody = creates[0]?.body as Record<string, unknown>;
|
||||
expect(firstBody).toMatchObject({
|
||||
fileName: "clip.mp4",
|
||||
contentType: "video/mp4",
|
||||
sizeBytes: 8,
|
||||
surface: "CompletionMedia",
|
||||
});
|
||||
expect(captured.filter((entry) => entry.method === "POST" && entry.url === BASE)).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("aborts the in-flight chunk and DELETEs the session on cancellation", async () => {
|
||||
const file = fileOf(16);
|
||||
const controller = new AbortController();
|
||||
handler = async (request) => {
|
||||
if (request.method === "POST" && request.url === BASE) {
|
||||
return jsonResponse(createdSession(16, 8));
|
||||
}
|
||||
const match = /chunks\/(\d+)$/.exec(request.url);
|
||||
if (request.method === "PUT" && match) {
|
||||
if (Number(match[1]) === 0) {
|
||||
controller.abort();
|
||||
}
|
||||
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
|
||||
}
|
||||
return jsonResponse({});
|
||||
};
|
||||
|
||||
const pending = uploadFileResumable({
|
||||
file,
|
||||
endpoints: endpointsWith(),
|
||||
surface: "WorkOrderTable",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(captured.some((entry) => entry.method === "DELETE" && entry.url === `${BASE}/u1`)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("uploads a queue with at most two files in flight", async () => {
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
handler = async (request) => {
|
||||
if (request.method === "POST" && request.url === BASE) {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
inFlight -= 1;
|
||||
return jsonResponse(createdSession(8, 8));
|
||||
}
|
||||
const match = /chunks\/(\d+)$/.exec(request.url);
|
||||
if (request.method === "PUT" && match) {
|
||||
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
|
||||
}
|
||||
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||
return jsonResponse(completedSession(8, 8, [0]));
|
||||
}
|
||||
return jsonResponse({});
|
||||
};
|
||||
|
||||
const files = [fileOf(8, "a.mp4"), fileOf(8, "b.mp4"), fileOf(8, "c.mp4")];
|
||||
await runMediaUploadQueue(
|
||||
files,
|
||||
(file) =>
|
||||
uploadFileResumable({ file, endpoints: endpointsWith(), surface: "WorkOrderTable" }).then(
|
||||
() => undefined,
|
||||
),
|
||||
2,
|
||||
);
|
||||
|
||||
expect(peak).toBe(2);
|
||||
expect(captured.filter((entry) => entry.method === "POST" && entry.url === BASE)).toHaveLength(
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
it("polls a 202 scanning commit until Completed and rejects terminal Rejected state", async () => {
|
||||
const file = fileOf(8);
|
||||
let statusCalls = 0;
|
||||
handler = async (request) => {
|
||||
if (request.method === "POST" && request.url === BASE) {
|
||||
return jsonResponse(createdSession(8, 8));
|
||||
}
|
||||
const match = /chunks\/(\d+)$/.exec(request.url);
|
||||
if (request.method === "PUT" && match) {
|
||||
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
|
||||
}
|
||||
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||
return jsonResponse(
|
||||
{ ...createdSession(8, 8), state: "Scanning", receivedChunks: [0] },
|
||||
202,
|
||||
);
|
||||
}
|
||||
if (request.method === "GET" && request.url === `${BASE}/u1`) {
|
||||
statusCalls += 1;
|
||||
if (statusCalls === 1) {
|
||||
return jsonResponse({ ...createdSession(8, 8), state: "Scanning", receivedChunks: [0] });
|
||||
}
|
||||
return jsonResponse(completedSession(8, 8, [0]));
|
||||
}
|
||||
return jsonResponse({});
|
||||
};
|
||||
|
||||
const session = await uploadFileResumable({
|
||||
file,
|
||||
endpoints: endpointsWith(),
|
||||
surface: "WorkOrderTable",
|
||||
pollDelayMs: 0,
|
||||
});
|
||||
expect(session.state).toBe("Completed");
|
||||
expect(statusCalls).toBe(2);
|
||||
|
||||
handler = async (request) => {
|
||||
if (request.method === "POST" && request.url === BASE) {
|
||||
return jsonResponse(createdSession(8, 8));
|
||||
}
|
||||
if (request.method === "PUT" && /chunks\/0$/.test(request.url)) {
|
||||
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
|
||||
}
|
||||
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||
return jsonResponse({ ...createdSession(8, 8), state: "Scanning" }, 202);
|
||||
}
|
||||
if (request.method === "GET" && request.url === `${BASE}/u1`) {
|
||||
return jsonResponse({ ...createdSession(8, 8), state: "Rejected" });
|
||||
}
|
||||
return jsonResponse({});
|
||||
};
|
||||
|
||||
await expect(
|
||||
uploadFileResumable({
|
||||
file,
|
||||
endpoints: endpointsWith(),
|
||||
surface: "WorkOrderTable",
|
||||
pollDelayMs: 0,
|
||||
}),
|
||||
).rejects.toThrow("rejected by the security scan");
|
||||
});
|
||||
|
||||
it("DELETEs the session when cancellation aborts scan polling", async () => {
|
||||
const file = fileOf(8);
|
||||
const controller = new AbortController();
|
||||
handler = async (request) => {
|
||||
if (request.method === "POST" && request.url === BASE) {
|
||||
return jsonResponse(createdSession(8, 8));
|
||||
}
|
||||
if (request.method === "PUT") {
|
||||
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
|
||||
}
|
||||
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||
setTimeout(() => controller.abort(), 0);
|
||||
return jsonResponse(
|
||||
{ ...createdSession(8, 8), state: "Scanning", receivedChunks: [0] },
|
||||
202,
|
||||
);
|
||||
}
|
||||
return jsonResponse({});
|
||||
};
|
||||
|
||||
await expect(
|
||||
uploadFileResumable({
|
||||
file,
|
||||
endpoints: endpointsWith(),
|
||||
surface: "WorkOrderTable",
|
||||
signal: controller.signal,
|
||||
pollDelayMs: 5,
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(captured.some((entry) => entry.method === "DELETE" && entry.url === `${BASE}/u1`)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chunk math helpers", () => {
|
||||
it("computes chunk counts and missing indices", () => {
|
||||
expect(chunkCountFor(0, 5_242_880)).toBe(1);
|
||||
expect(chunkCountFor(5_242_880, 5_242_880)).toBe(1);
|
||||
expect(chunkCountFor(5_242_881, 5_242_880)).toBe(2);
|
||||
expect(missingChunkIndices([0, 2], 4)).toEqual([1, 3]);
|
||||
expect(missingChunkIndices([], 0)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,17 +1,41 @@
|
|||
import { fireEvent, render, screen, 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 { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
const uploadMutateAsync = vi.fn();
|
||||
const resumableUploadMutateAsync = vi.fn();
|
||||
const resumableUploadHookSpy = vi.hoisted(() => vi.fn());
|
||||
const deleteMutate = vi.fn();
|
||||
const categorizeMutate = vi.fn();
|
||||
const mediaQueryState = vi.hoisted(() => ({
|
||||
detailType: "Emergency" as string | undefined,
|
||||
policy: {
|
||||
workOrderType: "Emergency",
|
||||
canUpload: false,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 10, videos: 3 },
|
||||
} as unknown,
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
vi.mock("react-toastify", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||
useWorkOrderBoardDetail: () => ({
|
||||
data: {
|
||||
info: { status: "Scheduled", rowVersion: "DETAIL-VER==" },
|
||||
info: { status: "Scheduled", rowVersion: "DETAIL-VER==", type: mediaQueryState.detailType },
|
||||
media: [
|
||||
{
|
||||
id: "11",
|
||||
|
|
@ -39,12 +63,27 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
|
|||
useDeleteWorkOrderMedia: () => ({ mutate: deleteMutate, isPending: false }),
|
||||
}));
|
||||
|
||||
function baseRow(): WorkOrderTableRow {
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({
|
||||
useWorkOrderMediaPolicy: () => ({
|
||||
data: mediaQueryState.policy,
|
||||
isError: mediaQueryState.isError,
|
||||
isLoading: mediaQueryState.isLoading,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({
|
||||
useResumableWorkOrderMediaUpload: (...args: unknown[]) => {
|
||||
resumableUploadHookSpy(...args);
|
||||
return { mutateAsync: resumableUploadMutateAsync, isPending: false };
|
||||
},
|
||||
}));
|
||||
|
||||
function baseRow(type: WorkOrderTableRow["type"] = "Emergency"): WorkOrderTableRow {
|
||||
return {
|
||||
id: 7,
|
||||
woNumber: "WO-7",
|
||||
site: "SEA1",
|
||||
type: "Emergency",
|
||||
type,
|
||||
severity: 1,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Dispatcher",
|
||||
|
|
@ -90,14 +129,14 @@ function baseRow(): WorkOrderTableRow {
|
|||
};
|
||||
}
|
||||
|
||||
function renderModal() {
|
||||
function renderModal(rowType: WorkOrderTableRow["type"] = "Emergency") {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<MediaUploadModal row={baseRow()} open onOpenChange={vi.fn()} />
|
||||
<MediaUploadModal row={baseRow(rowType)} open onOpenChange={vi.fn()} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
|
@ -105,8 +144,26 @@ function renderModal() {
|
|||
describe("MediaUploadModal API wiring", () => {
|
||||
beforeEach(() => {
|
||||
uploadMutateAsync.mockReset();
|
||||
resumableUploadMutateAsync.mockReset();
|
||||
resumableUploadHookSpy.mockClear();
|
||||
deleteMutate.mockReset();
|
||||
categorizeMutate.mockReset();
|
||||
mediaQueryState.detailType = "Emergency";
|
||||
mediaQueryState.policy = {
|
||||
workOrderType: "Emergency",
|
||||
canUpload: false,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 10, videos: 3 },
|
||||
};
|
||||
mediaQueryState.isError = false;
|
||||
mediaQueryState.isLoading = false;
|
||||
vi.mocked(toast.error).mockClear();
|
||||
});
|
||||
|
||||
it("renders persisted media from board detail (refresh-safe)", async () => {
|
||||
|
|
@ -117,7 +174,7 @@ describe("MediaUploadModal API wiring", () => {
|
|||
});
|
||||
expect(screen.getByText("Photos & Videos")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"),
|
||||
screen.getByText("Photos up to 10 MB · Videos up to 100 MB and 90 seconds"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -159,4 +216,107 @@ describe("MediaUploadModal API wiring", () => {
|
|||
workOrderVersion: "DETAIL-VER==",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when Emergency policy says uploads are unavailable", async () => {
|
||||
renderModal();
|
||||
|
||||
expect((document.querySelector('input[type="file"]') as HTMLInputElement).disabled).toBe(true);
|
||||
expect(screen.getByText("Uploads are not available for this work order.")).toBeInTheDocument();
|
||||
expect(uploadMutateAsync).not.toHaveBeenCalled();
|
||||
expect(resumableUploadMutateAsync).not.toHaveBeenCalled();
|
||||
expect(resumableUploadHookSpy).toHaveBeenCalledWith(7, "WorkOrderTable", expect.anything());
|
||||
});
|
||||
|
||||
it("routes by the detailed work-order type when policy denies uploads", () => {
|
||||
mediaQueryState.detailType = "Emergency";
|
||||
mediaQueryState.policy = {
|
||||
workOrderType: "Emergency",
|
||||
canUpload: false,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 10, videos: 3 },
|
||||
};
|
||||
renderModal("PM");
|
||||
|
||||
expect((document.querySelector('input[type="file"]') as HTMLInputElement).disabled).toBe(true);
|
||||
expect(screen.getByText("Uploads are not available for this work order.")).toBeInTheDocument();
|
||||
expect(resumableUploadHookSpy).toHaveBeenCalledWith(7, "WorkOrderTable", expect.anything());
|
||||
expect(uploadMutateAsync).not.toHaveBeenCalled();
|
||||
expect(resumableUploadMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed with a visible error when the Emergency policy request fails", () => {
|
||||
mediaQueryState.policy = undefined;
|
||||
mediaQueryState.isError = true;
|
||||
renderModal("PM");
|
||||
|
||||
expect((document.querySelector('input[type="file"]') as HTMLInputElement).disabled).toBe(true);
|
||||
expect(
|
||||
screen.getByText("Upload permissions could not be loaded. Refresh and try again."),
|
||||
).toBeInTheDocument();
|
||||
expect(uploadMutateAsync).not.toHaveBeenCalled();
|
||||
expect(resumableUploadMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the Emergency photo cap notice before starting an upload", () => {
|
||||
mediaQueryState.policy = {
|
||||
workOrderType: "Emergency",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 10, videos: 3 },
|
||||
};
|
||||
renderModal();
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(input, {
|
||||
target: {
|
||||
files: [new File([new Uint8Array(10_000_001)], "photo.jpg", { type: "image/jpeg" })],
|
||||
},
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('"photo.jpg" exceeds the photo limit (10 MB).');
|
||||
expect(resumableUploadMutateAsync).not.toHaveBeenCalled();
|
||||
expect(uploadMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects excess Emergency files in a multi-select batch using remaining policy slots", async () => {
|
||||
mediaQueryState.policy = {
|
||||
workOrderType: "Emergency",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 1, videos: 1 },
|
||||
};
|
||||
resumableUploadMutateAsync.mockResolvedValue({ id: "uploaded" });
|
||||
renderModal();
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const photoOne = new File(["one"], "one.jpg", { type: "image/jpeg" });
|
||||
const photoTwo = new File(["two"], "two.jpg", { type: "image/jpeg" });
|
||||
const video = new File(["clip"], "clip.mov", { type: "video/quicktime" });
|
||||
fireEvent.change(input, { target: { files: [photoOne, photoTwo, video] } });
|
||||
|
||||
await waitFor(() => expect(resumableUploadMutateAsync).toHaveBeenCalledTimes(2));
|
||||
expect(resumableUploadMutateAsync.mock.calls.map(([request]) => request.file)).toEqual([
|
||||
photoOne,
|
||||
video,
|
||||
]);
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"Photo limit reached (10 per work order). Remove a file to add another.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { toast } from "react-toastify";
|
||||
import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
vi.mock("react-toastify", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
function renderTab(pm: string, onOpenCompletionDoc = vi.fn()) {
|
||||
const row = { id: "1", type: "PM", pm, docStatus: "No", rowVersion: "1" } as WorkOrderTableRow;
|
||||
render(
|
||||
|
|
@ -43,4 +48,70 @@ describe("SlideOverCompletionTab service gate", () => {
|
|||
fireEvent.click(button);
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders disabled Emergency media upload with the policy failure message", () => {
|
||||
const onUpload = vi.fn();
|
||||
const row = {
|
||||
id: "1",
|
||||
type: "Emergency",
|
||||
docStatus: "No",
|
||||
rowVersion: "1",
|
||||
} as WorkOrderTableRow;
|
||||
render(
|
||||
<SlideOverCompletionTab
|
||||
row={row}
|
||||
isReadOnly={false}
|
||||
media={[]}
|
||||
uploadDisabled
|
||||
uploadUnavailableMessage="Upload permissions could not be loaded. Refresh and try again."
|
||||
onUploadMedia={onUpload}
|
||||
onOpenCompletionDoc={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"Upload permissions could not be loaded. Refresh and try again.",
|
||||
);
|
||||
expect(document.querySelector('input[type="file"]')).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /drag files here or click to browse/i }),
|
||||
).toBeDisabled();
|
||||
expect(onUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows per-kind Emergency limits before starting an oversized photo upload", () => {
|
||||
const onUpload = vi.fn();
|
||||
const row = {
|
||||
id: "1",
|
||||
type: "Emergency",
|
||||
docStatus: "No",
|
||||
rowVersion: "1",
|
||||
} as WorkOrderTableRow;
|
||||
render(
|
||||
<SlideOverCompletionTab
|
||||
row={row}
|
||||
isReadOnly={false}
|
||||
media={[]}
|
||||
typeAllowed={() => true}
|
||||
fileSizeError={(file) =>
|
||||
file.size > 10_000_000 ? `"${file.name}" exceeds the photo limit (10 MB).` : undefined
|
||||
}
|
||||
uploadHint="Photos up to 10 MB · Videos up to 100 MB and 90 seconds"
|
||||
onUploadMedia={onUpload}
|
||||
onOpenCompletionDoc={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Photos up to 10 MB · Videos up to 100 MB and 90 seconds"),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, {
|
||||
target: {
|
||||
files: [new File([new Uint8Array(10_000_001)], "photo.jpg", { type: "image/jpeg" })],
|
||||
},
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('"photo.jpg" exceeds the photo limit (10 MB).');
|
||||
expect(onUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,17 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({
|
||||
useWorkOrderMediaPolicy: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({
|
||||
useResumableWorkOrderMediaUpload: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// 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", () => ({
|
||||
|
|
|
|||
|
|
@ -1,8 +1,28 @@
|
|||
import { act, renderHook } from "@testing-library/react";
|
||||
import { act, fireEvent, render, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSlideOverMediaState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-media-state";
|
||||
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { SlideOverCompletionMedia } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media";
|
||||
|
||||
const policyState = vi.hoisted(() => ({
|
||||
data: undefined as unknown,
|
||||
isError: false,
|
||||
isLoading: true,
|
||||
}));
|
||||
const legacyUploadMutateAsync = vi.fn().mockResolvedValue({ id: "legacy" });
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({
|
||||
useWorkOrderMediaPolicy: () => policyState,
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({
|
||||
useResumableWorkOrderMediaUpload: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
|
|
@ -79,14 +99,18 @@ function detailWithMedia(
|
|||
function idleMutation() {
|
||||
return {
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
mutateAsync: legacyUploadMutateAsync,
|
||||
isPending: false,
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("useSlideOverMediaState completion filter", () => {
|
||||
beforeEach(() => {
|
||||
legacyUploadMutateAsync.mockClear();
|
||||
sessionStorage.clear();
|
||||
policyState.data = undefined;
|
||||
policyState.isError = false;
|
||||
policyState.isLoading = true;
|
||||
});
|
||||
|
||||
it("keeps Before/After/Completion after refetch mapped purpose (not file category)", () => {
|
||||
|
|
@ -327,4 +351,224 @@ describe("useSlideOverMediaState completion filter", () => {
|
|||
"pdf",
|
||||
]);
|
||||
});
|
||||
|
||||
it("blocks legacy completion upload while Emergency media policy is unavailable", async () => {
|
||||
const legacyUpload = idleMutation();
|
||||
const { result } = renderHook(() =>
|
||||
useSlideOverMediaState({
|
||||
detailForRow: detailWithMedia([], { type: "Emergency" }),
|
||||
uploadMediaMutation: legacyUpload,
|
||||
deleteMediaMutation: idleMutation(),
|
||||
categorizeMediaMutation: idleMutation(),
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.uploadMediaRequests([
|
||||
{
|
||||
localId: "local-emergency",
|
||||
file: new File(["x"], "photo.jpg", { type: "image/jpeg" }),
|
||||
onProgress: vi.fn(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(legacyUploadMutateAsync).not.toHaveBeenCalled();
|
||||
expect(result.current.completionMedia.some((item) => item.uploadStatus === "failed")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the Emergency completion surface disabled with a visible policy error", async () => {
|
||||
policyState.isError = true;
|
||||
policyState.isLoading = false;
|
||||
const legacyUpload = idleMutation();
|
||||
const { result } = renderHook(() =>
|
||||
useSlideOverMediaState({
|
||||
detailForRow: detailWithMedia([], { type: "Emergency" }),
|
||||
uploadMediaMutation: legacyUpload,
|
||||
deleteMediaMutation: idleMutation(),
|
||||
categorizeMediaMutation: idleMutation(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.emergencyMediaUnavailable).toBe(true);
|
||||
expect(result.current.emergencyMediaUnavailableMessage).toBe(
|
||||
"Upload permissions could not be loaded. Refresh and try again.",
|
||||
);
|
||||
act(() => {
|
||||
result.current.uploadMediaRequests([
|
||||
{
|
||||
localId: "local-policy-error",
|
||||
file: new File(["x"], "photo.jpg", { type: "image/jpeg" }),
|
||||
onProgress: vi.fn(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(legacyUploadMutateAsync).not.toHaveBeenCalled();
|
||||
expect(result.current.completionMedia[0]?.uploadStatus).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the table row type to fail closed while work-order detail is loading", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSlideOverMediaState({
|
||||
row: baseInfo({ type: "Reactive" }),
|
||||
uploadMediaMutation: idleMutation(),
|
||||
deleteMediaMutation: idleMutation(),
|
||||
categorizeMediaMutation: idleMutation(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.emergencyMediaUnavailable).toBe(true);
|
||||
expect(
|
||||
result.current.emergencyMediaTypeAllowed?.(
|
||||
new File(["x"], "clip.mov", { type: "video/quicktime" }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes server-derived Emergency size caps to the slide-over uploader", () => {
|
||||
policyState.data = {
|
||||
workOrderType: "Reactive",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 10, videos: 3 },
|
||||
};
|
||||
policyState.isLoading = false;
|
||||
const { result } = renderHook(() =>
|
||||
useSlideOverMediaState({
|
||||
detailForRow: detailWithMedia([], { type: "Reactive" }),
|
||||
uploadMediaMutation: idleMutation(),
|
||||
deleteMediaMutation: idleMutation(),
|
||||
categorizeMediaMutation: idleMutation(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.current.emergencyMediaSizeError?.(
|
||||
new File([new Uint8Array(10_000_001)], "photo.jpg", { type: "image/jpeg" }),
|
||||
),
|
||||
).toBe('"photo.jpg" exceeds the photo limit (10 MB).');
|
||||
});
|
||||
|
||||
it("exposes per-batch remaining-slot validation to the slide-over uploader", () => {
|
||||
policyState.data = {
|
||||
workOrderType: "Reactive",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 1, videos: 1 },
|
||||
};
|
||||
policyState.isLoading = false;
|
||||
const { result } = renderHook(() =>
|
||||
useSlideOverMediaState({
|
||||
detailForRow: detailWithMedia([], { type: "Reactive" }),
|
||||
uploadMediaMutation: idleMutation(),
|
||||
deleteMediaMutation: idleMutation(),
|
||||
categorizeMediaMutation: idleMutation(),
|
||||
}),
|
||||
);
|
||||
const photo = new File(["photo"], "photo.jpg", { type: "image/jpeg" });
|
||||
const video = new File(["video"], "video.mov", { type: "video/quicktime" });
|
||||
|
||||
expect(result.current.emergencyMediaBatchFileError?.(photo, [])).toBeUndefined();
|
||||
expect(result.current.emergencyMediaBatchFileError?.(video, [photo])).toBeUndefined();
|
||||
expect(result.current.emergencyMediaBatchFileError?.(photo, [photo])).toBe(
|
||||
"Photo limit reached (10 per work order). Remove a file to add another.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps multi-select within Reactive remaining slots on the visible slide-over surface", () => {
|
||||
policyState.data = {
|
||||
workOrderType: "Reactive",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 1, videos: 3 },
|
||||
};
|
||||
policyState.isLoading = false;
|
||||
const { result } = renderHook(() =>
|
||||
useSlideOverMediaState({
|
||||
detailForRow: detailWithMedia([], { type: "Reactive" }),
|
||||
uploadMediaMutation: idleMutation(),
|
||||
deleteMediaMutation: idleMutation(),
|
||||
categorizeMediaMutation: idleMutation(),
|
||||
}),
|
||||
);
|
||||
const onUploadMedia = vi.fn();
|
||||
render(
|
||||
<SlideOverCompletionMedia
|
||||
isReadOnly={false}
|
||||
media={[]}
|
||||
mediaPending={false}
|
||||
onUploadMedia={onUploadMedia}
|
||||
batchFileError={result.current.emergencyMediaBatchFileError}
|
||||
/>,
|
||||
);
|
||||
const photoOne = new File(["one"], "one.jpg", { type: "image/jpeg" });
|
||||
const photoTwo = new File(["two"], "two.jpg", { type: "image/jpeg" });
|
||||
fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, {
|
||||
target: { files: [photoOne, photoTwo] },
|
||||
});
|
||||
|
||||
expect(onUploadMedia).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
onUploadMedia.mock.calls[0]?.[0].map((request: MediaUploadRequest) => request.file),
|
||||
).toEqual([photoOne]);
|
||||
});
|
||||
|
||||
it("reuses the same idempotency key when a failed media tile is retried", async () => {
|
||||
const mutateAsync = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("network unavailable"))
|
||||
.mockResolvedValue({ id: "4" });
|
||||
const uploadMutation = {
|
||||
mutate: vi.fn(),
|
||||
mutateAsync,
|
||||
isPending: false,
|
||||
} as never;
|
||||
const { result } = renderHook(() =>
|
||||
useSlideOverMediaState({
|
||||
detailForRow: detailWithMedia([], { type: "PM" }),
|
||||
uploadMediaMutation: uploadMutation,
|
||||
deleteMediaMutation: idleMutation(),
|
||||
categorizeMediaMutation: idleMutation(),
|
||||
}),
|
||||
);
|
||||
const file = new File(["photo"], "photo.jpg", { type: "image/jpeg" });
|
||||
|
||||
act(() => {
|
||||
result.current.uploadMediaRequests([{ localId: "local-retry", file, onProgress: vi.fn() }]);
|
||||
});
|
||||
await waitFor(() => expect(result.current.completionMedia[0]?.uploadStatus).toBe("failed"));
|
||||
|
||||
act(() => result.current.retryUpload("local-retry"));
|
||||
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(2));
|
||||
expect(mutateAsync.mock.calls[0]?.[0].file).toBe(file);
|
||||
expect(mutateAsync.mock.calls[1]?.[0].file).toBe(file);
|
||||
expect(mutateAsync.mock.calls[1]?.[0].idempotencyKey).toBe(
|
||||
mutateAsync.mock.calls[0]?.[0].idempotencyKey,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,17 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({
|
||||
useWorkOrderMediaPolicy: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({
|
||||
useResumableWorkOrderMediaUpload: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
|
||||
useAddWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useUpdateWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
|
|
|
|||
217
src/test/app/v/vendor-media-section.test.tsx
Normal file
217
src/test/app/v/vendor-media-section.test.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as mediaUploadEngine from "@/api/media-upload-engine";
|
||||
import type { MediaUploadEndpointSet } from "@/api/media-upload-client";
|
||||
import { MediaUploadNetworkError } from "@/api/media-upload-client";
|
||||
import type { MediaChunkAckDto } from "@/api/media-upload-contracts";
|
||||
import * as mediaLimits from "@/domain/work-orders/utils/emergency-media-limits";
|
||||
import { vendorPortalMediaApi } from "@/domain/vendor-portal/api/vendor-portal-media-api";
|
||||
import type {
|
||||
VendorMediaItem,
|
||||
VendorPortalMedia,
|
||||
} from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { VendorMediaSection } from "@/app/v/[token]/dispatch/_components/vendor-media-section";
|
||||
|
||||
const MEDIA: VendorPortalMedia = {
|
||||
policy: {
|
||||
workOrderType: "Emergency",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 9, videos: 3 },
|
||||
},
|
||||
items: [],
|
||||
};
|
||||
|
||||
function renderVendorMedia() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<VendorMediaSection token="vendor-token" dispatchId={9} locked={false} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Vendor media controls", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(vendorPortalMediaApi, "getMedia").mockResolvedValue(MEDIA);
|
||||
vi.spyOn(vendorPortalMediaApi, "endpoints").mockReturnValue({} as never);
|
||||
vi.spyOn(mediaLimits, "validateMediaFileAgainstLimits").mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("retries the same file with the same idempotency key after a partial failure", async () => {
|
||||
const upload = vi
|
||||
.spyOn(mediaUploadEngine, "uploadFileResumable")
|
||||
.mockRejectedValueOnce(new MediaUploadNetworkError())
|
||||
.mockResolvedValue({
|
||||
uploadId: "upload-1",
|
||||
state: "Completed",
|
||||
chunkSizeBytes: 5_242_880,
|
||||
sizeBytes: 5,
|
||||
receivedChunks: [0],
|
||||
expiresAt: "2026-09-24T00:00:00Z",
|
||||
attachment: { id: 1 },
|
||||
});
|
||||
renderVendorMedia();
|
||||
|
||||
const file = new File(["video"], "clip.mov", { type: "video/quicktime" });
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Upload photo or video" })).toBeEnabled(),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Choose photos or videos"), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Retry upload" })).toBeInTheDocument(),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry upload" }));
|
||||
|
||||
await waitFor(() => expect(upload).toHaveBeenCalledTimes(2));
|
||||
expect(upload.mock.calls[0]?.[0].file).toBe(file);
|
||||
expect(upload.mock.calls[1]?.[0].file).toBe(file);
|
||||
expect(upload.mock.calls[1]?.[0].idempotencyKey).toBe(upload.mock.calls[0]?.[0].idempotencyKey);
|
||||
});
|
||||
|
||||
it("offers same-session retry after a transient HTTP 503", async () => {
|
||||
const unavailable = Object.assign(new Error("Upload request failed (HTTP 503)."), {
|
||||
name: "MediaUploadHttpError",
|
||||
status: 503,
|
||||
});
|
||||
const upload = vi
|
||||
.spyOn(mediaUploadEngine, "uploadFileResumable")
|
||||
.mockRejectedValueOnce(unavailable)
|
||||
.mockResolvedValue({
|
||||
uploadId: "upload-503",
|
||||
state: "Completed",
|
||||
chunkSizeBytes: 5_242_880,
|
||||
sizeBytes: 5,
|
||||
receivedChunks: [0],
|
||||
expiresAt: "2026-09-24T00:00:00Z",
|
||||
attachment: { id: 2 },
|
||||
});
|
||||
renderVendorMedia();
|
||||
|
||||
const file = new File(["video"], "clip.mov", { type: "video/quicktime" });
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Upload photo or video" })).toBeEnabled(),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Choose photos or videos"), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Retry upload" })).toBeInTheDocument(),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry upload" }));
|
||||
|
||||
await waitFor(() => expect(upload).toHaveBeenCalledTimes(2));
|
||||
expect(upload.mock.calls[1]?.[0].file).toBe(file);
|
||||
expect(upload.mock.calls[1]?.[0].idempotencyKey).toBe(upload.mock.calls[0]?.[0].idempotencyKey);
|
||||
});
|
||||
|
||||
it("aborts the active vendor upload from the visible Cancel control", async () => {
|
||||
const cancel = vi.fn().mockResolvedValue(undefined);
|
||||
const endpoints: MediaUploadEndpointSet = {
|
||||
create: vi.fn().mockResolvedValue({
|
||||
uploadId: "upload-cancel",
|
||||
state: "Created",
|
||||
chunkSizeBytes: 5_242_880,
|
||||
sizeBytes: 5,
|
||||
receivedChunks: [],
|
||||
expiresAt: "2026-09-24T00:00:00Z",
|
||||
}),
|
||||
putChunk: vi.fn(
|
||||
({ signal }: Parameters<MediaUploadEndpointSet["putChunk"]>[0]) =>
|
||||
new Promise<MediaChunkAckDto>((_resolve, reject) => {
|
||||
const abort = () => reject(new DOMException("Aborted", "AbortError"));
|
||||
if (signal?.aborted) abort();
|
||||
else signal?.addEventListener("abort", abort, { once: true });
|
||||
}),
|
||||
),
|
||||
commit: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
cancel,
|
||||
};
|
||||
vi.mocked(vendorPortalMediaApi.endpoints).mockReturnValue(endpoints);
|
||||
renderVendorMedia();
|
||||
|
||||
const file = new File(["video"], "clip.mov", { type: "video/quicktime" });
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Upload photo or video" })).toBeEnabled(),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Choose photos or videos"), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Cancel upload" })).toBeInTheDocument(),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel upload" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Upload canceled.")).toBeInTheDocument());
|
||||
expect(endpoints.putChunk).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
signal: expect.objectContaining({ aborted: true }),
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(cancel).toHaveBeenCalledWith("upload-cancel"));
|
||||
});
|
||||
|
||||
it("opens media from the View click before fetching and revokes its object URL on close", async () => {
|
||||
const item: VendorMediaItem = {
|
||||
id: 14,
|
||||
originalFileName: "photo.jpg",
|
||||
contentType: "image/jpeg",
|
||||
sizeBytes: 5,
|
||||
kind: "Photo",
|
||||
category: "Extra",
|
||||
scanStatus: "Passed",
|
||||
createdAt: "2026-09-23T00:00:00Z",
|
||||
canView: true,
|
||||
};
|
||||
vi.spyOn(vendorPortalMediaApi, "getMedia").mockResolvedValue({
|
||||
...MEDIA,
|
||||
items: [item],
|
||||
});
|
||||
const addEventListener = vi.fn();
|
||||
const previewWindow = {
|
||||
opener: window,
|
||||
closed: false,
|
||||
document: { title: "", body: { textContent: "" } },
|
||||
location: { replace: vi.fn() },
|
||||
addEventListener,
|
||||
close: vi.fn(),
|
||||
} as unknown as Window;
|
||||
const open = vi.spyOn(window, "open").mockReturnValue(previewWindow);
|
||||
const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:vendor-media");
|
||||
const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
|
||||
const fetchContent = vi
|
||||
.spyOn(vendorPortalMediaApi, "fetchContent")
|
||||
.mockImplementation(async (_token, _dispatchId, mediaId) => {
|
||||
expect(open).toHaveBeenCalledWith("about:blank", "_blank");
|
||||
expect(mediaId).toBe(item.id);
|
||||
return new Blob(["image"], { type: item.contentType });
|
||||
});
|
||||
renderVendorMedia();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "View" }));
|
||||
|
||||
await waitFor(() => expect(fetchContent).toHaveBeenCalledWith("vendor-token", 9, item.id));
|
||||
expect(createObjectURL).toHaveBeenCalledOnce();
|
||||
expect(previewWindow.document.title).toBe(item.originalFileName);
|
||||
expect(previewWindow.location.replace).toHaveBeenCalledWith("blob:vendor-media");
|
||||
const pageHide = addEventListener.mock.calls[0]?.[1] as EventListener | undefined;
|
||||
pageHide?.(new Event("pagehide"));
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:vendor-media");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const API_URL = "http://api.test/api";
|
||||
let captured: { method: string; url: string; headers: Headers; body: string }[] = [];
|
||||
|
||||
const MEDIA_PAYLOAD = {
|
||||
policy: {
|
||||
workOrderType: "Emergency",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 9, videos: 3 },
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: 5,
|
||||
originalFileName: "before.jpg",
|
||||
contentType: "image/jpeg",
|
||||
sizeBytes: 2048,
|
||||
kind: "Photo",
|
||||
category: "Extra",
|
||||
scanStatus: "Passed",
|
||||
createdAt: "2026-09-23T10:00:00Z",
|
||||
canView: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
captured = [];
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/env", () => ({ env: { apiUrl: API_URL } }));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init);
|
||||
captured.push({
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: await request.text(),
|
||||
});
|
||||
return new Response(JSON.stringify({ data: MEDIA_PAYLOAD }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function lastRequest() {
|
||||
const request = captured[captured.length - 1];
|
||||
if (!request) throw new Error("No request reached fetch");
|
||||
return request;
|
||||
}
|
||||
|
||||
describe("vendor portal media API", () => {
|
||||
it("lists media with policy and scan-passed items using X-Vendor-Token only", async () => {
|
||||
const { vendorPortalMediaApi } =
|
||||
await import("@/domain/vendor-portal/api/vendor-portal-media-api");
|
||||
const media = await vendorPortalMediaApi.getMedia("portal-token", 9);
|
||||
|
||||
const request = lastRequest();
|
||||
expect(request.url).toBe(`${API_URL}/vendor-portal/dispatches/9/media`);
|
||||
expect(request.headers.get("x-vendor-token")).toBe("portal-token");
|
||||
expect(request.headers.get("authorization")).toBeNull();
|
||||
expect(media.policy.canUpload).toBe(true);
|
||||
expect(media.items[0]).toMatchObject({
|
||||
id: 5,
|
||||
kind: "Photo",
|
||||
scanStatus: "Passed",
|
||||
canView: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates chunked upload sessions on the vendor uploads base with the exact DTO", async () => {
|
||||
const { vendorPortalMediaApi } =
|
||||
await import("@/domain/vendor-portal/api/vendor-portal-media-api");
|
||||
await vendorPortalMediaApi.endpoints("portal-token", 9).create({
|
||||
idempotencyKey: "99999999-8888-7777-6666-555555555555",
|
||||
fileName: "after.mp4",
|
||||
contentType: "video/mp4",
|
||||
sizeBytes: 2048,
|
||||
surface: "VendorPortal",
|
||||
});
|
||||
|
||||
const request = lastRequest();
|
||||
expect(request.url).toBe(`${API_URL}/vendor-portal/dispatches/9/media/uploads`);
|
||||
expect(request.headers.get("x-vendor-token")).toBe("portal-token");
|
||||
expect(request.headers.get("authorization")).toBeNull();
|
||||
expect(JSON.parse(request.body)).toEqual({
|
||||
idempotencyKey: "99999999-8888-7777-6666-555555555555",
|
||||
fileName: "after.mp4",
|
||||
contentType: "video/mp4",
|
||||
sizeBytes: 2048,
|
||||
surface: "VendorPortal",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends chunk, commit, status and cancel calls with the vendor token", async () => {
|
||||
const { vendorPortalMediaApi } =
|
||||
await import("@/domain/vendor-portal/api/vendor-portal-media-api");
|
||||
const endpoints = vendorPortalMediaApi.endpoints("portal-token", 9);
|
||||
|
||||
await endpoints.putChunk({
|
||||
uploadId: "u3",
|
||||
index: 0,
|
||||
blob: new Blob([new Uint8Array(2)]),
|
||||
start: 0,
|
||||
end: 2,
|
||||
totalBytes: 2,
|
||||
sha256: "ff00",
|
||||
});
|
||||
expect(lastRequest().url).toBe(
|
||||
`${API_URL}/vendor-portal/dispatches/9/media/uploads/u3/chunks/0`,
|
||||
);
|
||||
|
||||
await endpoints.commit("u3");
|
||||
expect(lastRequest().url).toBe(`${API_URL}/vendor-portal/dispatches/9/media/uploads/u3/commit`);
|
||||
|
||||
await endpoints.getStatus("u3");
|
||||
expect(lastRequest().url).toBe(`${API_URL}/vendor-portal/dispatches/9/media/uploads/u3`);
|
||||
|
||||
await endpoints.cancel("u3");
|
||||
expect(lastRequest()).toMatchObject({
|
||||
method: "DELETE",
|
||||
url: `${API_URL}/vendor-portal/dispatches/9/media/uploads/u3`,
|
||||
});
|
||||
expect(lastRequest().headers.get("x-vendor-token")).toBe("portal-token");
|
||||
});
|
||||
|
||||
it("fetches media content only on demand from the content route", async () => {
|
||||
const { vendorPortalMediaApi } =
|
||||
await import("@/domain/vendor-portal/api/vendor-portal-media-api");
|
||||
const blob = await vendorPortalMediaApi.fetchContent("portal-token", 9, 5);
|
||||
|
||||
const request = lastRequest();
|
||||
expect(request.url).toBe(`${API_URL}/vendor-portal/dispatches/9/media/5/content`);
|
||||
expect(request.headers.get("x-vendor-token")).toBe("portal-token");
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
});
|
||||
|
||||
it("never exposes object URLs or storage endpoints in the media payload", async () => {
|
||||
const { vendorPortalMediaApi } =
|
||||
await import("@/domain/vendor-portal/api/vendor-portal-media-api");
|
||||
const media = await vendorPortalMediaApi.getMedia("portal-token", 9);
|
||||
|
||||
const serialized = JSON.stringify(media);
|
||||
expect(serialized).not.toContain("blob:");
|
||||
expect(serialized).not.toContain("https://storage");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const API_URL = "http://api.test/api";
|
||||
let captured: { method: string; url: string; headers: Headers; body: string }[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
captured = [];
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/env", () => ({ env: { apiUrl: API_URL } }));
|
||||
vi.doMock("@/lib/auth/auth-storage", () => ({ getAccessToken: () => "Bearer test-token" }));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init);
|
||||
captured.push({
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: await request.text(),
|
||||
});
|
||||
return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function lastRequest() {
|
||||
const request = captured[captured.length - 1];
|
||||
if (!request) throw new Error("No request reached fetch");
|
||||
return request;
|
||||
}
|
||||
|
||||
describe("dispatcher resumable upload API", () => {
|
||||
it("fetches the Emergency/Reactive media policy from the documented route", async () => {
|
||||
const { workOrdersMediaUploadApi } =
|
||||
await import("@/domain/work-orders/api/work-order-media-upload-api");
|
||||
await workOrdersMediaUploadApi.getMediaPolicy(7);
|
||||
|
||||
const request = lastRequest();
|
||||
expect(request.url).toBe(`${API_URL}/workorders/7/media/policy`);
|
||||
expect(request.method).toBe("GET");
|
||||
expect(request.headers.get("authorization")).toBe("Bearer test-token");
|
||||
});
|
||||
|
||||
it("creates sessions on the work-order-bound uploads base with the exact DTO shape", async () => {
|
||||
const { workOrdersMediaUploadApi } =
|
||||
await import("@/domain/work-orders/api/work-order-media-upload-api");
|
||||
await workOrdersMediaUploadApi.endpoints(10).create({
|
||||
idempotencyKey: "11111111-2222-3333-4444-555555555555",
|
||||
fileName: "photo.jpg",
|
||||
contentType: "image/jpeg",
|
||||
sizeBytes: 12_345,
|
||||
surface: "WorkOrderTable",
|
||||
});
|
||||
|
||||
const request = lastRequest();
|
||||
expect(request.url).toBe(`${API_URL}/workorders/10/media/uploads`);
|
||||
expect(request.method).toBe("POST");
|
||||
expect(request.headers.get("authorization")).toBe("Bearer test-token");
|
||||
expect(request.headers.get("content-type")).toBe("application/json");
|
||||
expect(JSON.parse(request.body)).toEqual({
|
||||
idempotencyKey: "11111111-2222-3333-4444-555555555555",
|
||||
fileName: "photo.jpg",
|
||||
contentType: "image/jpeg",
|
||||
sizeBytes: 12_345,
|
||||
surface: "WorkOrderTable",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits category when not provided and sends it for explicit categories", async () => {
|
||||
const { workOrdersMediaUploadApi } =
|
||||
await import("@/domain/work-orders/api/work-order-media-upload-api");
|
||||
const endpoints = workOrdersMediaUploadApi.endpoints(10);
|
||||
|
||||
await endpoints.create({
|
||||
idempotencyKey: "11111111-2222-3333-4444-555555555556",
|
||||
fileName: "a.jpg",
|
||||
contentType: "image/jpeg",
|
||||
sizeBytes: 5,
|
||||
category: "Aveta",
|
||||
surface: "CompletionMedia",
|
||||
});
|
||||
expect(JSON.parse(lastRequest().body)).toMatchObject({
|
||||
category: "Aveta",
|
||||
surface: "CompletionMedia",
|
||||
});
|
||||
});
|
||||
|
||||
it("PUTs raw chunk bytes with Content-Range and X-Chunk-SHA256 on the session route", async () => {
|
||||
const { workOrdersMediaUploadApi } =
|
||||
await import("@/domain/work-orders/api/work-order-media-upload-api");
|
||||
const blob = new Blob([new Uint8Array(4)]);
|
||||
|
||||
await workOrdersMediaUploadApi.endpoints(10).putChunk({
|
||||
uploadId: "u9",
|
||||
index: 2,
|
||||
blob,
|
||||
start: 10_485_760,
|
||||
end: 10_485_764,
|
||||
totalBytes: 10_485_764,
|
||||
sha256: "ABCD",
|
||||
signal: undefined,
|
||||
});
|
||||
|
||||
const request = lastRequest();
|
||||
expect(request.url).toBe(`${API_URL}/workorders/10/media/uploads/u9/chunks/2`);
|
||||
expect(request.method).toBe("PUT");
|
||||
expect(request.headers.get("content-range")).toBe("bytes 10485760-10485763/10485764");
|
||||
expect(request.headers.get("x-chunk-sha256")).toBe("abcd");
|
||||
expect(request.headers.get("content-type")).toBe("application/octet-stream");
|
||||
});
|
||||
|
||||
it("routes commit, status and cancel under the same session base", async () => {
|
||||
const { workOrdersMediaUploadApi } =
|
||||
await import("@/domain/work-orders/api/work-order-media-upload-api");
|
||||
const endpoints = workOrdersMediaUploadApi.endpoints(10);
|
||||
|
||||
await endpoints.commit("u9");
|
||||
expect(lastRequest()).toMatchObject({ method: "POST" });
|
||||
expect(lastRequest().url).toBe(`${API_URL}/workorders/10/media/uploads/u9/commit`);
|
||||
|
||||
await endpoints.getStatus("u9");
|
||||
expect(lastRequest()).toMatchObject({
|
||||
method: "GET",
|
||||
url: `${API_URL}/workorders/10/media/uploads/u9`,
|
||||
});
|
||||
|
||||
await endpoints.cancel("u9");
|
||||
expect(lastRequest()).toMatchObject({
|
||||
method: "DELETE",
|
||||
url: `${API_URL}/workorders/10/media/uploads/u9`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("protected legacy upload surfaces", () => {
|
||||
it("keeps the signed completion-doc, media content and Extra Docs routes and limits untouched", async () => {
|
||||
const { API_PATHS } = await import("@/api/api-paths");
|
||||
const limits = await import("@/domain/work-orders/utils/work-order-media-limits");
|
||||
const { validateVendorDocument } =
|
||||
await import("@/domain/vendor-portal/lib/document-validation");
|
||||
|
||||
expect(API_PATHS.workOrder.mediaContent(10, 12)).toBe("workorders/10/media/12/content");
|
||||
expect(API_PATHS.workOrder.completionDoc(10)).toBe("workorders/10/completion-doc");
|
||||
expect(limits.MAX_WORK_ORDER_COMPLETION_DOC_BYTES).toBe(50_000_000);
|
||||
expect(limits.MAX_WORK_ORDER_MEDIA_BYTES).toBe(200_000_000);
|
||||
|
||||
// Vendor completion/uplift documents keep the 10,485,760-byte legacy limit.
|
||||
const legacyPdf = { size: 10 * 1024 * 1024, type: "application/pdf", name: "doc.pdf" } as File;
|
||||
const oversizedPdf = {
|
||||
size: 10 * 1024 * 1024 + 1,
|
||||
type: "application/pdf",
|
||||
name: "doc.pdf",
|
||||
} as File;
|
||||
expect(validateVendorDocument(legacyPdf)).toBeUndefined();
|
||||
expect(validateVendorDocument(oversizedPdf)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MediaUploadPolicy, MediaUploadSessionDto } from "@/api/media-upload-contracts";
|
||||
import * as mediaUploadEngine from "@/api/media-upload-engine";
|
||||
import { workOrdersMediaUploadApi } from "@/domain/work-orders/api/work-order-media-upload-api";
|
||||
import * as mediaLimits from "@/domain/work-orders/utils/emergency-media-limits";
|
||||
import { useResumableWorkOrderMediaUpload } from "@/domain/work-orders/use-cases/use-resumable-work-order-media-upload";
|
||||
import { useWorkOrderMediaPolicy } from "@/domain/work-orders/use-cases/use-work-order-media-policy";
|
||||
|
||||
const POLICY: MediaUploadPolicy = {
|
||||
workOrderType: "Emergency",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 2, videos: 3 },
|
||||
};
|
||||
|
||||
const COMPLETED: MediaUploadSessionDto = {
|
||||
uploadId: "upload-1",
|
||||
state: "Completed",
|
||||
chunkSizeBytes: 5_242_880,
|
||||
sizeBytes: 5,
|
||||
receivedChunks: [0],
|
||||
expiresAt: "2026-09-24T00:00:00Z",
|
||||
attachment: { id: 1 },
|
||||
};
|
||||
|
||||
describe("Emergency media upload policy refresh", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("keeps the upload mutation pending until fresh remaining slots arrive", async () => {
|
||||
let resolvePolicyRefresh: ((policy: MediaUploadPolicy) => void) | undefined;
|
||||
const policyRefresh = new Promise<MediaUploadPolicy>((resolve) => {
|
||||
resolvePolicyRefresh = resolve;
|
||||
});
|
||||
const getPolicy = vi
|
||||
.spyOn(workOrdersMediaUploadApi, "getMediaPolicy")
|
||||
.mockResolvedValueOnce(POLICY)
|
||||
.mockReturnValueOnce(policyRefresh);
|
||||
vi.spyOn(workOrdersMediaUploadApi, "endpoints").mockReturnValue({} as never);
|
||||
vi.spyOn(mediaUploadEngine, "uploadFileResumable").mockResolvedValue(COMPLETED);
|
||||
vi.spyOn(mediaLimits, "validateMediaFileAgainstLimits").mockResolvedValue({ ok: true });
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const policyQuery = useWorkOrderMediaPolicy(42, true);
|
||||
const upload = useResumableWorkOrderMediaUpload(42, "WorkOrderTable", policyQuery.data);
|
||||
return { policyQuery, upload };
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.policyQuery.data).toEqual(POLICY));
|
||||
const uploadPromise = result.current.upload.mutateAsync({
|
||||
file: new File(["photo"], "photo.jpg", { type: "image/jpeg" }),
|
||||
});
|
||||
await waitFor(() => expect(getPolicy).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(result.current.upload.isPending).toBe(true);
|
||||
expect(result.current.policyQuery.data?.remaining.photos).toBe(2);
|
||||
|
||||
await act(async () => {
|
||||
resolvePolicyRefresh?.({ ...POLICY, remaining: { photos: 1, videos: 3 } });
|
||||
await policyRefresh;
|
||||
await uploadPromise;
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.policyQuery.data?.remaining.photos).toBe(1));
|
||||
expect(result.current.upload.isPending).toBe(false);
|
||||
expect(mediaUploadEngine.uploadFileResumable).toHaveBeenCalledOnce();
|
||||
expect(mediaLimits.validateMediaFileAgainstLimits).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "photo.jpg" }),
|
||||
POLICY,
|
||||
);
|
||||
});
|
||||
});
|
||||
150
src/test/domain/work-orders/utils/emergency-media-limits.test.ts
Normal file
150
src/test/domain/work-orders/utils/emergency-media-limits.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
classifyMediaKind,
|
||||
emergencyMediaBatchCountError,
|
||||
emergencyMediaAcceptAttribute,
|
||||
isEmergencyReactiveWorkOrderType,
|
||||
mediaKindLimitReachedNotice,
|
||||
mediaOverLimitNotice,
|
||||
probeVideoDurationSeconds,
|
||||
validateMediaFileAgainstLimits,
|
||||
} from "@/domain/work-orders/utils/emergency-media-limits";
|
||||
|
||||
const BASE_POLICY = {
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 10, videos: 3 },
|
||||
};
|
||||
|
||||
function fileOf(size: number, name: string, type: string): File {
|
||||
return new File([new Uint8Array(size)], name, { type });
|
||||
}
|
||||
|
||||
const nullProbe = async () => null;
|
||||
|
||||
describe("Emergency/Reactive media limits", () => {
|
||||
it("classifies photos including HEIC and videos MP4/MOV, rejecting other kinds", () => {
|
||||
expect(classifyMediaKind(fileOf(1, "a.heic", "image/heic"))).toBe("Photo");
|
||||
expect(classifyMediaKind(fileOf(1, "a.jpg", ""))).toBe("Photo");
|
||||
expect(classifyMediaKind(fileOf(1, "a.mov", "video/quicktime"))).toBe("Video");
|
||||
expect(classifyMediaKind(fileOf(1, "a.gif", "image/gif"))).toBeNull();
|
||||
expect(classifyMediaKind(fileOf(1, "b.txt", "text/plain"))).toBeNull();
|
||||
});
|
||||
|
||||
it("applies size limits inclusively — at the cap passes, one byte over fails", async () => {
|
||||
const photoAtCap = fileOf(10_000_000, "a.jpg", "image/jpeg");
|
||||
const photoOver = fileOf(10_000_001, "a.jpg", "image/jpeg");
|
||||
expect((await validateMediaFileAgainstLimits(photoAtCap, BASE_POLICY, nullProbe)).ok).toBe(
|
||||
true,
|
||||
);
|
||||
const over = await validateMediaFileAgainstLimits(photoOver, BASE_POLICY, nullProbe);
|
||||
expect(over).toMatchObject({ ok: false });
|
||||
|
||||
const videoAtCap = fileOf(100_000_000, "v.mp4", "video/mp4");
|
||||
const videoOver = fileOf(100_000_001, "v.mp4", "video/mp4");
|
||||
expect((await validateMediaFileAgainstLimits(videoAtCap, BASE_POLICY, nullProbe)).ok).toBe(
|
||||
true,
|
||||
);
|
||||
expect((await validateMediaFileAgainstLimits(videoOver, BASE_POLICY, nullProbe)).ok).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces the video duration cap only when the duration can be probed", async () => {
|
||||
const longVideo = fileOf(1_000, "v.mp4", "video/mp4");
|
||||
expect((await validateMediaFileAgainstLimits(longVideo, BASE_POLICY, async () => 90)).ok).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
(await validateMediaFileAgainstLimits(longVideo, BASE_POLICY, async () => 90.5)).ok,
|
||||
).toBe(false);
|
||||
expect((await validateMediaFileAgainstLimits(longVideo, BASE_POLICY, async () => 91)).ok).toBe(
|
||||
false,
|
||||
);
|
||||
// MOV metadata probing fails — stays eligible, the server enforces at commit.
|
||||
expect(
|
||||
(await validateMediaFileAgainstLimits(longVideo, BASE_POLICY, async () => null)).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects files when the policy remaining count is exhausted", async () => {
|
||||
const policy = { ...BASE_POLICY, remaining: { photos: 0, videos: 3 } };
|
||||
const result = await validateMediaFileAgainstLimits(
|
||||
fileOf(10, "a.jpg", "image/jpeg"),
|
||||
policy,
|
||||
nullProbe,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("counts already accepted files in the current batch against remaining slots", () => {
|
||||
const policy = { limits: BASE_POLICY.limits, remaining: { photos: 1, videos: 1 } };
|
||||
const firstPhoto = fileOf(10, "first.jpg", "image/jpeg");
|
||||
const extraPhoto = fileOf(10, "extra.jpg", "image/jpeg");
|
||||
const firstVideo = fileOf(10, "first.mov", "video/quicktime");
|
||||
|
||||
expect(emergencyMediaBatchCountError(firstPhoto, [], policy)).toBeUndefined();
|
||||
expect(emergencyMediaBatchCountError(firstVideo, [firstPhoto], policy)).toBeUndefined();
|
||||
expect(emergencyMediaBatchCountError(extraPhoto, [firstPhoto], policy)).toBe(
|
||||
"Photo limit reached (10 per work order). Remove a file to add another.",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a standard safe over-limit notice without raw payloads", async () => {
|
||||
const notice = mediaOverLimitNotice(
|
||||
fileOf(100_000_001, "big.mov", "video/quicktime"),
|
||||
"Video",
|
||||
BASE_POLICY.limits,
|
||||
);
|
||||
expect(notice).toContain("big.mov");
|
||||
expect(notice).toContain("100 MB");
|
||||
expect(notice).toContain("90 seconds");
|
||||
expect(mediaKindLimitReachedNotice("Photo", BASE_POLICY.limits)).toContain("10");
|
||||
});
|
||||
|
||||
it("matches only Emergency and Reactive work order types", () => {
|
||||
expect(isEmergencyReactiveWorkOrderType("Emergency")).toBe(true);
|
||||
expect(isEmergencyReactiveWorkOrderType("reactive")).toBe(true);
|
||||
expect(isEmergencyReactiveWorkOrderType("Preventive")).toBe(false);
|
||||
expect(isEmergencyReactiveWorkOrderType(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("offers HEIC in the emergency accept attribute", () => {
|
||||
const accept = emergencyMediaAcceptAttribute();
|
||||
expect(accept).toContain("image/heic");
|
||||
expect(accept).toContain(".mov");
|
||||
});
|
||||
|
||||
it("revokes the preview URL after probing duration, resolving null when the decode fails", async () => {
|
||||
const createObjectURL = vi.fn(() => "blob:probe");
|
||||
const revokeObjectURL = vi.fn();
|
||||
vi.stubGlobal("URL", { ...URL, createObjectURL, revokeObjectURL });
|
||||
|
||||
const media: Record<string, unknown> = {};
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, "createElement").mockImplementation(((tag: string) => {
|
||||
if (tag === "video") {
|
||||
const element = originalCreateElement("video");
|
||||
Object.defineProperty(element, "duration", { value: NaN, configurable: true });
|
||||
media[element.src as string] = true;
|
||||
setTimeout(() => {
|
||||
element.dispatchEvent(new Event("error"));
|
||||
}, 0);
|
||||
return element;
|
||||
}
|
||||
return originalCreateElement(tag);
|
||||
}) as typeof document.createElement);
|
||||
|
||||
const duration = await probeVideoDurationSeconds(fileOf(10, "clip.mov", "video/quicktime"));
|
||||
|
||||
expect(duration).toBeNull();
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:probe");
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue