Files
HealthLog/src/components/documents/use-document-upload.ts
T
Marc-André Bombeck 5536749985 feat(documents): client upload queue with progress and duplicate handling
One multipart XHR POST per file (real upload-progress events, no new
dependency), concurrency capped at 3 to match the server's memory
envelope, an Idempotency-Key per file so a retried blip cannot
double-store, and a canvas downscale to 3000 px longest edge for raster
photos only — PDFs, Office files, and GIFs upload verbatim. Entries
appear synchronously before any network work; an over-limit file fails
locally against the configured cap while the batch continues; a
duplicate resolves to the existing row and hands its id to the timeline
for a brief highlight.

Plan-Task: W2-T4 (manager half)
2026-07-07 12:25:45 +02:00

283 lines
9.3 KiB
TypeScript

"use client";
/**
* The vault's upload manager: a client-side queue with concurrency 3, one
* multipart XHR POST per file (XHR because it exposes real upload-progress
* events with zero dependencies — fetch upload streaming is not portable),
* client-side image downscaling, and per-file terminal states.
*
* Perceived-speed contract: `enqueue()` adds pending entries synchronously
* (< 100 ms — before any network work), each carrying live progress. A
* finished file leaves the queue and the list/usage queries refetch; a
* failed file stays as an inline error card until dismissed (an over-limit
* file fails individually, the batch continues).
*
* Duplicate contract (§3.2): a same-user re-upload returns HTTP 200 with
* envelope-level `meta.duplicate: true` and the EXISTING row — the manager
* toasts "already stored" and hands the row id to the timeline for a brief
* highlight. This is the one call site that must read success-side `meta`;
* `parseUploadResponse` in `vault-utils.ts` owns that parse.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import {
parseUploadResponse,
type UploadFailure,
type UploadResult,
} from "./vault-utils";
/** Client-side parallelism — matches the plan's NAS memory envelope. */
const CONCURRENCY = 3;
/** Longest-edge ceiling for the client-side image downscale. */
const IMAGE_MAX_EDGE = 3000;
/** How long a duplicate-highlight ring stays on the existing row. */
const HIGHLIGHT_MS = 2600;
/** Only raster photo formats are re-encoded — never PDFs/Office, never GIF
* (re-encoding would flatten an animation and GIF photos don't exist). */
const DOWNSCALE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
export interface UploadQueueItem {
/** Local id — NOT a server id; stable for the item's queue lifetime. */
localId: string;
fileName: string;
byteSize: number;
status: "uploading" | "error";
/** 0..1 upload progress (transport bytes, not server processing). */
progress: number;
/** Terminal failure detail, present when `status === "error"`. */
failure?: UploadFailure;
}
export interface EnqueueOptions {
/** Pre-link every uploaded document to this episode (deep-link uploads). */
episodeId?: string;
}
interface UploadLimits {
maxFileBytes: number;
}
/**
* Downscale a raster photo to ≤ 3000 px longest edge (canvas, JPEG 0.85).
* Returns the original file untouched when the type is not a raster photo,
* the image is already small enough, or anything about decoding fails —
* a downscale must never block an upload.
*/
async function downscaleImage(file: File): Promise<File> {
if (!DOWNSCALE_TYPES.has(file.type)) return file;
try {
const bitmap = await createImageBitmap(file);
const longest = Math.max(bitmap.width, bitmap.height);
if (longest <= IMAGE_MAX_EDGE) {
bitmap.close();
return file;
}
const scale = IMAGE_MAX_EDGE / longest;
const width = Math.round(bitmap.width * scale);
const height = Math.round(bitmap.height * scale);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
bitmap.close();
return file;
}
ctx.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/jpeg", 0.85),
);
if (!blob) return file;
const stem = file.name.replace(/\.[^.]+$/u, "") || "photo";
return new File([blob], `${stem}.jpg`, { type: "image/jpeg" });
} catch {
return file;
}
}
/** One multipart POST via XHR, reporting upload progress. */
function uploadViaXhr(
file: File,
options: EnqueueOptions,
onProgress: (fraction: number) => void,
): Promise<UploadResult> {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/documents/inbound");
// Retry-safe by construction: the server honours Idempotency-Key, so a
// network blip that DID land server-side cannot double-store on retry.
xhr.setRequestHeader("Idempotency-Key", crypto.randomUUID());
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && event.total > 0) {
onProgress(event.loaded / event.total);
}
});
xhr.addEventListener("load", () => {
resolve(parseUploadResponse(xhr.status, xhr.responseText));
});
xhr.addEventListener("error", () =>
resolve({ ok: false, reason: "generic" }),
);
xhr.addEventListener("abort", () =>
resolve({ ok: false, reason: "generic" }),
);
const fd = new FormData();
fd.append("file", file);
if (options.episodeId) fd.append("episodeIds", options.episodeId);
xhr.send(fd);
});
}
export interface DocumentUploadManager {
/** Live queue — uploading entries first-in-first, then error entries. */
items: UploadQueueItem[];
/** Add files to the queue; returns immediately (optimistic entries). */
enqueue: (files: File[], options?: EnqueueOptions) => void;
/** Remove a terminal (error) entry from the queue. */
dismiss: (localId: string) => void;
/** Row id to briefly highlight (duplicate upload → the existing row). */
highlightId: string | null;
}
export function useDocumentUpload(
limits: UploadLimits | undefined,
): DocumentUploadManager {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [items, setItems] = useState<UploadQueueItem[]>([]);
const [highlightId, setHighlightId] = useState<string | null>(null);
const activeRef = useRef(0);
const pendingRef = useRef<
{ localId: string; file: File; options: EnqueueOptions }[]
>([]);
const mountedRef = useRef(true);
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (highlightTimer.current) clearTimeout(highlightTimer.current);
};
}, []);
const patchItem = useCallback(
(localId: string, patch: Partial<UploadQueueItem>) => {
if (!mountedRef.current) return;
setItems((prev) =>
prev.map((it) => (it.localId === localId ? { ...it, ...patch } : it)),
);
},
[],
);
const removeItem = useCallback((localId: string) => {
if (!mountedRef.current) return;
setItems((prev) => prev.filter((it) => it.localId !== localId));
}, []);
const flashHighlight = useCallback((id: string) => {
if (!mountedRef.current) return;
setHighlightId(id);
if (highlightTimer.current) clearTimeout(highlightTimer.current);
highlightTimer.current = setTimeout(() => {
if (mountedRef.current) setHighlightId(null);
}, HIGHLIGHT_MS);
}, []);
const settle = useCallback(
(localId: string, result: UploadResult) => {
if (result.ok) {
removeItem(localId);
void invalidateKeys(queryClient, [queryKeys.documents()]);
if (result.duplicate) {
toast.info(t("documents.toast.duplicate"));
flashHighlight(result.document.id);
}
return;
}
patchItem(localId, { status: "error", failure: result, progress: 0 });
},
[flashHighlight, patchItem, queryClient, removeItem, t],
);
const pump = useCallback(() => {
while (
activeRef.current < CONCURRENCY &&
pendingRef.current.length > 0 &&
mountedRef.current
) {
const next = pendingRef.current.shift();
if (!next) return;
activeRef.current += 1;
void (async () => {
try {
const prepared = await downscaleImage(next.file);
const result = await uploadViaXhr(prepared, next.options, (f) =>
patchItem(next.localId, { progress: f }),
);
settle(next.localId, result);
} finally {
activeRef.current -= 1;
pump();
}
})();
}
}, [patchItem, settle]);
const enqueue = useCallback(
(files: File[], options: EnqueueOptions = {}) => {
if (files.length === 0) return;
const fresh: UploadQueueItem[] = [];
for (const file of files) {
const localId = crypto.randomUUID();
// Client pre-flight against the server's configured cap: an obvious
// giant (a CD image) fails instantly and locally — friendly reason,
// no wasted transfer. The server re-enforces regardless.
if (limits && file.size > limits.maxFileBytes) {
fresh.push({
localId,
fileName: file.name,
byteSize: file.size,
status: "error",
progress: 0,
failure: {
ok: false,
reason: "fileTooLarge",
maxFileBytes: limits.maxFileBytes,
},
});
continue;
}
fresh.push({
localId,
fileName: file.name,
byteSize: file.size,
status: "uploading",
progress: 0,
});
pendingRef.current.push({ localId, file, options });
}
setItems((prev) => [...prev, ...fresh]);
pump();
},
[limits, pump],
);
const dismiss = useCallback(
(localId: string) => removeItem(localId),
[removeItem],
);
return { items, enqueue, dismiss, highlightId };
}