feat: implement XHR-based file upload with progress tracking to replace RTK-based mutations for personal documents

This commit is contained in:
estifanos
2026-08-28 10:42:04 +00:00
parent bd220a8fb2
commit 3368c9bd3f
6 changed files with 183 additions and 53 deletions

View File

@@ -1,2 +1,3 @@
export * from './personal-document.types';
export * from './personal-document.upload';
export * from './personal-document-api';

View File

@@ -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;

View File

@@ -0,0 +1,116 @@
import { resolveTokenFromStorage } from '../../session';
import type { PersonalDocumentSlot } from './personal-document.types';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).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<PersonalDocumentUploadResult> {
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<PersonalDocumentUploadResult> {
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<PersonalDocumentUploadResult> {
const body = new FormData();
body.append('file', params.file, params.file.name);
return upload(
`/profiles/me/documents/files/${params.fileId}`,
'PUT',
body,
params.onProgress,
);
}