From 3368c9bd3f5177b962c05a38f1f08610337efaed Mon Sep 17 00:00:00 2001 From: estifanos Date: Fri, 28 Aug 2026 10:42:04 +0000 Subject: [PATCH] feat: implement XHR-based file upload with progress tracking to replace RTK-based mutations for personal documents --- .../components/PersonalDocumentSlots.tsx | 80 +++++++++--- apps/portal/src/app/i18n/locales/am.ts | 1 + apps/portal/src/app/i18n/locales/en.ts | 1 + .../lib/features/personal-document/index.ts | 1 + .../personal-document-api.ts | 37 +----- .../personal-document.upload.ts | 116 ++++++++++++++++++ 6 files changed, 183 insertions(+), 53 deletions(-) create mode 100644 libs/api/src/lib/features/personal-document/personal-document.upload.ts diff --git a/apps/portal/src/app/features/documents/components/PersonalDocumentSlots.tsx b/apps/portal/src/app/features/documents/components/PersonalDocumentSlots.tsx index 0f813b457..7d7375da0 100644 --- a/apps/portal/src/app/features/documents/components/PersonalDocumentSlots.tsx +++ b/apps/portal/src/app/features/documents/components/PersonalDocumentSlots.tsx @@ -10,6 +10,7 @@ import { Group, Loader, Modal, + Progress, SimpleGrid, Stack, Text, @@ -26,24 +27,25 @@ import { useTranslation } from 'react-i18next'; import { ModalFooter } from '@ema-platform/ui'; import { useLocalized } from '@ema-platform/api'; import { + replacePersonalDocumentFile, + uploadPersonalDocumentFile, useDeletePersonalDocumentFileMutation, useGetMyPersonalDocumentsQuery, - useReplacePersonalDocumentFileMutation, - useUploadPersonalDocumentMutation, type AttachmentFile, + type PersonalDocumentError, type PersonalDocumentSlot, + type PersonalDocumentUploadResult, } from '@ema-platform/api'; import { PORTAL_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; /** - * A refused upload comes back as a structured payload — which slot, what was - * allowed, how big — so the applicant reads the real reason rather than - * "upload failed". + * A refused delete comes back through RTK, which nests the server's payload + * under `data`. Uploads report theirs directly — see the XHR helper. */ -function errorBody(err: unknown): { message?: string } & Record { +function errorBody(err: unknown): PersonalDocumentError { const payload = (err as { data?: { message?: unknown } })?.data?.message; return typeof payload === 'object' && payload !== null - ? (payload as { message?: string } & Record) + ? (payload as PersonalDocumentError) : { message: typeof payload === 'string' ? payload : undefined }; } @@ -63,12 +65,13 @@ export function PersonalDocumentSlots({ const { t } = useTranslation(); const localized = useLocalized(); - const { data, isLoading } = useGetMyPersonalDocumentsQuery(); - const [uploadDocument] = useUploadPersonalDocumentMutation(); - const [replaceFile] = useReplacePersonalDocumentFileMutation(); + const { data, isLoading, refetch } = useGetMyPersonalDocumentsQuery(); const [deleteFile] = useDeletePersonalDocumentFileMutation(); const [busy, setBusy] = useState(null); + // Percent for the upload in flight. A video is minutes of waiting, so the + // bar is the difference between waiting and reloading the page. + const [progress, setProgress] = useState(null); const [error, setError] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); // Mantine's FileButton clears its input through a ref object, and there is @@ -100,25 +103,46 @@ export function PersonalDocumentSlots({ return null; } + function describe(body: PersonalDocumentError): string { + return t(`documents.personal.errors.${body.message ?? 'unknown'}`, { + ...body, + defaultValue: t('documents.personal.errors.unknown'), + }); + } + + /** Deletes, which still go through RTK and refetch themselves. */ async function run(busyKey: string, action: () => Promise) { setBusy(busyKey); setError(null); try { await action(); } catch (err) { - const body = errorBody(err); - setError( - t(`documents.personal.errors.${body.message ?? 'unknown'}`, { - ...body, - defaultValue: t('documents.personal.errors.unknown'), - }), - ); + setError(describe(errorBody(err))); } finally { setBusy(null); clearInput(busyKey); } } + /** + * Uploads, which report progress and so bypass RTK — the vault is refetched + * by hand once the file has landed. + */ + async function send( + busyKey: string, + action: (onProgress: (percent: number) => void) => Promise, + ) { + setBusy(busyKey); + setProgress(0); + setError(null); + const result = await action(setProgress); + if (result.ok) await refetch(); + else setError(describe(result.error)); + setBusy(null); + setProgress(null); + clearInput(busyKey); + } + function handleUpload(slot: PersonalDocumentSlot, file: File | null) { if (!file) return; const rejected = rejectFile(slot, file); @@ -127,8 +151,8 @@ export function PersonalDocumentSlots({ clearInput(slot.key); return; } - return run(slot.key, () => - uploadDocument({ documentKey: slot.key, file }).unwrap(), + return send(slot.key, (onProgress) => + uploadPersonalDocumentFile({ documentKey: slot.key, file, onProgress }), ); } @@ -140,7 +164,9 @@ export function PersonalDocumentSlots({ clearInput(fileId); return; } - return run(fileId, () => replaceFile({ fileId, file }).unwrap()); + return send(fileId, (onProgress) => + replacePersonalDocumentFile({ fileId, file, onProgress }), + ); } async function confirmDelete() { @@ -149,6 +175,11 @@ export function PersonalDocumentSlots({ setDeleteTarget(null); } + /** The bar belongs to the card whose slot, or whose file, is uploading. */ + function isThisSlot(slot: PersonalDocumentSlot, busyKey: string) { + return busyKey === slot.key || slot.files.some((f) => f.id === busyKey); + } + if (isLoading) return ; const slots = data?.slots ?? []; @@ -274,6 +305,15 @@ export function PersonalDocumentSlots({ )} + {busy !== null && progress !== null && isThisSlot(slot, busy) && ( + + + + {t('documents.personal.uploading', { percent: progress })} + + + )} +
diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index dfe67deca..139d0463e 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -1334,6 +1334,7 @@ export const am: Translations = { fileCountUnlimited_one: '{{count}} ፋይል', fileCountUnlimited_other: '{{count}} ፋይሎች', slotFull: 'ይህ ሰነድ ተሟልቷል። ለመቀየር አንዱን ፋይል ይተኩ ወይም ያስወግዱ።', + uploading: 'በመስቀል ላይ… {{percent}}%', delete: 'ፋይል አስወግድ', confirmDelete: { title: 'ይህን ፋይል ያስወግዱ?', diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index 83267dae4..3ed42954d 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -1338,6 +1338,7 @@ export const en = { fileCountUnlimited_one: '{{count}} file', fileCountUnlimited_other: '{{count}} files', slotFull: 'This document is complete. Replace or remove a file to change it.', + uploading: 'Uploading… {{percent}}%', delete: 'Remove file', confirmDelete: { title: 'Remove this file?', diff --git a/libs/api/src/lib/features/personal-document/index.ts b/libs/api/src/lib/features/personal-document/index.ts index 441de9289..19d08eb8f 100644 --- a/libs/api/src/lib/features/personal-document/index.ts +++ b/libs/api/src/lib/features/personal-document/index.ts @@ -1,2 +1,3 @@ export * from './personal-document.types'; +export * from './personal-document.upload'; export * from './personal-document-api'; diff --git a/libs/api/src/lib/features/personal-document/personal-document-api.ts b/libs/api/src/lib/features/personal-document/personal-document-api.ts index 0b3fd210c..80b92faa7 100644 --- a/libs/api/src/lib/features/personal-document/personal-document-api.ts +++ b/libs/api/src/lib/features/personal-document/personal-document-api.ts @@ -9,10 +9,10 @@ const LIST = { type: TAG, id: 'LIST' } as const; * kept against their profile rather than any one application, so they survive * having no seafarer registration yet. * - * Bodies are `FormData`: `fetchBaseQuery` passes one through untouched and - * never sets `Content-Type`, so the browser's multipart boundary survives. - * Going through RTK rather than the raw `uploadDocument` helper is what buys - * the automatic refetch after every write. + * Reads and deletes live here; the two uploads do not. `fetch` — what + * `fetchBaseQuery` runs on — cannot report how much of a request body has gone + * up, so they use XHR instead (`personal-document.upload.ts`) and the page + * refetches this query when one finishes. */ export const personalDocumentApi = baseApi .enhanceEndpoints({ addTagTypes: [TAG] }) @@ -23,33 +23,6 @@ export const personalDocumentApi = baseApi providesTags: () => [LIST], }), - /** One file per call — front and back of an ID are two uploads. */ - uploadPersonalDocument: builder.mutation< - PersonalDocumentSlot, - { documentKey: string; file: File } - >({ - query: ({ documentKey, file }) => { - const body = new FormData(); - body.append('documentKey', documentKey); - body.append('file', file, file.name); - return { url: '/profiles/me/documents', method: 'POST', body }; - }, - invalidatesTags: (_r, error) => (error ? [] : [LIST]), - }), - - /** Swaps one file in place; the slot comes from the file, not the caller. */ - replacePersonalDocumentFile: builder.mutation< - PersonalDocumentSlot, - { fileId: string; file: File } - >({ - query: ({ fileId, file }) => { - const body = new FormData(); - body.append('file', file, file.name); - return { url: `/profiles/me/documents/files/${fileId}`, method: 'PUT', body }; - }, - invalidatesTags: (_r, error) => (error ? [] : [LIST]), - }), - deletePersonalDocumentFile: builder.mutation<{ deleted: boolean }, string>({ query: (fileId) => ({ url: `/profiles/me/documents/files/${fileId}`, @@ -63,7 +36,5 @@ export const personalDocumentApi = baseApi export const { useGetMyPersonalDocumentsQuery, - useUploadPersonalDocumentMutation, - useReplacePersonalDocumentFileMutation, useDeletePersonalDocumentFileMutation, } = personalDocumentApi; diff --git a/libs/api/src/lib/features/personal-document/personal-document.upload.ts b/libs/api/src/lib/features/personal-document/personal-document.upload.ts new file mode 100644 index 000000000..1a95edc10 --- /dev/null +++ b/libs/api/src/lib/features/personal-document/personal-document.upload.ts @@ -0,0 +1,116 @@ +import { resolveTokenFromStorage } from '../../session'; +import type { PersonalDocumentSlot } from './personal-document.types'; + +const BASE_API_URL = + (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? + 'http://localhost:3000/api'; + +/** What the server says when it refuses a file — see ProfileDocumentsService. */ +export interface PersonalDocumentError { + message?: string; + [detail: string]: unknown; +} + +export type PersonalDocumentUploadResult = + | { ok: true; slot: PersonalDocumentSlot } + | { ok: false; error: PersonalDocumentError }; + +/** + * Uploads one file and reports how far it has got. + * + * XHR rather than `fetch`, and therefore outside RTK Query: `fetch` has no + * upload progress event, so a request body of any size is a spinner with + * nothing behind it. That is tolerable for a 5 MB scan and not for the video a + * slot can now be opened to, where the difference between "uploading" and + * "uploading, 12%" is the difference between waiting and reloading the page. + * + * The caller refetches the vault afterwards; nothing here touches the cache. + */ +function upload( + path: string, + method: 'POST' | 'PUT', + body: FormData, + onProgress?: (percent: number) => void, +): Promise { + return new Promise((resolve) => { + const request = new XMLHttpRequest(); + request.open(method, `${BASE_API_URL}${path}`); + + const token = resolveTokenFromStorage(); + if (token) request.setRequestHeader('Authorization', `Bearer ${token}`); + + request.upload.onprogress = (event) => { + // Not every browser knows the total for a streamed body; without it a + // percentage would be invented, so the caller keeps its spinner. + if (!event.lengthComputable || !onProgress) return; + onProgress(Math.round((event.loaded / event.total) * 100)); + }; + + request.onload = () => { + const parsed = parseBody(request.responseText); + if (request.status >= 200 && request.status < 300) { + resolve({ ok: true, slot: parsed as PersonalDocumentSlot }); + return; + } + resolve({ ok: false, error: toError(parsed, request.status) }); + }; + + // A dropped connection and a cancelled request both land here; neither + // carries a server message, so the caller falls back to its own wording. + request.onerror = () => resolve({ ok: false, error: {} }); + request.onabort = () => resolve({ ok: false, error: {} }); + + request.send(body); + }); +} + +function parseBody(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +/** + * Nest wraps a thrown `BadRequestException({ message, ... })` as + * `{ message: { message, ... } }`, and a plain string message as + * `{ message: "slot_full" }`. Both are flattened to the object the UI + * translates by its `message` key. + */ +function toError(parsed: unknown, status: number): PersonalDocumentError { + const message = (parsed as { message?: unknown } | null)?.message; + if (typeof message === 'object' && message !== null) { + return message as PersonalDocumentError; + } + if (typeof message === 'string') return { message }; + return { message: `http_${status}` }; +} + +/** Adds one file to a personal document slot. */ +export function uploadPersonalDocumentFile(params: { + documentKey: string; + file: File; + onProgress?: (percent: number) => void; +}): Promise { + const body = new FormData(); + body.append('documentKey', params.documentKey); + body.append('file', params.file, params.file.name); + return upload('/profiles/me/documents', 'POST', body, params.onProgress); +} + +/** Swaps one file for another in the same slot. */ +export function replacePersonalDocumentFile(params: { + fileId: string; + file: File; + onProgress?: (percent: number) => void; +}): Promise { + const body = new FormData(); + body.append('file', params.file, params.file.name); + return upload( + `/profiles/me/documents/files/${params.fileId}`, + 'PUT', + body, + params.onProgress, + ); +}