mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
feat: add resumable SH-116 media uploads
This commit is contained in:
parent
6367dc2a7a
commit
42d26b8a6b
29 changed files with 2565 additions and 30 deletions
|
|
@ -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) =>
|
||||
|
|
|
|||
160
src/api/media-upload-client.ts
Normal file
160
src/api/media-upload-client.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
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;
|
||||
}
|
||||
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === "AbortError";
|
||||
}
|
||||
|
||||
export async function computeSha256Hex(buffer: ArrayBuffer): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
interface EndpointOptions {
|
||||
resolveUrl: (suffix: string) => string;
|
||||
headers: () => Record<string, string>;
|
||||
}
|
||||
|
||||
function parseSessionPayload(payload: unknown): MediaUploadSessionDto {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
throw new MediaUploadNetworkError();
|
||||
}
|
||||
return payload as MediaUploadSessionDto;
|
||||
}
|
||||
|
||||
async function readJsonBody(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
throw new MediaUploadNetworkError();
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
authHeaders: Record<string, string>,
|
||||
): Promise<unknown> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...init,
|
||||
headers: { Accept: "application/json", ...authHeaders, ...init.headers },
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw new MediaUploadNetworkError();
|
||||
}
|
||||
|
||||
const payload = await readJsonBody(response);
|
||||
if (!response.ok) {
|
||||
const error = new Error(`Upload request failed (HTTP ${response.status}).`);
|
||||
error.name = "MediaUploadHttpError";
|
||||
Object.assign(error, { status: response.status, payload });
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function createMediaUploadEndpoints({
|
||||
resolveUrl,
|
||||
headers,
|
||||
}: EndpointOptions): MediaUploadEndpointSet {
|
||||
const sessionUrl = (uploadId: string, suffix = ""): string =>
|
||||
resolveUrl([uploadId, suffix].filter(Boolean).join("/"));
|
||||
|
||||
return {
|
||||
create: async (dto, signal) =>
|
||||
parseSessionPayload(
|
||||
await requestJson(
|
||||
resolveUrl(""),
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(dto),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal,
|
||||
},
|
||||
headers(),
|
||||
),
|
||||
),
|
||||
|
||||
putChunk: async ({ uploadId, index, blob, start, end, totalBytes, sha256, signal }) => {
|
||||
const payload = await requestJson(
|
||||
sessionUrl(uploadId, `chunks/${index}`),
|
||||
{
|
||||
method: "PUT",
|
||||
body: blob,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Range": `bytes ${start}-${end - 1}/${totalBytes}`,
|
||||
"X-Chunk-SHA256": sha256.toLowerCase(),
|
||||
},
|
||||
signal,
|
||||
},
|
||||
headers(),
|
||||
);
|
||||
return payload as MediaChunkAckDto;
|
||||
},
|
||||
|
||||
commit: async (uploadId, signal) =>
|
||||
parseSessionPayload(
|
||||
await requestJson(sessionUrl(uploadId, "commit"), { method: "POST", signal }, headers()),
|
||||
),
|
||||
|
||||
getStatus: async (uploadId, signal) =>
|
||||
parseSessionPayload(
|
||||
await requestJson(sessionUrl(uploadId), { method: "GET", signal }, headers()),
|
||||
),
|
||||
|
||||
cancel: async (uploadId) => {
|
||||
try {
|
||||
await fetch(sessionUrl(uploadId), {
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json", ...headers() },
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cancel — surface nothing when the session is already gone.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
130
src/api/media-upload-contracts.ts
Normal file
130
src/api/media-upload-contracts.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/** Shared SH-116 resumable media upload contract (dispatcher + vendor portal). */
|
||||
|
||||
export const MEDIA_UPLOAD_CHUNK_SIZE_BYTES = 5_242_880;
|
||||
|
||||
export const MEDIA_UPLOAD_SCANNING_STATE = "Scanning" as const;
|
||||
|
||||
export type UploadSurface = "WorkOrderTable" | "CompletionMedia" | "VendorPortal";
|
||||
|
||||
export type UploadCategory = "Before" | "After" | "Extra" | "Aveta";
|
||||
|
||||
export type MediaKind = "Photo" | "Video";
|
||||
|
||||
export type MediaUploadState =
|
||||
"Created" | "Uploading" | "Scanning" | "Completed" | "Rejected" | "Canceled" | "Expired";
|
||||
|
||||
export interface MediaUploadLimits {
|
||||
photoMaxBytes: number;
|
||||
videoMaxBytes: number;
|
||||
videoMaxSeconds: number;
|
||||
maxPhotos: number;
|
||||
maxVideos: number;
|
||||
}
|
||||
|
||||
export interface MediaUploadRemaining {
|
||||
photos: number;
|
||||
videos: number;
|
||||
}
|
||||
|
||||
export interface MediaUploadPolicy {
|
||||
workOrderType: string;
|
||||
canUpload: boolean;
|
||||
limits: MediaUploadLimits;
|
||||
remaining: MediaUploadRemaining;
|
||||
}
|
||||
|
||||
export interface MediaUploadCreateDto {
|
||||
idempotencyKey: string;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
category?: UploadCategory;
|
||||
surface: UploadSurface;
|
||||
}
|
||||
|
||||
export interface MediaUploadSessionDto {
|
||||
uploadId: string;
|
||||
state: MediaUploadState;
|
||||
chunkSizeBytes: number;
|
||||
sizeBytes: number;
|
||||
receivedChunks: number[];
|
||||
expiresAt: string;
|
||||
attachment?: { id: string | number };
|
||||
}
|
||||
|
||||
export interface MediaChunkAckDto {
|
||||
index: number;
|
||||
sha256: string;
|
||||
sizeBytes: number;
|
||||
receivedChunks: number[];
|
||||
}
|
||||
|
||||
export const MEDIA_UPLOAD_TERMINAL_STATES: ReadonlySet<MediaUploadState> = new Set([
|
||||
"Completed",
|
||||
"Rejected",
|
||||
"Canceled",
|
||||
"Expired",
|
||||
]);
|
||||
|
||||
export function isTerminalMediaUploadState(state: MediaUploadState): boolean {
|
||||
return MEDIA_UPLOAD_TERMINAL_STATES.has(state);
|
||||
}
|
||||
|
||||
export function isUploadCategory(value: string | undefined): value is UploadCategory {
|
||||
return value === "Before" || value === "After" || value === "Extra" || value === "Aveta";
|
||||
}
|
||||
|
||||
/** Safe, stable user-facing messages. Never render raw server payloads. */
|
||||
export function safeMediaUploadErrorMessage(error: Error): string {
|
||||
if (error instanceof MediaUploadValidationError) {
|
||||
return error.message;
|
||||
}
|
||||
if (error instanceof MediaUploadRejectedError) {
|
||||
return "The file was rejected by the security scan and was not saved.";
|
||||
}
|
||||
if (error instanceof MediaUploadExpiredError) {
|
||||
return "The upload session expired. Please try again.";
|
||||
}
|
||||
if (error instanceof MediaUploadScanTimeoutError) {
|
||||
return "The upload is still processing. Check back shortly to see the result.";
|
||||
}
|
||||
if (error.name === "AbortError") {
|
||||
return "Upload canceled.";
|
||||
}
|
||||
return "Upload failed. Check your connection and try again.";
|
||||
}
|
||||
|
||||
export class MediaUploadValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "MediaUploadValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaUploadRejectedError extends Error {
|
||||
constructor() {
|
||||
super("The file was rejected by the security scan.");
|
||||
this.name = "MediaUploadRejectedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaUploadExpiredError extends Error {
|
||||
constructor() {
|
||||
super("The upload session expired.");
|
||||
this.name = "MediaUploadExpiredError";
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaUploadCanceledError extends Error {
|
||||
constructor() {
|
||||
super("The upload session was canceled.");
|
||||
this.name = "MediaUploadCanceledError";
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaUploadScanTimeoutError extends Error {
|
||||
constructor() {
|
||||
super("The upload is still processing.");
|
||||
this.name = "MediaUploadScanTimeoutError";
|
||||
}
|
||||
}
|
||||
309
src/api/media-upload-engine.ts
Normal file
309
src/api/media-upload-engine.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
try {
|
||||
const ack = await sendChunk(args, session.uploadId, index, chunkSize);
|
||||
const acked = Array.isArray(ack?.receivedChunks) ? ack.receivedChunks : [];
|
||||
if (!acked.includes(index)) {
|
||||
// Treat a malformed ack as not-yet-acked so the loop cannot spin forever.
|
||||
throw new Error("Chunk acknowledgement missing from server response.");
|
||||
}
|
||||
session = { ...session, receivedChunks: acked };
|
||||
} catch (error) {
|
||||
if (!isNetworkUploadError(error)) throw error;
|
||||
session = await recoverSession(args.endpoints, session.uploadId, args.signal);
|
||||
if (session.receivedChunks.includes(index)) {
|
||||
recoveryAttempts.delete(index);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
break; // Re-derive missing indices from the recovered server state.
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
|
@ -11,12 +11,15 @@ import type {
|
|||
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 { isEmergencyReactiveWorkOrderType } from "@/domain/work-orders/utils/emergency-media-limits";
|
||||
|
||||
type UseSlideOverMediaStateArgs = {
|
||||
detailForRow?: WorkOrderBoardDetail;
|
||||
|
|
@ -82,6 +85,16 @@ export function useSlideOverMediaState({
|
|||
}: UseSlideOverMediaStateArgs) {
|
||||
const workOrderId = detailForRow?.info.id;
|
||||
const completionHasGallery = hasCompletionMediaGallery(detailForRow?.info.type);
|
||||
const { data: mediaPolicy } = useWorkOrderMediaPolicy(workOrderId, workOrderId != null);
|
||||
const resumableCompletionUpload = useResumableWorkOrderMediaUpload(
|
||||
workOrderId ?? "",
|
||||
"CompletionMedia",
|
||||
mediaPolicy ?? undefined,
|
||||
);
|
||||
const policyCanUpload = mediaPolicy?.canUpload === true;
|
||||
const emergencyMedia =
|
||||
isEmergencyReactiveWorkOrderType(mediaPolicy?.workOrderType) ||
|
||||
isEmergencyReactiveWorkOrderType(detailForRow?.info.type);
|
||||
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
||||
const [ownership, setOwnershipState] = useState<MediaSurfaceOwnership>(() =>
|
||||
loadOwnership(workOrderId),
|
||||
|
|
@ -103,7 +116,25 @@ export function useSlideOverMediaState({
|
|||
);
|
||||
|
||||
const persistCompletionUpload = useCallback(
|
||||
async (input: UploadWorkOrderMediaInput) => {
|
||||
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,7 +142,13 @@ export function useSlideOverMediaState({
|
|||
}));
|
||||
return item;
|
||||
},
|
||||
[updateOwnership, uploadMediaMutation],
|
||||
[
|
||||
emergencyMedia,
|
||||
policyCanUpload,
|
||||
resumableCompletionUpload,
|
||||
updateOwnership,
|
||||
uploadMediaMutation,
|
||||
],
|
||||
);
|
||||
|
||||
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||
|
|
@ -135,6 +172,7 @@ export function useSlideOverMediaState({
|
|||
mediaBatchPending ||
|
||||
hasLocalUploading ||
|
||||
uploadMediaMutation.isPending ||
|
||||
resumableCompletionUpload.isPending ||
|
||||
deleteMediaMutation.isPending ||
|
||||
categorizeMediaMutation.isPending;
|
||||
|
||||
|
|
|
|||
|
|
@ -9,11 +9,18 @@ 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,
|
||||
emergencyMediaAcceptAttribute,
|
||||
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";
|
||||
|
||||
|
|
@ -33,8 +40,19 @@ function resolveWorkOrderVersion(
|
|||
|
||||
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
|
||||
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
|
||||
const uploadMutation = useUploadWorkOrderMedia(row.id);
|
||||
const { data: policy } = useWorkOrderMediaPolicy(row.id, open);
|
||||
const legacyUploadMutation = useUploadWorkOrderMedia(row.id);
|
||||
const resumableUploadMutation = useResumableWorkOrderMediaUpload(
|
||||
row.id,
|
||||
"WorkOrderTable",
|
||||
policy ?? undefined,
|
||||
);
|
||||
const deleteMutation = useDeleteWorkOrderMedia(row.id);
|
||||
const isEmergency =
|
||||
isEmergencyReactiveWorkOrderType(policy?.workOrderType) ||
|
||||
isEmergencyReactiveWorkOrderType(row.type);
|
||||
const uploadMutation = isEmergency ? resumableUploadMutation : legacyUploadMutation;
|
||||
const emergencyUploadUnavailable = isEmergency && policy?.canUpload !== true;
|
||||
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
|
||||
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||
useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
|
||||
|
|
@ -105,9 +123,26 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
onRetry={retryUpload}
|
||||
onCancel={cancelUpload}
|
||||
readOnly={isReadOnly}
|
||||
uploadDisabled={emergencyUploadUnavailable}
|
||||
pending={pending}
|
||||
typeAllowed={isEmergency ? (file) => classifyMediaKind(file) !== null : undefined}
|
||||
typeErrorMessage={
|
||||
isEmergency
|
||||
? "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported."
|
||||
: undefined
|
||||
}
|
||||
acceptAttribute={isEmergency ? emergencyMediaAcceptAttribute() : undefined}
|
||||
/>
|
||||
)}
|
||||
{emergencyUploadUnavailable && !isReadOnly && (
|
||||
<div role="status" className="mt-2">
|
||||
<Text variant="description">
|
||||
{policy
|
||||
? "Uploads are not available for this work order."
|
||||
: "Loading upload permissions…"}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ type MediaUploaderDropzoneProps = {
|
|||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
pending: boolean;
|
||||
categorizeAfterUpload?: boolean;
|
||||
acceptAttribute?: string;
|
||||
onFilesSelected: (files: FileList | null) => void;
|
||||
};
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ export function MediaUploaderDropzone({
|
|||
inputRef,
|
||||
pending,
|
||||
categorizeAfterUpload = false,
|
||||
acceptAttribute,
|
||||
onFilesSelected,
|
||||
}: MediaUploaderDropzoneProps) {
|
||||
return (
|
||||
|
|
@ -48,7 +50,7 @@ export function MediaUploaderDropzone({
|
|||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={workOrderMediaAcceptAttribute()}
|
||||
accept={acceptAttribute ?? workOrderMediaAcceptAttribute()}
|
||||
multiple
|
||||
hidden
|
||||
disabled={pending}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export type MediaUploadRequest = {
|
|||
localId: string;
|
||||
file: File;
|
||||
onProgress: (percent: number) => void;
|
||||
idempotencyKey?: string;
|
||||
};
|
||||
|
||||
type MediaUploaderProps = {
|
||||
|
|
@ -30,6 +31,11 @@ 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;
|
||||
};
|
||||
|
||||
export function MediaUploader({
|
||||
|
|
@ -42,6 +48,10 @@ export function MediaUploader({
|
|||
onChange,
|
||||
readOnly = false,
|
||||
pending = false,
|
||||
uploadDisabled = false,
|
||||
typeAllowed,
|
||||
typeErrorMessage = "Unsupported file type.",
|
||||
acceptAttribute,
|
||||
}: MediaUploaderProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
|
|
@ -50,8 +60,9 @@ 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;
|
||||
}
|
||||
if (isWorkOrderFileTooLarge(file)) {
|
||||
|
|
@ -93,8 +104,9 @@ export function MediaUploader({
|
|||
{!readOnly && (
|
||||
<MediaUploaderDropzone
|
||||
inputRef={inputRef}
|
||||
pending={pending}
|
||||
pending={pending || uploadDisabled}
|
||||
categorizeAfterUpload={Boolean(onCategorize)}
|
||||
acceptAttribute={acceptAttribute}
|
||||
onFilesSelected={addFiles}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,19 @@ import type { MediaUploadRequest } from "@/app/(protected)/workorders/_component
|
|||
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import { runMediaUploadQueue } from "@/api/media-upload-engine";
|
||||
import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils";
|
||||
|
||||
type LocalUpload = MediaFile & { sourceFile?: File; signal?: AbortSignal };
|
||||
type LocalUpload = MediaFile & {
|
||||
sourceFile?: File;
|
||||
signal?: AbortSignal;
|
||||
idempotencyKey: string;
|
||||
};
|
||||
|
||||
type UploadMutate = (input: {
|
||||
file: File;
|
||||
signal?: AbortSignal;
|
||||
idempotencyKey: string;
|
||||
onProgress?: (percent: number) => void;
|
||||
}) => Promise<unknown>;
|
||||
|
||||
|
|
@ -17,6 +23,7 @@ type SessionUploadRequest = {
|
|||
localId: string;
|
||||
file: File;
|
||||
signal: AbortSignal;
|
||||
idempotencyKey: string;
|
||||
};
|
||||
|
||||
function revokeBlobUrl(url: string | undefined) {
|
||||
|
|
@ -38,6 +45,7 @@ function toLocalUpload(request: SessionUploadRequest): LocalUpload {
|
|||
uploadStatus: "uploading" as const,
|
||||
sourceFile: request.file,
|
||||
signal: request.signal,
|
||||
idempotencyKey: request.idempotencyKey,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -148,32 +156,38 @@ export function useMediaUploadSession(
|
|||
localId: request.localId,
|
||||
file: request.file,
|
||||
signal: replaceController(request.localId).signal,
|
||||
idempotencyKey:
|
||||
request.idempotencyKey ??
|
||||
localUploads.find((item) => item.id === request.localId)?.idempotencyKey ??
|
||||
crypto.randomUUID(),
|
||||
}));
|
||||
|
||||
const entries = sessionRequests.map(toLocalUpload);
|
||||
setLocalUploads((prev) => upsertUploads(prev, entries));
|
||||
|
||||
for (const request of sessionRequests) {
|
||||
void uploadMutateAsync({
|
||||
file: request.file,
|
||||
signal: request.signal,
|
||||
onProgress: (percent) => {
|
||||
setLocalUploads((prev) =>
|
||||
prev.map((item) =>
|
||||
isSameAttempt(item, request) ? { ...item, progress: percent } : item,
|
||||
),
|
||||
);
|
||||
},
|
||||
})
|
||||
.then(() => settleAttempt(request))
|
||||
.catch((error: Error) => {
|
||||
if (error.name === "AbortError") {
|
||||
settleAttempt(request);
|
||||
return;
|
||||
}
|
||||
failAttempt(request, error);
|
||||
void runMediaUploadQueue(sessionRequests, async (request) => {
|
||||
try {
|
||||
await uploadMutateAsync({
|
||||
file: request.file,
|
||||
signal: request.signal,
|
||||
idempotencyKey: request.idempotencyKey,
|
||||
onProgress: (percent) => {
|
||||
setLocalUploads((prev) =>
|
||||
prev.map((item) =>
|
||||
isSameAttempt(item, request) ? { ...item, progress: percent } : item,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
settleAttempt(request);
|
||||
} catch (error) {
|
||||
if ((error as Error).name === "AbortError") {
|
||||
settleAttempt(request);
|
||||
return;
|
||||
}
|
||||
failAttempt(request, error as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function retryUpload(localId: string) {
|
||||
|
|
@ -184,6 +198,7 @@ export function useMediaUploadSession(
|
|||
localId,
|
||||
file: target.sourceFile,
|
||||
onProgress: () => undefined,
|
||||
idempotencyKey: target.idempotencyKey,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { ChecklistSection } from "@/app/v/[token]/dispatch/_components/checklist
|
|||
import { CommentsSection } from "@/app/v/[token]/dispatch/_components/comments-section";
|
||||
import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section";
|
||||
import { UpliftRequestsSection } from "@/app/v/[token]/dispatch/_components/uplift-requests-section";
|
||||
import { VendorMediaCard } from "@/app/v/[token]/dispatch/_components/vendor-media-section";
|
||||
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
|
||||
import type {
|
||||
VendorPortalDispatchDetail,
|
||||
|
|
@ -61,6 +62,8 @@ export function DispatchDetailSections({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<VendorMediaCard token={token} dispatchId={dispatchId} locked={locked} />
|
||||
|
||||
<div className="vp-card">
|
||||
<Text variant="heading">NTE Uplift Requests</Text>
|
||||
<UpliftRequestsSection
|
||||
|
|
|
|||
255
src/app/v/[token]/dispatch/_components/vendor-media-section.tsx
Normal file
255
src/app/v/[token]/dispatch/_components/vendor-media-section.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
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 { MediaUploadNetworkError } 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): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, "_blank", "noopener");
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
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 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 handleView = async (item: VendorMediaItem) => {
|
||||
try {
|
||||
const blob = await vendorPortalMediaApi.fetchContent(token, dispatchId, item.id);
|
||||
viewMediaItem(item, blob);
|
||||
} catch {
|
||||
setLoadState((current) => ({
|
||||
...current,
|
||||
error: "Unable to open the media file. Please try again.",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{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={() => void handleView(item)}
|
||||
>
|
||||
{item.canView ? "View" : "Unavailable"}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
{uploadMutation.isPending && (
|
||||
<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={() => controllerRef.current?.abort()}
|
||||
>
|
||||
Cancel upload
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadCanceled && <VendorPortalFeedback>Upload canceled.</VendorPortalFeedback>}
|
||||
|
||||
{uploadMutation.isError && !uploadCanceled && (
|
||||
<div>
|
||||
<VendorPortalFeedback tone="error">
|
||||
{safeMediaUploadErrorMessage(uploadMutation.error)}
|
||||
</VendorPortalFeedback>
|
||||
{!locked && uploadMutation.error instanceof MediaUploadNetworkError && (
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
disabled={uploadMutation.isPending}
|
||||
onClick={() => startUpload(failedFileRef.current, true)}
|
||||
>
|
||||
Retry upload
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canUpload && (
|
||||
<div className="vp-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-primary"
|
||||
disabled={uploadMutation.isPending}
|
||||
onClick={() => {
|
||||
uploadMutation.reset();
|
||||
inputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
Upload photo or video
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{locked && (
|
||||
<VendorPortalFeedback>
|
||||
Media is read-only while this dispatch is locked.
|
||||
</VendorPortalFeedback>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function VendorMediaCard(props: VendorMediaSectionProps) {
|
||||
return (
|
||||
<div className="vp-card">
|
||||
<Text variant="heading">Photos & Videos</Text>
|
||||
<VendorMediaSection {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -113,7 +113,7 @@ async function downloadVendorDocument(
|
|||
);
|
||||
}
|
||||
|
||||
async function vendorPortalCall<T>(
|
||||
export async function vendorPortalCall<T>(
|
||||
token: string,
|
||||
path: string,
|
||||
options?: { method?: string; body?: unknown },
|
||||
|
|
|
|||
49
src/domain/vendor-portal/api/vendor-portal-media-api.ts
Normal file
49
src/domain/vendor-portal/api/vendor-portal-media-api.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { createMediaUploadEndpoints, type MediaUploadEndpointSet } from "@/api/media-upload-client";
|
||||
import { env } from "@/lib/env";
|
||||
import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url";
|
||||
import { vendorPortalCall } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
import type { VendorPortalMedia } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
|
||||
function vendorMediaUrl(dispatchId: string | number, suffix = ""): string {
|
||||
const base = `${resolveApiBaseUrl(env.apiUrl)}/vendor-portal/dispatches/${dispatchId}/media`;
|
||||
return suffix ? `${base}/${suffix}` : base;
|
||||
}
|
||||
|
||||
export const vendorPortalMediaApi = {
|
||||
/**
|
||||
* GET /api/vendor-portal/dispatches/{dispatchId}/media — policy plus scan-passed
|
||||
* own-dispatch items. The server derives work-order type and own-dispatch scope.
|
||||
*/
|
||||
getMedia: (token: string, dispatchId: string | number): Promise<VendorPortalMedia> =>
|
||||
vendorPortalCall<VendorPortalMedia>(token, `/dispatches/${dispatchId}/media`),
|
||||
|
||||
/** Content is fetched only on explicit user action; no object URLs are persisted. */
|
||||
fetchContent: async (
|
||||
token: string,
|
||||
dispatchId: string | number,
|
||||
mediaId: number,
|
||||
): Promise<Blob> => {
|
||||
const response = await fetch(vendorMediaUrl(dispatchId, `${mediaId}/content`), {
|
||||
headers: { "X-Vendor-Token": token },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
response.status === 423 ? "File is still being scanned" : "Unable to load the media file.",
|
||||
);
|
||||
}
|
||||
return response.blob();
|
||||
},
|
||||
|
||||
/**
|
||||
* Vendor transport bound to /api/vendor-portal/dispatches/{dispatchId}/media/uploads.
|
||||
* X-Vendor-Token only — never the dispatcher Authorization header.
|
||||
*/
|
||||
endpoints: (token: string, dispatchId: string | number): MediaUploadEndpointSet =>
|
||||
createMediaUploadEndpoints({
|
||||
resolveUrl: (suffix) => {
|
||||
const base = `${resolveApiBaseUrl(env.apiUrl)}/vendor-portal/dispatches/${dispatchId}/media/uploads`;
|
||||
return suffix ? `${base}/${suffix}` : base;
|
||||
},
|
||||
headers: () => ({ "X-Vendor-Token": token }),
|
||||
}),
|
||||
};
|
||||
|
|
@ -138,3 +138,20 @@ export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary
|
|||
signoffs: VendorPortalSignoff[];
|
||||
documents: VendorPortalDocument[];
|
||||
}
|
||||
|
||||
export interface VendorMediaItem {
|
||||
id: number;
|
||||
originalFileName: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
kind: "Photo" | "Video";
|
||||
category: "Extra";
|
||||
scanStatus: "Passed";
|
||||
createdAt: string;
|
||||
canView: boolean;
|
||||
}
|
||||
|
||||
export interface VendorPortalMedia {
|
||||
policy: import("@/api/media-upload-contracts").MediaUploadPolicy;
|
||||
items: VendorMediaItem[];
|
||||
}
|
||||
|
|
|
|||
31
src/domain/work-orders/api/work-order-media-upload-api.ts
Normal file
31
src/domain/work-orders/api/work-order-media-upload-api.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { apiGet } from "@/api/api";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { createMediaUploadEndpoints, type MediaUploadEndpointSet } from "@/api/media-upload-client";
|
||||
import type { MediaUploadPolicy } from "@/api/media-upload-contracts";
|
||||
import { env } from "@/lib/env";
|
||||
import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url";
|
||||
import { getAccessToken } from "@/lib/auth/auth-storage";
|
||||
|
||||
function workOrderMediaUploadUrl(workOrderId: string | number, suffix: string): string {
|
||||
const base = `${resolveApiBaseUrl(env.apiUrl)}/${API_PATHS.workOrder.mediaUploads(workOrderId)}`;
|
||||
return suffix ? `${base}/${suffix}` : base;
|
||||
}
|
||||
|
||||
export const workOrdersMediaUploadApi = {
|
||||
/** GET workorders/{woId}/media/policy — server-derived type, limits and remaining counts. */
|
||||
getMediaPolicy: async (workOrderId: string | number): Promise<MediaUploadPolicy> =>
|
||||
apiGet<MediaUploadPolicy>(API_PATHS.workOrder.mediaPolicy(workOrderId)),
|
||||
|
||||
/**
|
||||
* Dispatcher transport bound to /api/workorders/{woId}/media/uploads.
|
||||
* Work-order binding comes from the URL; clients cannot repoint it.
|
||||
*/
|
||||
endpoints: (workOrderId: string | number): MediaUploadEndpointSet =>
|
||||
createMediaUploadEndpoints({
|
||||
resolveUrl: (suffix) => workOrderMediaUploadUrl(workOrderId, suffix),
|
||||
headers: (): Record<string, string> => {
|
||||
const token = getAccessToken();
|
||||
return token ? { Authorization: token } : {};
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
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;
|
||||
}
|
||||
|
||||
function invalidateMediaQueries(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
workOrderId: string | number,
|
||||
): void {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.workOrders.boardDetail(workOrderId),
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.boardMedia(workOrderId) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.mediaPolicy(workOrderId) });
|
||||
void 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: (session) => {
|
||||
invalidateMediaQueries(queryClient, workOrderId);
|
||||
if (session.state === "Completed") {
|
||||
toast.success("File uploaded");
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(safeMediaUploadErrorMessage(error));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import type { MediaUploadPolicy } from "@/api/media-upload-contracts";
|
||||
import { workOrdersMediaUploadApi } from "@/domain/work-orders/api/work-order-media-upload-api";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
/**
|
||||
* Server-derived Emergency/Reactive media policy: type, canUpload, limits and
|
||||
* remaining counts (existing + pending reservations included server-side).
|
||||
*/
|
||||
export function useWorkOrderMediaPolicy(
|
||||
workOrderId: string | number | undefined,
|
||||
enabled: boolean,
|
||||
): UseQueryResult<MediaUploadPolicy, Error> {
|
||||
return useQuery({
|
||||
queryKey:
|
||||
workOrderId == null
|
||||
? [...queryKeys.workOrders.all, "mediaPolicy", "none"]
|
||||
: queryKeys.workOrders.mediaPolicy(workOrderId),
|
||||
queryFn: () => workOrdersMediaUploadApi.getMediaPolicy(workOrderId as string | number),
|
||||
enabled: enabled && workOrderId != null,
|
||||
staleTime: 15_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
|
@ -52,6 +52,7 @@ export interface UpdateWorkOrderCommentInput {
|
|||
export interface UploadWorkOrderMediaInput {
|
||||
file: File;
|
||||
category?: UploadableMediaCategory;
|
||||
idempotencyKey?: string;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (percent: number) => void;
|
||||
}
|
||||
|
|
|
|||
151
src/domain/work-orders/utils/emergency-media-limits.ts
Normal file
151
src/domain/work-orders/utils/emergency-media-limits.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
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 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one media file against the policy limits: kind, inclusive size caps and
|
||||
* duration when it can be probed. A failed duration probe keeps the file eligible.
|
||||
*/
|
||||
export async function validateMediaFileAgainstLimits(
|
||||
file: File,
|
||||
policy: Pick<MediaUploadPolicy, "limits" | "remaining">,
|
||||
probe: MediaValidationProbe = probeVideoDurationSeconds,
|
||||
): Promise<MediaValidationResult> {
|
||||
const limits = defaultLimits(policy.limits);
|
||||
const kind = classifyMediaKind(file);
|
||||
if (!kind) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported.",
|
||||
};
|
||||
}
|
||||
if (!isMediaSizeWithinLimit(file, kind, limits)) {
|
||||
return { ok: false, reason: mediaOverLimitNotice(file, kind, limits) };
|
||||
}
|
||||
const remaining = kind === "Photo" ? policy.remaining?.photos : policy.remaining?.videos;
|
||||
if (typeof remaining === "number" && remaining <= 0) {
|
||||
return { ok: false, reason: mediaKindLimitReachedNotice(kind, limits) };
|
||||
}
|
||||
if (kind === "Video") {
|
||||
const seconds = await probe(file);
|
||||
if (seconds != null && seconds > limits.videoMaxSeconds) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `"${file.name}" is longer than the ${limits.videoMaxSeconds} second video limit.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ export const queryKeys = {
|
|||
detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const,
|
||||
boardDetail: (id: string | number) => [...queryKeys.workOrders.all, "boardDetail", id] as const,
|
||||
boardMedia: (id: string | number) => [...queryKeys.workOrders.all, "boardMedia", id] as const,
|
||||
mediaPolicy: (id: string | number) => [...queryKeys.workOrders.all, "mediaPolicy", id] as const,
|
||||
dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const,
|
||||
taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const,
|
||||
dispatchers: () => [...queryKeys.workOrders.all, "dispatchers"] as const,
|
||||
|
|
@ -159,5 +160,7 @@ export const queryKeys = {
|
|||
dispatches: (token: string) => [...queryKeys.vendorPortal.all, "dispatches", token] as const,
|
||||
dispatch: (token: string, id: string | number) =>
|
||||
[...queryKeys.vendorPortal.all, "dispatch", token, id] as const,
|
||||
media: (token: string, id: string | number) =>
|
||||
[...queryKeys.vendorPortal.all, "media", token, id] as const,
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
511
src/test/api/media-upload-engine.test.ts
Normal file
511
src/test/api/media-upload-engine.test.ts
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
// @vitest-environment node
|
||||
// Node's fetch/Request/Blob follow WHATWG body-serialization rules; jsdom mangles raw
|
||||
// chunk bodies (the same reason api-formdata-content-type.test.ts runs in node).
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
chunkCountFor,
|
||||
missingChunkIndices,
|
||||
runMediaUploadQueue,
|
||||
uploadFileResumable,
|
||||
} from "@/api/media-upload-engine";
|
||||
import { computeSha256Hex, createMediaUploadEndpoints } from "@/api/media-upload-client";
|
||||
|
||||
type CapturedRequest = { method: string; url: string; headers: Headers; body: unknown };
|
||||
|
||||
const BASE = "https://api.test/uploads";
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function fileOf(size: number, name = "clip.mp4", type = "video/mp4"): File {
|
||||
return new File([new Uint8Array(size)], name, { type });
|
||||
}
|
||||
|
||||
let captured: CapturedRequest[] = [];
|
||||
let handler: (request: CapturedRequest) => Promise<Response>;
|
||||
|
||||
function endpointsWith() {
|
||||
return createMediaUploadEndpoints({
|
||||
resolveUrl: (suffix) => (suffix ? `${BASE}/${suffix}` : BASE),
|
||||
headers: () => ({}),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
captured = [];
|
||||
handler = async () => jsonResponse({});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init);
|
||||
const body =
|
||||
request.method === "PUT"
|
||||
? await request.arrayBuffer()
|
||||
: await request.json().catch(() => ({}));
|
||||
const capturedRequest: CapturedRequest = {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body,
|
||||
};
|
||||
captured.push(capturedRequest);
|
||||
return handler(capturedRequest);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function createdSession(sizeBytes: number, chunkSizeBytes: number) {
|
||||
return {
|
||||
uploadId: "u1",
|
||||
state: "Uploading",
|
||||
chunkSizeBytes,
|
||||
sizeBytes,
|
||||
receivedChunks: [],
|
||||
expiresAt: "2026-09-24T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function completedSession(sizeBytes: number, chunkSizeBytes: number, received: number[]) {
|
||||
return {
|
||||
uploadId: "u1",
|
||||
state: "Completed",
|
||||
chunkSizeBytes,
|
||||
sizeBytes,
|
||||
receivedChunks: received,
|
||||
expiresAt: "2026-09-24T00:00:00Z",
|
||||
attachment: { id: 42 },
|
||||
};
|
||||
}
|
||||
|
||||
describe("resumable media upload engine", () => {
|
||||
it("sends raw chunk bytes with Content-Range, digest header, and acknowledged-only progress", async () => {
|
||||
const file = fileOf(20);
|
||||
handler = async (request) => {
|
||||
if (request.method === "POST" && request.url === BASE) {
|
||||
return jsonResponse(createdSession(20, 8));
|
||||
}
|
||||
const match = /chunks\/(\d+)$/.exec(request.url);
|
||||
if (request.method === "PUT" && match) {
|
||||
const index = Number(match[1]);
|
||||
const received = [0, 1, 2].filter((seen) => seen <= index);
|
||||
return jsonResponse({
|
||||
index,
|
||||
sha256: "server-digest",
|
||||
sizeBytes: (request.body as ArrayBuffer).byteLength,
|
||||
receivedChunks: received,
|
||||
});
|
||||
}
|
||||
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||
return jsonResponse(completedSession(20, 8, [0, 1, 2]));
|
||||
}
|
||||
return jsonResponse({});
|
||||
};
|
||||
|
||||
const percents: number[] = [];
|
||||
const session = await uploadFileResumable({
|
||||
file,
|
||||
endpoints: endpointsWith(),
|
||||
surface: "WorkOrderTable",
|
||||
onProgress: percents.push.bind(percents) as (percent: number) => void,
|
||||
});
|
||||
|
||||
expect(session.state).toBe("Completed");
|
||||
expect(session.attachment?.id).toBe(42);
|
||||
const puts = captured.filter((entry) => entry.method === "PUT");
|
||||
expect(puts).toHaveLength(3);
|
||||
|
||||
const firstSlice = file.slice(0, 8);
|
||||
const expectedDigest = await computeSha256Hex(await firstSlice.arrayBuffer());
|
||||
expect(puts[0]?.url).toBe(`${BASE}/u1/chunks/0`);
|
||||
expect(puts[0]?.headers.get("content-range")).toBe("bytes 0-7/20");
|
||||
expect(puts[0]?.headers.get("x-chunk-sha256")).toBe(expectedDigest);
|
||||
expect((puts[0]?.body as ArrayBuffer).byteLength).toBe(8);
|
||||
expect(puts[2]?.headers.get("content-range")).toBe("bytes 16-19/20");
|
||||
expect((puts[2]?.body as ArrayBuffer).byteLength).toBe(4);
|
||||
|
||||
// Progress derives from acknowledged chunks only (server receivedChunks), ending at 100.
|
||||
expect(percents[percents.length - 1]).toBe(100);
|
||||
expect(percents.every((percent, index) => index === 0 || percent >= percents[index - 1]!)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("recovers from network loss by re-reading session status and sending only missing chunks", async () => {
|
||||
const file = fileOf(24);
|
||||
let failedIndexOnce = false;
|
||||
handler = async (request) => {
|
||||
if (request.method === "POST" && request.url === BASE) {
|
||||
return jsonResponse(createdSession(24, 8));
|
||||
}
|
||||
const match = /chunks\/(\d+)$/.exec(request.url);
|
||||
if (request.method === "PUT" && match) {
|
||||
const index = Number(match[1]);
|
||||
if (index === 1 && !failedIndexOnce) {
|
||||
failedIndexOnce = true;
|
||||
throw new TypeError("network down");
|
||||
}
|
||||
const received = Array.from({ length: index + 1 }, (_, seen) => seen);
|
||||
return jsonResponse({ index, sha256: "x", sizeBytes: 8, receivedChunks: received });
|
||||
}
|
||||
if (request.method === "GET" && request.url === `${BASE}/u1`) {
|
||||
return jsonResponse({ ...createdSession(24, 8), receivedChunks: [0] });
|
||||
}
|
||||
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||
return jsonResponse(completedSession(24, 8, [0, 1, 2]));
|
||||
}
|
||||
return jsonResponse({});
|
||||
};
|
||||
|
||||
const session = await uploadFileResumable({
|
||||
file,
|
||||
endpoints: endpointsWith(),
|
||||
surface: "WorkOrderTable",
|
||||
});
|
||||
|
||||
expect(session.state).toBe("Completed");
|
||||
const putIndices = captured
|
||||
.filter((entry) => entry.method === "PUT")
|
||||
.map((entry) => Number(/chunks\/(\d+)$/.exec(entry.url)?.[1]));
|
||||
expect(putIndices).toEqual([0, 1, 1, 2]);
|
||||
expect(captured.some((entry) => entry.method === "GET" && entry.url === `${BASE}/u1`)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("stops retrying a persistently failing chunk after bounded status recovery", async () => {
|
||||
const file = fileOf(8);
|
||||
let puts = 0;
|
||||
let statusReads = 0;
|
||||
handler = async (request) => {
|
||||
if (request.method === "POST" && request.url === BASE) {
|
||||
return jsonResponse(createdSession(8, 8));
|
||||
}
|
||||
if (request.method === "PUT") {
|
||||
puts += 1;
|
||||
throw new TypeError("network down");
|
||||
}
|
||||
if (request.method === "GET" && request.url === `${BASE}/u1`) {
|
||||
statusReads += 1;
|
||||
return jsonResponse(createdSession(8, 8));
|
||||
}
|
||||
return jsonResponse({});
|
||||
};
|
||||
|
||||
await expect(
|
||||
uploadFileResumable({ file, endpoints: endpointsWith(), surface: "WorkOrderTable" }),
|
||||
).rejects.toMatchObject({ name: "MediaUploadNetworkError" });
|
||||
expect(puts).toBe(3);
|
||||
expect(statusReads).toBe(3);
|
||||
});
|
||||
|
||||
it("recovers a lost commit response from Completed status without a duplicate commit", async () => {
|
||||
const file = fileOf(8);
|
||||
let commits = 0;
|
||||
handler = async (request) => {
|
||||
if (request.method === "POST" && request.url === BASE) {
|
||||
return jsonResponse(createdSession(8, 8));
|
||||
}
|
||||
if (request.method === "PUT") {
|
||||
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
|
||||
}
|
||||
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||
commits += 1;
|
||||
throw new TypeError("commit response lost");
|
||||
}
|
||||
if (request.method === "GET" && request.url === `${BASE}/u1`) {
|
||||
return jsonResponse(completedSession(8, 8, [0]));
|
||||
}
|
||||
return jsonResponse({});
|
||||
};
|
||||
|
||||
const result = await uploadFileResumable({
|
||||
file,
|
||||
endpoints: endpointsWith(),
|
||||
surface: "WorkOrderTable",
|
||||
});
|
||||
expect(result.state).toBe("Completed");
|
||||
expect(result.attachment?.id).toBe(42);
|
||||
expect(commits).toBe(1);
|
||||
expect(captured.filter((entry) => entry.method === "GET")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("retries the same idempotent session and sends only chunks missing after partial failure", async () => {
|
||||
const file = fileOf(16);
|
||||
const idempotencyKey = "11111111-2222-3333-4444-555555555555";
|
||||
let createCount = 0;
|
||||
let chunkOneFailures = 0;
|
||||
handler = async (request) => {
|
||||
if (request.method === "POST" && request.url === BASE) {
|
||||
createCount += 1;
|
||||
return jsonResponse({
|
||||
...createdSession(16, 8),
|
||||
receivedChunks: createCount === 1 ? [] : [0],
|
||||
});
|
||||
}
|
||||
if (request.method === "PUT" && request.url.endsWith("/chunks/0")) {
|
||||
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
|
||||
}
|
||||
if (request.method === "PUT" && request.url.endsWith("/chunks/1")) {
|
||||
chunkOneFailures += 1;
|
||||
if (chunkOneFailures <= 3) throw new TypeError("network down");
|
||||
return jsonResponse({ index: 1, sha256: "x", sizeBytes: 8, receivedChunks: [0, 1] });
|
||||
}
|
||||
if (request.method === "GET" && request.url === `${BASE}/u1`) {
|
||||
return jsonResponse({ ...createdSession(16, 8), receivedChunks: [0] });
|
||||
}
|
||||
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||
return jsonResponse(completedSession(16, 8, [0, 1]));
|
||||
}
|
||||
return jsonResponse({});
|
||||
};
|
||||
|
||||
await expect(
|
||||
uploadFileResumable({
|
||||
file,
|
||||
endpoints: endpointsWith(),
|
||||
surface: "WorkOrderTable",
|
||||
idempotencyKey,
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "MediaUploadNetworkError" });
|
||||
|
||||
const resumed = await uploadFileResumable({
|
||||
file,
|
||||
endpoints: endpointsWith(),
|
||||
surface: "WorkOrderTable",
|
||||
idempotencyKey,
|
||||
});
|
||||
expect(resumed.state).toBe("Completed");
|
||||
const creates = captured.filter((entry) => entry.method === "POST" && entry.url === BASE);
|
||||
expect(creates).toHaveLength(2);
|
||||
expect(
|
||||
creates.map((entry) => (entry.body as { idempotencyKey: string }).idempotencyKey),
|
||||
).toEqual([idempotencyKey, idempotencyKey]);
|
||||
const putIndices = captured
|
||||
.filter((entry) => entry.method === "PUT")
|
||||
.map((entry) => Number(/chunks\/(\d+)$/.exec(entry.url)?.[1]));
|
||||
expect(putIndices.filter((index) => index === 0)).toEqual([0]);
|
||||
expect(putIndices.at(-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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -39,6 +39,27 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
|
|||
useDeleteWorkOrderMedia: () => ({ mutate: deleteMutate, isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({
|
||||
useWorkOrderMediaPolicy: () => ({
|
||||
data: {
|
||||
workOrderType: "Emergency",
|
||||
canUpload: false,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 10, videos: 3 },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({
|
||||
useResumableWorkOrderMediaUpload: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
function baseRow(): WorkOrderTableRow {
|
||||
return {
|
||||
id: 7,
|
||||
|
|
@ -159,4 +180,16 @@ describe("MediaUploadModal API wiring", () => {
|
|||
workOrderVersion: "DETAIL-VER==",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when Emergency policy says uploads are unavailable", async () => {
|
||||
renderModal();
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
expect(input.disabled).toBe(true);
|
||||
expect(screen.getByText("Uploads are not available for this work order.")).toBeInTheDocument();
|
||||
fireEvent.change(input, {
|
||||
target: { files: [new File(["x"], "photo.jpg", { type: "image/jpeg" })] },
|
||||
});
|
||||
expect(uploadMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,17 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({
|
||||
useWorkOrderMediaPolicy: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({
|
||||
useResumableWorkOrderMediaUpload: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// A completion-doc upload that settles successfully. The mock forwards any onSuccess the
|
||||
// caller passes; the fix means the slide-over passes none (no follow-up docStatus patch).
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
|
||||
|
|
|
|||
|
|
@ -1,9 +1,20 @@
|
|||
import { act, renderHook } from "@testing-library/react";
|
||||
import { act, 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 { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
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,
|
||||
}),
|
||||
}));
|
||||
|
||||
function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
id: 100,
|
||||
|
|
@ -327,4 +338,67 @@ 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(legacyUpload.mutateAsync).not.toHaveBeenCalled();
|
||||
expect(result.current.completionMedia.some((item) => item.uploadStatus === "failed")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses the same idempotency key when a failed media tile is retried", async () => {
|
||||
const mutateAsync = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("network unavailable"))
|
||||
.mockResolvedValue({ id: "4" });
|
||||
const uploadMutation = {
|
||||
mutate: vi.fn(),
|
||||
mutateAsync,
|
||||
isPending: false,
|
||||
} as never;
|
||||
const { result } = renderHook(() =>
|
||||
useSlideOverMediaState({
|
||||
detailForRow: detailWithMedia([], { type: "PM" }),
|
||||
uploadMediaMutation: uploadMutation,
|
||||
deleteMediaMutation: idleMutation(),
|
||||
categorizeMediaMutation: idleMutation(),
|
||||
}),
|
||||
);
|
||||
const file = new File(["photo"], "photo.jpg", { type: "image/jpeg" });
|
||||
|
||||
act(() => {
|
||||
result.current.uploadMediaRequests([{ localId: "local-retry", file, onProgress: vi.fn() }]);
|
||||
});
|
||||
await waitFor(() => expect(result.current.completionMedia[0]?.uploadStatus).toBe("failed"));
|
||||
|
||||
act(() => result.current.retryUpload("local-retry"));
|
||||
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(2));
|
||||
expect(mutateAsync.mock.calls[0]?.[0].file).toBe(file);
|
||||
expect(mutateAsync.mock.calls[1]?.[0].file).toBe(file);
|
||||
expect(mutateAsync.mock.calls[1]?.[0].idempotencyKey).toBe(
|
||||
mutateAsync.mock.calls[0]?.[0].idempotencyKey,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,17 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({
|
||||
useWorkOrderMediaPolicy: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({
|
||||
useResumableWorkOrderMediaUpload: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
|
||||
useAddWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useUpdateWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
|
|
|
|||
113
src/test/app/v/vendor-media-section.test.tsx
Normal file
113
src/test/app/v/vendor-media-section.test.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
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 { MediaUploadNetworkError } from "@/api/media-upload-client";
|
||||
import * as mediaLimits from "@/domain/work-orders/utils/emergency-media-limits";
|
||||
import { vendorPortalMediaApi } from "@/domain/vendor-portal/api/vendor-portal-media-api";
|
||||
import type { 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("aborts the active vendor upload from the visible Cancel control", async () => {
|
||||
const upload = vi.spyOn(mediaUploadEngine, "uploadFileResumable").mockImplementation(
|
||||
({ signal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(new DOMException("Aborted", "AbortError")),
|
||||
{
|
||||
once: true,
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
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(),
|
||||
);
|
||||
const signal = upload.mock.calls[0]?.[0].signal;
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel upload" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Upload canceled.")).toBeInTheDocument());
|
||||
expect(signal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const API_URL = "http://api.test/api";
|
||||
let captured: { method: string; url: string; headers: Headers; body: string }[] = [];
|
||||
|
||||
const MEDIA_PAYLOAD = {
|
||||
policy: {
|
||||
workOrderType: "Emergency",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 9, videos: 3 },
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: 5,
|
||||
originalFileName: "before.jpg",
|
||||
contentType: "image/jpeg",
|
||||
sizeBytes: 2048,
|
||||
kind: "Photo",
|
||||
category: "Extra",
|
||||
scanStatus: "Passed",
|
||||
createdAt: "2026-09-23T10:00:00Z",
|
||||
canView: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
captured = [];
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/env", () => ({ env: { apiUrl: API_URL } }));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init);
|
||||
captured.push({
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: await request.text(),
|
||||
});
|
||||
return new Response(JSON.stringify({ data: MEDIA_PAYLOAD }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function lastRequest() {
|
||||
const request = captured[captured.length - 1];
|
||||
if (!request) throw new Error("No request reached fetch");
|
||||
return request;
|
||||
}
|
||||
|
||||
describe("vendor portal media API", () => {
|
||||
it("lists media with policy and scan-passed items using X-Vendor-Token only", async () => {
|
||||
const { vendorPortalMediaApi } =
|
||||
await import("@/domain/vendor-portal/api/vendor-portal-media-api");
|
||||
const media = await vendorPortalMediaApi.getMedia("portal-token", 9);
|
||||
|
||||
const request = lastRequest();
|
||||
expect(request.url).toBe(`${API_URL}/vendor-portal/dispatches/9/media`);
|
||||
expect(request.headers.get("x-vendor-token")).toBe("portal-token");
|
||||
expect(request.headers.get("authorization")).toBeNull();
|
||||
expect(media.policy.canUpload).toBe(true);
|
||||
expect(media.items[0]).toMatchObject({
|
||||
id: 5,
|
||||
kind: "Photo",
|
||||
scanStatus: "Passed",
|
||||
canView: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates chunked upload sessions on the vendor uploads base with the exact DTO", async () => {
|
||||
const { vendorPortalMediaApi } =
|
||||
await import("@/domain/vendor-portal/api/vendor-portal-media-api");
|
||||
await vendorPortalMediaApi.endpoints("portal-token", 9).create({
|
||||
idempotencyKey: "99999999-8888-7777-6666-555555555555",
|
||||
fileName: "after.mp4",
|
||||
contentType: "video/mp4",
|
||||
sizeBytes: 2048,
|
||||
surface: "VendorPortal",
|
||||
});
|
||||
|
||||
const request = lastRequest();
|
||||
expect(request.url).toBe(`${API_URL}/vendor-portal/dispatches/9/media/uploads`);
|
||||
expect(request.headers.get("x-vendor-token")).toBe("portal-token");
|
||||
expect(request.headers.get("authorization")).toBeNull();
|
||||
expect(JSON.parse(request.body)).toEqual({
|
||||
idempotencyKey: "99999999-8888-7777-6666-555555555555",
|
||||
fileName: "after.mp4",
|
||||
contentType: "video/mp4",
|
||||
sizeBytes: 2048,
|
||||
surface: "VendorPortal",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends chunk, commit, status and cancel calls with the vendor token", async () => {
|
||||
const { vendorPortalMediaApi } =
|
||||
await import("@/domain/vendor-portal/api/vendor-portal-media-api");
|
||||
const endpoints = vendorPortalMediaApi.endpoints("portal-token", 9);
|
||||
|
||||
await endpoints.putChunk({
|
||||
uploadId: "u3",
|
||||
index: 0,
|
||||
blob: new Blob([new Uint8Array(2)]),
|
||||
start: 0,
|
||||
end: 2,
|
||||
totalBytes: 2,
|
||||
sha256: "ff00",
|
||||
});
|
||||
expect(lastRequest().url).toBe(
|
||||
`${API_URL}/vendor-portal/dispatches/9/media/uploads/u3/chunks/0`,
|
||||
);
|
||||
|
||||
await endpoints.commit("u3");
|
||||
expect(lastRequest().url).toBe(`${API_URL}/vendor-portal/dispatches/9/media/uploads/u3/commit`);
|
||||
|
||||
await endpoints.getStatus("u3");
|
||||
expect(lastRequest().url).toBe(`${API_URL}/vendor-portal/dispatches/9/media/uploads/u3`);
|
||||
|
||||
await endpoints.cancel("u3");
|
||||
expect(lastRequest()).toMatchObject({
|
||||
method: "DELETE",
|
||||
url: `${API_URL}/vendor-portal/dispatches/9/media/uploads/u3`,
|
||||
});
|
||||
expect(lastRequest().headers.get("x-vendor-token")).toBe("portal-token");
|
||||
});
|
||||
|
||||
it("fetches media content only on demand from the content route", async () => {
|
||||
const { vendorPortalMediaApi } =
|
||||
await import("@/domain/vendor-portal/api/vendor-portal-media-api");
|
||||
const blob = await vendorPortalMediaApi.fetchContent("portal-token", 9, 5);
|
||||
|
||||
const request = lastRequest();
|
||||
expect(request.url).toBe(`${API_URL}/vendor-portal/dispatches/9/media/5/content`);
|
||||
expect(request.headers.get("x-vendor-token")).toBe("portal-token");
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
});
|
||||
|
||||
it("never exposes object URLs or storage endpoints in the media payload", async () => {
|
||||
const { vendorPortalMediaApi } =
|
||||
await import("@/domain/vendor-portal/api/vendor-portal-media-api");
|
||||
const media = await vendorPortalMediaApi.getMedia("portal-token", 9);
|
||||
|
||||
const serialized = JSON.stringify(media);
|
||||
expect(serialized).not.toContain("blob:");
|
||||
expect(serialized).not.toContain("https://storage");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const API_URL = "http://api.test/api";
|
||||
let captured: { method: string; url: string; headers: Headers; body: string }[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
captured = [];
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/env", () => ({ env: { apiUrl: API_URL } }));
|
||||
vi.doMock("@/lib/auth/auth-storage", () => ({ getAccessToken: () => "Bearer test-token" }));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init);
|
||||
captured.push({
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: await request.text(),
|
||||
});
|
||||
return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function lastRequest() {
|
||||
const request = captured[captured.length - 1];
|
||||
if (!request) throw new Error("No request reached fetch");
|
||||
return request;
|
||||
}
|
||||
|
||||
describe("dispatcher resumable upload API", () => {
|
||||
it("fetches the Emergency/Reactive media policy from the documented route", async () => {
|
||||
const { workOrdersMediaUploadApi } =
|
||||
await import("@/domain/work-orders/api/work-order-media-upload-api");
|
||||
await workOrdersMediaUploadApi.getMediaPolicy(7);
|
||||
|
||||
const request = lastRequest();
|
||||
expect(request.url).toBe(`${API_URL}/workorders/7/media/policy`);
|
||||
expect(request.method).toBe("GET");
|
||||
expect(request.headers.get("authorization")).toBe("Bearer test-token");
|
||||
});
|
||||
|
||||
it("creates sessions on the work-order-bound uploads base with the exact DTO shape", async () => {
|
||||
const { workOrdersMediaUploadApi } =
|
||||
await import("@/domain/work-orders/api/work-order-media-upload-api");
|
||||
await workOrdersMediaUploadApi.endpoints(10).create({
|
||||
idempotencyKey: "11111111-2222-3333-4444-555555555555",
|
||||
fileName: "photo.jpg",
|
||||
contentType: "image/jpeg",
|
||||
sizeBytes: 12_345,
|
||||
surface: "WorkOrderTable",
|
||||
});
|
||||
|
||||
const request = lastRequest();
|
||||
expect(request.url).toBe(`${API_URL}/workorders/10/media/uploads`);
|
||||
expect(request.method).toBe("POST");
|
||||
expect(request.headers.get("authorization")).toBe("Bearer test-token");
|
||||
expect(request.headers.get("content-type")).toBe("application/json");
|
||||
expect(JSON.parse(request.body)).toEqual({
|
||||
idempotencyKey: "11111111-2222-3333-4444-555555555555",
|
||||
fileName: "photo.jpg",
|
||||
contentType: "image/jpeg",
|
||||
sizeBytes: 12_345,
|
||||
surface: "WorkOrderTable",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits category when not provided and sends it for explicit categories", async () => {
|
||||
const { workOrdersMediaUploadApi } =
|
||||
await import("@/domain/work-orders/api/work-order-media-upload-api");
|
||||
const endpoints = workOrdersMediaUploadApi.endpoints(10);
|
||||
|
||||
await endpoints.create({
|
||||
idempotencyKey: "11111111-2222-3333-4444-555555555556",
|
||||
fileName: "a.jpg",
|
||||
contentType: "image/jpeg",
|
||||
sizeBytes: 5,
|
||||
category: "Aveta",
|
||||
surface: "CompletionMedia",
|
||||
});
|
||||
expect(JSON.parse(lastRequest().body)).toMatchObject({
|
||||
category: "Aveta",
|
||||
surface: "CompletionMedia",
|
||||
});
|
||||
});
|
||||
|
||||
it("PUTs raw chunk bytes with Content-Range and X-Chunk-SHA256 on the session route", async () => {
|
||||
const { workOrdersMediaUploadApi } =
|
||||
await import("@/domain/work-orders/api/work-order-media-upload-api");
|
||||
const blob = new Blob([new Uint8Array(4)]);
|
||||
|
||||
await workOrdersMediaUploadApi.endpoints(10).putChunk({
|
||||
uploadId: "u9",
|
||||
index: 2,
|
||||
blob,
|
||||
start: 10_485_760,
|
||||
end: 10_485_764,
|
||||
totalBytes: 10_485_764,
|
||||
sha256: "ABCD",
|
||||
signal: undefined,
|
||||
});
|
||||
|
||||
const request = lastRequest();
|
||||
expect(request.url).toBe(`${API_URL}/workorders/10/media/uploads/u9/chunks/2`);
|
||||
expect(request.method).toBe("PUT");
|
||||
expect(request.headers.get("content-range")).toBe("bytes 10485760-10485763/10485764");
|
||||
expect(request.headers.get("x-chunk-sha256")).toBe("abcd");
|
||||
expect(request.headers.get("content-type")).toBe("application/octet-stream");
|
||||
});
|
||||
|
||||
it("routes commit, status and cancel under the same session base", async () => {
|
||||
const { workOrdersMediaUploadApi } =
|
||||
await import("@/domain/work-orders/api/work-order-media-upload-api");
|
||||
const endpoints = workOrdersMediaUploadApi.endpoints(10);
|
||||
|
||||
await endpoints.commit("u9");
|
||||
expect(lastRequest()).toMatchObject({ method: "POST" });
|
||||
expect(lastRequest().url).toBe(`${API_URL}/workorders/10/media/uploads/u9/commit`);
|
||||
|
||||
await endpoints.getStatus("u9");
|
||||
expect(lastRequest()).toMatchObject({
|
||||
method: "GET",
|
||||
url: `${API_URL}/workorders/10/media/uploads/u9`,
|
||||
});
|
||||
|
||||
await endpoints.cancel("u9");
|
||||
expect(lastRequest()).toMatchObject({
|
||||
method: "DELETE",
|
||||
url: `${API_URL}/workorders/10/media/uploads/u9`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("protected legacy upload surfaces", () => {
|
||||
it("keeps the signed completion-doc, media content and Extra Docs routes and limits untouched", async () => {
|
||||
const { API_PATHS } = await import("@/api/api-paths");
|
||||
const limits = await import("@/domain/work-orders/utils/work-order-media-limits");
|
||||
const { validateVendorDocument } =
|
||||
await import("@/domain/vendor-portal/lib/document-validation");
|
||||
|
||||
expect(API_PATHS.workOrder.mediaContent(10, 12)).toBe("workorders/10/media/12/content");
|
||||
expect(API_PATHS.workOrder.completionDoc(10)).toBe("workorders/10/completion-doc");
|
||||
expect(limits.MAX_WORK_ORDER_COMPLETION_DOC_BYTES).toBe(50_000_000);
|
||||
expect(limits.MAX_WORK_ORDER_MEDIA_BYTES).toBe(200_000_000);
|
||||
|
||||
// Vendor completion/uplift documents keep the 10,485,760-byte legacy limit.
|
||||
const legacyPdf = { size: 10 * 1024 * 1024, type: "application/pdf", name: "doc.pdf" } as File;
|
||||
const oversizedPdf = {
|
||||
size: 10 * 1024 * 1024 + 1,
|
||||
type: "application/pdf",
|
||||
name: "doc.pdf",
|
||||
} as File;
|
||||
expect(validateVendorDocument(legacyPdf)).toBeUndefined();
|
||||
expect(validateVendorDocument(oversizedPdf)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
136
src/test/domain/work-orders/utils/emergency-media-limits.test.ts
Normal file
136
src/test/domain/work-orders/utils/emergency-media-limits.test.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
classifyMediaKind,
|
||||
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("renders a standard safe over-limit notice without raw payloads", async () => {
|
||||
const notice = mediaOverLimitNotice(
|
||||
fileOf(100_000_001, "big.mov", "video/quicktime"),
|
||||
"Video",
|
||||
BASE_POLICY.limits,
|
||||
);
|
||||
expect(notice).toContain("big.mov");
|
||||
expect(notice).toContain("100 MB");
|
||||
expect(notice).toContain("90 seconds");
|
||||
expect(mediaKindLimitReachedNotice("Photo", BASE_POLICY.limits)).toContain("10");
|
||||
});
|
||||
|
||||
it("matches only Emergency and Reactive work order types", () => {
|
||||
expect(isEmergencyReactiveWorkOrderType("Emergency")).toBe(true);
|
||||
expect(isEmergencyReactiveWorkOrderType("reactive")).toBe(true);
|
||||
expect(isEmergencyReactiveWorkOrderType("Preventive")).toBe(false);
|
||||
expect(isEmergencyReactiveWorkOrderType(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("offers HEIC in the emergency accept attribute", () => {
|
||||
const accept = emergencyMediaAcceptAttribute();
|
||||
expect(accept).toContain("image/heic");
|
||||
expect(accept).toContain(".mov");
|
||||
});
|
||||
|
||||
it("revokes the preview URL after probing duration, resolving null when the decode fails", async () => {
|
||||
const createObjectURL = vi.fn(() => "blob:probe");
|
||||
const revokeObjectURL = vi.fn();
|
||||
vi.stubGlobal("URL", { ...URL, createObjectURL, revokeObjectURL });
|
||||
|
||||
const media: Record<string, unknown> = {};
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, "createElement").mockImplementation(((tag: string) => {
|
||||
if (tag === "video") {
|
||||
const element = originalCreateElement("video");
|
||||
Object.defineProperty(element, "duration", { value: NaN, configurable: true });
|
||||
media[element.src as string] = true;
|
||||
setTimeout(() => {
|
||||
element.dispatchEvent(new Event("error"));
|
||||
}, 0);
|
||||
return element;
|
||||
}
|
||||
return originalCreateElement(tag);
|
||||
}) as typeof document.createElement);
|
||||
|
||||
const duration = await probeVideoDurationSeconds(fileOf(10, "clip.mov", "video/quicktime"));
|
||||
|
||||
expect(duration).toBeNull();
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:probe");
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue