/** 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 = 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"; } }