mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Persisted HEIC files and local files with an empty or octet-stream type were classified as "other", so they did not count toward the 10 photo / 3 video limit. Both classifiers now recognise them by extension. The Photos & Videos uploader and Extra Docs ignore a new selection while the previous one is still being screened, and the vendor portal drops an earlier pick whose video check finishes after a newer pick. Also removes ticket keys from source comments.
125 lines
4.2 KiB
TypeScript
125 lines
4.2 KiB
TypeScript
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<string, VendorUploadKind> = {
|
|
"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<string, VendorUploadKind> = {
|
|
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<string | undefined> {
|
|
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;
|
|
}
|