Merge pull request #43 from Tria-plc/estif-branch-1

merge from documentVault
This commit is contained in:
Nati Nigussie
2026-08-31 10:23:56 +03:00
committed by GitHub
22 changed files with 2143 additions and 90 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/biometric-enrollment';
export * from './lib/features/vessel';
export { BASE_API_URL, baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';

View File

@@ -41,6 +41,8 @@ import type {
TemplateLogoPlacement,
TemplatePageOptions,
TemplateVariable,
PersonalDocumentFilter,
PersonalDocumentGroup,
} from './licensing.types';
/**
@@ -67,6 +69,16 @@ function serialiseQueueFilter(
return params;
}
/** Sends only the facets that are set; `search=` would match nothing. */
function dropEmpty(filter: object): Record<string, unknown> {
const params: Record<string, unknown> = {};
for (const [key, value] of Object.entries(filter)) {
if (value === undefined || value === null || value === '' || value === false) continue;
params[key] = value;
}
return params;
}
const TAGS = [
'LicenseType',
'OperatorType',
@@ -83,6 +95,9 @@ const TAGS = [
'PickupAppointment',
'Department',
'Rank',
// Owned by the personal-document slice; named here so declaring a mode of
// operation can invalidate the vault, whose slots depend on it.
'PersonalDocument',
] as const;
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
@@ -127,9 +142,17 @@ export const licensingApi = baseApi
method: 'PUT',
body,
}),
// The catalogue is filtered by this, so it has to refetch too.
// The catalogue is filtered by this, so it has to refetch too — and so
// is the personal document vault, which asks for the documents the
// declared modes of operation need.
invalidatesTags: (_r, error) =>
error ? [] : [listTag('OperatorType'), listTag('LicenseType')],
error
? []
: [
listTag('OperatorType'),
listTag('LicenseType'),
listTag('PersonalDocument'),
],
}),
/**
@@ -240,9 +263,30 @@ export const licensingApi = baseApi
providesTags: () => [listTag('DocumentRequirement')],
}),
/**
* Personal document slots, grouped by key and paged by the server.
*
* Its own endpoint rather than filtering `getDocumentRequirements` in the
* browser: one document can be configured against several licence types,
* so a page of rows would split a document in half and misreport its
* scope. The server groups first, then pages.
*/
getPersonalDocuments: builder.query<
Paginated<PersonalDocumentGroup>,
PersonalDocumentFilter | void
>({
query: (filter) => ({
url: '/document-requirements/personal',
params: dropEmpty(filter ?? {}),
}),
providesTags: () => [listTag('DocumentRequirement')],
}),
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')]),
@@ -1205,6 +1249,7 @@ export const {
useValidateFormSchemaMutation,
useGetFormSchemaPaletteQuery,
useGetDocumentRequirementsQuery,
useGetPersonalDocumentsQuery,
useCreateDocumentRequirementMutation,
useUpdateDocumentRequirementMutation,
useDeleteDocumentRequirementMutation,

View File

@@ -264,7 +264,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;
@@ -275,6 +280,14 @@ export interface DocumentRequirement {
maxSizeMb: number;
requiresValidityDates: boolean;
allowMultiple: boolean;
/**
* True for a personal document — one the applicant keeps in their own vault
* — rather than an upload slot on an application form. Orthogonal to
* `licenseTypeId`, which still says which licences it applies to.
*/
isPersonal: boolean;
/** Files the slot accepts: 1, 2 for a front and back, null for unlimited. */
maxFiles: number | null;
sortOrder: number;
isActive: boolean;
}
@@ -815,6 +828,31 @@ export interface IssuedLicense {
certificateFileKey: string | null;
}
/**
* One personal document as the backoffice manages it: every configured row
* sharing a key, which is one slot in the applicant's vault. Several rows mean
* the document is scoped to several licence types.
*/
export interface PersonalDocumentGroup {
key: string;
rows: DocumentRequirement[];
}
export interface PersonalDocumentFilter {
/** Matches the key and the name in either locale. */
search?: string;
/** A licence type also matches the documents every licence asks for. */
licenseTypeId?: string;
/** Narrows to the documents configured against no licence type at all. */
globalOnly?: boolean;
sortBy?: 'sortOrder' | 'key' | 'name';
sortDir?: 'ASC' | 'DESC';
take?: number;
skip?: number;
/** Which locale `sortBy: "name"` sorts on. */
locale?: 'en' | 'am';
}
/** One sitting offered to a claimed examined-certificate application, scoped to its rank. */
export interface EligibleExam {
id: string;

View File

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

View File

@@ -0,0 +1,40 @@
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.
*
* 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] })
.injectEndpoints({
endpoints: (builder) => ({
getMyPersonalDocuments: builder.query<{ slots: PersonalDocumentSlot[] }, void>({
query: () => ({ url: '/profiles/me/documents' }),
providesTags: () => [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,
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[];
}

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,
);
}

View File

@@ -2,6 +2,7 @@ export * from "./lib/input/BilingualInput";
export * from "./lib/input/AmharicDatePicker";
export * from "./lib/feedback/ConfirmModal";
export * from "./lib/feedback/PdfPreviewModal";
export * from "./lib/feedback/FilePreviewModal";
export * from "./lib/feedback/ModalFooter";
export * from "./lib/feedback/ApiErrorAlert";
export * from "./lib/feedback/notify";

View File

@@ -0,0 +1,169 @@
import { Anchor, Button, Group, Modal, Stack, Text, ThemeIcon } from '@mantine/core';
import { IconExternalLink, IconFileUnknown } from '@tabler/icons-react';
/** How a file is shown, once its type is known. */
type PreviewKind = 'image' | 'video' | 'audio' | 'embed' | 'unsupported';
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'svg'];
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'ogv', 'mov', 'm4v'];
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'm4a'];
const EMBED_EXTENSIONS = ['pdf', 'txt', 'csv', 'json', 'xml'];
/**
* What the browser can actually render, decided from the mime type where there
* is one and the URL's extension where there is not.
*
* Presigned links carry the storage key in the path, so the extension survives
* even when the caller only has a URL. `image/tiff` and `image/heic` are
* deliberately treated as images: Safari renders both, and everywhere else the
* `<img>` fails visibly rather than an iframe offering a silent download.
*/
export function resolvePreviewKind(url: string, mimeType?: string | null): PreviewKind {
const mime = mimeType?.toLowerCase() ?? '';
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('video/')) return 'video';
if (mime.startsWith('audio/')) return 'audio';
if (mime === 'application/pdf' || mime.startsWith('text/')) return 'embed';
// Word, Excel and the rest: nothing renders them inline, and an iframe would
// quietly start a download instead of previewing anything.
if (mime) return 'unsupported';
const extension = extensionOf(url);
if (!extension) return 'embed';
if (IMAGE_EXTENSIONS.includes(extension)) return 'image';
if (VIDEO_EXTENSIONS.includes(extension)) return 'video';
if (AUDIO_EXTENSIONS.includes(extension)) return 'audio';
if (EMBED_EXTENSIONS.includes(extension)) return 'embed';
return 'unsupported';
}
function extensionOf(url: string): string | null {
// Presigned URLs carry a query string; the path is the part with the name.
const path = url.split(/[?#]/)[0];
const name = path.slice(path.lastIndexOf('/') + 1);
const dot = name.lastIndexOf('.');
return dot > 0 ? name.slice(dot + 1).toLowerCase() : null;
}
/**
* The one place a stored file gets opened anywhere in the app.
*
* Never `window.open` / `target="_blank"` a file that can be shown here —
* route it through this modal so the reviewer never loses their place to a new
* tab. What a slot accepts is configuration now, so this had to grow past the
* PDF it started as: a national ID arrives as a photograph, evidence arrives
* as video, and an academic record sometimes arrives as the Word file its
* institution issued. The last of those genuinely cannot be rendered by a
* browser, so it gets an honest panel and a link out rather than an iframe
* that silently downloads it.
*/
export function FilePreviewModal({
opened,
onClose,
url,
title = 'Document',
mimeType,
/** Overrides the detected kind — for a blob URL with no extension. */
kind,
labels,
}: {
opened: boolean;
onClose: () => void;
url: string;
title?: string;
mimeType?: string | null;
kind?: PreviewKind;
/** Supplied by the app so this stays out of the i18n bundles. */
labels?: { unsupported?: string; openInNewTab?: string; close?: string };
}) {
const resolved = kind ?? resolvePreviewKind(url, mimeType);
return (
<Modal
opened={opened}
onClose={onClose}
title={title}
size="80%"
centered
trapFocus
returnFocus
styles={
resolved === 'image' || resolved === 'video'
? // A photograph on a white sheet loses its own edges; the dark mat
// is what tells the eye where the file ends.
{ body: { background: 'var(--mantine-color-dark-8)', padding: 0 } }
: undefined
}
>
{url && resolved === 'image' && (
<img
src={url}
alt={title}
style={{
display: 'block',
margin: '0 auto',
maxWidth: '100%',
maxHeight: '85vh',
objectFit: 'contain',
}}
/>
)}
{url && resolved === 'video' && (
// Controls only, no autoplay: a review screen that starts making noise
// on open is a review screen people mute and then miss the audio on.
<video
src={url}
controls
preload="metadata"
style={{ display: 'block', width: '100%', maxHeight: '85vh' }}
>
<track kind="captions" />
</video>
)}
{url && resolved === 'audio' && (
<Stack p="md">
<audio src={url} controls style={{ width: '100%' }}>
<track kind="captions" />
</audio>
</Stack>
)}
{url && resolved === 'embed' && (
<iframe
src={url}
title={title}
style={{ width: '100%', height: '85vh', border: 'none' }}
/>
)}
{url && resolved === 'unsupported' && (
<Stack align="center" gap="sm" py="xl">
<ThemeIcon size={56} radius="xl" variant="light" color="gray">
<IconFileUnknown size={28} stroke={1.5} />
</ThemeIcon>
<Text fz="sm" c="dimmed" ta="center" maw={420}>
{labels?.unsupported ??
'This file type cannot be shown here. Open it in a new tab to download it.'}
</Text>
<Group>
<Button
component="a"
href={url}
target="_blank"
rel="noopener noreferrer"
variant="light"
leftSection={<IconExternalLink size={15} />}
>
{labels?.openInNewTab ?? 'Open in a new tab'}
</Button>
<Anchor component="button" type="button" fz="sm" onClick={onClose}>
{labels?.close ?? 'Close'}
</Anchor>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -1,4 +1,4 @@
import { Modal } from '@mantine/core';
import { FilePreviewModal } from './FilePreviewModal';
interface PdfPreviewModalProps {
opened: boolean;
@@ -8,9 +8,14 @@ interface PdfPreviewModalProps {
}
/**
* The one place a PDF gets opened anywhere in the app. Never `window.open` /
* `target="_blank"` a PDF directly — route it through this modal instead, so
* the reviewer never loses their place to a new tab.
* A PDF viewer, kept as its own name because most callers only ever open a
* PDF and say so at the call site.
*
* The rendering lives in {@link FilePreviewModal}, which also handles images,
* video and the file types no browser can show. Callers that know the mime
* type should use that directly; the ones here pass a URL alone and get the
* same iframe they always had, since a link with no `.something` on the end
* resolves to the embed view.
*/
export function PdfPreviewModal({
opened,
@@ -19,22 +24,6 @@ export function PdfPreviewModal({
title = 'Document',
}: PdfPreviewModalProps) {
return (
<Modal
opened={opened}
onClose={onClose}
title={title}
size="80%"
centered
trapFocus
returnFocus
>
{url && (
<iframe
src={url}
title={title}
style={{ width: '100%', height: '85vh', border: 'none' }}
/>
)}
</Modal>
<FilePreviewModal opened={opened} onClose={onClose} url={url} title={title} />
);
}