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 { import {
File as FileIcon, File as FileIcon,
FileText, FileText,
@ -18,7 +18,10 @@ import {
hasExplicitAvetaFile, hasExplicitAvetaFile,
resolveExtraDocCategory, resolveExtraDocCategory,
} from "@/domain/work-orders/utils/extra-docs-aveta"; } 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 { import {
canDeleteWorkOrderMedia, canDeleteWorkOrderMedia,
countWorkOrderMediaKinds, countWorkOrderMediaKinds,
@ -189,16 +192,30 @@ export function SlideOverExtrasTab({
}: SlideOverExtrasTabProps) { }: SlideOverExtrasTabProps) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const avetaAttached = hasExplicitAvetaFile(uploads); 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) => { 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. // Snapshot synchronously: the input is reset right after this handler returns.
const selected = Array.from(files); const selected = Array.from(files);
const { accepted, rejections } = await screenWorkOrderUploads(selected, { screeningRef.current = true;
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads), setScreening(true);
isTypeAllowed: isWorkOrderExtraDocTypeAllowed, let screened: WorkOrderUploadScreenResult;
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE, 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); for (const message of rejections) toast.error(message);
if (accepted.length > 0) { if (accepted.length > 0) {
onUpload(accepted); onUpload(accepted);
@ -231,7 +248,7 @@ export function SlideOverExtrasTab({
<> <>
<button <button
type="button" type="button"
disabled={pending} disabled={busy}
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
onDragOver={(event) => event.preventDefault()} onDragOver={(event) => event.preventDefault()}
onDrop={(event) => { onDrop={(event) => {
@ -248,9 +265,9 @@ export function SlideOverExtrasTab({
<Upload size={20} style={{ color: "var(--color-text-muted)" }} /> <Upload size={20} style={{ color: "var(--color-text-muted)" }} />
<span <span
style={{ fontSize: 13, color: "var(--color-text-muted)" }} 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>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}> <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 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()} accept={workOrderExtraDocsAcceptAttribute()}
multiple multiple
hidden hidden
disabled={pending} disabled={busy}
onChange={(event) => { onChange={(event) => {
void addFiles(event.target.files); void addFiles(event.target.files);
event.target.value = ""; event.target.value = "";

View file

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

View file

@ -38,6 +38,8 @@ export function VendorPortalDocuments({
const [replacementId, setReplacementId] = useState<number>(); const [replacementId, setReplacementId] = useState<number>();
const [progress, setProgress] = useState<number>(); const [progress, setProgress] = useState<number>();
const [validationError, setValidationError] = useState<string>(); 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({ const uploadMutation = useMutation({
mutationFn: ({ file, replacementId: requestedReplacementId }: UploadRequest) => mutationFn: ({ file, replacementId: requestedReplacementId }: UploadRequest) =>
@ -59,6 +61,7 @@ export function VendorPortalDocuments({
}); });
const chooseFile = (documentId?: number) => { const chooseFile = (documentId?: number) => {
selectionRef.current += 1;
setValidationError(undefined); setValidationError(undefined);
uploadMutation.reset(); uploadMutation.reset();
setReplacementId(documentId); setReplacementId(documentId);
@ -70,11 +73,15 @@ export function VendorPortalDocuments({
return; return;
} }
const selection = ++selectionRef.current;
const replaced = documents.find((document) => document.id === replacementId); const replaced = documents.find((document) => document.id === replacementId);
const validationMessage = await validateVendorDocument( const validationMessage = await validateVendorDocument(
file, file,
vendorMediaUsage(mediaCounts, "completion", replaced?.contentType), vendorMediaUsage(mediaCounts, "completion", replaced?.contentType),
); );
if (selection !== selectionRef.current) {
return;
}
if (validationMessage) { if (validationMessage) {
setValidationError(validationMessage); setValidationError(validationMessage);
return; return;

View file

@ -2,7 +2,7 @@ import type { VendorPortalMediaCounts } from "@/domain/vendor-portal/types/vendo
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; 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). * 10 MB (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV).
* PDF documents keep the previous 10 MB document cap. * PDF documents keep the previous 10 MB document cap.
*/ */

View file

@ -147,6 +147,6 @@ export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary
upliftRequests: VendorPortalUpliftRequest[]; upliftRequests: VendorPortalUpliftRequest[];
signoffs: VendorPortalSignoff[]; signoffs: VendorPortalSignoff[];
documents: VendorPortalDocument[]; 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; mediaCounts?: VendorPortalMediaCounts | null;
} }

View file

@ -233,7 +233,7 @@ function cryptoRandomId(): string {
function classifyFileName(fileName: string, url: string): FileCategory { function classifyFileName(fileName: string, url: string): FileCategory {
const name = `${fileName} ${url}`.toLowerCase(); 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"; return "image";
} }
if (/\.(mp4|mov|webm|m4v)(\?|$)/i.test(name) || name.includes("video/")) { 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[] }; 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) * 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 * and video duration. The server enforces the same contract; a video whose duration
* the browser cannot read is never blocked here. * the browser cannot read is never blocked here.

View file

@ -1,7 +1,7 @@
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock"; 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 * (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, * 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 * the Completion Doc → Media tab and the vendor portal upload. Duration is

View file

@ -3,6 +3,7 @@ import type {
MediaCategory, MediaCategory,
MediaFile, MediaFile,
} from "@/domain/work-orders/types/work-order-media"; } from "@/domain/work-orders/types/work-order-media";
import { resolveWorkOrderUploadKind } from "@/domain/work-orders/utils/work-order-media-limits";
let mediaIdCounter = 0; let mediaIdCounter = 0;
@ -16,6 +17,10 @@ export function classifyUpload(f: File): {
if (t === "application/pdf" || /\.pdf$/i.test(f.name)) { if (t === "application/pdf" || /\.pdf$/i.test(f.name)) {
return { fileType: "pdf", category: "Report" }; 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" }; 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 * (`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. * 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 * 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). * header and also lets unreadable metadata through).
*/ */
const MAX_BOXES_PER_LEVEL = 1024; const MAX_BOXES_PER_LEVEL = 1024;

View file

@ -79,4 +79,36 @@ describe("SlideOverExtrasTab SH-116 media contract", () => {
await waitFor(() => expect(onUpload).toHaveBeenCalledWith([video])); await waitFor(() => expect(onUpload).toHaveBeenCalledWith([video]));
expect(toast.error).not.toHaveBeenCalled(); 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?.(); finishUpload?.();
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce()); 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);
});
});