mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Merge 6321f176aa into 6367dc2a7a
This commit is contained in:
commit
36f82f21c8
34 changed files with 3254 additions and 112 deletions
|
|
@ -41,6 +41,8 @@ export const API_PATHS = {
|
||||||
`workorders/${id}/media/${mediaId}`,
|
`workorders/${id}/media/${mediaId}`,
|
||||||
mediaContent: (id: string | number, mediaId: string | number) =>
|
mediaContent: (id: string | number, mediaId: string | number) =>
|
||||||
`workorders/${id}/media/${mediaId}/content`,
|
`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`,
|
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
|
||||||
uplifts: (id: string | number) => `workorders/${id}/uplifts`,
|
uplifts: (id: string | number) => `workorders/${id}/uplifts`,
|
||||||
upliftCancel: (workOrderId: string | number, upliftId: string | number) =>
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
336
src/api/media-upload-engine.ts
Normal file
336
src/api/media-upload-engine.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
import {
|
||||||
|
type MediaChunkAckDto,
|
||||||
|
type MediaUploadCreateDto,
|
||||||
|
type MediaUploadSessionDto,
|
||||||
|
type UploadCategory,
|
||||||
|
type UploadSurface,
|
||||||
|
MEDIA_UPLOAD_CHUNK_SIZE_BYTES,
|
||||||
|
MediaUploadCanceledError,
|
||||||
|
MediaUploadExpiredError,
|
||||||
|
MediaUploadRejectedError,
|
||||||
|
MediaUploadScanTimeoutError,
|
||||||
|
isTerminalMediaUploadState,
|
||||||
|
} from "@/api/media-upload-contracts";
|
||||||
|
import {
|
||||||
|
type MediaUploadEndpointSet,
|
||||||
|
computeSha256Hex,
|
||||||
|
isAbortError,
|
||||||
|
isNetworkUploadError,
|
||||||
|
} from "@/api/media-upload-client";
|
||||||
|
|
||||||
|
const MAX_CREATE_ATTEMPTS = 2;
|
||||||
|
const MAX_CHUNK_RECOVERY_ATTEMPTS = 3;
|
||||||
|
const CHUNK_RECOVERY_BACKOFF_MS = 200;
|
||||||
|
const MAX_COMMIT_ATTEMPTS = 2;
|
||||||
|
const MAX_POLL_ATTEMPTS = 120;
|
||||||
|
const DEFAULT_POLL_DELAY_MS = 1_500;
|
||||||
|
|
||||||
|
export interface ResumableUploadArgs {
|
||||||
|
file: File;
|
||||||
|
endpoints: MediaUploadEndpointSet;
|
||||||
|
surface: UploadSurface;
|
||||||
|
category?: UploadCategory;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onProgress?: (percent: number) => void;
|
||||||
|
/** Defaults to a fresh random UUID per attempt. */
|
||||||
|
idempotencyKey?: string;
|
||||||
|
digest?: (buffer: ArrayBuffer) => Promise<string>;
|
||||||
|
pollDelayMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chunkCountFor(sizeBytes: number, chunkSizeBytes: number): number {
|
||||||
|
if (sizeBytes <= 0) return 1;
|
||||||
|
return Math.ceil(sizeBytes / chunkSizeBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function missingChunkIndices(received: readonly number[], total: number): number[] {
|
||||||
|
const acked = new Set(received);
|
||||||
|
const missing: number[] = [];
|
||||||
|
for (let index = 0; index < total; index += 1) {
|
||||||
|
if (!acked.has(index)) {
|
||||||
|
missing.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function acknowledgedBytes(
|
||||||
|
received: readonly number[],
|
||||||
|
totalBytes: number,
|
||||||
|
chunkSize: number,
|
||||||
|
): number {
|
||||||
|
let bytes = 0;
|
||||||
|
for (const index of received) {
|
||||||
|
if (index >= 0 && index < chunkCountFor(totalBytes, chunkSize)) {
|
||||||
|
bytes += Math.min(chunkSize, totalBytes - index * chunkSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportProgress(
|
||||||
|
args: ResumableUploadArgs,
|
||||||
|
received: readonly number[],
|
||||||
|
totalBytes: number,
|
||||||
|
chunkSize: number,
|
||||||
|
): void {
|
||||||
|
if (!args.onProgress || totalBytes <= 0) return;
|
||||||
|
const percent = Math.min(
|
||||||
|
100,
|
||||||
|
Math.round((acknowledgedBytes(received, totalBytes, chunkSize) / totalBytes) * 100),
|
||||||
|
);
|
||||||
|
args.onProgress(percent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNotAborted(signal: AbortSignal | undefined): void {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new DOMException("The upload was aborted.", "AbortError");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwForTerminalState(state: MediaUploadSessionDto["state"]): void {
|
||||||
|
if (state === "Rejected") throw new MediaUploadRejectedError();
|
||||||
|
if (state === "Expired") throw new MediaUploadExpiredError();
|
||||||
|
if (state === "Canceled") throw new MediaUploadCanceledError();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Idempotent create: a network failure retries the same idempotency key, never a new session. */
|
||||||
|
async function createSessionIdempotent(
|
||||||
|
endpoints: MediaUploadEndpointSet,
|
||||||
|
dto: MediaUploadCreateDto,
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
|
): Promise<MediaUploadSessionDto> {
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 0; attempt < MAX_CREATE_ATTEMPTS; attempt += 1) {
|
||||||
|
try {
|
||||||
|
return await endpoints.create(dto, signal);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNetworkUploadError(error)) throw error;
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network failure recovery: re-read the session; terminal states settle here,
|
||||||
|
* otherwise the returned receivedChunks define what still needs sending.
|
||||||
|
*/
|
||||||
|
async function recoverSession(
|
||||||
|
endpoints: MediaUploadEndpointSet,
|
||||||
|
uploadId: string,
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
|
): Promise<MediaUploadSessionDto> {
|
||||||
|
let session: MediaUploadSessionDto;
|
||||||
|
try {
|
||||||
|
session = await endpoints.getStatus(uploadId, signal);
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) throw error;
|
||||||
|
if (isNetworkUploadError(error)) {
|
||||||
|
// Still offline — bubble so the attempt surfaces as retryable failure.
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (isTerminalMediaUploadState(session.state)) {
|
||||||
|
throwForTerminalState(session.state);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForScanTerminal(
|
||||||
|
args: ResumableUploadArgs,
|
||||||
|
uploadId: string,
|
||||||
|
): Promise<MediaUploadSessionDto> {
|
||||||
|
const delayMs = args.pollDelayMs ?? DEFAULT_POLL_DELAY_MS;
|
||||||
|
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
|
||||||
|
assertNotAborted(args.signal);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
assertNotAborted(args.signal);
|
||||||
|
let session: MediaUploadSessionDto;
|
||||||
|
try {
|
||||||
|
session = await args.endpoints.getStatus(uploadId, args.signal);
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) throw error;
|
||||||
|
continue; // Transient poll failure — keep polling until the deadline.
|
||||||
|
}
|
||||||
|
if (isTerminalMediaUploadState(session.state)) {
|
||||||
|
throwForTerminalState(session.state);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new MediaUploadScanTimeoutError();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitAndObserve(
|
||||||
|
args: ResumableUploadArgs,
|
||||||
|
uploadId: string,
|
||||||
|
): Promise<MediaUploadSessionDto> {
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 0; attempt < MAX_COMMIT_ATTEMPTS; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const committed = await args.endpoints.commit(uploadId, args.signal);
|
||||||
|
return committed.state === "Scanning" ? waitForScanTerminal(args, uploadId) : committed;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNetworkUploadError(error)) throw error;
|
||||||
|
lastError = error;
|
||||||
|
const observed = await args.endpoints.getStatus(uploadId, args.signal);
|
||||||
|
if (observed.state === "Completed") return observed;
|
||||||
|
if (isTerminalMediaUploadState(observed.state)) {
|
||||||
|
throwForTerminalState(observed.state);
|
||||||
|
}
|
||||||
|
if (observed.state === "Scanning") return waitForScanTerminal(args, uploadId);
|
||||||
|
if (attempt + 1 < MAX_COMMIT_ATTEMPTS) {
|
||||||
|
continue; // Commit is idempotent for this uploadId.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendChunk(
|
||||||
|
args: ResumableUploadArgs,
|
||||||
|
uploadId: string,
|
||||||
|
index: number,
|
||||||
|
chunkSize: number,
|
||||||
|
): Promise<MediaChunkAckDto> {
|
||||||
|
const { file } = args;
|
||||||
|
const start = index * chunkSize;
|
||||||
|
const end = Math.min(start + chunkSize, file.size);
|
||||||
|
const blob = file.slice(start, end);
|
||||||
|
const buffer = await blob.arrayBuffer();
|
||||||
|
const digest = args.digest ?? computeSha256Hex;
|
||||||
|
const sha256 = await digest(buffer);
|
||||||
|
return args.endpoints.putChunk({
|
||||||
|
uploadId,
|
||||||
|
index,
|
||||||
|
blob,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
totalBytes: file.size,
|
||||||
|
sha256,
|
||||||
|
signal: args.signal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChunkRecoveryContext = {
|
||||||
|
args: ResumableUploadArgs;
|
||||||
|
session: MediaUploadSessionDto;
|
||||||
|
index: number;
|
||||||
|
chunkSize: number;
|
||||||
|
recoveryAttempts: Map<number, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function sendChunkWithRecovery({
|
||||||
|
args,
|
||||||
|
session,
|
||||||
|
index,
|
||||||
|
chunkSize,
|
||||||
|
recoveryAttempts,
|
||||||
|
}: ChunkRecoveryContext): Promise<{
|
||||||
|
session: MediaUploadSessionDto;
|
||||||
|
recovered: boolean;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const ack = await sendChunk(args, session.uploadId, index, chunkSize);
|
||||||
|
const receivedChunks = Array.isArray(ack?.receivedChunks) ? ack.receivedChunks : [];
|
||||||
|
if (!receivedChunks.includes(index)) {
|
||||||
|
throw new Error("Chunk acknowledgement missing from server response.");
|
||||||
|
}
|
||||||
|
return { session: { ...session, receivedChunks }, recovered: false };
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNetworkUploadError(error)) throw error;
|
||||||
|
const recovered = await recoverSession(args.endpoints, session.uploadId, args.signal);
|
||||||
|
if (recovered.receivedChunks.includes(index)) {
|
||||||
|
recoveryAttempts.delete(index);
|
||||||
|
return { session: recovered, recovered: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const attempts = (recoveryAttempts.get(index) ?? 0) + 1;
|
||||||
|
recoveryAttempts.set(index, attempts);
|
||||||
|
if (attempts >= MAX_CHUNK_RECOVERY_ATTEMPTS) throw error;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, CHUNK_RECOVERY_BACKOFF_MS * attempts));
|
||||||
|
assertNotAborted(args.signal);
|
||||||
|
return { session: recovered, recovered: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload one file through the resumable session API: create → chunks → commit → poll.
|
||||||
|
* One in-flight chunk request per file; durable progress counts only acknowledged parts.
|
||||||
|
*/
|
||||||
|
export async function uploadFileResumable(
|
||||||
|
args: ResumableUploadArgs,
|
||||||
|
): Promise<MediaUploadSessionDto> {
|
||||||
|
assertNotAborted(args.signal);
|
||||||
|
const createDto: MediaUploadCreateDto = {
|
||||||
|
idempotencyKey: args.idempotencyKey ?? crypto.randomUUID(),
|
||||||
|
fileName: args.file.name,
|
||||||
|
contentType: args.file.type,
|
||||||
|
sizeBytes: args.file.size,
|
||||||
|
surface: args.surface,
|
||||||
|
...(args.category ? { category: args.category } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = await createSessionIdempotent(args.endpoints, createDto, args.signal);
|
||||||
|
const uploadId = session.uploadId;
|
||||||
|
const chunkSize =
|
||||||
|
Number.isFinite(session.chunkSizeBytes) && session.chunkSizeBytes > 0
|
||||||
|
? session.chunkSizeBytes
|
||||||
|
: MEDIA_UPLOAD_CHUNK_SIZE_BYTES;
|
||||||
|
const totalChunks = chunkCountFor(args.file.size, chunkSize);
|
||||||
|
reportProgress(args, session.receivedChunks, args.file.size, chunkSize);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recoveryAttempts = new Map<number, number>();
|
||||||
|
for (;;) {
|
||||||
|
assertNotAborted(args.signal);
|
||||||
|
const missing = missingChunkIndices(session.receivedChunks, totalChunks);
|
||||||
|
if (missing.length === 0) break;
|
||||||
|
for (const index of missing) {
|
||||||
|
assertNotAborted(args.signal);
|
||||||
|
const result = await sendChunkWithRecovery({
|
||||||
|
args,
|
||||||
|
session,
|
||||||
|
index,
|
||||||
|
chunkSize,
|
||||||
|
recoveryAttempts,
|
||||||
|
});
|
||||||
|
session = result.session;
|
||||||
|
if (result.recovered) break;
|
||||||
|
reportProgress(args, session.receivedChunks, args.file.size, chunkSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNotAborted(args.signal);
|
||||||
|
session = await commitAndObserve(args, uploadId);
|
||||||
|
} catch (error) {
|
||||||
|
// Cancellation aborts the in-flight request and DELETEs the known session.
|
||||||
|
if (isAbortError(error)) {
|
||||||
|
await args.endpoints.cancel(uploadId);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throwForTerminalState(session.state);
|
||||||
|
reportProgress(args, session.receivedChunks, args.file.size, chunkSize);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload a queue of files with at most `maxConcurrent` files in flight. */
|
||||||
|
export async function runMediaUploadQueue<T>(
|
||||||
|
items: readonly T[],
|
||||||
|
runner: (item: T) => Promise<void>,
|
||||||
|
maxConcurrent = 2,
|
||||||
|
): Promise<void> {
|
||||||
|
let cursor = 0;
|
||||||
|
const workers = Array.from({ length: Math.min(maxConcurrent, items.length) }, async () => {
|
||||||
|
while (cursor < items.length) {
|
||||||
|
const item = items[cursor];
|
||||||
|
cursor += 1;
|
||||||
|
await runner(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,13 @@ type SlideOverCompletionMediaProps = {
|
||||||
onRemoveMedia?: (id: string) => void;
|
onRemoveMedia?: (id: string) => void;
|
||||||
onRetryMedia?: (localId: string) => void;
|
onRetryMedia?: (localId: string) => void;
|
||||||
onCancelMedia?: (localId: string) => void;
|
onCancelMedia?: (localId: string) => void;
|
||||||
|
uploadDisabled?: boolean;
|
||||||
|
uploadUnavailableMessage?: string;
|
||||||
|
typeAllowed?: (file: File) => boolean;
|
||||||
|
typeErrorMessage?: string;
|
||||||
|
acceptAttribute?: string;
|
||||||
|
fileSizeError?: (file: File) => string | undefined;
|
||||||
|
uploadHint?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SlideOverCompletionMedia({
|
export function SlideOverCompletionMedia({
|
||||||
|
|
@ -26,6 +33,13 @@ export function SlideOverCompletionMedia({
|
||||||
onRemoveMedia,
|
onRemoveMedia,
|
||||||
onRetryMedia,
|
onRetryMedia,
|
||||||
onCancelMedia,
|
onCancelMedia,
|
||||||
|
uploadDisabled = false,
|
||||||
|
uploadUnavailableMessage,
|
||||||
|
typeAllowed,
|
||||||
|
typeErrorMessage,
|
||||||
|
acceptAttribute,
|
||||||
|
fileSizeError,
|
||||||
|
uploadHint,
|
||||||
}: SlideOverCompletionMediaProps) {
|
}: SlideOverCompletionMediaProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -45,8 +59,19 @@ export function SlideOverCompletionMedia({
|
||||||
onCancel={onCancelMedia}
|
onCancel={onCancelMedia}
|
||||||
readOnly={isReadOnly}
|
readOnly={isReadOnly}
|
||||||
pending={mediaPending}
|
pending={mediaPending}
|
||||||
|
uploadDisabled={uploadDisabled}
|
||||||
|
typeAllowed={typeAllowed}
|
||||||
|
typeErrorMessage={typeErrorMessage}
|
||||||
|
acceptAttribute={acceptAttribute}
|
||||||
|
fileSizeError={fileSizeError}
|
||||||
|
uploadHint={uploadHint}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{uploadDisabled && Boolean(uploadUnavailableMessage) && (
|
||||||
|
<div role="status" className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{uploadUnavailableMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,13 @@ type SlideOverCompletionTabProps = {
|
||||||
onRemoveMedia?: (id: string) => void;
|
onRemoveMedia?: (id: string) => void;
|
||||||
onRetryMedia?: (localId: string) => void;
|
onRetryMedia?: (localId: string) => void;
|
||||||
onCancelMedia?: (localId: string) => void;
|
onCancelMedia?: (localId: string) => void;
|
||||||
|
uploadDisabled?: boolean;
|
||||||
|
uploadUnavailableMessage?: string;
|
||||||
|
typeAllowed?: (file: File) => boolean;
|
||||||
|
typeErrorMessage?: string;
|
||||||
|
acceptAttribute?: string;
|
||||||
|
fileSizeError?: (file: File) => string | undefined;
|
||||||
|
uploadHint?: string;
|
||||||
onUploadCompletionPdf?: (file: File) => void;
|
onUploadCompletionPdf?: (file: File) => void;
|
||||||
onOpenCompletionDoc: () => void;
|
onOpenCompletionDoc: () => void;
|
||||||
};
|
};
|
||||||
|
|
@ -46,6 +53,13 @@ export function SlideOverCompletionTab({
|
||||||
onRemoveMedia,
|
onRemoveMedia,
|
||||||
onRetryMedia,
|
onRetryMedia,
|
||||||
onCancelMedia,
|
onCancelMedia,
|
||||||
|
uploadDisabled,
|
||||||
|
uploadUnavailableMessage,
|
||||||
|
typeAllowed,
|
||||||
|
typeErrorMessage,
|
||||||
|
acceptAttribute,
|
||||||
|
fileSizeError,
|
||||||
|
uploadHint,
|
||||||
onUploadCompletionPdf,
|
onUploadCompletionPdf,
|
||||||
onOpenCompletionDoc,
|
onOpenCompletionDoc,
|
||||||
}: SlideOverCompletionTabProps) {
|
}: SlideOverCompletionTabProps) {
|
||||||
|
|
@ -65,6 +79,13 @@ export function SlideOverCompletionTab({
|
||||||
onRemoveMedia={onRemoveMedia}
|
onRemoveMedia={onRemoveMedia}
|
||||||
onRetryMedia={onRetryMedia}
|
onRetryMedia={onRetryMedia}
|
||||||
onCancelMedia={onCancelMedia}
|
onCancelMedia={onCancelMedia}
|
||||||
|
uploadDisabled={uploadDisabled}
|
||||||
|
uploadUnavailableMessage={uploadUnavailableMessage}
|
||||||
|
typeAllowed={typeAllowed}
|
||||||
|
typeErrorMessage={typeErrorMessage}
|
||||||
|
acceptAttribute={acceptAttribute}
|
||||||
|
fileSizeError={fileSizeError}
|
||||||
|
uploadHint={uploadHint}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,34 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
|
||||||
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
||||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import type {
|
import type {
|
||||||
useDeleteWorkOrderMedia,
|
useDeleteWorkOrderMedia,
|
||||||
useUpdateWorkOrderMediaCategory,
|
useUpdateWorkOrderMediaCategory,
|
||||||
useUploadWorkOrderMedia,
|
useUploadWorkOrderMedia,
|
||||||
UploadWorkOrderMediaInput,
|
UploadWorkOrderMediaInput,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
} 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 {
|
import {
|
||||||
hasCompletionMediaGallery,
|
hasCompletionMediaGallery,
|
||||||
isCompletionSurfaceMedia,
|
isCompletionSurfaceMedia,
|
||||||
isExtrasSurfaceMedia,
|
isExtrasSurfaceMedia,
|
||||||
type MediaSurfaceOwnership,
|
type MediaSurfaceOwnership,
|
||||||
} from "@/domain/work-orders/utils/partition-slide-over-media";
|
} from "@/domain/work-orders/utils/partition-slide-over-media";
|
||||||
|
import {
|
||||||
|
classifyMediaKind,
|
||||||
|
emergencyMediaAcceptAttribute,
|
||||||
|
emergencyMediaSizeError,
|
||||||
|
isEmergencyReactiveWorkOrderType,
|
||||||
|
} from "@/domain/work-orders/utils/emergency-media-limits";
|
||||||
|
|
||||||
type UseSlideOverMediaStateArgs = {
|
type UseSlideOverMediaStateArgs = {
|
||||||
detailForRow?: WorkOrderBoardDetail;
|
detailForRow?: WorkOrderBoardDetail;
|
||||||
|
row?: WorkOrderTableRow | null;
|
||||||
uploadMediaMutation: ReturnType<typeof useUploadWorkOrderMedia>;
|
uploadMediaMutation: ReturnType<typeof useUploadWorkOrderMedia>;
|
||||||
deleteMediaMutation: ReturnType<typeof useDeleteWorkOrderMedia>;
|
deleteMediaMutation: ReturnType<typeof useDeleteWorkOrderMedia>;
|
||||||
categorizeMediaMutation: ReturnType<typeof useUpdateWorkOrderMediaCategory>;
|
categorizeMediaMutation: ReturnType<typeof useUpdateWorkOrderMediaCategory>;
|
||||||
|
|
@ -74,15 +83,7 @@ function saveOwnership(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSlideOverMediaState({
|
function useMediaOwnership(workOrderId: string | number | undefined) {
|
||||||
detailForRow,
|
|
||||||
uploadMediaMutation,
|
|
||||||
deleteMediaMutation,
|
|
||||||
categorizeMediaMutation,
|
|
||||||
}: UseSlideOverMediaStateArgs) {
|
|
||||||
const workOrderId = detailForRow?.info.id;
|
|
||||||
const completionHasGallery = hasCompletionMediaGallery(detailForRow?.info.type);
|
|
||||||
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
|
||||||
const [ownership, setOwnershipState] = useState<MediaSurfaceOwnership>(() =>
|
const [ownership, setOwnershipState] = useState<MediaSurfaceOwnership>(() =>
|
||||||
loadOwnership(workOrderId),
|
loadOwnership(workOrderId),
|
||||||
);
|
);
|
||||||
|
|
@ -102,8 +103,46 @@ export function useSlideOverMediaState({
|
||||||
[workOrderId],
|
[workOrderId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const persistCompletionUpload = useCallback(
|
return { ownership, updateOwnership };
|
||||||
async (input: UploadWorkOrderMediaInput) => {
|
}
|
||||||
|
|
||||||
|
type OwnershipUpdater = (update: (current: MediaSurfaceOwnership) => MediaSurfaceOwnership) => void;
|
||||||
|
|
||||||
|
type CompletionUploadArgs = {
|
||||||
|
emergencyMedia: boolean;
|
||||||
|
policyCanUpload: boolean;
|
||||||
|
resumableCompletionUpload: ReturnType<typeof useResumableWorkOrderMediaUpload>;
|
||||||
|
uploadMediaMutation: ReturnType<typeof useUploadWorkOrderMedia>;
|
||||||
|
updateOwnership: OwnershipUpdater;
|
||||||
|
};
|
||||||
|
|
||||||
|
function usePersistCompletionUpload({
|
||||||
|
emergencyMedia,
|
||||||
|
policyCanUpload,
|
||||||
|
resumableCompletionUpload,
|
||||||
|
uploadMediaMutation,
|
||||||
|
updateOwnership,
|
||||||
|
}: CompletionUploadArgs) {
|
||||||
|
return useCallback(
|
||||||
|
async (input: UploadWorkOrderMediaInput): Promise<{ id: string }> => {
|
||||||
|
if (emergencyMedia) {
|
||||||
|
if (!policyCanUpload) {
|
||||||
|
throw new Error("Uploads are not available for this work order.");
|
||||||
|
}
|
||||||
|
const session = await resumableCompletionUpload.mutateAsync(input);
|
||||||
|
const attachmentId = session.attachment?.id;
|
||||||
|
if (attachmentId == null) {
|
||||||
|
throw new Error(
|
||||||
|
"Upload completed but no media item was returned. Refresh and try again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const mediaId = String(attachmentId);
|
||||||
|
updateOwnership((current) => ({
|
||||||
|
completionOwnedIds: addOwnedId(new Set(current.completionOwnedIds), mediaId),
|
||||||
|
extraOwnedIds: dropOwnedId(new Set(current.extraOwnedIds), mediaId),
|
||||||
|
}));
|
||||||
|
return { id: mediaId };
|
||||||
|
}
|
||||||
const item = await uploadMediaMutation.mutateAsync(input);
|
const item = await uploadMediaMutation.mutateAsync(input);
|
||||||
updateOwnership((current) => ({
|
updateOwnership((current) => ({
|
||||||
completionOwnedIds: addOwnedId(new Set(current.completionOwnedIds), item.id),
|
completionOwnedIds: addOwnedId(new Set(current.completionOwnedIds), item.id),
|
||||||
|
|
@ -111,11 +150,209 @@ export function useSlideOverMediaState({
|
||||||
}));
|
}));
|
||||||
return item;
|
return item;
|
||||||
},
|
},
|
||||||
[updateOwnership, uploadMediaMutation],
|
[
|
||||||
|
emergencyMedia,
|
||||||
|
policyCanUpload,
|
||||||
|
resumableCompletionUpload,
|
||||||
|
updateOwnership,
|
||||||
|
uploadMediaMutation,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SlideOverActionArgs = {
|
||||||
|
detailForRow: WorkOrderBoardDetail | undefined;
|
||||||
|
isLocal: (id: string) => boolean;
|
||||||
|
removeLocal: (id: string) => void;
|
||||||
|
updateOwnership: OwnershipUpdater;
|
||||||
|
uploadMediaMutation: ReturnType<typeof useUploadWorkOrderMedia>;
|
||||||
|
deleteMediaMutation: ReturnType<typeof useDeleteWorkOrderMedia>;
|
||||||
|
categorizeMediaMutation: ReturnType<typeof useUpdateWorkOrderMediaCategory>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function useSlideOverMutationActions({
|
||||||
|
detailForRow,
|
||||||
|
isLocal,
|
||||||
|
removeLocal,
|
||||||
|
updateOwnership,
|
||||||
|
uploadMediaMutation,
|
||||||
|
deleteMediaMutation,
|
||||||
|
categorizeMediaMutation,
|
||||||
|
}: SlideOverActionArgs) {
|
||||||
|
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
||||||
|
|
||||||
|
const uploadExtraFiles = useCallback(
|
||||||
|
async (files: File[]) => {
|
||||||
|
setMediaBatchPending(true);
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
// Extra Docs stay on the legacy route and change category only by explicit PATCH.
|
||||||
|
const item = await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
||||||
|
updateOwnership((current) => ({
|
||||||
|
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), item.id),
|
||||||
|
extraOwnedIds: addOwnedId(new Set(current.extraOwnedIds), item.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setMediaBatchPending(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[uploadMediaMutation, updateOwnership],
|
||||||
|
);
|
||||||
|
|
||||||
|
const categorizeMedia = useCallback(
|
||||||
|
(mediaId: string, category: UploadableMediaCategory) => {
|
||||||
|
const workOrderVersion = detailForRow?.info.rowVersion;
|
||||||
|
if (!workOrderVersion) {
|
||||||
|
toast.error("Missing work order version. Refresh and try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
categorizeMediaMutation.mutate(
|
||||||
|
{ mediaId, category, workOrderVersion },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
updateOwnership((current) => ({
|
||||||
|
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), mediaId),
|
||||||
|
extraOwnedIds:
|
||||||
|
category === "Extra"
|
||||||
|
? addOwnedId(new Set(current.extraOwnedIds), mediaId)
|
||||||
|
: dropOwnedId(new Set(current.extraOwnedIds), mediaId),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[detailForRow?.info.rowVersion, categorizeMediaMutation, updateOwnership],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeMedia = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
if (isLocal(id)) {
|
||||||
|
removeLocal(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const workOrderVersion = detailForRow?.info.rowVersion;
|
||||||
|
if (!workOrderVersion) {
|
||||||
|
toast.error("Missing work order version. Refresh and try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteMediaMutation.mutate({ mediaId: id, workOrderVersion });
|
||||||
|
},
|
||||||
|
[detailForRow?.info.rowVersion, isLocal, removeLocal, deleteMediaMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { mediaBatchPending, uploadExtraFiles, categorizeMedia, removeMedia };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnyCompletionGallery(...types: Array<string | undefined>): boolean {
|
||||||
|
return types.some(hasCompletionMediaGallery);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasEmergencyMediaType(...types: Array<string | undefined>): boolean {
|
||||||
|
return types.some(isEmergencyReactiveWorkOrderType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmergencyUploaderOptions(
|
||||||
|
emergencyMedia: boolean,
|
||||||
|
limits: Parameters<typeof emergencyMediaSizeError>[1],
|
||||||
|
) {
|
||||||
|
if (!emergencyMedia) {
|
||||||
|
return {
|
||||||
|
emergencyMediaTypeAllowed: undefined,
|
||||||
|
emergencyMediaTypeErrorMessage: undefined,
|
||||||
|
emergencyMediaAcceptAttribute: undefined,
|
||||||
|
emergencyMediaSizeError: undefined,
|
||||||
|
emergencyMediaUploadHint: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
emergencyMediaTypeAllowed: (file: File) => classifyMediaKind(file) !== null,
|
||||||
|
emergencyMediaTypeErrorMessage:
|
||||||
|
"Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported.",
|
||||||
|
emergencyMediaAcceptAttribute: emergencyMediaAcceptAttribute(),
|
||||||
|
emergencyMediaSizeError: (file: File) => emergencyMediaSizeError(file, limits),
|
||||||
|
emergencyMediaUploadHint: "Photos up to 10 MB · Videos up to 100 MB and 90 seconds",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAnyMediaMutationPending(...states: boolean[]): boolean {
|
||||||
|
return states.some(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmergencyMediaUnavailableMessage(
|
||||||
|
canUpload: boolean | undefined,
|
||||||
|
policyError: boolean,
|
||||||
|
policyLoading: boolean,
|
||||||
|
hasPolicy: boolean,
|
||||||
|
): string {
|
||||||
|
if (canUpload === false) return "Uploads are not available for this work order.";
|
||||||
|
if (policyError) return "Upload permissions could not be loaded. Refresh and try again.";
|
||||||
|
if (policyLoading || !hasPolicy) return "Loading upload permissions…";
|
||||||
|
return "Uploads are not available for this work order.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPolicyUploadAllowed(canUpload: boolean | undefined, policyError: boolean): boolean {
|
||||||
|
return canUpload === true && !policyError;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmergencyUploadUnavailable(emergencyMedia: boolean, policyCanUpload: boolean): boolean {
|
||||||
|
return emergencyMedia && !policyCanUpload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSlideOverMediaState({
|
||||||
|
detailForRow,
|
||||||
|
row,
|
||||||
|
uploadMediaMutation,
|
||||||
|
deleteMediaMutation,
|
||||||
|
categorizeMediaMutation,
|
||||||
|
}: UseSlideOverMediaStateArgs) {
|
||||||
|
const workOrderId = detailForRow?.info.id ?? row?.id;
|
||||||
|
const workOrderType = detailForRow?.info.type ?? row?.type;
|
||||||
|
const {
|
||||||
|
data: mediaPolicy,
|
||||||
|
isError: mediaPolicyError,
|
||||||
|
isLoading: mediaPolicyLoading,
|
||||||
|
} = useWorkOrderMediaPolicy(workOrderId, workOrderId != null);
|
||||||
|
const resumableCompletionUpload = useResumableWorkOrderMediaUpload(
|
||||||
|
workOrderId ?? "",
|
||||||
|
"CompletionMedia",
|
||||||
|
mediaPolicy ?? undefined,
|
||||||
|
);
|
||||||
|
const policyCanUpload = isPolicyUploadAllowed(mediaPolicy?.canUpload, mediaPolicyError);
|
||||||
|
const completionHasGallery = hasAnyCompletionGallery(
|
||||||
|
detailForRow?.info.type,
|
||||||
|
mediaPolicy?.workOrderType,
|
||||||
|
row?.type,
|
||||||
|
);
|
||||||
|
const emergencyMedia = hasEmergencyMediaType(mediaPolicy?.workOrderType, workOrderType);
|
||||||
|
const emergencyMediaUnavailable = isEmergencyUploadUnavailable(emergencyMedia, policyCanUpload);
|
||||||
|
const emergencyMediaUnavailableMessage = getEmergencyMediaUnavailableMessage(
|
||||||
|
mediaPolicy?.canUpload,
|
||||||
|
mediaPolicyError,
|
||||||
|
mediaPolicyLoading,
|
||||||
|
Boolean(mediaPolicy),
|
||||||
|
);
|
||||||
|
const { ownership, updateOwnership } = useMediaOwnership(workOrderId);
|
||||||
|
const persistCompletionUpload = usePersistCompletionUpload({
|
||||||
|
emergencyMedia,
|
||||||
|
policyCanUpload,
|
||||||
|
resumableCompletionUpload,
|
||||||
|
uploadMediaMutation,
|
||||||
|
updateOwnership,
|
||||||
|
});
|
||||||
|
|
||||||
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||||
useMediaUploadSession(detailForRow, persistCompletionUpload);
|
useMediaUploadSession(detailForRow, persistCompletionUpload);
|
||||||
|
const { mediaBatchPending, uploadExtraFiles, categorizeMedia, removeMedia } =
|
||||||
|
useSlideOverMutationActions({
|
||||||
|
detailForRow,
|
||||||
|
isLocal,
|
||||||
|
removeLocal,
|
||||||
|
updateOwnership,
|
||||||
|
uploadMediaMutation,
|
||||||
|
deleteMediaMutation,
|
||||||
|
categorizeMediaMutation,
|
||||||
|
});
|
||||||
|
|
||||||
const completionMedia = useMemo(
|
const completionMedia = useMemo(
|
||||||
() => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership, completionHasGallery)),
|
() => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership, completionHasGallery)),
|
||||||
|
|
@ -131,73 +368,25 @@ export function useSlideOverMediaState({
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
|
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
|
||||||
const mediaPending =
|
const mediaPending = isAnyMediaMutationPending(
|
||||||
mediaBatchPending ||
|
mediaBatchPending,
|
||||||
hasLocalUploading ||
|
hasLocalUploading,
|
||||||
uploadMediaMutation.isPending ||
|
uploadMediaMutation.isPending,
|
||||||
deleteMediaMutation.isPending ||
|
resumableCompletionUpload.isPending,
|
||||||
categorizeMediaMutation.isPending;
|
deleteMediaMutation.isPending,
|
||||||
|
categorizeMediaMutation.isPending,
|
||||||
const uploadMediaRequests = (requests: MediaUploadRequest[]) => {
|
);
|
||||||
startUploads(requests);
|
const emergencyUploaderOptions = getEmergencyUploaderOptions(emergencyMedia, mediaPolicy?.limits);
|
||||||
};
|
|
||||||
|
|
||||||
const uploadExtraFiles = async (files: File[]) => {
|
|
||||||
setMediaBatchPending(true);
|
|
||||||
try {
|
|
||||||
for (const file of files) {
|
|
||||||
// Upload stays Extra; Aveta is applied only by an explicit Extra Docs PATCH.
|
|
||||||
const item = await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
|
||||||
updateOwnership((current) => ({
|
|
||||||
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), item.id),
|
|
||||||
extraOwnedIds: addOwnedId(new Set(current.extraOwnedIds), item.id),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setMediaBatchPending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const categorizeMedia = (mediaId: string, category: UploadableMediaCategory) => {
|
|
||||||
const workOrderVersion = detailForRow?.info.rowVersion;
|
|
||||||
if (!workOrderVersion) {
|
|
||||||
toast.error("Missing work order version. Refresh and try again.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
categorizeMediaMutation.mutate(
|
|
||||||
{ mediaId, category, workOrderVersion },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
updateOwnership((current) => ({
|
|
||||||
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), mediaId),
|
|
||||||
extraOwnedIds:
|
|
||||||
category === "Extra"
|
|
||||||
? addOwnedId(new Set(current.extraOwnedIds), mediaId)
|
|
||||||
: dropOwnedId(new Set(current.extraOwnedIds), mediaId),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeMedia = (id: string) => {
|
|
||||||
if (isLocal(id)) {
|
|
||||||
removeLocal(id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const workOrderVersion = detailForRow?.info.rowVersion;
|
|
||||||
if (!workOrderVersion) {
|
|
||||||
toast.error("Missing work order version. Refresh and try again.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
deleteMediaMutation.mutate({ mediaId: id, workOrderVersion });
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
completionMedia,
|
completionMedia,
|
||||||
extrasUploads,
|
extrasUploads,
|
||||||
mediaPending,
|
mediaPending,
|
||||||
uploadMediaRequests,
|
emergencyMedia,
|
||||||
|
emergencyMediaUnavailable,
|
||||||
|
emergencyMediaUnavailableMessage,
|
||||||
|
...emergencyUploaderOptions,
|
||||||
|
uploadMediaRequests: startUploads,
|
||||||
uploadExtraFiles,
|
uploadExtraFiles,
|
||||||
categorizeMedia,
|
categorizeMedia,
|
||||||
retryUpload,
|
retryUpload,
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,13 @@ export function useWorkOrderSlideOver({
|
||||||
completionMedia,
|
completionMedia,
|
||||||
extrasUploads,
|
extrasUploads,
|
||||||
mediaPending,
|
mediaPending,
|
||||||
|
emergencyMediaUnavailable,
|
||||||
|
emergencyMediaUnavailableMessage,
|
||||||
|
emergencyMediaTypeAllowed,
|
||||||
|
emergencyMediaTypeErrorMessage,
|
||||||
|
emergencyMediaAcceptAttribute,
|
||||||
|
emergencyMediaSizeError,
|
||||||
|
emergencyMediaUploadHint,
|
||||||
uploadMediaRequests,
|
uploadMediaRequests,
|
||||||
uploadExtraFiles,
|
uploadExtraFiles,
|
||||||
categorizeMedia,
|
categorizeMedia,
|
||||||
|
|
@ -127,6 +134,7 @@ export function useWorkOrderSlideOver({
|
||||||
removeMedia,
|
removeMedia,
|
||||||
} = useSlideOverMediaState({
|
} = useSlideOverMediaState({
|
||||||
detailForRow,
|
detailForRow,
|
||||||
|
row,
|
||||||
uploadMediaMutation,
|
uploadMediaMutation,
|
||||||
deleteMediaMutation,
|
deleteMediaMutation,
|
||||||
categorizeMediaMutation,
|
categorizeMediaMutation,
|
||||||
|
|
@ -175,6 +183,13 @@ export function useWorkOrderSlideOver({
|
||||||
completionMedia,
|
completionMedia,
|
||||||
extrasUploads,
|
extrasUploads,
|
||||||
mediaPending,
|
mediaPending,
|
||||||
|
emergencyMediaUnavailable,
|
||||||
|
emergencyMediaUnavailableMessage,
|
||||||
|
emergencyMediaTypeAllowed,
|
||||||
|
emergencyMediaTypeErrorMessage,
|
||||||
|
emergencyMediaAcceptAttribute,
|
||||||
|
emergencyMediaSizeError,
|
||||||
|
emergencyMediaUploadHint,
|
||||||
completionPending,
|
completionPending,
|
||||||
closeDisabled,
|
closeDisabled,
|
||||||
uploadMediaRequests,
|
uploadMediaRequests,
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,13 @@ export function WorkOrderSlideOverTabs({
|
||||||
completion={state.detailForRow?.completion}
|
completion={state.detailForRow?.completion}
|
||||||
media={state.completionMedia}
|
media={state.completionMedia}
|
||||||
mediaPending={state.mediaPending}
|
mediaPending={state.mediaPending}
|
||||||
|
uploadDisabled={state.emergencyMediaUnavailable}
|
||||||
|
uploadUnavailableMessage={state.emergencyMediaUnavailableMessage}
|
||||||
|
typeAllowed={state.emergencyMediaTypeAllowed}
|
||||||
|
typeErrorMessage={state.emergencyMediaTypeErrorMessage}
|
||||||
|
acceptAttribute={state.emergencyMediaAcceptAttribute}
|
||||||
|
fileSizeError={state.emergencyMediaSizeError}
|
||||||
|
uploadHint={state.emergencyMediaUploadHint}
|
||||||
completionPending={state.completionPending}
|
completionPending={state.completionPending}
|
||||||
onUploadMedia={(requests) => {
|
onUploadMedia={(requests) => {
|
||||||
state.uploadMediaRequests(requests);
|
state.uploadMediaRequests(requests);
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,19 @@ import { Text } from "@/components/ui/text";
|
||||||
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||||
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
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 { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
import {
|
import {
|
||||||
useDeleteWorkOrderMedia,
|
useDeleteWorkOrderMedia,
|
||||||
useUploadWorkOrderMedia,
|
useUploadWorkOrderMedia,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
|
import {
|
||||||
|
classifyMediaKind,
|
||||||
|
emergencyMediaAcceptAttribute,
|
||||||
|
emergencyMediaSizeError,
|
||||||
|
isEmergencyReactiveWorkOrderType,
|
||||||
|
} from "@/domain/work-orders/utils/emergency-media-limits";
|
||||||
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
import { guardDialogClose } from "@/lib/dialog-guard";
|
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||||
|
|
||||||
|
|
@ -31,16 +39,63 @@ function resolveWorkOrderVersion(
|
||||||
return version || null;
|
return version || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEmergencyReactive(row: WorkOrderTableRow, detailType?: string, policyType?: string) {
|
||||||
|
return (
|
||||||
|
isEmergencyReactiveWorkOrderType(detailType) ||
|
||||||
|
isEmergencyReactiveWorkOrderType(policyType) ||
|
||||||
|
isEmergencyReactiveWorkOrderType(row.type)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emergencyPolicyMessage(
|
||||||
|
canUpload: boolean | undefined,
|
||||||
|
isError: boolean,
|
||||||
|
isLoading: boolean,
|
||||||
|
): string {
|
||||||
|
if (canUpload === false) return "Uploads are not available for this work order.";
|
||||||
|
if (isError) return "Upload permissions could not be loaded. Refresh and try again.";
|
||||||
|
if (isLoading) return "Loading upload permissions…";
|
||||||
|
return "Uploads are not available for this work order.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmergencyUploaderOptions(
|
||||||
|
isEmergency: boolean,
|
||||||
|
limits: Parameters<typeof emergencyMediaSizeError>[1],
|
||||||
|
) {
|
||||||
|
if (!isEmergency) return {};
|
||||||
|
return {
|
||||||
|
fileSizeError: (file: File) => emergencyMediaSizeError(file, limits),
|
||||||
|
uploadHint: "Photos up to 10 MB · Videos up to 100 MB and 90 seconds",
|
||||||
|
typeAllowed: (file: File) => classifyMediaKind(file) !== null,
|
||||||
|
typeErrorMessage: "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported.",
|
||||||
|
acceptAttribute: emergencyMediaAcceptAttribute(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
|
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
|
||||||
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
|
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
|
||||||
const uploadMutation = useUploadWorkOrderMedia(row.id);
|
const {
|
||||||
|
data: policy,
|
||||||
|
isError: policyError,
|
||||||
|
isLoading: policyLoading,
|
||||||
|
} = useWorkOrderMediaPolicy(row.id, open);
|
||||||
|
const legacyUploadMutation = useUploadWorkOrderMedia(row.id);
|
||||||
|
const resumableUploadMutation = useResumableWorkOrderMediaUpload(
|
||||||
|
row.id,
|
||||||
|
"WorkOrderTable",
|
||||||
|
policyError ? undefined : (policy ?? undefined),
|
||||||
|
);
|
||||||
const deleteMutation = useDeleteWorkOrderMedia(row.id);
|
const deleteMutation = useDeleteWorkOrderMedia(row.id);
|
||||||
|
const isEmergency = isEmergencyReactive(row, detail?.info.type, policy?.workOrderType);
|
||||||
|
const uploadMutation = isEmergency ? resumableUploadMutation : legacyUploadMutation;
|
||||||
|
const emergencyUploadUnavailable = isEmergency && (policy?.canUpload !== true || policyError);
|
||||||
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
|
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
|
||||||
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||||
useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
|
useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
|
||||||
|
|
||||||
const pending = uploadMutation.isPending || deleteMutation.isPending;
|
const pending = uploadMutation.isPending || deleteMutation.isPending;
|
||||||
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
|
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
|
||||||
|
const emergencyUploaderOptions = getEmergencyUploaderOptions(isEmergency, policy?.limits);
|
||||||
|
|
||||||
const requireVersion = (): string | null => {
|
const requireVersion = (): string | null => {
|
||||||
const version = resolveWorkOrderVersion(detail?.info.rowVersion, row.rowVersion);
|
const version = resolveWorkOrderVersion(detail?.info.rowVersion, row.rowVersion);
|
||||||
|
|
@ -105,9 +160,18 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
||||||
onRetry={retryUpload}
|
onRetry={retryUpload}
|
||||||
onCancel={cancelUpload}
|
onCancel={cancelUpload}
|
||||||
readOnly={isReadOnly}
|
readOnly={isReadOnly}
|
||||||
|
uploadDisabled={emergencyUploadUnavailable}
|
||||||
|
{...emergencyUploaderOptions}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{emergencyUploadUnavailable && !isReadOnly && (
|
||||||
|
<div role="status" className="mt-2">
|
||||||
|
<Text variant="description">
|
||||||
|
{emergencyPolicyMessage(policy?.canUpload, policyError, policyLoading || !policy)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex justify-end">
|
<div className="mt-2 flex justify-end">
|
||||||
|
|
|
||||||
|
|
@ -8,21 +8,27 @@ import {
|
||||||
type MediaUploaderDropzoneProps = {
|
type MediaUploaderDropzoneProps = {
|
||||||
inputRef: RefObject<HTMLInputElement | null>;
|
inputRef: RefObject<HTMLInputElement | null>;
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
helperText?: string;
|
||||||
categorizeAfterUpload?: boolean;
|
categorizeAfterUpload?: boolean;
|
||||||
|
acceptAttribute?: string;
|
||||||
onFilesSelected: (files: FileList | null) => void;
|
onFilesSelected: (files: FileList | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MediaUploaderDropzone({
|
export function MediaUploaderDropzone({
|
||||||
inputRef,
|
inputRef,
|
||||||
pending,
|
pending,
|
||||||
|
disabled = false,
|
||||||
|
helperText,
|
||||||
categorizeAfterUpload = false,
|
categorizeAfterUpload = false,
|
||||||
|
acceptAttribute,
|
||||||
onFilesSelected,
|
onFilesSelected,
|
||||||
}: MediaUploaderDropzoneProps) {
|
}: MediaUploaderDropzoneProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={pending}
|
disabled={pending || disabled}
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
|
|
@ -41,17 +47,18 @@ export function MediaUploaderDropzone({
|
||||||
{pending ? "Uploading…" : "Drag files here or click to browse"}
|
{pending ? "Uploading…" : "Drag files here or click to browse"}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit().replace(" ", "")} per file
|
{helperText ??
|
||||||
|
`Accepted: JPG, PNG, MP4, MOV · Max ${formatMediaSizeLimit().replace(" ", "")} per file`}
|
||||||
{categorizeAfterUpload ? " · categorize after upload" : ""}
|
{categorizeAfterUpload ? " · categorize after upload" : ""}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept={workOrderMediaAcceptAttribute()}
|
accept={acceptAttribute ?? workOrderMediaAcceptAttribute()}
|
||||||
multiple
|
multiple
|
||||||
hidden
|
hidden
|
||||||
disabled={pending}
|
disabled={pending || disabled}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
onFilesSelected(e.target.files);
|
onFilesSelected(e.target.files);
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export type MediaUploadRequest = {
|
||||||
localId: string;
|
localId: string;
|
||||||
file: File;
|
file: File;
|
||||||
onProgress: (percent: number) => void;
|
onProgress: (percent: number) => void;
|
||||||
|
idempotencyKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MediaUploaderProps = {
|
type MediaUploaderProps = {
|
||||||
|
|
@ -30,6 +31,13 @@ type MediaUploaderProps = {
|
||||||
onChange?: (next: MediaFile[]) => void;
|
onChange?: (next: MediaFile[]) => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
pending?: boolean;
|
pending?: boolean;
|
||||||
|
uploadDisabled?: boolean;
|
||||||
|
/** Emergency/Reactive override: policy-driven type check incl. HEIC. */
|
||||||
|
typeAllowed?: (file: File) => boolean;
|
||||||
|
typeErrorMessage?: string;
|
||||||
|
acceptAttribute?: string;
|
||||||
|
fileSizeError?: (file: File) => string | undefined;
|
||||||
|
uploadHint?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MediaUploader({
|
export function MediaUploader({
|
||||||
|
|
@ -42,6 +50,12 @@ export function MediaUploader({
|
||||||
onChange,
|
onChange,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
pending = false,
|
pending = false,
|
||||||
|
uploadDisabled = false,
|
||||||
|
typeAllowed,
|
||||||
|
typeErrorMessage = "Unsupported file type.",
|
||||||
|
acceptAttribute,
|
||||||
|
fileSizeError,
|
||||||
|
uploadHint,
|
||||||
}: MediaUploaderProps) {
|
}: MediaUploaderProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
|
@ -50,8 +64,14 @@ export function MediaUploader({
|
||||||
|
|
||||||
const accepted: File[] = [];
|
const accepted: File[] = [];
|
||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
if (!isWorkOrderMediaTypeAllowed(file)) {
|
const typeOk = typeAllowed ? typeAllowed(file) : isWorkOrderMediaTypeAllowed(file);
|
||||||
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
|
if (!typeOk) {
|
||||||
|
toast.error(`${file.name} — ${typeErrorMessage}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sizeError = fileSizeError?.(file);
|
||||||
|
if (sizeError) {
|
||||||
|
toast.error(sizeError);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isWorkOrderFileTooLarge(file)) {
|
if (isWorkOrderFileTooLarge(file)) {
|
||||||
|
|
@ -94,7 +114,10 @@ export function MediaUploader({
|
||||||
<MediaUploaderDropzone
|
<MediaUploaderDropzone
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
|
disabled={uploadDisabled}
|
||||||
|
helperText={uploadHint}
|
||||||
categorizeAfterUpload={Boolean(onCategorize)}
|
categorizeAfterUpload={Boolean(onCategorize)}
|
||||||
|
acceptAttribute={acceptAttribute}
|
||||||
onFilesSelected={addFiles}
|
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 { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
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";
|
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: {
|
type UploadMutate = (input: {
|
||||||
file: File;
|
file: File;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
idempotencyKey: string;
|
||||||
onProgress?: (percent: number) => void;
|
onProgress?: (percent: number) => void;
|
||||||
}) => Promise<unknown>;
|
}) => Promise<unknown>;
|
||||||
|
|
||||||
|
|
@ -17,6 +23,7 @@ type SessionUploadRequest = {
|
||||||
localId: string;
|
localId: string;
|
||||||
file: File;
|
file: File;
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
|
idempotencyKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function revokeBlobUrl(url: string | undefined) {
|
function revokeBlobUrl(url: string | undefined) {
|
||||||
|
|
@ -38,6 +45,7 @@ function toLocalUpload(request: SessionUploadRequest): LocalUpload {
|
||||||
uploadStatus: "uploading" as const,
|
uploadStatus: "uploading" as const,
|
||||||
sourceFile: request.file,
|
sourceFile: request.file,
|
||||||
signal: request.signal,
|
signal: request.signal,
|
||||||
|
idempotencyKey: request.idempotencyKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,32 +156,38 @@ export function useMediaUploadSession(
|
||||||
localId: request.localId,
|
localId: request.localId,
|
||||||
file: request.file,
|
file: request.file,
|
||||||
signal: replaceController(request.localId).signal,
|
signal: replaceController(request.localId).signal,
|
||||||
|
idempotencyKey:
|
||||||
|
request.idempotencyKey ??
|
||||||
|
localUploads.find((item) => item.id === request.localId)?.idempotencyKey ??
|
||||||
|
crypto.randomUUID(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const entries = sessionRequests.map(toLocalUpload);
|
const entries = sessionRequests.map(toLocalUpload);
|
||||||
setLocalUploads((prev) => upsertUploads(prev, entries));
|
setLocalUploads((prev) => upsertUploads(prev, entries));
|
||||||
|
|
||||||
for (const request of sessionRequests) {
|
void runMediaUploadQueue(sessionRequests, async (request) => {
|
||||||
void uploadMutateAsync({
|
try {
|
||||||
file: request.file,
|
await uploadMutateAsync({
|
||||||
signal: request.signal,
|
file: request.file,
|
||||||
onProgress: (percent) => {
|
signal: request.signal,
|
||||||
setLocalUploads((prev) =>
|
idempotencyKey: request.idempotencyKey,
|
||||||
prev.map((item) =>
|
onProgress: (percent) => {
|
||||||
isSameAttempt(item, request) ? { ...item, progress: percent } : item,
|
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);
|
|
||||||
});
|
});
|
||||||
}
|
settleAttempt(request);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as Error).name === "AbortError") {
|
||||||
|
settleAttempt(request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
failAttempt(request, error as Error);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function retryUpload(localId: string) {
|
function retryUpload(localId: string) {
|
||||||
|
|
@ -184,6 +198,7 @@ export function useMediaUploadSession(
|
||||||
localId,
|
localId,
|
||||||
file: target.sourceFile,
|
file: target.sourceFile,
|
||||||
onProgress: () => undefined,
|
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 { CommentsSection } from "@/app/v/[token]/dispatch/_components/comments-section";
|
||||||
import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section";
|
import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section";
|
||||||
import { UpliftRequestsSection } from "@/app/v/[token]/dispatch/_components/uplift-requests-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 { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
|
||||||
import type {
|
import type {
|
||||||
VendorPortalDispatchDetail,
|
VendorPortalDispatchDetail,
|
||||||
|
|
@ -61,6 +62,8 @@ export function DispatchDetailSections({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<VendorMediaCard token={token} dispatchId={dispatchId} locked={locked} />
|
||||||
|
|
||||||
<div className="vp-card">
|
<div className="vp-card">
|
||||||
<Text variant="heading">NTE Uplift Requests</Text>
|
<Text variant="heading">NTE Uplift Requests</Text>
|
||||||
<UpliftRequestsSection
|
<UpliftRequestsSection
|
||||||
|
|
|
||||||
300
src/app/v/[token]/dispatch/_components/vendor-media-section.tsx
Normal file
300
src/app/v/[token]/dispatch/_components/vendor-media-section.tsx
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VendorMediaItems({
|
||||||
|
items,
|
||||||
|
onView,
|
||||||
|
}: {
|
||||||
|
items: VendorMediaItem[];
|
||||||
|
onView: (item: VendorMediaItem) => void;
|
||||||
|
}) {
|
||||||
|
return items.map((item) => (
|
||||||
|
<article key={item.id} className="vp-uplift-row">
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>File</strong>
|
||||||
|
<span>
|
||||||
|
{item.originalFileName} · {item.kind} · {formatBytes(item.sizeBytes)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="vp-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vp-btn vp-btn-ghost"
|
||||||
|
disabled={!item.canView}
|
||||||
|
onClick={() => onView(item)}
|
||||||
|
>
|
||||||
|
{item.canView ? "View" : "Unavailable"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function VendorUploadProgress({
|
||||||
|
pending,
|
||||||
|
progress,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
pending: boolean;
|
||||||
|
progress: number | undefined;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
if (!pending) return null;
|
||||||
|
return (
|
||||||
|
<div role="status" aria-live="polite">
|
||||||
|
{progress !== undefined ? (
|
||||||
|
<>
|
||||||
|
<progress max={100} value={progress} style={{ width: "100%" }} />
|
||||||
|
<VendorPortalFeedback>Uploading… {progress}%</VendorPortalFeedback>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<VendorPortalFeedback>Preparing upload…</VendorPortalFeedback>
|
||||||
|
)}
|
||||||
|
<button type="button" className="vp-btn vp-btn-ghost" onClick={onCancel}>
|
||||||
|
Cancel upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VendorUploadError({
|
||||||
|
error,
|
||||||
|
canceled,
|
||||||
|
locked,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
error: Error | null;
|
||||||
|
canceled: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
onRetry: () => void;
|
||||||
|
}) {
|
||||||
|
if (canceled) return <VendorPortalFeedback>Upload canceled.</VendorPortalFeedback>;
|
||||||
|
if (!error) return null;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<VendorPortalFeedback tone="error">{safeMediaUploadErrorMessage(error)}</VendorPortalFeedback>
|
||||||
|
{!locked && error instanceof MediaUploadNetworkError && (
|
||||||
|
<button type="button" className="vp-btn vp-btn-ghost" onClick={onRetry}>
|
||||||
|
Retry upload
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VendorUploadButton({
|
||||||
|
canUpload,
|
||||||
|
pending,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
canUpload: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
if (!canUpload) return null;
|
||||||
|
return (
|
||||||
|
<div className="vp-actions">
|
||||||
|
<button type="button" className="vp-btn vp-btn-primary" disabled={pending} onClick={onClick}>
|
||||||
|
Upload photo or video
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSectionProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const controllerRef = useRef<AbortController | null>(null);
|
||||||
|
const idempotencyKeyRef = useRef<string | undefined>(undefined);
|
||||||
|
const failedFileRef = useRef<File | null>(null);
|
||||||
|
const [loadState, setLoadState] = useState<MediaLoadState>({ loading: true });
|
||||||
|
const [progress, setProgress] = useState<number>();
|
||||||
|
const [uploadCanceled, setUploadCanceled] = useState(false);
|
||||||
|
|
||||||
|
const 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<VendorMediaItems items={items} onView={(item) => void handleView(item)} />
|
||||||
|
<VendorUploadProgress
|
||||||
|
pending={uploadMutation.isPending}
|
||||||
|
progress={progress}
|
||||||
|
onCancel={() => controllerRef.current?.abort()}
|
||||||
|
/>
|
||||||
|
<VendorUploadError
|
||||||
|
error={uploadMutation.error}
|
||||||
|
canceled={uploadCanceled}
|
||||||
|
locked={locked}
|
||||||
|
onRetry={() => startUpload(failedFileRef.current, true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VendorUploadButton
|
||||||
|
canUpload={canUpload}
|
||||||
|
pending={uploadMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
uploadMutation.reset();
|
||||||
|
inputRef.current?.click();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{locked && (
|
||||||
|
<VendorPortalFeedback>
|
||||||
|
Media is read-only while this dispatch is locked.
|
||||||
|
</VendorPortalFeedback>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorMediaCard(props: VendorMediaSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="vp-card">
|
||||||
|
<Text variant="heading">Photos & Videos</Text>
|
||||||
|
<VendorMediaSection {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -113,7 +113,7 @@ async function downloadVendorDocument(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function vendorPortalCall<T>(
|
export async function vendorPortalCall<T>(
|
||||||
token: string,
|
token: string,
|
||||||
path: string,
|
path: string,
|
||||||
options?: { method?: string; body?: unknown },
|
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[];
|
signoffs: VendorPortalSignoff[];
|
||||||
documents: VendorPortalDocument[];
|
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 {
|
export interface UploadWorkOrderMediaInput {
|
||||||
file: File;
|
file: File;
|
||||||
category?: UploadableMediaCategory;
|
category?: UploadableMediaCategory;
|
||||||
|
idempotencyKey?: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
onProgress?: (percent: number) => void;
|
onProgress?: (percent: number) => void;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
163
src/domain/work-orders/utils/emergency-media-limits.ts
Normal file
163
src/domain/work-orders/utils/emergency-media-limits.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emergencyMediaSizeError(
|
||||||
|
file: File,
|
||||||
|
limits?: Partial<MediaUploadLimits>,
|
||||||
|
): string | undefined {
|
||||||
|
const kind = classifyMediaKind(file);
|
||||||
|
if (!kind) return undefined;
|
||||||
|
const resolvedLimits = defaultLimits(limits);
|
||||||
|
return isMediaSizeWithinLimit(file, kind, resolvedLimits)
|
||||||
|
? undefined
|
||||||
|
: mediaOverLimitNotice(file, kind, resolvedLimits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate one media file against the policy limits: kind, inclusive size caps and
|
||||||
|
* duration when it can be probed. A failed duration probe keeps the file eligible.
|
||||||
|
*/
|
||||||
|
export async function validateMediaFileAgainstLimits(
|
||||||
|
file: File,
|
||||||
|
policy: Pick<MediaUploadPolicy, "limits" | "remaining">,
|
||||||
|
probe: MediaValidationProbe = probeVideoDurationSeconds,
|
||||||
|
): Promise<MediaValidationResult> {
|
||||||
|
const limits = defaultLimits(policy.limits);
|
||||||
|
const kind = classifyMediaKind(file);
|
||||||
|
if (!kind) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!isMediaSizeWithinLimit(file, kind, limits)) {
|
||||||
|
return { ok: false, reason: mediaOverLimitNotice(file, kind, limits) };
|
||||||
|
}
|
||||||
|
const remaining = kind === "Photo" ? policy.remaining?.photos : policy.remaining?.videos;
|
||||||
|
if (typeof remaining === "number" && remaining <= 0) {
|
||||||
|
return { ok: false, reason: mediaKindLimitReachedNotice(kind, limits) };
|
||||||
|
}
|
||||||
|
if (kind === "Video") {
|
||||||
|
const seconds = await probe(file);
|
||||||
|
if (seconds != null && seconds > limits.videoMaxSeconds) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: `"${file.name}" is longer than the ${limits.videoMaxSeconds} second video limit.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ export const queryKeys = {
|
||||||
detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const,
|
detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const,
|
||||||
boardDetail: (id: string | number) => [...queryKeys.workOrders.all, "boardDetail", 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,
|
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,
|
dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const,
|
||||||
taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const,
|
taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const,
|
||||||
dispatchers: () => [...queryKeys.workOrders.all, "dispatchers"] 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,
|
dispatches: (token: string) => [...queryKeys.vendorPortal.all, "dispatches", token] as const,
|
||||||
dispatch: (token: string, id: string | number) =>
|
dispatch: (token: string, id: string | number) =>
|
||||||
[...queryKeys.vendorPortal.all, "dispatch", token, id] as const,
|
[...queryKeys.vendorPortal.all, "dispatch", token, id] as const,
|
||||||
|
media: (token: string, id: string | number) =>
|
||||||
|
[...queryKeys.vendorPortal.all, "media", token, id] as const,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
511
src/test/api/media-upload-engine.test.ts
Normal file
511
src/test/api/media-upload-engine.test.ts
Normal file
|
|
@ -0,0 +1,511 @@
|
||||||
|
// @vitest-environment node
|
||||||
|
// Node's fetch/Request/Blob follow WHATWG body-serialization rules; jsdom mangles raw
|
||||||
|
// chunk bodies (the same reason api-formdata-content-type.test.ts runs in node).
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
chunkCountFor,
|
||||||
|
missingChunkIndices,
|
||||||
|
runMediaUploadQueue,
|
||||||
|
uploadFileResumable,
|
||||||
|
} from "@/api/media-upload-engine";
|
||||||
|
import { computeSha256Hex, createMediaUploadEndpoints } from "@/api/media-upload-client";
|
||||||
|
|
||||||
|
type CapturedRequest = { method: string; url: string; headers: Headers; body: unknown };
|
||||||
|
|
||||||
|
const BASE = "https://api.test/uploads";
|
||||||
|
|
||||||
|
function jsonResponse(payload: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileOf(size: number, name = "clip.mp4", type = "video/mp4"): File {
|
||||||
|
return new File([new Uint8Array(size)], name, { type });
|
||||||
|
}
|
||||||
|
|
||||||
|
let captured: CapturedRequest[] = [];
|
||||||
|
let handler: (request: CapturedRequest) => Promise<Response>;
|
||||||
|
|
||||||
|
function endpointsWith() {
|
||||||
|
return createMediaUploadEndpoints({
|
||||||
|
resolveUrl: (suffix) => (suffix ? `${BASE}/${suffix}` : BASE),
|
||||||
|
headers: () => ({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
captured = [];
|
||||||
|
handler = async () => jsonResponse({});
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const request = new Request(input, init);
|
||||||
|
const body =
|
||||||
|
request.method === "PUT"
|
||||||
|
? await request.arrayBuffer()
|
||||||
|
: await request.json().catch(() => ({}));
|
||||||
|
const capturedRequest: CapturedRequest = {
|
||||||
|
method: request.method,
|
||||||
|
url: request.url,
|
||||||
|
headers: request.headers,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
captured.push(capturedRequest);
|
||||||
|
return handler(capturedRequest);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function createdSession(sizeBytes: number, chunkSizeBytes: number) {
|
||||||
|
return {
|
||||||
|
uploadId: "u1",
|
||||||
|
state: "Uploading",
|
||||||
|
chunkSizeBytes,
|
||||||
|
sizeBytes,
|
||||||
|
receivedChunks: [],
|
||||||
|
expiresAt: "2026-09-24T00:00:00Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function completedSession(sizeBytes: number, chunkSizeBytes: number, received: number[]) {
|
||||||
|
return {
|
||||||
|
uploadId: "u1",
|
||||||
|
state: "Completed",
|
||||||
|
chunkSizeBytes,
|
||||||
|
sizeBytes,
|
||||||
|
receivedChunks: received,
|
||||||
|
expiresAt: "2026-09-24T00:00:00Z",
|
||||||
|
attachment: { id: 42 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resumable media upload engine", () => {
|
||||||
|
it("sends raw chunk bytes with Content-Range, digest header, and acknowledged-only progress", async () => {
|
||||||
|
const file = fileOf(20);
|
||||||
|
handler = async (request) => {
|
||||||
|
if (request.method === "POST" && request.url === BASE) {
|
||||||
|
return jsonResponse(createdSession(20, 8));
|
||||||
|
}
|
||||||
|
const match = /chunks\/(\d+)$/.exec(request.url);
|
||||||
|
if (request.method === "PUT" && match) {
|
||||||
|
const index = Number(match[1]);
|
||||||
|
const received = [0, 1, 2].filter((seen) => seen <= index);
|
||||||
|
return jsonResponse({
|
||||||
|
index,
|
||||||
|
sha256: "server-digest",
|
||||||
|
sizeBytes: (request.body as ArrayBuffer).byteLength,
|
||||||
|
receivedChunks: received,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||||
|
return jsonResponse(completedSession(20, 8, [0, 1, 2]));
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const percents: number[] = [];
|
||||||
|
const session = await uploadFileResumable({
|
||||||
|
file,
|
||||||
|
endpoints: endpointsWith(),
|
||||||
|
surface: "WorkOrderTable",
|
||||||
|
onProgress: percents.push.bind(percents) as (percent: number) => void,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.state).toBe("Completed");
|
||||||
|
expect(session.attachment?.id).toBe(42);
|
||||||
|
const puts = captured.filter((entry) => entry.method === "PUT");
|
||||||
|
expect(puts).toHaveLength(3);
|
||||||
|
|
||||||
|
const firstSlice = file.slice(0, 8);
|
||||||
|
const expectedDigest = await computeSha256Hex(await firstSlice.arrayBuffer());
|
||||||
|
expect(puts[0]?.url).toBe(`${BASE}/u1/chunks/0`);
|
||||||
|
expect(puts[0]?.headers.get("content-range")).toBe("bytes 0-7/20");
|
||||||
|
expect(puts[0]?.headers.get("x-chunk-sha256")).toBe(expectedDigest);
|
||||||
|
expect((puts[0]?.body as ArrayBuffer).byteLength).toBe(8);
|
||||||
|
expect(puts[2]?.headers.get("content-range")).toBe("bytes 16-19/20");
|
||||||
|
expect((puts[2]?.body as ArrayBuffer).byteLength).toBe(4);
|
||||||
|
|
||||||
|
// Progress derives from acknowledged chunks only (server receivedChunks), ending at 100.
|
||||||
|
expect(percents[percents.length - 1]).toBe(100);
|
||||||
|
expect(percents.every((percent, index) => index === 0 || percent >= percents[index - 1]!)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers from network loss by re-reading session status and sending only missing chunks", async () => {
|
||||||
|
const file = fileOf(24);
|
||||||
|
let failedIndexOnce = false;
|
||||||
|
handler = async (request) => {
|
||||||
|
if (request.method === "POST" && request.url === BASE) {
|
||||||
|
return jsonResponse(createdSession(24, 8));
|
||||||
|
}
|
||||||
|
const match = /chunks\/(\d+)$/.exec(request.url);
|
||||||
|
if (request.method === "PUT" && match) {
|
||||||
|
const index = Number(match[1]);
|
||||||
|
if (index === 1 && !failedIndexOnce) {
|
||||||
|
failedIndexOnce = true;
|
||||||
|
throw new TypeError("network down");
|
||||||
|
}
|
||||||
|
const received = Array.from({ length: index + 1 }, (_, seen) => seen);
|
||||||
|
return jsonResponse({ index, sha256: "x", sizeBytes: 8, receivedChunks: received });
|
||||||
|
}
|
||||||
|
if (request.method === "GET" && request.url === `${BASE}/u1`) {
|
||||||
|
return jsonResponse({ ...createdSession(24, 8), receivedChunks: [0] });
|
||||||
|
}
|
||||||
|
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||||
|
return jsonResponse(completedSession(24, 8, [0, 1, 2]));
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const session = await uploadFileResumable({
|
||||||
|
file,
|
||||||
|
endpoints: endpointsWith(),
|
||||||
|
surface: "WorkOrderTable",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.state).toBe("Completed");
|
||||||
|
const putIndices = captured
|
||||||
|
.filter((entry) => entry.method === "PUT")
|
||||||
|
.map((entry) => Number(/chunks\/(\d+)$/.exec(entry.url)?.[1]));
|
||||||
|
expect(putIndices).toEqual([0, 1, 1, 2]);
|
||||||
|
expect(captured.some((entry) => entry.method === "GET" && entry.url === `${BASE}/u1`)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops retrying a persistently failing chunk after bounded status recovery", async () => {
|
||||||
|
const file = fileOf(8);
|
||||||
|
let puts = 0;
|
||||||
|
let statusReads = 0;
|
||||||
|
handler = async (request) => {
|
||||||
|
if (request.method === "POST" && request.url === BASE) {
|
||||||
|
return jsonResponse(createdSession(8, 8));
|
||||||
|
}
|
||||||
|
if (request.method === "PUT") {
|
||||||
|
puts += 1;
|
||||||
|
throw new TypeError("network down");
|
||||||
|
}
|
||||||
|
if (request.method === "GET" && request.url === `${BASE}/u1`) {
|
||||||
|
statusReads += 1;
|
||||||
|
return jsonResponse(createdSession(8, 8));
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
uploadFileResumable({ file, endpoints: endpointsWith(), surface: "WorkOrderTable" }),
|
||||||
|
).rejects.toMatchObject({ name: "MediaUploadNetworkError" });
|
||||||
|
expect(puts).toBe(3);
|
||||||
|
expect(statusReads).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers a lost commit response from Completed status without a duplicate commit", async () => {
|
||||||
|
const file = fileOf(8);
|
||||||
|
let commits = 0;
|
||||||
|
handler = async (request) => {
|
||||||
|
if (request.method === "POST" && request.url === BASE) {
|
||||||
|
return jsonResponse(createdSession(8, 8));
|
||||||
|
}
|
||||||
|
if (request.method === "PUT") {
|
||||||
|
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
|
||||||
|
}
|
||||||
|
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||||
|
commits += 1;
|
||||||
|
throw new TypeError("commit response lost");
|
||||||
|
}
|
||||||
|
if (request.method === "GET" && request.url === `${BASE}/u1`) {
|
||||||
|
return jsonResponse(completedSession(8, 8, [0]));
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await uploadFileResumable({
|
||||||
|
file,
|
||||||
|
endpoints: endpointsWith(),
|
||||||
|
surface: "WorkOrderTable",
|
||||||
|
});
|
||||||
|
expect(result.state).toBe("Completed");
|
||||||
|
expect(result.attachment?.id).toBe(42);
|
||||||
|
expect(commits).toBe(1);
|
||||||
|
expect(captured.filter((entry) => entry.method === "GET")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries the same idempotent session and sends only chunks missing after partial failure", async () => {
|
||||||
|
const file = fileOf(16);
|
||||||
|
const idempotencyKey = "11111111-2222-3333-4444-555555555555";
|
||||||
|
let createCount = 0;
|
||||||
|
let chunkOneFailures = 0;
|
||||||
|
handler = async (request) => {
|
||||||
|
if (request.method === "POST" && request.url === BASE) {
|
||||||
|
createCount += 1;
|
||||||
|
return jsonResponse({
|
||||||
|
...createdSession(16, 8),
|
||||||
|
receivedChunks: createCount === 1 ? [] : [0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (request.method === "PUT" && request.url.endsWith("/chunks/0")) {
|
||||||
|
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
|
||||||
|
}
|
||||||
|
if (request.method === "PUT" && request.url.endsWith("/chunks/1")) {
|
||||||
|
chunkOneFailures += 1;
|
||||||
|
if (chunkOneFailures <= 3) throw new TypeError("network down");
|
||||||
|
return jsonResponse({ index: 1, sha256: "x", sizeBytes: 8, receivedChunks: [0, 1] });
|
||||||
|
}
|
||||||
|
if (request.method === "GET" && request.url === `${BASE}/u1`) {
|
||||||
|
return jsonResponse({ ...createdSession(16, 8), receivedChunks: [0] });
|
||||||
|
}
|
||||||
|
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||||
|
return jsonResponse(completedSession(16, 8, [0, 1]));
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
uploadFileResumable({
|
||||||
|
file,
|
||||||
|
endpoints: endpointsWith(),
|
||||||
|
surface: "WorkOrderTable",
|
||||||
|
idempotencyKey,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ name: "MediaUploadNetworkError" });
|
||||||
|
|
||||||
|
const resumed = await uploadFileResumable({
|
||||||
|
file,
|
||||||
|
endpoints: endpointsWith(),
|
||||||
|
surface: "WorkOrderTable",
|
||||||
|
idempotencyKey,
|
||||||
|
});
|
||||||
|
expect(resumed.state).toBe("Completed");
|
||||||
|
const creates = captured.filter((entry) => entry.method === "POST" && entry.url === BASE);
|
||||||
|
expect(creates).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
creates.map((entry) => (entry.body as { idempotencyKey: string }).idempotencyKey),
|
||||||
|
).toEqual([idempotencyKey, idempotencyKey]);
|
||||||
|
const putIndices = captured
|
||||||
|
.filter((entry) => entry.method === "PUT")
|
||||||
|
.map((entry) => Number(/chunks\/(\d+)$/.exec(entry.url)?.[1]));
|
||||||
|
expect(putIndices.filter((index) => index === 0)).toEqual([0]);
|
||||||
|
expect(putIndices[putIndices.length - 1]).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries a failed create with the same idempotency key and never re-creates after chunk loss", async () => {
|
||||||
|
const file = fileOf(8);
|
||||||
|
let createAttempts = 0;
|
||||||
|
handler = async (request) => {
|
||||||
|
if (request.method === "POST" && request.url === BASE) {
|
||||||
|
createAttempts += 1;
|
||||||
|
if (createAttempts === 1) {
|
||||||
|
throw new TypeError("network down");
|
||||||
|
}
|
||||||
|
return jsonResponse(createdSession(8, 8));
|
||||||
|
}
|
||||||
|
const match = /chunks\/(\d+)$/.exec(request.url);
|
||||||
|
if (request.method === "PUT" && match) {
|
||||||
|
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
|
||||||
|
}
|
||||||
|
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||||
|
return jsonResponse(completedSession(8, 8, [0]));
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
};
|
||||||
|
|
||||||
|
await uploadFileResumable({ file, endpoints: endpointsWith(), surface: "CompletionMedia" });
|
||||||
|
|
||||||
|
const creates = captured.filter((entry) => entry.method === "POST" && entry.url === BASE);
|
||||||
|
expect(creates).toHaveLength(2);
|
||||||
|
const keys = creates.map((entry) => (entry.body as { idempotencyKey: string }).idempotencyKey);
|
||||||
|
expect(keys[0]).toBe(keys[1]);
|
||||||
|
expect(keys[0]).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
const firstBody = creates[0]?.body as Record<string, unknown>;
|
||||||
|
expect(firstBody).toMatchObject({
|
||||||
|
fileName: "clip.mp4",
|
||||||
|
contentType: "video/mp4",
|
||||||
|
sizeBytes: 8,
|
||||||
|
surface: "CompletionMedia",
|
||||||
|
});
|
||||||
|
expect(captured.filter((entry) => entry.method === "POST" && entry.url === BASE)).toHaveLength(
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts the in-flight chunk and DELETEs the session on cancellation", async () => {
|
||||||
|
const file = fileOf(16);
|
||||||
|
const controller = new AbortController();
|
||||||
|
handler = async (request) => {
|
||||||
|
if (request.method === "POST" && request.url === BASE) {
|
||||||
|
return jsonResponse(createdSession(16, 8));
|
||||||
|
}
|
||||||
|
const match = /chunks\/(\d+)$/.exec(request.url);
|
||||||
|
if (request.method === "PUT" && match) {
|
||||||
|
if (Number(match[1]) === 0) {
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pending = uploadFileResumable({
|
||||||
|
file,
|
||||||
|
endpoints: endpointsWith(),
|
||||||
|
surface: "WorkOrderTable",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(captured.some((entry) => entry.method === "DELETE" && entry.url === `${BASE}/u1`)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uploads a queue with at most two files in flight", async () => {
|
||||||
|
let inFlight = 0;
|
||||||
|
let peak = 0;
|
||||||
|
handler = async (request) => {
|
||||||
|
if (request.method === "POST" && request.url === BASE) {
|
||||||
|
inFlight += 1;
|
||||||
|
peak = Math.max(peak, inFlight);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
inFlight -= 1;
|
||||||
|
return jsonResponse(createdSession(8, 8));
|
||||||
|
}
|
||||||
|
const match = /chunks\/(\d+)$/.exec(request.url);
|
||||||
|
if (request.method === "PUT" && match) {
|
||||||
|
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
|
||||||
|
}
|
||||||
|
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||||
|
return jsonResponse(completedSession(8, 8, [0]));
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const files = [fileOf(8, "a.mp4"), fileOf(8, "b.mp4"), fileOf(8, "c.mp4")];
|
||||||
|
await runMediaUploadQueue(
|
||||||
|
files,
|
||||||
|
(file) =>
|
||||||
|
uploadFileResumable({ file, endpoints: endpointsWith(), surface: "WorkOrderTable" }).then(
|
||||||
|
() => undefined,
|
||||||
|
),
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(peak).toBe(2);
|
||||||
|
expect(captured.filter((entry) => entry.method === "POST" && entry.url === BASE)).toHaveLength(
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polls a 202 scanning commit until Completed and rejects terminal Rejected state", async () => {
|
||||||
|
const file = fileOf(8);
|
||||||
|
let statusCalls = 0;
|
||||||
|
handler = async (request) => {
|
||||||
|
if (request.method === "POST" && request.url === BASE) {
|
||||||
|
return jsonResponse(createdSession(8, 8));
|
||||||
|
}
|
||||||
|
const match = /chunks\/(\d+)$/.exec(request.url);
|
||||||
|
if (request.method === "PUT" && match) {
|
||||||
|
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
|
||||||
|
}
|
||||||
|
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||||
|
return jsonResponse(
|
||||||
|
{ ...createdSession(8, 8), state: "Scanning", receivedChunks: [0] },
|
||||||
|
202,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (request.method === "GET" && request.url === `${BASE}/u1`) {
|
||||||
|
statusCalls += 1;
|
||||||
|
if (statusCalls === 1) {
|
||||||
|
return jsonResponse({ ...createdSession(8, 8), state: "Scanning", receivedChunks: [0] });
|
||||||
|
}
|
||||||
|
return jsonResponse(completedSession(8, 8, [0]));
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const session = await uploadFileResumable({
|
||||||
|
file,
|
||||||
|
endpoints: endpointsWith(),
|
||||||
|
surface: "WorkOrderTable",
|
||||||
|
pollDelayMs: 0,
|
||||||
|
});
|
||||||
|
expect(session.state).toBe("Completed");
|
||||||
|
expect(statusCalls).toBe(2);
|
||||||
|
|
||||||
|
handler = async (request) => {
|
||||||
|
if (request.method === "POST" && request.url === BASE) {
|
||||||
|
return jsonResponse(createdSession(8, 8));
|
||||||
|
}
|
||||||
|
if (request.method === "PUT" && /chunks\/0$/.test(request.url)) {
|
||||||
|
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
|
||||||
|
}
|
||||||
|
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||||
|
return jsonResponse({ ...createdSession(8, 8), state: "Scanning" }, 202);
|
||||||
|
}
|
||||||
|
if (request.method === "GET" && request.url === `${BASE}/u1`) {
|
||||||
|
return jsonResponse({ ...createdSession(8, 8), state: "Rejected" });
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
uploadFileResumable({
|
||||||
|
file,
|
||||||
|
endpoints: endpointsWith(),
|
||||||
|
surface: "WorkOrderTable",
|
||||||
|
pollDelayMs: 0,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("rejected by the security scan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DELETEs the session when cancellation aborts scan polling", async () => {
|
||||||
|
const file = fileOf(8);
|
||||||
|
const controller = new AbortController();
|
||||||
|
handler = async (request) => {
|
||||||
|
if (request.method === "POST" && request.url === BASE) {
|
||||||
|
return jsonResponse(createdSession(8, 8));
|
||||||
|
}
|
||||||
|
if (request.method === "PUT") {
|
||||||
|
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
|
||||||
|
}
|
||||||
|
if (request.method === "POST" && request.url.endsWith("/commit")) {
|
||||||
|
setTimeout(() => controller.abort(), 0);
|
||||||
|
return jsonResponse(
|
||||||
|
{ ...createdSession(8, 8), state: "Scanning", receivedChunks: [0] },
|
||||||
|
202,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
uploadFileResumable({
|
||||||
|
file,
|
||||||
|
endpoints: endpointsWith(),
|
||||||
|
surface: "WorkOrderTable",
|
||||||
|
signal: controller.signal,
|
||||||
|
pollDelayMs: 5,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ name: "AbortError" });
|
||||||
|
expect(captured.some((entry) => entry.method === "DELETE" && entry.url === `${BASE}/u1`)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chunk math helpers", () => {
|
||||||
|
it("computes chunk counts and missing indices", () => {
|
||||||
|
expect(chunkCountFor(0, 5_242_880)).toBe(1);
|
||||||
|
expect(chunkCountFor(5_242_880, 5_242_880)).toBe(1);
|
||||||
|
expect(chunkCountFor(5_242_881, 5_242_880)).toBe(2);
|
||||||
|
expect(missingChunkIndices([0, 2], 4)).toEqual([1, 3]);
|
||||||
|
expect(missingChunkIndices([], 0)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,17 +1,41 @@
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
const uploadMutateAsync = vi.fn();
|
const uploadMutateAsync = vi.fn();
|
||||||
|
const resumableUploadMutateAsync = vi.fn();
|
||||||
|
const resumableUploadHookSpy = vi.hoisted(() => vi.fn());
|
||||||
const deleteMutate = vi.fn();
|
const deleteMutate = vi.fn();
|
||||||
const categorizeMutate = vi.fn();
|
const categorizeMutate = vi.fn();
|
||||||
|
const mediaQueryState = vi.hoisted(() => ({
|
||||||
|
detailType: "Emergency" as string | undefined,
|
||||||
|
policy: {
|
||||||
|
workOrderType: "Emergency",
|
||||||
|
canUpload: false,
|
||||||
|
limits: {
|
||||||
|
photoMaxBytes: 10_000_000,
|
||||||
|
videoMaxBytes: 100_000_000,
|
||||||
|
videoMaxSeconds: 90,
|
||||||
|
maxPhotos: 10,
|
||||||
|
maxVideos: 3,
|
||||||
|
},
|
||||||
|
remaining: { photos: 10, videos: 3 },
|
||||||
|
} as unknown,
|
||||||
|
isError: false,
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-toastify", () => ({
|
||||||
|
toast: { error: vi.fn(), success: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||||
useWorkOrderBoardDetail: () => ({
|
useWorkOrderBoardDetail: () => ({
|
||||||
data: {
|
data: {
|
||||||
info: { status: "Scheduled", rowVersion: "DETAIL-VER==" },
|
info: { status: "Scheduled", rowVersion: "DETAIL-VER==", type: mediaQueryState.detailType },
|
||||||
media: [
|
media: [
|
||||||
{
|
{
|
||||||
id: "11",
|
id: "11",
|
||||||
|
|
@ -39,12 +63,27 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
|
||||||
useDeleteWorkOrderMedia: () => ({ mutate: deleteMutate, isPending: false }),
|
useDeleteWorkOrderMedia: () => ({ mutate: deleteMutate, isPending: false }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function baseRow(): WorkOrderTableRow {
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({
|
||||||
|
useWorkOrderMediaPolicy: () => ({
|
||||||
|
data: mediaQueryState.policy,
|
||||||
|
isError: mediaQueryState.isError,
|
||||||
|
isLoading: mediaQueryState.isLoading,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({
|
||||||
|
useResumableWorkOrderMediaUpload: (...args: unknown[]) => {
|
||||||
|
resumableUploadHookSpy(...args);
|
||||||
|
return { mutateAsync: resumableUploadMutateAsync, isPending: false };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function baseRow(type: WorkOrderTableRow["type"] = "Emergency"): WorkOrderTableRow {
|
||||||
return {
|
return {
|
||||||
id: 7,
|
id: 7,
|
||||||
woNumber: "WO-7",
|
woNumber: "WO-7",
|
||||||
site: "SEA1",
|
site: "SEA1",
|
||||||
type: "Emergency",
|
type,
|
||||||
severity: 1,
|
severity: 1,
|
||||||
dispatcherId: "u1",
|
dispatcherId: "u1",
|
||||||
dispatcherName: "Dispatcher",
|
dispatcherName: "Dispatcher",
|
||||||
|
|
@ -90,14 +129,14 @@ function baseRow(): WorkOrderTableRow {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderModal() {
|
function renderModal(rowType: WorkOrderTableRow["type"] = "Emergency") {
|
||||||
const client = new QueryClient({
|
const client = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
});
|
});
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
<MediaUploadModal row={baseRow()} open onOpenChange={vi.fn()} />
|
<MediaUploadModal row={baseRow(rowType)} open onOpenChange={vi.fn()} />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -105,8 +144,26 @@ function renderModal() {
|
||||||
describe("MediaUploadModal API wiring", () => {
|
describe("MediaUploadModal API wiring", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
uploadMutateAsync.mockReset();
|
uploadMutateAsync.mockReset();
|
||||||
|
resumableUploadMutateAsync.mockReset();
|
||||||
|
resumableUploadHookSpy.mockClear();
|
||||||
deleteMutate.mockReset();
|
deleteMutate.mockReset();
|
||||||
categorizeMutate.mockReset();
|
categorizeMutate.mockReset();
|
||||||
|
mediaQueryState.detailType = "Emergency";
|
||||||
|
mediaQueryState.policy = {
|
||||||
|
workOrderType: "Emergency",
|
||||||
|
canUpload: false,
|
||||||
|
limits: {
|
||||||
|
photoMaxBytes: 10_000_000,
|
||||||
|
videoMaxBytes: 100_000_000,
|
||||||
|
videoMaxSeconds: 90,
|
||||||
|
maxPhotos: 10,
|
||||||
|
maxVideos: 3,
|
||||||
|
},
|
||||||
|
remaining: { photos: 10, videos: 3 },
|
||||||
|
};
|
||||||
|
mediaQueryState.isError = false;
|
||||||
|
mediaQueryState.isLoading = false;
|
||||||
|
vi.mocked(toast.error).mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders persisted media from board detail (refresh-safe)", async () => {
|
it("renders persisted media from board detail (refresh-safe)", async () => {
|
||||||
|
|
@ -117,7 +174,7 @@ describe("MediaUploadModal API wiring", () => {
|
||||||
});
|
});
|
||||||
expect(screen.getByText("Photos & Videos")).toBeInTheDocument();
|
expect(screen.getByText("Photos & Videos")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"),
|
screen.getByText("Photos up to 10 MB · Videos up to 100 MB and 90 seconds"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
@ -159,4 +216,76 @@ describe("MediaUploadModal API wiring", () => {
|
||||||
workOrderVersion: "DETAIL-VER==",
|
workOrderVersion: "DETAIL-VER==",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fails closed when Emergency policy says uploads are unavailable", async () => {
|
||||||
|
renderModal();
|
||||||
|
|
||||||
|
expect((document.querySelector('input[type="file"]') as HTMLInputElement).disabled).toBe(true);
|
||||||
|
expect(screen.getByText("Uploads are not available for this work order.")).toBeInTheDocument();
|
||||||
|
expect(uploadMutateAsync).not.toHaveBeenCalled();
|
||||||
|
expect(resumableUploadMutateAsync).not.toHaveBeenCalled();
|
||||||
|
expect(resumableUploadHookSpy).toHaveBeenCalledWith(7, "WorkOrderTable", expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes by the detailed work-order type when policy denies uploads", () => {
|
||||||
|
mediaQueryState.detailType = "Emergency";
|
||||||
|
mediaQueryState.policy = {
|
||||||
|
workOrderType: "Emergency",
|
||||||
|
canUpload: false,
|
||||||
|
limits: {
|
||||||
|
photoMaxBytes: 10_000_000,
|
||||||
|
videoMaxBytes: 100_000_000,
|
||||||
|
videoMaxSeconds: 90,
|
||||||
|
maxPhotos: 10,
|
||||||
|
maxVideos: 3,
|
||||||
|
},
|
||||||
|
remaining: { photos: 10, videos: 3 },
|
||||||
|
};
|
||||||
|
renderModal("PM");
|
||||||
|
|
||||||
|
expect((document.querySelector('input[type="file"]') as HTMLInputElement).disabled).toBe(true);
|
||||||
|
expect(screen.getByText("Uploads are not available for this work order.")).toBeInTheDocument();
|
||||||
|
expect(resumableUploadHookSpy).toHaveBeenCalledWith(7, "WorkOrderTable", expect.anything());
|
||||||
|
expect(uploadMutateAsync).not.toHaveBeenCalled();
|
||||||
|
expect(resumableUploadMutateAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed with a visible error when the Emergency policy request fails", () => {
|
||||||
|
mediaQueryState.policy = undefined;
|
||||||
|
mediaQueryState.isError = true;
|
||||||
|
renderModal("PM");
|
||||||
|
|
||||||
|
expect((document.querySelector('input[type="file"]') as HTMLInputElement).disabled).toBe(true);
|
||||||
|
expect(
|
||||||
|
screen.getByText("Upload permissions could not be loaded. Refresh and try again."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(uploadMutateAsync).not.toHaveBeenCalled();
|
||||||
|
expect(resumableUploadMutateAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the Emergency photo cap notice before starting an upload", () => {
|
||||||
|
mediaQueryState.policy = {
|
||||||
|
workOrderType: "Emergency",
|
||||||
|
canUpload: true,
|
||||||
|
limits: {
|
||||||
|
photoMaxBytes: 10_000_000,
|
||||||
|
videoMaxBytes: 100_000_000,
|
||||||
|
videoMaxSeconds: 90,
|
||||||
|
maxPhotos: 10,
|
||||||
|
maxVideos: 3,
|
||||||
|
},
|
||||||
|
remaining: { photos: 10, videos: 3 },
|
||||||
|
};
|
||||||
|
renderModal();
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
fireEvent.change(input, {
|
||||||
|
target: {
|
||||||
|
files: [new File([new Uint8Array(10_000_001)], "photo.jpg", { type: "image/jpeg" })],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('"photo.jpg" exceeds the photo limit (10 MB).');
|
||||||
|
expect(resumableUploadMutateAsync).not.toHaveBeenCalled();
|
||||||
|
expect(uploadMutateAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
|
import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
vi.mock("react-toastify", () => ({
|
||||||
|
toast: { error: vi.fn(), success: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
function renderTab(pm: string, onOpenCompletionDoc = vi.fn()) {
|
function renderTab(pm: string, onOpenCompletionDoc = vi.fn()) {
|
||||||
const row = { id: "1", type: "PM", pm, docStatus: "No", rowVersion: "1" } as WorkOrderTableRow;
|
const row = { id: "1", type: "PM", pm, docStatus: "No", rowVersion: "1" } as WorkOrderTableRow;
|
||||||
render(
|
render(
|
||||||
|
|
@ -43,4 +48,70 @@ describe("SlideOverCompletionTab service gate", () => {
|
||||||
fireEvent.click(button);
|
fireEvent.click(button);
|
||||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders disabled Emergency media upload with the policy failure message", () => {
|
||||||
|
const onUpload = vi.fn();
|
||||||
|
const row = {
|
||||||
|
id: "1",
|
||||||
|
type: "Emergency",
|
||||||
|
docStatus: "No",
|
||||||
|
rowVersion: "1",
|
||||||
|
} as WorkOrderTableRow;
|
||||||
|
render(
|
||||||
|
<SlideOverCompletionTab
|
||||||
|
row={row}
|
||||||
|
isReadOnly={false}
|
||||||
|
media={[]}
|
||||||
|
uploadDisabled
|
||||||
|
uploadUnavailableMessage="Upload permissions could not be loaded. Refresh and try again."
|
||||||
|
onUploadMedia={onUpload}
|
||||||
|
onOpenCompletionDoc={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent(
|
||||||
|
"Upload permissions could not be loaded. Refresh and try again.",
|
||||||
|
);
|
||||||
|
expect(document.querySelector('input[type="file"]')).toBeDisabled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /drag files here or click to browse/i }),
|
||||||
|
).toBeDisabled();
|
||||||
|
expect(onUpload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows per-kind Emergency limits before starting an oversized photo upload", () => {
|
||||||
|
const onUpload = vi.fn();
|
||||||
|
const row = {
|
||||||
|
id: "1",
|
||||||
|
type: "Emergency",
|
||||||
|
docStatus: "No",
|
||||||
|
rowVersion: "1",
|
||||||
|
} as WorkOrderTableRow;
|
||||||
|
render(
|
||||||
|
<SlideOverCompletionTab
|
||||||
|
row={row}
|
||||||
|
isReadOnly={false}
|
||||||
|
media={[]}
|
||||||
|
typeAllowed={() => true}
|
||||||
|
fileSizeError={(file) =>
|
||||||
|
file.size > 10_000_000 ? `"${file.name}" exceeds the photo limit (10 MB).` : undefined
|
||||||
|
}
|
||||||
|
uploadHint="Photos up to 10 MB · Videos up to 100 MB and 90 seconds"
|
||||||
|
onUploadMedia={onUpload}
|
||||||
|
onOpenCompletionDoc={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("Photos up to 10 MB · Videos up to 100 MB and 90 seconds"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, {
|
||||||
|
target: {
|
||||||
|
files: [new File([new Uint8Array(10_000_001)], "photo.jpg", { type: "image/jpeg" })],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('"photo.jpg" exceeds the photo limit (10 MB).');
|
||||||
|
expect(onUpload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,17 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({
|
||||||
|
useWorkOrderMediaPolicy: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({
|
||||||
|
useResumableWorkOrderMediaUpload: () => ({
|
||||||
|
mutateAsync: vi.fn(),
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
// A completion-doc upload that settles successfully. The mock forwards any onSuccess the
|
// 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).
|
// 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", () => ({
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,27 @@
|
||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useSlideOverMediaState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-media-state";
|
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 { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
const policyState = vi.hoisted(() => ({
|
||||||
|
data: undefined as unknown,
|
||||||
|
isError: false,
|
||||||
|
isLoading: true,
|
||||||
|
}));
|
||||||
|
const legacyUploadMutateAsync = vi.fn().mockResolvedValue({ id: "legacy" });
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({
|
||||||
|
useWorkOrderMediaPolicy: () => policyState,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({
|
||||||
|
useResumableWorkOrderMediaUpload: () => ({
|
||||||
|
mutateAsync: vi.fn(),
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
return {
|
return {
|
||||||
id: 100,
|
id: 100,
|
||||||
|
|
@ -79,14 +97,18 @@ function detailWithMedia(
|
||||||
function idleMutation() {
|
function idleMutation() {
|
||||||
return {
|
return {
|
||||||
mutate: vi.fn(),
|
mutate: vi.fn(),
|
||||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
mutateAsync: legacyUploadMutateAsync,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
} as never;
|
} as never;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("useSlideOverMediaState completion filter", () => {
|
describe("useSlideOverMediaState completion filter", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
legacyUploadMutateAsync.mockClear();
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
|
policyState.data = undefined;
|
||||||
|
policyState.isError = false;
|
||||||
|
policyState.isLoading = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps Before/After/Completion after refetch mapped purpose (not file category)", () => {
|
it("keeps Before/After/Completion after refetch mapped purpose (not file category)", () => {
|
||||||
|
|
@ -327,4 +349,148 @@ describe("useSlideOverMediaState completion filter", () => {
|
||||||
"pdf",
|
"pdf",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks legacy completion upload while Emergency media policy is unavailable", async () => {
|
||||||
|
const legacyUpload = idleMutation();
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useSlideOverMediaState({
|
||||||
|
detailForRow: detailWithMedia([], { type: "Emergency" }),
|
||||||
|
uploadMediaMutation: legacyUpload,
|
||||||
|
deleteMediaMutation: idleMutation(),
|
||||||
|
categorizeMediaMutation: idleMutation(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.uploadMediaRequests([
|
||||||
|
{
|
||||||
|
localId: "local-emergency",
|
||||||
|
file: new File(["x"], "photo.jpg", { type: "image/jpeg" }),
|
||||||
|
onProgress: vi.fn(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(legacyUploadMutateAsync).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.completionMedia.some((item) => item.uploadStatus === "failed")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the Emergency completion surface disabled with a visible policy error", async () => {
|
||||||
|
policyState.isError = true;
|
||||||
|
policyState.isLoading = false;
|
||||||
|
const legacyUpload = idleMutation();
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useSlideOverMediaState({
|
||||||
|
detailForRow: detailWithMedia([], { type: "Emergency" }),
|
||||||
|
uploadMediaMutation: legacyUpload,
|
||||||
|
deleteMediaMutation: idleMutation(),
|
||||||
|
categorizeMediaMutation: idleMutation(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.emergencyMediaUnavailable).toBe(true);
|
||||||
|
expect(result.current.emergencyMediaUnavailableMessage).toBe(
|
||||||
|
"Upload permissions could not be loaded. Refresh and try again.",
|
||||||
|
);
|
||||||
|
act(() => {
|
||||||
|
result.current.uploadMediaRequests([
|
||||||
|
{
|
||||||
|
localId: "local-policy-error",
|
||||||
|
file: new File(["x"], "photo.jpg", { type: "image/jpeg" }),
|
||||||
|
onProgress: vi.fn(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(legacyUploadMutateAsync).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.completionMedia[0]?.uploadStatus).toBe("failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the table row type to fail closed while work-order detail is loading", () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useSlideOverMediaState({
|
||||||
|
row: baseInfo({ type: "Reactive" }),
|
||||||
|
uploadMediaMutation: idleMutation(),
|
||||||
|
deleteMediaMutation: idleMutation(),
|
||||||
|
categorizeMediaMutation: idleMutation(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.emergencyMediaUnavailable).toBe(true);
|
||||||
|
expect(
|
||||||
|
result.current.emergencyMediaTypeAllowed?.(
|
||||||
|
new File(["x"], "clip.mov", { type: "video/quicktime" }),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes server-derived Emergency size caps to the slide-over uploader", () => {
|
||||||
|
policyState.data = {
|
||||||
|
workOrderType: "Reactive",
|
||||||
|
canUpload: true,
|
||||||
|
limits: {
|
||||||
|
photoMaxBytes: 10_000_000,
|
||||||
|
videoMaxBytes: 100_000_000,
|
||||||
|
videoMaxSeconds: 90,
|
||||||
|
maxPhotos: 10,
|
||||||
|
maxVideos: 3,
|
||||||
|
},
|
||||||
|
remaining: { photos: 10, videos: 3 },
|
||||||
|
};
|
||||||
|
policyState.isLoading = false;
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useSlideOverMediaState({
|
||||||
|
detailForRow: detailWithMedia([], { type: "Reactive" }),
|
||||||
|
uploadMediaMutation: idleMutation(),
|
||||||
|
deleteMediaMutation: idleMutation(),
|
||||||
|
categorizeMediaMutation: idleMutation(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.current.emergencyMediaSizeError?.(
|
||||||
|
new File([new Uint8Array(10_000_001)], "photo.jpg", { type: "image/jpeg" }),
|
||||||
|
),
|
||||||
|
).toBe('"photo.jpg" exceeds the photo limit (10 MB).');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("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", () => ({
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
|
||||||
useAddWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
|
useAddWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
|
||||||
useUpdateWorkOrderComment: () => ({ 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