feat: add support for global personal document requirements in applicant vaults

This commit is contained in:
estifanos
2026-08-28 08:54:10 +00:00
parent 627fcfbfde
commit f255621672
15 changed files with 780 additions and 171 deletions

View File

@@ -6,6 +6,7 @@ export * from './lib/features/location';
export * from './lib/features/seafarer';
export * from './lib/features/seafarer-registration';
export * from './lib/features/seafarer-document';
export * from './lib/features/personal-document';
export * from './lib/features/vessel';
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download';

View File

@@ -236,7 +236,9 @@ export const licensingApi = baseApi
createDocumentRequirement: builder.mutation<
DocumentRequirement,
Partial<DocumentRequirement> & { licenseTypeId: string; key: string; name: DocumentRequirement['name'] }
// No `licenseTypeId` means a personal document, required for every
// licence and served from the applicant's own vault.
Partial<DocumentRequirement> & { key: string; name: DocumentRequirement['name'] }
>({
query: (body) => ({ url: '/document-requirements', method: 'POST', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),

View File

@@ -252,7 +252,12 @@ export interface StcwCapacityRow {
export interface DocumentRequirement {
id: string;
licenseTypeId: string;
/**
* Null for a personal document — one every applicant keeps in their own
* vault regardless of what they apply for, rather than a slot on one
* licence's application form.
*/
licenseTypeId: string | null;
key: string;
name: Bilingual;
description?: Bilingual;
@@ -263,6 +268,8 @@ export interface DocumentRequirement {
maxSizeMb: number;
requiresValidityDates: boolean;
allowMultiple: boolean;
/** Files the slot accepts: 1, 2 for a front and back, null for unlimited. */
maxFiles: number | null;
sortOrder: number;
isActive: boolean;
}

View File

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

View File

@@ -0,0 +1,69 @@
import { baseApi } from '../../base-api';
import type { PersonalDocumentSlot } from './personal-document.types';
const TAG = 'PersonalDocument' as const;
const LIST = { type: TAG, id: 'LIST' } as const;
/**
* The applicant's own documents — identity card, photograph, education —
* 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.
*/
export const personalDocumentApi = baseApi
.enhanceEndpoints({ addTagTypes: [TAG] })
.injectEndpoints({
endpoints: (builder) => ({
getMyPersonalDocuments: builder.query<{ slots: PersonalDocumentSlot[] }, void>({
query: () => ({ url: '/profiles/me/documents' }),
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}`,
method: 'DELETE',
}),
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
}),
}),
overrideExisting: false,
});
export const {
useGetMyPersonalDocumentsQuery,
useUploadPersonalDocumentMutation,
useReplacePersonalDocumentFileMutation,
useDeletePersonalDocumentFileMutation,
} = personalDocumentApi;

View File

@@ -0,0 +1,22 @@
import type { AttachmentFile, Bilingual } from '../licensing/licensing.types';
/**
* One slot in the applicant's personal document vault, with whatever they
* have put in it.
*
* The slot itself is configuration: a document requirement that names no
* licence type applies to every licence, so the backoffice adds and retires
* these without a release. That is why the label, the accepted types and the
* limits arrive from the API rather than living in the portal.
*/
export interface PersonalDocumentSlot {
key: string;
name: Bilingual;
description: Bilingual | null;
/** How many files the slot holds; null means as many as the holder has. */
maxFiles: number | null;
allowedMimeTypes: string[];
maxSizeMb: number;
sortOrder: number;
files: AttachmentFile[];
}