mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 04:33:11 +00:00
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:
parent
b316168930
commit
6012ebabe7
13 changed files with 171 additions and 27 deletions
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/")) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue