Compare commits

...

4 commits

Author SHA1 Message Date
Alexandre Brandizzi
d573ab59a9 refactor(vendor-portal): extract document row from VendorPortalDocuments
The changed-file maintainability gate caps functions at 150 lines;
VendorPortalDocuments reached 156. Move the per-document row into
VendorPortalDocumentRow with no behavior change.
2026-09-24 23:03:23 -03:00
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
Alexandre Brandizzi
b316168930 fix(vendor-portal): pre-check the per-work-order photo/video limit
The vendor completion upload and uplift evidence checked type, size and
duration but not the 10-photo / 3-video work-order limit. Use the counts the
dispatch detail now reports (shoc-backend#173), on the same basis as the
server: a new completion version does not count the document it replaces.
When the backend does not report counts yet, the server check still applies.
2026-09-24 22:29:20 -03:00
Alexandre Brandizzi
cfe83fc489 fix(media): read video duration from the MP4/MOV header in the browser
The browser pre-check decoded the picked file through a video element and
an object URL, which CodeQL flags as DOM text reinterpreted as HTML. Parse
the moov/mvhd movie header from file slices instead, the same way the
server enforces the 90-second limit, so both sides read one duration.
Unreadable headers still never block an upload.
2026-09-24 22:14:42 -03:00
19 changed files with 578 additions and 102 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

@ -56,6 +56,7 @@ export function DispatchDetailSections({
token={token}
dispatchId={dispatchId}
documents={data.documents ?? []}
mediaCounts={data.mediaCounts}
locked={locked}
onChanged={refresh}
/>
@ -68,6 +69,7 @@ export function DispatchDetailSections({
dispatchId={dispatchId}
currentNte={data.nteAmount}
upliftRequests={data.upliftRequests ?? []}
mediaCounts={data.mediaCounts}
locked={locked}
onChanged={refresh}
pollDocument={pollEvidenceDocument}

View file

@ -1,6 +1,7 @@
import { useState } from "react";
import { Text } from "@/components/ui/text";
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
import type { VendorMediaUsage } from "@/domain/vendor-portal/lib/document-validation";
import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation";
import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal";
import { formatDateTime } from "@/lib/time-utils";
@ -15,6 +16,7 @@ type UpliftRequestCardProps = {
onWithdraw: (request: VendorPortalUpliftRequest) => void;
onRevise: (request: VendorPortalUpliftRequest, values: UpliftFormValues) => void;
onCancelRevise: () => void;
mediaUsage?: VendorMediaUsage;
};
const WITHDRAWABLE_STATUSES = new Set(["Pending", "ChangesRequested"]);
@ -33,6 +35,7 @@ export function UpliftRequestCard({
onWithdraw,
onRevise,
onCancelRevise,
mediaUsage,
}: UpliftRequestCardProps) {
const [revising, setRevising] = useState(false);
const canWithdraw = !locked && canAct && WITHDRAWABLE_STATUSES.has(request.status);
@ -100,6 +103,7 @@ export function UpliftRequestCard({
initialRequested={String(request.requestedNTE)}
initialReason={request.vendorReason ?? ""}
hasExistingEvidence={request.evidenceDocumentId != null}
mediaUsage={mediaUsage}
submitting={submitting}
uploadingPercent={uploadingPercent}
errorMessage=""

View file

@ -1,6 +1,9 @@
import { useRef, useState } from "react";
import { Text } from "@/components/ui/text";
import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation";
import {
validateVendorDocument,
type VendorMediaUsage,
} from "@/domain/vendor-portal/lib/document-validation";
import {
isApprovalRequired,
validateUpliftAmount,
@ -13,6 +16,8 @@ type UpliftRequestFormProps = {
initialRequested?: string;
initialReason?: string;
hasExistingEvidence?: boolean;
/** Work-order photo/video usage for the evidence pre-check. */
mediaUsage?: VendorMediaUsage;
submitting: boolean;
uploadingPercent: number | undefined;
errorMessage: string;
@ -26,6 +31,7 @@ export function UpliftRequestForm({
initialRequested = "",
initialReason = "",
hasExistingEvidence = false,
mediaUsage,
submitting,
uploadingPercent,
errorMessage,
@ -57,7 +63,7 @@ export function UpliftRequestForm({
const chooseFile = async (file?: File) => {
const selection = ++latestSelection.current;
const validationMessage = file ? await validateVendorDocument(file) : undefined;
const validationMessage = file ? await validateVendorDocument(file, mediaUsage) : undefined;
// A newer pick may have settled while this one's video metadata was still loading.
if (selection !== latestSelection.current) return;
setFormError(validationMessage ?? "");

View file

@ -4,10 +4,12 @@ import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import { createUpliftRequestKey } from "@/domain/vendor-portal/lib/request-key";
import type {
VendorPortalDocumentStatus,
VendorPortalMediaCounts,
VendorPortalUpliftRequest,
} from "@/domain/vendor-portal/types/vendor-portal";
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation";
import { vendorMediaUsage } from "@/domain/vendor-portal/lib/document-validation";
import { UpliftRequestCard } from "@/app/v/[token]/dispatch/_components/uplift-request-card";
import {
useVendorUpliftEvidence,
@ -24,6 +26,7 @@ type UpliftRequestsSectionProps = {
locked: boolean;
onChanged: () => Promise<void>;
pollDocument: (documentId: number) => Promise<VendorPortalDocumentStatus | undefined>;
mediaCounts?: VendorPortalMediaCounts | null;
};
type UseVendorUpliftActionsArgs = {
@ -168,7 +171,9 @@ export function UpliftRequestsSection({
locked,
onChanged,
pollDocument,
mediaCounts,
}: UpliftRequestsSectionProps) {
const mediaUsage = vendorMediaUsage(mediaCounts, "evidence");
const evidence = useVendorUpliftEvidence({ pollDocument });
const {
submitting,
@ -205,6 +210,7 @@ export function UpliftRequestsSection({
onWithdraw={(r) => void handleWithdraw(r)}
onRevise={(r, values) => void handleRevise(r, values)}
onCancelRevise={evidence.reset}
mediaUsage={mediaUsage}
/>
))}
@ -212,6 +218,7 @@ export function UpliftRequestsSection({
<UpliftRequestForm
mode="create"
currentNte={currentNte}
mediaUsage={mediaUsage}
submitting={submitting}
uploadingPercent={evidence.uploadingPercent}
errorMessage={formError}

View file

@ -3,17 +3,82 @@ import { useMutation } from "@tanstack/react-query";
import { VendorPortalFeedback } from "@/app/v/_components/vendor-portal-feedback";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation";
import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal";
import {
validateVendorDocument,
vendorMediaUsage,
} from "@/domain/vendor-portal/lib/document-validation";
import type {
VendorPortalDocument,
VendorPortalMediaCounts,
} from "@/domain/vendor-portal/types/vendor-portal";
type VendorPortalDocumentsProps = {
token: string;
dispatchId: number;
documents: VendorPortalDocument[];
locked: boolean;
mediaCounts?: VendorPortalMediaCounts | null;
onChanged: () => Promise<void>;
};
type VendorPortalDocumentRowProps = {
document: VendorPortalDocument;
locked: boolean;
uploading: boolean;
onDownload: () => void;
onReplace: () => void;
};
function VendorPortalDocumentRow({
document,
locked,
uploading,
onDownload,
onReplace,
}: VendorPortalDocumentRowProps) {
return (
<article className="vp-uplift-row">
<div className="vp-row">
<strong>File</strong>
<span>
{document.originalFileName} · v{document.version}
</span>
</div>
<div className="vp-row">
<strong>Security scan</strong>
<span>{document.scanStatus}</span>
</div>
<div className="vp-row">
<strong>Review</strong>
<span>{document.reviewStatus}</span>
</div>
{Boolean(document.rejectionReason) && (
<VendorPortalFeedback tone="error">{document.rejectionReason}</VendorPortalFeedback>
)}
<div className="vp-actions">
<button
type="button"
className="vp-btn vp-btn-ghost"
disabled={!document.canDownload}
onClick={onDownload}
>
{document.canDownload ? "Download" : "Quarantined"}
</button>
{!locked && (
<button
type="button"
className="vp-btn vp-btn-ghost"
disabled={uploading}
onClick={onReplace}
>
Replace
</button>
)}
</div>
</article>
);
}
type UploadRequest = {
file: File;
replacementId?: number;
@ -24,12 +89,15 @@ export function VendorPortalDocuments({
dispatchId,
documents,
locked,
mediaCounts,
onChanged,
}: VendorPortalDocumentsProps) {
const inputRef = useRef<HTMLInputElement>(null);
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) =>
@ -51,6 +119,7 @@ export function VendorPortalDocuments({
});
const chooseFile = (documentId?: number) => {
selectionRef.current += 1;
setValidationError(undefined);
uploadMutation.reset();
setReplacementId(documentId);
@ -62,7 +131,15 @@ export function VendorPortalDocuments({
return;
}
const validationMessage = await validateVendorDocument(file);
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;
@ -93,45 +170,14 @@ export function VendorPortalDocuments({
)}
{documents.map((document) => (
<article key={document.id} className="vp-uplift-row">
<div className="vp-row">
<strong>File</strong>
<span>
{document.originalFileName} · v{document.version}
</span>
</div>
<div className="vp-row">
<strong>Security scan</strong>
<span>{document.scanStatus}</span>
</div>
<div className="vp-row">
<strong>Review</strong>
<span>{document.reviewStatus}</span>
</div>
{Boolean(document.rejectionReason) && (
<VendorPortalFeedback tone="error">{document.rejectionReason}</VendorPortalFeedback>
)}
<div className="vp-actions">
<button
type="button"
className="vp-btn vp-btn-ghost"
disabled={!document.canDownload}
onClick={() => void vendorPortalApi.downloadDocument(token, dispatchId, document)}
>
{document.canDownload ? "Download" : "Quarantined"}
</button>
{!locked && (
<button
type="button"
className="vp-btn vp-btn-ghost"
disabled={uploadMutation.isPending}
onClick={() => chooseFile(document.id)}
>
Replace
</button>
)}
</div>
</article>
<VendorPortalDocumentRow
key={document.id}
document={document}
locked={locked}
uploading={uploadMutation.isPending}
onDownload={() => void vendorPortalApi.downloadDocument(token, dispatchId, document)}
onReplace={() => chooseFile(document.id)}
/>
))}
{progress !== undefined && (

View file

@ -1,7 +1,8 @@
import type { VendorPortalMediaCounts } from "@/domain/vendor-portal/types/vendor-portal";
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.
*/
@ -42,12 +43,65 @@ function resolveKind(file: File): VendorUploadKind | null {
return KIND_BY_MIME[file.type.toLowerCase()] ?? KIND_BY_EXTENSION[extension] ?? null;
}
export async function validateVendorDocument(file: File): Promise<string | undefined> {
/** 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.";
}

View file

@ -23,6 +23,16 @@ export interface VendorPortalDispatchSummary {
refusedAt?: string;
}
/** Counts for a new completion version exclude the document it replaces. */
export interface VendorPortalMediaCounts {
maxPhotos: number;
maxVideos: number;
photos: number;
videos: number;
completionPhotos: number;
completionVideos: number;
}
export interface VendorPortalDocument {
id: number;
originalFileName: string;
@ -137,4 +147,6 @@ export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary
upliftRequests: VendorPortalUpliftRequest[];
signoffs: VendorPortalSignoff[];
documents: VendorPortalDocument[];
/** 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

@ -1,37 +1,75 @@
/**
* Best-effort client-side video duration probe. Resolves null when the browser
* cannot decode the container's metadata — callers must not block the upload in
* that case (SH-116: the server enforces the limit from the MP4/MOV header and
* also lets unreadable metadata through).
* Best-effort video duration probe for MP4/MOV. Reads the movie header
* (`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 (the server enforces the limit from the same
* header and also lets unreadable metadata through).
*/
export const VIDEO_DURATION_PROBE_TIMEOUT_MS = 10_000;
const MAX_BOXES_PER_LEVEL = 1024;
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;
type Box = { bodyStart: number; end: number };
const done = (value: number | null) => {
if (settled) return;
settled = true;
video.removeAttribute("src");
video.load();
URL.revokeObjectURL(url);
resolve(value);
};
const timeout = window.setTimeout(() => done(null), VIDEO_DURATION_PROBE_TIMEOUT_MS);
video.preload = "metadata";
video.onloadedmetadata = () => {
window.clearTimeout(timeout);
done(Number.isFinite(video.duration) ? video.duration : null);
};
video.onerror = () => {
window.clearTimeout(timeout);
done(null);
};
video.src = url;
});
async function readView(file: Blob, start: number, length: number): Promise<DataView | null> {
if (start < 0 || length <= 0 || start + length > file.size) return null;
const buffer = await file.slice(start, start + length).arrayBuffer();
return buffer.byteLength === length ? new DataView(buffer) : null;
}
function boxType(view: DataView): string {
return String.fromCharCode(
view.getUint8(4),
view.getUint8(5),
view.getUint8(6),
view.getUint8(7),
);
}
async function findBox(file: Blob, start: number, end: number, type: string): Promise<Box | null> {
let offset = start;
for (let index = 0; index < MAX_BOXES_PER_LEVEL && offset + 8 <= end; index += 1) {
const header = await readView(file, offset, Math.min(16, end - offset));
if (!header) return null;
let size = header.getUint32(0);
let headerLength = 8;
if (size === 1) {
if (header.byteLength < 16) return null;
const largeSize = header.getBigUint64(8);
if (largeSize > BigInt(Number.MAX_SAFE_INTEGER)) return null;
size = Number(largeSize);
headerLength = 16;
} else if (size === 0) {
size = end - offset;
}
if (size < headerLength || offset + size > end) return null;
if (boxType(header) === type) {
return { bodyStart: offset + headerLength, end: offset + size };
}
offset += size;
}
return null;
}
async function readMovieHeaderSeconds(file: Blob, mvhd: Box): Promise<number | null> {
const version = await readView(file, mvhd.bodyStart, 1);
if (!version) return null;
const isVersion1 = version.getUint8(0) === 1;
const fields = await readView(file, mvhd.bodyStart, isVersion1 ? 32 : 20);
if (!fields || mvhd.bodyStart + fields.byteLength > mvhd.end) return null;
const timescale = fields.getUint32(isVersion1 ? 20 : 12);
const duration = isVersion1 ? fields.getBigUint64(24) : BigInt(fields.getUint32(16));
const unknown = isVersion1 ? 0xffff_ffff_ffff_ffffn : 0xffff_ffffn;
if (timescale === 0 || duration === unknown) return null;
return Number(duration) / timescale;
}
export async function probeVideoDurationSeconds(file: Blob): Promise<number | null> {
try {
const moov = await findBox(file, 0, file.size, "moov");
if (!moov) return null;
const mvhd = await findBox(file, moov.bodyStart, moov.end, "mvhd");
return mvhd ? await readMovieHeaderSeconds(file, mvhd) : null;
} catch {
return null;
}
}

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

@ -38,6 +38,16 @@ describe("UpliftRequestForm revise mode", () => {
expect(screen.getByText(/must be greater than the current NTE/i)).toBeInTheDocument();
});
it("refuses photo evidence when the work order already has 10 photos", async () => {
renderForm({ mediaUsage: { photos: 10, videos: 0, maxPhotos: 10, maxVideos: 3 } });
fireEvent.change(screen.getByLabelText(/evidence/i), {
target: { files: [new File(["jpg"], "receipt.jpg", { type: "image/jpeg" })] },
});
expect(await screen.findByText("A work order can have at most 10 photos.")).toBeInTheDocument();
});
it("reports the current NTE threshold in the actionable error", () => {
renderForm({ initialRequested: "900", currentNte: 1000 });

View file

@ -143,6 +143,73 @@ describe("VendorPortalDocuments", () => {
expect(await screen.findByRole("alert")).toHaveTextContent(message);
expect(upload).not.toHaveBeenCalled();
});
// Three videos already on the work order (dispatcher and vendor uploads together).
const fullCounts = {
maxPhotos: 10,
maxVideos: 3,
photos: 10,
videos: 3,
completionPhotos: 10,
completionVideos: 3,
};
const videoDocument: VendorPortalDocument = {
...quarantinedDocument,
id: 21,
originalFileName: "walkthrough.mp4",
contentType: "video/mp4",
};
it("rejects the 4th video and 11th photo counted across the whole work order", async () => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
const upload = vi.spyOn(vendorPortalApi, "uploadDocument");
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[]}
mediaCounts={fullCounts}
locked={false}
onChanged={vi.fn()}
/>,
{ withAuth: false },
);
choose({ name: "IMG_0004.MOV", type: "video/quicktime", size: 20 * MB } as File);
expect(await screen.findByRole("alert")).toHaveTextContent(
"A work order can have at most 3 videos.",
);
choose({ name: "IMG_0011.jpg", type: "image/jpeg", size: 2 * MB } as File);
await waitFor(() =>
expect(screen.getByRole("alert")).toHaveTextContent(
"A work order can have at most 10 photos.",
),
);
expect(upload).not.toHaveBeenCalled();
});
it("lets a vendor replace one of the counted videos on a full work order", async () => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
const upload = vi.spyOn(vendorPortalApi, "uploadDocument").mockResolvedValue(videoDocument);
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[videoDocument]}
mediaCounts={fullCounts}
locked={false}
onChanged={vi.fn().mockResolvedValue(undefined)}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
const replacement = { name: "walkthrough-v2.mp4", type: "video/mp4", size: 20 * MB } as File;
choose(replacement);
await waitFor(() => expect(upload).toHaveBeenCalledOnce());
expect(upload.mock.calls[0][0]).toMatchObject({ file: replacement, replacesDocumentId: 21 });
});
});
it("reports upload progress and refreshes the dispatch after success", async () => {
@ -180,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);
});
});

View file

@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
function concat(...parts: Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
function ascii(text: string): Uint8Array {
return Uint8Array.from(text, (char) => char.charCodeAt(0));
}
function box(type: string, ...children: Uint8Array[]): Uint8Array {
const body = concat(...children);
const out = new Uint8Array(8 + body.length);
new DataView(out.buffer).setUint32(0, out.length);
out.set(ascii(type), 4);
out.set(body, 8);
return out;
}
function movieHeader(timescale: number, duration: bigint, version1 = false): Uint8Array {
const body = new Uint8Array(version1 ? 32 : 20);
const view = new DataView(body.buffer);
body[0] = version1 ? 1 : 0;
if (version1) {
view.setUint32(20, timescale);
view.setBigUint64(24, duration);
} else {
view.setUint32(12, timescale);
view.setUint32(16, Number(duration));
}
return box("mvhd", body);
}
/** A phone-style file: ftyp, a large mdat, then moov at the end (not fast-start). */
function mp4(timescale: number, duration: bigint, version1 = false): Uint8Array {
return concat(
box("ftyp", ascii("isom"), new Uint8Array(4)),
box("mdat", new Uint8Array(4096)),
box("moov", movieHeader(timescale, duration, version1)),
);
}
function asFile(bytes: Uint8Array, name = "IMG_0001.MOV"): File {
return new File([new Uint8Array(bytes)], name, { type: "video/quicktime" });
}
describe("probeVideoDurationSeconds", () => {
it("reads the duration from a moov box after the media data", async () => {
await expect(probeVideoDurationSeconds(asFile(mp4(600, 57_000n)))).resolves.toBe(95);
});
it("reads a version 1 movie header", async () => {
await expect(probeVideoDurationSeconds(asFile(mp4(1000, 30_500n, true)))).resolves.toBe(30.5);
});
it("follows 64-bit box sizes", async () => {
const mdat = new Uint8Array(16 + 512);
const view = new DataView(mdat.buffer);
view.setUint32(0, 1);
mdat.set(ascii("mdat"), 4);
view.setBigUint64(8, BigInt(mdat.length));
const file = concat(
box("ftyp", ascii("qt "), new Uint8Array(4)),
mdat,
box("moov", movieHeader(90_000, 90_000n * 120n)),
);
await expect(probeVideoDurationSeconds(asFile(file))).resolves.toBe(120);
});
it.each([
[
"no moov",
concat(box("ftyp", ascii("isom"), new Uint8Array(4)), box("mdat", new Uint8Array(64))),
],
["truncated", mp4(600, 57_000n).slice(0, -10)],
["zero timescale", mp4(0, 57_000n)],
[
"oversized box",
concat(box("ftyp", new Uint8Array(8)), Uint8Array.of(0x7f, 0xff, 0xff, 0xff), ascii("moov")),
],
["not a video", ascii("%PDF-1.7 not a movie")],
])("returns null for an unreadable file (%s)", async (_shape, bytes) => {
await expect(probeVideoDurationSeconds(asFile(bytes))).resolves.toBeNull();
});
});