This commit is contained in:
Alexandre Brandizzi 2026-09-23 08:40:54 +00:00 • committed by GitHub
commit bd602f1d25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 3789 additions and 148 deletions

View file

@ -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 }),
);

View file

@ -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;

View file

@ -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();
});
});

View file

@ -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) =>

View 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.
}
},
};
}

View 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";
}
}

View 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);
}

View file

@ -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>
);
}

View file

@ -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}
/>
);
}

View file

@ -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,

View file

@ -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,

View file

@ -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);

View file

@ -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">

View file

@ -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 = "";

View file

@ -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}
/>
)}

View file

@ -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,
},
]);
}

View file

@ -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

View 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 &amp; Videos</Text>
<VendorMediaSection {...props} />
</div>
);
}

View file

@ -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 },

View 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 }),
}),
};

View file

@ -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[];
}

View 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 } : {};
},
}),
};

View file

@ -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));
},
});
}

View file

@ -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,
});
}

View file

@ -52,6 +52,7 @@ export interface UpdateWorkOrderCommentInput {
export interface UploadWorkOrderMediaInput {
file: File;
category?: UploadableMediaCategory;
idempotencyKey?: string;
signal?: AbortSignal;
onProgress?: (percent: number) => void;
}

View 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 };
}

View file

@ -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;

View 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([]);
});
});

View file

@ -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.",
);
});
});

View file

@ -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();
});
});

View file

@ -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", () => ({

View file

@ -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,
);
});
});

View file

@ -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 }),

View 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");
});
});

View file

@ -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");
});
});

View file

@ -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();
});
});

View file

@ -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,
);
});
});

View 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();
});
});