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.
This commit is contained in:
Alexandre Brandizzi 2026-09-24 22:51:17 -03:00
parent b316168930
commit 6012ebabe7
13 changed files with 171 additions and 27 deletions

View file

@ -1,4 +1,4 @@
import { useRef } from "react";
import { useRef, useState } from "react";
import {
File as FileIcon,
FileText,
@ -18,7 +18,10 @@ import {
hasExplicitAvetaFile,
resolveExtraDocCategory,
} from "@/domain/work-orders/utils/extra-docs-aveta";
import { screenWorkOrderUploads } from "@/domain/work-orders/utils/screen-work-order-uploads";
import {
screenWorkOrderUploads,
type WorkOrderUploadScreenResult,
} from "@/domain/work-orders/utils/screen-work-order-uploads";
import {
canDeleteWorkOrderMedia,
countWorkOrderMediaKinds,
@ -189,16 +192,30 @@ export function SlideOverExtrasTab({
}: SlideOverExtrasTabProps) {
const inputRef = useRef<HTMLInputElement>(null);
const avetaAttached = hasExplicitAvetaFile(uploads);
// One selection at a time: a second pick during the async screen would read the
// same photo/video counts and could pass the per-work-order limit.
const screeningRef = useRef(false);
const [screening, setScreening] = useState(false);
const busy = pending || screening;
const addFiles = async (files: FileList | null) => {
if (isReadOnly || pending || !files || files.length === 0) return;
if (isReadOnly || pending || screeningRef.current || !files || files.length === 0) return;
// Snapshot synchronously: the input is reset right after this handler returns.
const selected = Array.from(files);
const { accepted, rejections } = await screenWorkOrderUploads(selected, {
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
isTypeAllowed: isWorkOrderExtraDocTypeAllowed,
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
});
screeningRef.current = true;
setScreening(true);
let screened: WorkOrderUploadScreenResult;
try {
screened = await screenWorkOrderUploads(selected, {
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
isTypeAllowed: isWorkOrderExtraDocTypeAllowed,
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
});
} finally {
screeningRef.current = false;
setScreening(false);
}
const { accepted, rejections } = screened;
for (const message of rejections) toast.error(message);
if (accepted.length > 0) {
onUpload(accepted);
@ -231,7 +248,7 @@ export function SlideOverExtrasTab({
<>
<button
type="button"
disabled={pending}
disabled={busy}
onClick={() => inputRef.current?.click()}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
@ -248,9 +265,9 @@ export function SlideOverExtrasTab({
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
<span
style={{ fontSize: 13, color: "var(--color-text-muted)" }}
aria-busy={pending || undefined}
aria-busy={busy || undefined}
>
{pending ? "Uploading…" : "Upload documents, photos or videos"}
{busy ? "Uploading…" : "Upload documents, photos or videos"}
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Accepted: JPG, PNG, HEIC, MP4, MOV, PDF, DOC · Photos up to 10 MB · Videos up to 100
@ -263,7 +280,7 @@ export function SlideOverExtrasTab({
accept={workOrderExtraDocsAcceptAttribute()}
multiple
hidden
disabled={pending}
disabled={busy}
onChange={(event) => {
void addFiles(event.target.files);
event.target.value = "";

View file

@ -1,4 +1,4 @@
import { useRef } from "react";
import { useRef, useState } from "react";
import { toast } from "react-toastify";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
@ -9,7 +9,10 @@ import {
type WorkOrderMediaKindCounts,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
import { screenWorkOrderUploads } from "@/domain/work-orders/utils/screen-work-order-uploads";
import {
screenWorkOrderUploads,
type WorkOrderUploadScreenResult,
} from "@/domain/work-orders/utils/screen-work-order-uploads";
import { MediaUploaderDropzone } from "./media-uploader-dropzone";
import { MediaUploaderGrid } from "./media-uploader-grid";
@ -50,18 +53,31 @@ export function MediaUploader({
mediaCounts,
}: MediaUploaderProps) {
const inputRef = useRef<HTMLInputElement>(null);
// One selection at a time: a second pick during the async screen would read the
// same photo/video counts and could pass the per-work-order limit.
const screeningRef = useRef(false);
const [screening, setScreening] = useState(false);
const addFiles = async (files: FileList | null) => {
if (readOnly || pending || !files || files.length === 0) return;
if (readOnly || pending || screeningRef.current || !files || files.length === 0) return;
// Snapshot synchronously: the caller resets the input value right after this
// handler returns, which can detach a live FileList across awaits.
const selected = Array.from(files);
const { accepted, rejections } = await screenWorkOrderUploads(selected, {
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
isTypeAllowed: isWorkOrderMediaTypeAllowed,
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
});
screeningRef.current = true;
setScreening(true);
let screened: WorkOrderUploadScreenResult;
try {
screened = await screenWorkOrderUploads(selected, {
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
isTypeAllowed: isWorkOrderMediaTypeAllowed,
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
});
} finally {
screeningRef.current = false;
setScreening(false);
}
const { accepted, rejections } = screened;
for (const message of rejections) toast.error(message);
if (accepted.length === 0) return;
@ -96,7 +112,7 @@ export function MediaUploader({
{!readOnly && (
<MediaUploaderDropzone
inputRef={inputRef}
pending={pending}
pending={pending || screening}
categorizeAfterUpload={Boolean(onCategorize)}
onFilesSelected={(files) => void addFiles(files)}
/>

View file

@ -38,6 +38,8 @@ export function VendorPortalDocuments({
const [replacementId, setReplacementId] = useState<number>();
const [progress, setProgress] = useState<number>();
const [validationError, setValidationError] = useState<string>();
// Bumped on every new pick so a slower validation of an earlier file cannot upload it.
const selectionRef = useRef(0);
const uploadMutation = useMutation({
mutationFn: ({ file, replacementId: requestedReplacementId }: UploadRequest) =>
@ -59,6 +61,7 @@ export function VendorPortalDocuments({
});
const chooseFile = (documentId?: number) => {
selectionRef.current += 1;
setValidationError(undefined);
uploadMutation.reset();
setReplacementId(documentId);
@ -70,11 +73,15 @@ export function VendorPortalDocuments({
return;
}
const selection = ++selectionRef.current;
const replaced = documents.find((document) => document.id === replacementId);
const validationMessage = await validateVendorDocument(
file,
vendorMediaUsage(mediaCounts, "completion", replaced?.contentType),
);
if (selection !== selectionRef.current) {
return;
}
if (validationMessage) {
setValidationError(validationMessage);
return;

View file

@ -2,7 +2,7 @@ import type { VendorPortalMediaCounts } from "@/domain/vendor-portal/types/vendo
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
/**
* SH-116 client-confirmed media contract on the vendor portal: photos up to
* 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.
*/

View file

@ -147,6 +147,6 @@ export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary
upliftRequests: VendorPortalUpliftRequest[];
signoffs: VendorPortalSignoff[];
documents: VendorPortalDocument[];
/** SH-116 per-work-order photo/video usage; absent until the backend reports it. */
/** Per-work-order photo/video usage; absent until the backend reports it. */
mediaCounts?: VendorPortalMediaCounts | null;
}

View file

@ -233,7 +233,7 @@ function cryptoRandomId(): string {
function classifyFileName(fileName: string, url: string): FileCategory {
const name = `${fileName} ${url}`.toLowerCase();
if (/\.(jpe?g|png|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) {
if (/\.(jpe?g|png|heic|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) {
return "image";
}
if (/\.(mp4|mov|webm|m4v)(\?|$)/i.test(name) || name.includes("video/")) {

View file

@ -18,7 +18,7 @@ export type WorkOrderUploadScreen = {
export type WorkOrderUploadScreenResult = { accepted: File[]; rejections: string[] };
/**
* SH-116 browser pre-check shared by every dispatcher upload surface: type, size,
* Browser pre-check shared by every dispatcher upload surface: type, size,
* per-work-order photo/video count (including earlier files in the same selection)
* and video duration. The server enforces the same contract; a video whose duration
* the browser cannot read is never blocked here.

View file

@ -1,7 +1,7 @@
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
/**
* SH-116 client-confirmed media contract (2026-09-22): photos up to 10 MB
* Client-confirmed media contract (2026-09-22): photos up to 10 MB
* (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV), at most
* 10 photos and 3 videos per work order. Applies to the Photos & Videos modal,
* the Completion Doc → Media tab and the vendor portal upload. Duration is

View file

@ -3,6 +3,7 @@ import type {
MediaCategory,
MediaFile,
} from "@/domain/work-orders/types/work-order-media";
import { resolveWorkOrderUploadKind } from "@/domain/work-orders/utils/work-order-media-limits";
let mediaIdCounter = 0;
@ -16,6 +17,10 @@ export function classifyUpload(f: File): {
if (t === "application/pdf" || /\.pdf$/i.test(f.name)) {
return { fileType: "pdf", category: "Report" };
}
// Phones often send an empty or octet-stream type; fall back to the extension.
const kind = resolveWorkOrderUploadKind(f);
if (kind === "photo") return { fileType: "image", category: "Photo" };
if (kind === "video") return { fileType: "video", category: "Video" };
return { fileType: "other", category: "Other" };
}

View file

@ -3,7 +3,7 @@
* (`moov` → `mvhd`) the same way the server does, seeking over box headers so a
* phone file with `moov` after a large `mdat` costs only a few small reads.
* Resolves null when the header cannot be read — callers must not block the
* upload in that case (SH-116: the server enforces the limit from the same
* upload in that case (the server enforces the limit from the same
* header and also lets unreadable metadata through).
*/
const MAX_BOXES_PER_LEVEL = 1024;

View file

@ -79,4 +79,36 @@ describe("SlideOverExtrasTab SH-116 media contract", () => {
await waitFor(() => expect(onUpload).toHaveBeenCalledWith([video]));
expect(toast.error).not.toHaveBeenCalled();
});
it("ignores a second pick while the first one is still being checked", async () => {
let finishProbe: ((seconds: number) => void) | undefined;
vi.mocked(probeVideoDurationSeconds).mockImplementationOnce(
() =>
new Promise((resolve) => {
finishProbe = resolve;
}),
);
const onUpload = vi.fn();
renderWithProviders(
<SlideOverExtrasTab
uploads={[]}
mediaCounts={{ photos: 0, videos: 2 }}
isReadOnly={false}
onUpload={onUpload}
onRemove={vi.fn()}
/>,
);
const first = phoneFile("IMG_0710.MOV", "video/quicktime", 20 * MB);
const second = phoneFile("IMG_0711.MOV", "video/quicktime", 20 * MB);
selectFiles([first]);
await waitFor(() => expect(finishProbe).toBeDefined());
expect(document.querySelector('input[type="file"]')).toBeDisabled();
selectFiles([second]);
finishProbe?.(30);
await waitFor(() => expect(onUpload).toHaveBeenCalledOnce());
expect(onUpload).toHaveBeenCalledWith([first]);
expect(probeVideoDurationSeconds).toHaveBeenCalledOnce();
});
});

View file

@ -247,4 +247,40 @@ describe("VendorPortalDocuments", () => {
finishUpload?.();
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
});
it("drops an earlier pick whose video check finishes after a newer pick", async () => {
let finishProbe: ((seconds: number) => void) | undefined;
vi.mocked(probeVideoDurationSeconds).mockImplementationOnce(
() =>
new Promise((resolve) => {
finishProbe = resolve;
}),
);
const upload = vi
.spyOn(vendorPortalApi, "uploadDocument")
.mockResolvedValue(quarantinedDocument);
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[]}
locked={false}
onChanged={vi.fn().mockResolvedValue(undefined)}
/>,
{ withAuth: false },
);
const input = screen.getByLabelText("Choose completion document");
const video = { name: "IMG_0001.MOV", type: "video/quicktime", size: 20_000_000 } as File;
const photo = { name: "IMG_0002.jpg", type: "image/jpeg", size: 2_000_000 } as File;
fireEvent.change(input, { target: { files: [video] } });
await waitFor(() => expect(finishProbe).toBeDefined());
fireEvent.change(input, { target: { files: [photo] } });
await waitFor(() => expect(upload).toHaveBeenCalledOnce());
finishProbe?.(30);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(upload).toHaveBeenCalledOnce();
expect(upload.mock.calls[0][0].file).toBe(photo);
});
});

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import {
boardMediaItemsToMediaFiles,
mapBoardDetailMediaItem,
} from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import { countWorkOrderMediaKinds } from "@/domain/work-orders/utils/work-order-media-limits";
import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils";
describe("photo/video counting for the per-work-order limit", () => {
it("counts a persisted HEIC photo after a refresh", () => {
const heic = mapBoardDetailMediaItem({
id: 41,
category: 3,
url: "https://cdn.example.com/IMG_0042.HEIC",
fileName: "IMG_0042.HEIC",
});
const files = boardMediaItemsToMediaFiles([heic]);
expect(files[0]?.fileType).toBe("image");
expect(countWorkOrderMediaKinds(files)).toEqual({ photos: 1, videos: 0 });
});
it.each([
[{ name: "IMG_0043.HEIC", type: "" }, "image"],
[{ name: "IMG_0044.MOV", type: "" }, "video"],
[{ name: "IMG_0045.jpg", type: "application/octet-stream" }, "image"],
[{ name: "report.docx", type: "" }, "other"],
])("classifies a local %o by extension when the browser type is missing", (file, fileType) => {
expect(classifyUpload({ ...file, size: 1 } as File).fileType).toBe(fileType);
});
});