shoc-frontend-new/src/domain/vendor-portal/lib/document-validation.ts
Alexandre Brandizzi 6012ebabe7 fix(media): count HEIC and untyped phone files, guard overlapping picks
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.
2026-09-24 22:51:17 -03:00

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;
}