diff --git a/apps/portal/src/app/features/vessel-transfer/mock.ts b/apps/portal/src/app/features/vessel-transfer/mock.ts index b7d6aacfa..6281c923e 100644 --- a/apps/portal/src/app/features/vessel-transfer/mock.ts +++ b/apps/portal/src/app/features/vessel-transfer/mock.ts @@ -44,6 +44,7 @@ export interface TransferRequest { submitted: string; approvedOn?: string; rejectionReason?: string; + documentFileInfo?: unknown; } export const MOCK_REQUESTS: TransferRequest[] = [ diff --git a/apps/portal/src/app/features/vessel-transfer/pages/VesselTransferApplicationPage.tsx b/apps/portal/src/app/features/vessel-transfer/pages/VesselTransferApplicationPage.tsx index d79fc2abc..1f2ad69b9 100644 --- a/apps/portal/src/app/features/vessel-transfer/pages/VesselTransferApplicationPage.tsx +++ b/apps/portal/src/app/features/vessel-transfer/pages/VesselTransferApplicationPage.tsx @@ -18,6 +18,7 @@ import { } from '@mantine/core'; import { IconArrowLeft, IconArrowRight, IconCheck, IconInfoCircle, IconUpload } from '@tabler/icons-react'; import { notify } from '@ema-platform/ui'; +import { isStorageUploadError, useDocumentUpload } from '@ema-platform/api'; import { addTransferRequest, MOCK_VESSELS, TRANSFER_REASONS } from '../mock'; import { useAppSelector } from '../../../store/hooks'; @@ -45,22 +46,31 @@ export function VesselTransferApplicationPage() { const [reason, setReason] = useState(null); const [document, setDocument] = useState(null); + const { upload, isUploading } = useDocumentUpload(); + const step0Ok = !!vessel; const step1Ok = !!newOwnerName && !!newOwnerIdOrTin && !!newOwnerPhone && EMAIL_RE.test(newOwnerEmail) && !!newOwnerAddress; const step2Ok = !!reason && !!document; - const handleSubmit = () => { - if (!vessel || !reason) return; - addTransferRequest({ - vesselName: vessel.name, - vesselRegNo: vessel.regNo, - currentOwner, - newOwnerName, - reason: reason as (typeof TRANSFER_REASONS)[number], - }); - notify.success('Transfer request submitted. SMS and email confirmation sent.'); - navigate('/vessel-transfers'); + const handleSubmit = async () => { + if (!vessel || !reason || !document) return; + try { + const { fileInfo } = await upload(document, '/documents/get-file-upload-key'); + addTransferRequest({ + vesselName: vessel.name, + vesselRegNo: vessel.regNo, + currentOwner, + newOwnerName, + reason: reason as (typeof TRANSFER_REASONS)[number], + documentFileInfo: fileInfo, + }); + notify.success('Transfer request submitted. SMS and email confirmation sent.'); + navigate('/vessel-transfers'); + } catch (error) { + const tooLarge = isStorageUploadError(error) && error.message === 'STORAGE_UPLOAD_TOO_LARGE'; + notify.error(tooLarge ? 'File is too large to upload.' : 'Failed to upload document. Please try again.'); + } }; return ( @@ -189,7 +199,7 @@ export function VesselTransferApplicationPage() { Next ) : ( - )} diff --git a/libs/api/src/index.ts b/libs/api/src/index.ts index b24880dd7..ff1fd4761 100644 --- a/libs/api/src/index.ts +++ b/libs/api/src/index.ts @@ -1,4 +1,5 @@ export * from './lib/base-api'; export * from './lib/query-and-mutation'; export * from './lib/session'; +export * from './lib/file-upload'; export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth'; diff --git a/libs/api/src/lib/file-upload/index.ts b/libs/api/src/lib/file-upload/index.ts new file mode 100644 index 000000000..ff7b2c2be --- /dev/null +++ b/libs/api/src/lib/file-upload/index.ts @@ -0,0 +1,87 @@ +import { useCallback, useState } from 'react'; +import { baseApi } from '../base-api'; +import { resolveTokenFromStorage } from '../session'; + +export interface UploadKeyResponse { + presigned: string; + fileInfo: TFileInfo; +} + +const fileUploadApi = baseApi.injectEndpoints({ + endpoints: (builder) => ({ + getFileUploadKey: builder.mutation({ + query: ({ endpoint, file }) => ({ + url: endpoint, + method: 'POST', + body: { + fileName: file.name, + contentType: file.type || 'application/octet-stream', + size: file.size, + originalname: file.name, + }, + }), + }), + }), + overrideExisting: false, +}); + +export const { useGetFileUploadKeyMutation } = fileUploadApi; + +export const STORAGE_UPLOAD_ERROR = 'STORAGE_UPLOAD_ERROR'; +export const STORAGE_UPLOAD_TOO_LARGE = 'STORAGE_UPLOAD_TOO_LARGE'; + +export function isStorageUploadError( + error: unknown, +): error is Error & { message: typeof STORAGE_UPLOAD_ERROR | typeof STORAGE_UPLOAD_TOO_LARGE } { + return ( + error instanceof Error && + (error.message === STORAGE_UPLOAD_ERROR || error.message === STORAGE_UPLOAD_TOO_LARGE) + ); +} + +// Bare fetch on purpose: this PUT targets the presigned storage URL directly, not +// VITE_BASE_API_URL, so it must not go through baseApi/fetchBaseQuery. +async function uploadToPresigned(file: File, presignedUrl: string): Promise { + if (!presignedUrl) throw new Error(STORAGE_UPLOAD_ERROR); + + const token = resolveTokenFromStorage(); + const res = await fetch(presignedUrl, { + method: 'PUT', + body: file, + headers: { + 'Content-Type': file.type || 'application/octet-stream', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + + if (res.status === 413) throw new Error(STORAGE_UPLOAD_TOO_LARGE); + if (!res.ok) throw new Error(STORAGE_UPLOAD_ERROR); +} + +export function useDocumentUpload() { + const [getFileUploadKey, mutationState] = useGetFileUploadKeyMutation(); + // Spans both steps (key request + presigned PUT) — mutationState.isLoading alone + // would drop to false once the key request resolves, before the file PUT finishes. + const [isUploading, setIsUploading] = useState(false); + + const upload = useCallback( + async (file: File, endpoint: string): Promise> => { + setIsUploading(true); + try { + const { presigned, fileInfo } = await getFileUploadKey({ endpoint, file }).unwrap(); + await uploadToPresigned(file, presigned); + return { presigned, fileInfo: fileInfo as TFileInfo }; + } finally { + setIsUploading(false); + } + }, + [getFileUploadKey], + ); + + return { + upload, + isUploading, + error: mutationState.error, + reset: mutationState.reset, + }; +}