shoc-frontend-new/src/domain/work-orders/utils/emergency-media-limits.ts
2026-09-23 05:17:54 -03:00

176 lines
6.3 KiB
TypeScript

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 emergencyMediaBatchCountError(
file: File,
acceptedFiles: readonly File[],
policy?: Pick<MediaUploadPolicy, "limits" | "remaining">,
): string | undefined {
const kind = classifyMediaKind(file);
if (!kind || !policy) return undefined;
const available = kind === "Photo" ? policy.remaining.photos : policy.remaining.videos;
const alreadyAccepted = acceptedFiles.filter((accepted) => classifyMediaKind(accepted) === kind);
if (alreadyAccepted.length < available) return undefined;
return mediaKindLimitReachedNotice(kind, defaultLimits(policy.limits));
}
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 };
}