import type { VendorPortalMediaCounts } from "@/domain/vendor-portal/types/vendor-portal"; import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; /** * Client-confirmed media contract on the vendor portal: photos up to * 10 MB (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV). * PDF documents keep the previous 10 MB document cap. */ const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024; const MAX_PHOTO_BYTES = 10_000_000; const MAX_VIDEO_BYTES = 100_000_000; const MAX_VIDEO_DURATION_SECONDS = 90; const UNSUPPORTED_TYPE_MESSAGE = "Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed."; type VendorUploadKind = "document" | "photo" | "video"; // Mirrors the server allowlist: an allowlisted browser type wins, otherwise the // extension decides (mobile browsers often send an empty or octet-stream type). const KIND_BY_MIME: Record = { "application/pdf": "document", "image/jpeg": "photo", "image/jpg": "photo", "image/png": "photo", "image/heic": "photo", "video/mp4": "video", "video/quicktime": "video", }; const KIND_BY_EXTENSION: Record = { pdf: "document", jpg: "photo", jpeg: "photo", png: "photo", heic: "photo", mp4: "video", mov: "video", }; function resolveKind(file: File): VendorUploadKind | null { const name = file.name.toLowerCase(); const extension = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : ""; return KIND_BY_MIME[file.type.toLowerCase()] ?? KIND_BY_EXTENSION[extension] ?? null; } /** Photos/videos already counted against the work order for one upload, with the limits. */ export type VendorMediaUsage = { photos: number; videos: number; maxPhotos: number; maxVideos: number; }; /** * The count basis the server uses for this upload. A completion upload replaces a * document: the latest one by default (already excluded in `completion*`), or the * one the vendor picked, which is not counted when it is the same kind. Evidence * uploads count everything. Undefined when the backend does not report counts. */ export function vendorMediaUsage( counts: VendorPortalMediaCounts | null | undefined, purpose: "completion" | "evidence", replacedContentType?: string, ): VendorMediaUsage | undefined { if (!counts) return undefined; const limits = { maxPhotos: counts.maxPhotos, maxVideos: counts.maxVideos }; if (purpose === "evidence") { return { ...limits, photos: counts.photos, videos: counts.videos }; } if (replacedContentType === undefined) { return { ...limits, photos: counts.completionPhotos, videos: counts.completionVideos }; } const replacedKind = KIND_BY_MIME[replacedContentType.toLowerCase()]; return { ...limits, photos: Math.max(0, counts.photos - (replacedKind === "photo" ? 1 : 0)), videos: Math.max(0, counts.videos - (replacedKind === "video" ? 1 : 0)), }; } function countRejection(kind: VendorUploadKind, usage: VendorMediaUsage): string | undefined { if (kind === "photo" && usage.photos >= usage.maxPhotos) { return `A work order can have at most ${usage.maxPhotos} photos.`; } if (kind === "video" && usage.videos >= usage.maxVideos) { return `A work order can have at most ${usage.maxVideos} videos.`; } return undefined; } export async function validateVendorDocument( file: File, usage?: VendorMediaUsage, ): Promise { const kind = resolveKind(file); if (!kind) { return UNSUPPORTED_TYPE_MESSAGE; } const countMessage = usage && countRejection(kind, usage); if (countMessage) { return countMessage; } if (kind === "photo" && file.size > MAX_PHOTO_BYTES) { return "Photos must be 10 MB or smaller."; } if (kind === "video") { if (file.size > MAX_VIDEO_BYTES) { return "Videos must be 100 MB or smaller."; } // Pre-check; the server enforces it too. Unreadable metadata never blocks. const duration = await probeVideoDurationSeconds(file); if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) { return `Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`; } } if (kind === "document" && file.size > MAX_DOCUMENT_BYTES) { return "Files must be 10 MB or smaller."; } return undefined; }