diff --git a/src/app/(companies)/companies/details/[id]/page.tsx b/src/app/(companies)/companies/details/[id]/page.tsx index 3b39f1a..28565b9 100644 --- a/src/app/(companies)/companies/details/[id]/page.tsx +++ b/src/app/(companies)/companies/details/[id]/page.tsx @@ -1,6 +1,7 @@ "use client"; import Loading from "@/components/loading"; import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb"; +import DocumentItem from "@/components/forms/companies/documents/DocumentItem"; import { ShowcaseSection } from "@/components/Layouts/showcase-section"; import Status from "@/components/ui/Status"; import { useCompanyDetails } from "@/hooks/queries"; @@ -72,6 +73,9 @@ const CompanyDetailsPage = ({ params }: IProps) => { if (isLoading) return ; const company = data.data; const website = company.website?.trim(); + const documentFileIds: string[] = Array.isArray(company.document_file_ids) + ? company.document_file_ids + : []; return ( <> @@ -215,6 +219,17 @@ const CompanyDetailsPage = ({ params }: IProps) => { )} + + {documentFileIds.length > 0 && ( +
+ Documents +
+ {documentFileIds.map((fileId) => ( + + ))} +
+
+ )} diff --git a/src/components/forms/companies/CreateCompany.tsx b/src/components/forms/companies/CreateCompany.tsx index 459d6dc..fbb3575 100644 --- a/src/components/forms/companies/CreateCompany.tsx +++ b/src/components/forms/companies/CreateCompany.tsx @@ -10,6 +10,7 @@ import FormFooter from "@/components/ui/FormFooter"; import { REGEX } from "@/constants/regex"; import { FormErrorMessages } from "@/constants/Texts"; import { ICreateCompanyCredentials } from "@/lib/api/types/Company"; +import CompanyDocuments from "./documents/CompanyDocuments"; import useCreateCompanyPresenter from "./useCreateCompanyPresenter"; interface IProps { @@ -18,6 +19,12 @@ interface IProps { id?: string; } +/** Inline error for nested fields whose dotted name can't use the inputs' built-in lookup. */ +const FieldError = ({ message }: { message?: string }) => + message ? ( +

{message}

+ ) : null; + const CreateCompany = ({ defaultValues, editMode, id }: IProps) => { const { errors, @@ -32,37 +39,41 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => { defaultValues, id, }); + return ( -
-
+ +
+

+ {editMode ? "Edit company" : "New company"} +

+

+ Profile, business details, address, tags, and supporting documents. +

+
+ +
- {errors.name && ( -

{errors.name.message}

- )} { message: FormErrorMessages.invalidEmail, }, })} + errors={errors} label="Email" name="email" required type="email" placeholder="name@company.com" /> - {errors.email && ( -

{errors.email.message}

- )} { message: FormErrorMessages.invalidPattern, }, })} + errors={errors} label="Phone" name="phone" required type="tel" placeholder="+971 50 123 4567" /> - {errors.phone && ( -

{errors.phone.message}

- )} { defaultValue="en" prefixIcon={} /> - {errors.language && ( -

- {errors.language.message} -

- )} + + + {isUploading ? ( +
+

+ Uploading documents… {companyId ? "" : `${uploadProgress}%`} +

+
+
+
+
+ ) : null} +
+ + {errorMessage ? ( +

{errorMessage}

+ ) : null} + + {value.length > 0 ? ( +
+ {value.map((fileId) => ( + + ))} +
+ ) : ( +

No documents attached yet.

+ )} + + {failed.length > 0 ? ( +
+

+ Failed uploads +

+
    + {failed.map((item, index) => ( +
  • + {item.filename}: {item.error} +
  • + ))} +
+
+ ) : null} +
+ ); +}; + +export default CompanyDocuments; diff --git a/src/components/forms/companies/documents/DocumentItem.tsx b/src/components/forms/companies/documents/DocumentItem.tsx new file mode 100644 index 0000000..2520230 --- /dev/null +++ b/src/components/forms/companies/documents/DocumentItem.tsx @@ -0,0 +1,62 @@ +"use client"; +import { formatFileSize } from "@/utils/shared"; +import useDocumentItemPresenter from "./useDocumentItemPresenter"; + +interface IProps { + fileId: string; + /** When provided, a remove button is shown and invoked with the file id. */ + onRemove?: (fileId: string) => void; + disabled?: boolean; +} + +const DocumentItem = ({ fileId, onRemove, disabled }: IProps) => { + const { info, isLoading, isDownloading, download } = + useDocumentItemPresenter(fileId); + + return ( +
+
+ + + +
+ +
+

+ {isLoading ? "Loading…" : info?.filename || "Document"} +

+ {info?.size ? ( +

{formatFileSize(info.size)}

+ ) : null} +
+ +
+ + {onRemove ? ( + + ) : null} +
+
+ ); +}; + +export default DocumentItem; diff --git a/src/components/forms/companies/documents/useCompanyDocumentsPresenter.ts b/src/components/forms/companies/documents/useCompanyDocumentsPresenter.ts new file mode 100644 index 0000000..ddf036d --- /dev/null +++ b/src/components/forms/companies/documents/useCompanyDocumentsPresenter.ts @@ -0,0 +1,121 @@ +"use client"; +import { useUploadCompanyDocuments } from "@/hooks/queries"; +import { fileService, FailedFile } from "@/lib/api"; +import { + MAX_DOCUMENT_FILES, + validateDocumentFile, +} from "@/utils/shared"; +import { ChangeEvent, useState } from "react"; + +interface IProps { + /** Present in edit mode (company exists); drives the upload strategy. */ + companyId?: string; + /** Current document file ids held by the form. */ + value: string[]; + /** Persists the updated document file id list back to the form. */ + onChange: (ids: string[]) => void; +} + +/** + * Owns the multi-file document upload lifecycle for the company form. + * + * - Edit mode (companyId set): uploads via `POST /companies/:id/documents`, which + * appends + links files server-side and reports per-file failures. + * - Create mode (no company yet): uploads via `POST /files/upload/batch` and + * appends the returned ids to the form so they're sent with the create call. + */ +const useCompanyDocumentsPresenter = ({ + companyId, + value, + onChange, +}: IProps) => { + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [failed, setFailed] = useState([]); + const [errorMessage, setErrorMessage] = useState(); + + const { mutateAsync: uploadToCompany } = useUploadCompanyDocuments( + companyId ?? "", + ); + + const validateSelection = (files: File[]): string | null => { + if (value.length + files.length > MAX_DOCUMENT_FILES) { + return `You can attach at most ${MAX_DOCUMENT_FILES} documents.`; + } + for (const file of files) { + const error = validateDocumentFile({ file }); + if (error) return `${file.name}: ${error}`; + } + return null; + }; + + const uploadFiles = async (files: File[]) => { + if (companyId) { + const response = await uploadToCompany(files); + onChange(response.data.document_file_ids ?? value); + return response.data.failed ?? []; + } + + const result = await fileService.uploadBatch({ + files, + category: "company_document", + onProgress: setUploadProgress, + }); + const newIds = (result.uploaded ?? []).map((item) => item.file_id); + onChange([...value, ...newIds]); + return result.failed ?? []; + }; + + const onSelectFiles = async (event: ChangeEvent) => { + const input = event.target; + const files = input.files ? Array.from(input.files) : []; + // Reset so selecting the same file again re-triggers change. + input.value = ""; + if (!files.length) return; + + const validationError = validateSelection(files); + if (validationError) { + setErrorMessage(validationError); + return; + } + + setErrorMessage(undefined); + setFailed([]); + setIsUploading(true); + setUploadProgress(0); + + try { + setFailed(await uploadFiles(files)); + } catch (error) { + console.error("ERROR caught while uploading company documents:", error); + setErrorMessage("Failed to upload documents. Please try again."); + } finally { + setIsUploading(false); + } + }; + + const onRemove = async (fileId: string) => { + onChange(value.filter((id) => id !== fileId)); + // In create mode the file is an orphan in storage (not linked to any + // company), so clean it up. In edit mode the removal is persisted by the + // form's update (PUT replaces the full document list). + if (!companyId) { + try { + await fileService.deleteFile(fileId); + } catch (error) { + console.error("ERROR caught while deleting orphan document:", error); + } + } + }; + + return { + isUploading, + uploadProgress, + failed, + errorMessage, + onSelectFiles, + onRemove, + }; +}; + +export default useCompanyDocumentsPresenter; diff --git a/src/components/forms/companies/documents/useDocumentItemPresenter.ts b/src/components/forms/companies/documents/useDocumentItemPresenter.ts new file mode 100644 index 0000000..4e749d7 --- /dev/null +++ b/src/components/forms/companies/documents/useDocumentItemPresenter.ts @@ -0,0 +1,30 @@ +"use client"; +import { useFileInfo } from "@/hooks/queries"; +import { fileService } from "@/lib/api"; +import { saveBlobFromAxiosResponse } from "@/utils/shared"; +import { useState } from "react"; + +/** + * Loads metadata for a single document file id and exposes a download action. + * Keeps the `DocumentItem` view free of data-fetching / side effects. + */ +const useDocumentItemPresenter = (fileId: string) => { + const { data: info, isLoading } = useFileInfo(fileId); + const [isDownloading, setIsDownloading] = useState(false); + + const download = async () => { + setIsDownloading(true); + try { + const response = await fileService.downloadFile(fileId); + saveBlobFromAxiosResponse(response, info?.filename ?? fileId); + } catch (error) { + console.error("ERROR caught while downloading document:", error); + } finally { + setIsDownloading(false); + } + }; + + return { info, isLoading, isDownloading, download }; +}; + +export default useDocumentItemPresenter; diff --git a/src/components/forms/companies/useCreateCompanyPresenter.ts b/src/components/forms/companies/useCreateCompanyPresenter.ts index 5de2201..1bcc8d6 100644 --- a/src/components/forms/companies/useCreateCompanyPresenter.ts +++ b/src/components/forms/companies/useCreateCompanyPresenter.ts @@ -52,6 +52,7 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => { founded_year: data.founded_year ? +data.founded_year : 0, employee_count: data.employee_count ? +data.employee_count : 0, annual_revenue: data.annual_revenue ? +data.annual_revenue : 0, + document_file_ids: data.document_file_ids ?? [], tags: { keywords: data?.tags?.keywords?.length ? data?.tags?.keywords : [], categories: data?.tags?.categories?.length diff --git a/src/hooks/queries/index.ts b/src/hooks/queries/index.ts index ab221cc..c09b184 100644 --- a/src/hooks/queries/index.ts +++ b/src/hooks/queries/index.ts @@ -16,3 +16,4 @@ export * from "./useUsersQueries"; export * from "./useAdminListQuery"; export * from "./useSelectSearchQuery"; export * from "./useDashboardQueries"; +export * from "./useFilesQueries"; diff --git a/src/hooks/queries/useCompaniesQueries.ts b/src/hooks/queries/useCompaniesQueries.ts index 1192d4e..5ab1706 100644 --- a/src/hooks/queries/useCompaniesQueries.ts +++ b/src/hooks/queries/useCompaniesQueries.ts @@ -64,6 +64,24 @@ export const useCompanyDetails = (id: string) => { queryFn: () => companiesService.companyDetails(id), }); }; +export const useUploadCompanyDocuments = (id: string) => { + const queryClient = useQueryClient(); + const mutationKey = useMemo( + () => [API_ENDPOINTS.COMPANIES.DOCUMENTS(id), "upload-documents"], + [id], + ); + return useMutation({ + mutationKey, + mutationFn: (files: File[]) => + companiesService.uploadDocuments({ id, files }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL, id, "details"], + }); + }, + }); +}; + export const useDeleteCompanyQuery = (successCallback?: () => void) => { const queryClient = useQueryClient(); const mutationKey = useMemo( diff --git a/src/hooks/queries/useFilesQueries.ts b/src/hooks/queries/useFilesQueries.ts new file mode 100644 index 0000000..b477f48 --- /dev/null +++ b/src/hooks/queries/useFilesQueries.ts @@ -0,0 +1,14 @@ +import { API_ENDPOINTS, fileService } from "@/lib/api"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; + +/** Fetches (and caches) metadata for a single stored file. */ +export const useFileInfo = (fileId: string, options?: { enabled?: boolean }) => { + const queryKey = useMemo(() => [API_ENDPOINTS.FILES.INFO(fileId)], [fileId]); + return useQuery({ + queryKey, + queryFn: () => fileService.getFileInfo(fileId), + enabled: (options?.enabled ?? true) && Boolean(fileId), + staleTime: 5 * 60 * 1000, + }); +}; diff --git a/src/lib/api/endpoints.ts b/src/lib/api/endpoints.ts index fc7bca4..e7be7bd 100644 --- a/src/lib/api/endpoints.ts +++ b/src/lib/api/endpoints.ts @@ -17,7 +17,10 @@ export const API_ENDPOINTS = { }, FILES: { UPLOAD: "files/upload", + UPLOAD_BATCH: "files/upload/batch", DOWNLOAD: (fileId: string) => `files/${fileId}/download`, + INFO: (fileId: string) => `files/${fileId}/info`, + DELETE: (fileId: string) => `files/${fileId}`, }, PROFILE: { READ: "profile", @@ -29,6 +32,7 @@ export const API_ENDPOINTS = { UPDATE: (id: string) => `companies/${id}`, DELETE: (id: string) => `companies/${id}`, DETAILS: (id: string) => `companies/${id}`, + DOCUMENTS: (id: string) => `companies/${id}/documents`, CATEGORIES: { READ_ALL: "company-categories", CREATE: "company-categories", diff --git a/src/lib/api/services/companies-service.ts b/src/lib/api/services/companies-service.ts index 12a35b8..84f6bbf 100644 --- a/src/lib/api/services/companies-service.ts +++ b/src/lib/api/services/companies-service.ts @@ -6,6 +6,7 @@ import { TCompaniesResponse, TCompanyCategoryApiResponse, TCreateCompanyCategoryCredentials, + UploadDocumentsData, } from "../types"; import { ICreateCompanyCredentials } from "../types"; @@ -51,6 +52,45 @@ export const companiesService = { const response = await api.get(API_ENDPOINTS.COMPANIES.DETAILS(id)); return response.data; }, + + /** + * Uploads documents and links them to a company in one step. New file IDs are + * appended to the company's existing `document_file_ids` (server-side), and + * any per-file failures are returned in `data.failed`. + */ + uploadDocuments: async ({ + id, + files, + onProgress, + }: { + id: string; + files: File[]; + onProgress?: (percent: number) => void; + }): Promise> => { + try { + const formData = new FormData(); + files.forEach((file) => formData.append("files", file, file.name)); + + const response = await api.post( + API_ENDPOINTS.COMPANIES.DOCUMENTS(id), + formData, + { + headers: { "Content-Type": "multipart/form-data" }, + onUploadProgress: (progressEvent) => { + if (!progressEvent.total) return; + onProgress?.( + Math.round((progressEvent.loaded * 100) / progressEvent.total), + ); + }, + }, + ); + + return response.data; + } catch (error) { + console.error("ERROR caught in Companies Services Upload Documents:", error); + throw error; + } + }, deleteCompany: async (id: string) => { try { return (await api.delete(API_ENDPOINTS.COMPANIES.DELETE(id))).data; diff --git a/src/lib/api/services/file-service.ts b/src/lib/api/services/file-service.ts index 4d5398a..a2557fe 100644 --- a/src/lib/api/services/file-service.ts +++ b/src/lib/api/services/file-service.ts @@ -1,6 +1,7 @@ import api from "../axios"; import { buildFileDownloadUrl } from "../config"; import { API_ENDPOINTS } from "../endpoints"; +import { BatchUploadResult, FileInfo } from "../types"; interface IUploadFileParams { file: File; @@ -10,6 +11,23 @@ interface IUploadFileParams { onProgress?: (percent: number) => void; } +interface IUploadBatchParams { + files: File[]; + category?: string; + tags?: string[]; + description?: string; + onProgress?: (percent: number) => void; +} + +const trackUploadProgress = + (onProgress?: (percent: number) => void) => + (progressEvent: { loaded: number; total?: number }) => { + if (!progressEvent.total) return; + onProgress?.( + Math.round((progressEvent.loaded * 100) / progressEvent.total), + ); + }; + export const fileService = { /** * Uploads a binary file to the shared filestore endpoint and returns the @@ -36,13 +54,7 @@ export const fileService = { headers: { "Content-Type": "multipart/form-data", }, - onUploadProgress: (progressEvent) => { - if (!progressEvent.total) return; - const percent = Math.round( - (progressEvent.loaded * 100) / progressEvent.total, - ); - onProgress?.(percent); - }, + onUploadProgress: trackUploadProgress(onProgress), }); const fileId = response?.data?.file_id as string | undefined; @@ -56,4 +68,72 @@ export const fileService = { throw error; } }, + + /** + * Uploads multiple files in one request. Returns the batch body directly + * (this endpoint is not wrapped in the `success`/`data` envelope). + */ + uploadBatch: async ({ + files, + category, + tags = [], + description, + onProgress, + }: IUploadBatchParams): Promise => { + try { + const formData = new FormData(); + files.forEach((file) => formData.append("files", file, file.name)); + if (category) formData.append("category", category); + tags.forEach((tag) => formData.append("tags", tag)); + if (description) formData.append("description", description); + + const response = await api.post( + API_ENDPOINTS.FILES.UPLOAD_BATCH, + formData, + { + headers: { "Content-Type": "multipart/form-data" }, + onUploadProgress: trackUploadProgress(onProgress), + }, + ); + + return response.data as BatchUploadResult; + } catch (error) { + console.error("ERROR caught in File Service Upload Batch", error); + throw error; + } + }, + + /** Normalized metadata for a stored file, tolerant of envelope/key variations. */ + getFileInfo: async (fileId: string): Promise => { + try { + const response = await api.get(API_ENDPOINTS.FILES.INFO(fileId)); + const info = response.data?.data ?? response.data ?? {}; + + return { + file_id: fileId, + filename: info.filename ?? info.name ?? "Document", + content_type: info.content_type ?? info.contentType ?? "", + size: Number(info.size ?? info.length ?? 0), + }; + } catch (error) { + console.error("ERROR caught in File Service Get File Info", error); + throw error; + } + }, + + /** Downloads a stored file as a blob (caller saves it). */ + downloadFile: (fileId: string) => + api.get(API_ENDPOINTS.FILES.DOWNLOAD(fileId), { + responseType: "blob", + }), + + /** Permanently removes a file from storage. */ + deleteFile: async (fileId: string) => { + try { + return (await api.delete(API_ENDPOINTS.FILES.DELETE(fileId))).data; + } catch (error) { + console.error("ERROR caught in File Service Delete File", error); + throw error; + } + }, }; diff --git a/src/lib/api/types/Company.ts b/src/lib/api/types/Company.ts index 59c490e..b16e44f 100644 --- a/src/lib/api/types/Company.ts +++ b/src/lib/api/types/Company.ts @@ -47,6 +47,7 @@ export interface ICreateCompanyCredentials { address: IAddress; contact_person: IContactPerson; tags: ITags; + document_file_ids?: string[]; } export const companySchema = z.object({ @@ -90,6 +91,7 @@ export const companySchema = z.object({ certifications: z.array(z.string()).optional(), cpv_codes: z.array(z.string()).optional(), }), + document_file_ids: z.array(z.string()).optional(), }); export const companiesResponseSchema = z.object({ diff --git a/src/lib/api/types/CompanyDocuments.ts b/src/lib/api/types/CompanyDocuments.ts new file mode 100644 index 0000000..77666ec --- /dev/null +++ b/src/lib/api/types/CompanyDocuments.ts @@ -0,0 +1,37 @@ +/** A file that uploaded successfully via a batch / company-documents endpoint. */ +export interface UploadedFile { + file_id: string; + filename: string; + content_type: string; + size: number; + message?: string; +} + +/** A file that failed to upload, surfaced so the user can retry it. */ +export interface FailedFile { + filename: string; + error: string; + code?: string; +} + +/** `data` payload of `POST /companies/:id/documents` (wrapped in ApiResponse). */ +export interface UploadDocumentsData { + document_file_ids: string[]; + uploaded: UploadedFile[]; + failed?: FailedFile[]; +} + +/** Body of `POST /files/upload/batch` — returned directly, not wrapped. */ +export interface BatchUploadResult { + uploaded: UploadedFile[]; + failed?: FailedFile[]; + message?: string; +} + +/** Normalized metadata for a stored file (`GET /files/:id/info`). */ +export interface FileInfo { + file_id: string; + filename: string; + content_type: string; + size: number; +} diff --git a/src/lib/api/types/TCompany.ts b/src/lib/api/types/TCompany.ts index 8e5a9e0..370e20c 100644 --- a/src/lib/api/types/TCompany.ts +++ b/src/lib/api/types/TCompany.ts @@ -47,6 +47,7 @@ export interface ICreateCompanyCredentials { address: IAddress; contact_person?: IContactPerson; tags: ITags; + document_file_ids?: string[]; } export const companySchema = z.object({ @@ -92,6 +93,7 @@ export const companySchema = z.object({ certifications: z.array(z.string()).or(z.null()).optional(), cpv_codes: z.array(z.string()).or(z.null()).optional(), }), + document_file_ids: z.array(z.string()).or(z.null()).optional(), }); export const companiesResponseSchema = z.object({ diff --git a/src/lib/api/types/index.ts b/src/lib/api/types/index.ts index 6d02388..f83c1bf 100644 --- a/src/lib/api/types/index.ts +++ b/src/lib/api/types/index.ts @@ -1,4 +1,5 @@ export * from "./CompanyCategories"; +export * from "./CompanyDocuments"; export * from "./Customers"; export * from "./Factory"; export * from "./Posts"; diff --git a/src/middleware/response-interceptor.ts b/src/middleware/response-interceptor.ts index 9d7f84f..0fcf5ab 100644 --- a/src/middleware/response-interceptor.ts +++ b/src/middleware/response-interceptor.ts @@ -1,160 +1,208 @@ -import { AxiosError, AxiosResponse } from "axios"; +import { + AxiosError, + AxiosResponse, + InternalAxiosRequestConfig, +} from "axios"; import Cookies from "js-cookie"; import { toast } from "react-toastify"; import { getLogoutCallback } from "../contexts/User.ctx"; import { API_ENDPOINTS, api } from "../lib/api"; import { COOKIE_KEYS } from "../lib/shared/cookies"; -let isRefreshing = false; -let failedQueue: any[] = []; +const SIGN_IN_PATH = "/auth/sign-in"; -const processQueue = (error: any, token: string | null = null) => { - failedQueue.forEach((prom) => { +type RetriableRequest = InternalAxiosRequestConfig & { _retry?: boolean }; + +interface TokenPair { + access_token: string; + refresh_token: string; +} + +interface ApiErrorData { + message?: string; + error?: { + code?: string; + message?: string; + details?: string; + }; +} + +interface QueuedRequest { + resolve: (token: string) => void; + reject: (reason?: unknown) => void; +} + +// --------------------------------------------------------------------------- +// Refresh-token queue: requests that arrive mid-refresh wait here and replay +// once a fresh access token is available (or fail together if refresh fails). +// --------------------------------------------------------------------------- +let isRefreshing = false; +let failedQueue: QueuedRequest[] = []; + +const processQueue = (error: unknown, token: string | null = null) => { + failedQueue.forEach(({ resolve, reject }) => { if (error) { - prom.reject(error); - } else { - prom.resolve(token); + reject(error); + } else if (token) { + resolve(token); } }); - failedQueue = []; }; -export const responseInterceptor = (response: AxiosResponse) => { - return response; +const queueRequest = ( + originalRequest: RetriableRequest, +): Promise => + new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then((token) => { + originalRequest.headers.Authorization = `Bearer ${token}`; + return api(originalRequest); + }); + +// --------------------------------------------------------------------------- +// Session / auth helpers +// --------------------------------------------------------------------------- +const clearLocalSession = () => { + Cookies.remove(COOKIE_KEYS.access_token); + Cookies.remove(COOKIE_KEYS.refresh_token); + Cookies.remove(COOKIE_KEYS.user); + + if (typeof window !== "undefined") { + localStorage.removeItem("auth-token"); + localStorage.removeItem("user-data"); + } }; -export const responseErrorHandler = async (error: AxiosError) => { - const originalRequest = error.config as any; +const redirectToSignIn = () => { + if (typeof window !== "undefined") { + window.location.href = SIGN_IN_PATH; + } +}; - // Skip token refresh for login endpoint - let login errors pass through - const isLoginRequest = originalRequest?.url?.includes( - API_ENDPOINTS.USER.LOGIN, - ); +/** Logs the user out via the registered callback, falling back to a manual reset. */ +const forceLogout = () => { + const logout = getLogoutCallback(); + if (logout) { + logout(); + return; + } - const isRefreshTokenRequest = originalRequest?.url?.includes( - API_ENDPOINTS.USER.REFRESH_TOKEN, - ); + clearLocalSession(); + redirectToSignIn(); +}; - // If we get a 401 on refresh token endpoint, logout immediately (token refresh failed) - if (error.response?.status === 401 && isRefreshTokenRequest) { - const logout = getLogoutCallback(); - if (logout) { - logout(); - } else { - // Fallback if logout callback is not registered - Cookies.remove(COOKIE_KEYS.access_token); - Cookies.remove(COOKIE_KEYS.refresh_token); - Cookies.remove(COOKIE_KEYS.user); - if (typeof window !== "undefined") { - window.location.href = "/auth/sign-in"; - } +// --------------------------------------------------------------------------- +// Error reporting +// --------------------------------------------------------------------------- +/** + * Builds the message shown in the error toast, appending the API-provided + * `details` (e.g. field-level validation hints) to the main error message + * when available. + */ +const buildErrorMessage = (data: unknown, fallback: string): string => { + const payload = data as ApiErrorData | undefined; + const message = payload?.error?.message || payload?.message || fallback; + const details = payload?.error?.details; + + return details ? `${message}: ${details}` : message; +}; + +const notifyError = (error: unknown) => { + const data = (error as AxiosError).response?.data; + toast.error(buildErrorMessage(data, (error as Error).message)); +}; + +// --------------------------------------------------------------------------- +// Token refresh +// --------------------------------------------------------------------------- +const requestNewAccessToken = async (refreshToken: string): Promise => { + const response = await api.post(API_ENDPOINTS.USER.REFRESH_TOKEN, { + refresh_token: refreshToken, + }); + + const { access_token, refresh_token } = response.data.data as TokenPair; + + if (!access_token || !refresh_token) { + throw new Error("Invalid token received from refresh endpoint."); + } + + Cookies.set(COOKIE_KEYS.access_token, access_token); + Cookies.set(COOKIE_KEYS.refresh_token, refresh_token); + + return access_token; +}; + +/** + * Handles a 401 by refreshing the access token and replaying the request. + * Concurrent requests are queued until the in-flight refresh settles. + */ +const handleUnauthorized = async (originalRequest: RetriableRequest) => { + if (isRefreshing) { + return queueRequest(originalRequest); + } + + originalRequest._retry = true; + isRefreshing = true; + + try { + const refreshToken = Cookies.get(COOKIE_KEYS.refresh_token); + if (!refreshToken) { + forceLogout(); + return Promise.reject(new Error("Refresh token not found.")); } + + const accessToken = await requestNewAccessToken(refreshToken); + processQueue(null, accessToken); + originalRequest.headers.Authorization = `Bearer ${accessToken}`; + return api(originalRequest); + } catch (refreshError) { + processQueue(refreshError, null); + forceLogout(); + notifyError(refreshError); + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } +}; + +// --------------------------------------------------------------------------- +// Request predicates +// --------------------------------------------------------------------------- +const matchesEndpoint = (request: RetriableRequest | undefined, endpoint: string) => + request?.url?.includes(endpoint) ?? false; + +// Login errors must surface to the caller instead of triggering a refresh. +const isLoginRequest = (request?: RetriableRequest) => + matchesEndpoint(request, API_ENDPOINTS.USER.LOGIN); + +const isRefreshTokenRequest = (request?: RetriableRequest) => + matchesEndpoint(request, API_ENDPOINTS.USER.REFRESH_TOKEN); + +// --------------------------------------------------------------------------- +// Interceptors +// --------------------------------------------------------------------------- +export const responseInterceptor = (response: AxiosResponse) => response; + +export const responseErrorHandler = (error: AxiosError) => { + const originalRequest = error.config as RetriableRequest | undefined; + const isUnauthorized = error.response?.status === 401; + + // A 401 from the refresh endpoint itself means the session is unrecoverable. + if (isUnauthorized && isRefreshTokenRequest(originalRequest)) { + forceLogout(); return Promise.reject(error); } if ( - error.response?.status === 401 && + isUnauthorized && + originalRequest && !originalRequest._retry && - !isLoginRequest + !isLoginRequest(originalRequest) ) { - if (isRefreshing) { - return new Promise((resolve, reject) => { - failedQueue.push({ resolve, reject }); - }) - .then((token: string) => { - originalRequest.headers["Authorization"] = "Bearer " + token; - return api(originalRequest); - }) - .catch((err) => { - return Promise.reject(err); - }); - } - - originalRequest._retry = true; - isRefreshing = true; - - try { - const refreshToken = Cookies.get(COOKIE_KEYS.refresh_token); - if (!refreshToken) { - // No refresh token available, logout immediately - const logout = getLogoutCallback(); - if (logout) { - logout(); - } else { - // Fallback if logout callback is not registered - Cookies.remove(COOKIE_KEYS.access_token); - Cookies.remove(COOKIE_KEYS.refresh_token); - Cookies.remove(COOKIE_KEYS.user); - if (typeof window !== "undefined") { - window.location.href = "/auth/sign-in"; - } - } - return Promise.reject(new Error("Refresh token not found.")); - } - - const response = await api.post(API_ENDPOINTS.USER.REFRESH_TOKEN, { - refresh_token: refreshToken, - }); - - const { access_token, refresh_token } = response.data.data as { - access_token: string; - refresh_token: string; - }; - - if (!access_token || !refresh_token) { - throw new Error("Invalid token received from refresh endpoint."); - } - - Cookies.set(COOKIE_KEYS.access_token, access_token); - Cookies.set(COOKIE_KEYS.refresh_token, refresh_token); - - if (originalRequest.headers) { - originalRequest.headers.Authorization = `Bearer ${access_token}`; - } - processQueue(null, access_token); - return api(originalRequest); - } catch (refreshError) { - processQueue(refreshError, null); - - // Use logout callback from User context - const logout = getLogoutCallback(); - if (logout) { - logout(); - } else { - // Fallback if logout callback is not registered - Cookies.remove(COOKIE_KEYS.access_token); - Cookies.remove(COOKIE_KEYS.refresh_token); - Cookies.remove(COOKIE_KEYS.user); - if (typeof window !== "undefined") { - localStorage.removeItem("auth-token"); - localStorage.removeItem("user-data"); - window.location.href = "/auth/sign-in"; - } - } - - const errorMessage = - ( - (refreshError as AxiosError).response?.data as { - message: string; - error: { code: string; message: string }; - } - )?.error?.message || (refreshError as Error).message; - toast.error(errorMessage); - - return Promise.reject(refreshError); - } finally { - isRefreshing = false; - } + return handleUnauthorized(originalRequest); } - const errorMessage = - ( - error.response?.data as { - message: string; - error: { code: string; message: string }; - } - )?.error?.message || error.message; - toast.error(errorMessage); + notifyError(error); return Promise.reject(error); }; diff --git a/src/utils/shared.ts b/src/utils/shared.ts index 51159d2..1b9064d 100644 --- a/src/utils/shared.ts +++ b/src/utils/shared.ts @@ -193,6 +193,59 @@ export const validateImageFile = ({ return null; }; +/** Company document upload limits (see company-documents API guide). */ +export const ALLOWED_DOCUMENT_FILE_TYPES = [ + // Images + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/svg+xml", + // Documents + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + // Text / data + "text/plain", + "text/csv", + "application/json", + // Archives + "application/zip", + "application/x-rar-compressed", + "application/x-7z-compressed", +]; + +export const MAX_DOCUMENT_FILE_SIZE = 100 * 1024 * 1024; // 100 MB +export const MAX_DOCUMENT_FILES = 20; + +/** `accept` attribute mirroring the allowed document MIME types. */ +export const DOCUMENT_FILE_ACCEPT = + ".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.gif,.webp,.svg,.txt,.csv,.json,.zip,.rar,.7z"; + +export const validateDocumentFile = ({ + file, + allowedTypes = ALLOWED_DOCUMENT_FILE_TYPES, + maxSize = MAX_DOCUMENT_FILE_SIZE, +}: { + file: File; + allowedTypes?: string[]; + maxSize?: number; +}): string | null => { + // Browsers occasionally send an empty type; the server then infers it from + // the file extension, so only reject explicitly disallowed types. + if (file.type && !allowedTypes.includes(file.type)) { + return `Unsupported file type: ${file.type}`; + } + + if (file.size > maxSize) { + return "File size must be 100MB or less"; + } + + return null; +}; + export const formatFileSize = (bytes: number) => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;