import { AllObjectsResponse, BatchResponse, ClientProfile, ColumnMappingSuggestion, ExportFormat, ExportFormatResponse, LineCheckRequest, LineCheckResponse, MakeRuleRequest, ReferenceDataStatus, ReviewQuery, ReviewRow, ReviewTableResponse, RowReviewRequest, RuleResponse, UploadOptions, } from './types'; export const API_BASE_URL = ( process.env.REACT_APP_API_URL ?? 'http://127.0.0.1:8000' ).replace(/\/$/, ''); /** The envelope every endpoint returns — `cbh/core/wrappers.py`. */ interface CbhResponseWrapper { data: T | null; successful: boolean; error: { message: string } | null; } export class ApiError extends Error { readonly status: number; constructor(message: string, status: number) { super(message); this.name = 'ApiError'; this.status = status; } } const unreachable = () => new ApiError( `Cannot reach the check service at ${API_BASE_URL}. Is the backend running?`, 0 ); async function send(path: string, init?: RequestInit): Promise { // A FormData body must set its own multipart boundary, so the JSON header is // only added for the string bodies that need it. const headers = typeof init?.body === 'string' ? { 'Content-Type': 'application/json', ...(init?.headers ?? {}) } : init?.headers; try { return await fetch(`${API_BASE_URL}${path}`, { ...init, headers }); } catch { throw unreachable(); } } async function request(path: string, init?: RequestInit): Promise { const response = await send(path, init); let body: CbhResponseWrapper | null = null; try { body = (await response.json()) as CbhResponseWrapper; } catch { body = null; } if (!response.ok || body?.successful === false) { throw new ApiError( body?.error?.message ?? `The check service returned status ${response.status}.`, response.status ); } if (body === null || body.data === null || body.data === undefined) { throw new ApiError('The check service returned an empty response.', response.status); } return body.data; } export function checkLine(payload: LineCheckRequest): Promise { return request('/api/linecheck/line', { method: 'POST', body: JSON.stringify(payload), }); } export function getReferenceStatus(): Promise { return request('/api/taric/status'); } export function listClientProfiles(): Promise> { return request>('/api/client?pageSize=100'); } /** * The header row is the profile's, not row 1. A Softpak pack carries five rows * of declaration metadata above its column headers, and reading row 1 reports * every required field as missing on a file that processes perfectly. */ export function suggestMapping( file: File, headerRow: number ): Promise { const form = new FormData(); form.append('file', file); return request( `/api/client/suggest-mapping?headerRow=${headerRow}`, { method: 'POST', body: form } ); } export function uploadBatch(options: UploadOptions): Promise { const form = new FormData(); form.append('clientProfileId', options.clientProfileId); form.append('file', options.file); if (options.name) form.append('name', options.name); if (options.checkDate) form.append('checkDate', options.checkDate); if (options.destinationCountry) form.append('destinationCountry', options.destinationCountry); if (options.direction !== undefined) form.append('direction', String(options.direction)); if (options.shipmentReference) form.append('shipmentReference', options.shipmentReference); return request('/api/batch/upload', { method: 'POST', body: form }); } export function listBatches(): Promise> { return request>('/api/batch?pageSize=25'); } export function getReviewTable( batchId: string, query: ReviewQuery = {} ): Promise { const params = new URLSearchParams(); if (query.needsReviewOnly) params.set('needsReviewOnly', 'true'); if (query.confidence !== undefined) params.set('confidence', String(query.confidence)); params.set('pageSize', String(query.pageSize ?? 100)); params.set('pageIndex', String(query.pageIndex ?? 0)); return request(`/api/batch/${batchId}/review?${params}`); } export function reviewRow( batchId: string, rowIndex: number, payload: RowReviewRequest ): Promise { return request(`/api/batch/${batchId}/rows/${rowIndex}`, { method: 'PATCH', body: JSON.stringify(payload), }); } export function makeRuleFromRow( batchId: string, rowIndex: number, payload: MakeRuleRequest ): Promise { return request(`/api/batch/${batchId}/rows/${rowIndex}/make-rule`, { method: 'POST', body: JSON.stringify(payload), }); } export function recheckBatch(batchId: string): Promise { return request(`/api/batch/${batchId}/recheck`, { method: 'POST' }); } export function startReview(batchId: string): Promise { return request(`/api/batch/${batchId}/review/start`, { method: 'POST' }); } export function finishReview(batchId: string): Promise { return request(`/api/batch/${batchId}/review/finish`, { method: 'POST' }); } export function listExportFormats( clientProfileId?: string ): Promise { const query = clientProfileId ? `?clientProfileId=${encodeURIComponent(clientProfileId)}` : ''; return request(`/api/batch/export-formats${query}`); } /** * The export is a file, not an envelope — but a refused format still answers * with one (501 plus the reason the layout was not guessed at), so a failure * is read as JSON and surfaced as its own message. */ export async function downloadExport( batchId: string, format: ExportFormat ): Promise<{ blob: Blob; fileName: string }> { const response = await send(`/api/batch/${batchId}/export?format=${format}`); if (!response.ok) { let message = `The export failed with status ${response.status}.`; try { const body = (await response.json()) as CbhResponseWrapper; if (body?.error?.message) message = body.error.message; } catch { /* the body was not an envelope; the status message stands */ } throw new ApiError(message, response.status); } const disposition = response.headers.get('content-disposition') ?? ''; const match = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition); return { blob: await response.blob(), fileName: match ? decodeURIComponent(match[1]) : `shipment_${batchId}.xlsx`, }; }