brestok's picture
init
122c1d2
Raw History Blame Contribute Delete
6.93 kB
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<T> {
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<Response> {
// 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<T>(path: string, init?: RequestInit): Promise<T> {
const response = await send(path, init);
let body: CbhResponseWrapper<T> | null = null;
try {
body = (await response.json()) as CbhResponseWrapper<T>;
} 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<LineCheckResponse> {
return request<LineCheckResponse>('/api/linecheck/line', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function getReferenceStatus(): Promise<ReferenceDataStatus> {
return request<ReferenceDataStatus>('/api/taric/status');
}
export function listClientProfiles(): Promise<AllObjectsResponse<ClientProfile>> {
return request<AllObjectsResponse<ClientProfile>>('/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<ColumnMappingSuggestion> {
const form = new FormData();
form.append('file', file);
return request<ColumnMappingSuggestion>(
`/api/client/suggest-mapping?headerRow=${headerRow}`,
{ method: 'POST', body: form }
);
}
export function uploadBatch(options: UploadOptions): Promise<BatchResponse> {
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<BatchResponse>('/api/batch/upload', { method: 'POST', body: form });
}
export function listBatches(): Promise<AllObjectsResponse<BatchResponse>> {
return request<AllObjectsResponse<BatchResponse>>('/api/batch?pageSize=25');
}
export function getReviewTable(
batchId: string,
query: ReviewQuery = {}
): Promise<ReviewTableResponse> {
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<ReviewTableResponse>(`/api/batch/${batchId}/review?${params}`);
}
export function reviewRow(
batchId: string,
rowIndex: number,
payload: RowReviewRequest
): Promise<ReviewRow> {
return request<ReviewRow>(`/api/batch/${batchId}/rows/${rowIndex}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
}
export function makeRuleFromRow(
batchId: string,
rowIndex: number,
payload: MakeRuleRequest
): Promise<RuleResponse> {
return request<RuleResponse>(`/api/batch/${batchId}/rows/${rowIndex}/make-rule`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function recheckBatch(batchId: string): Promise<BatchResponse> {
return request<BatchResponse>(`/api/batch/${batchId}/recheck`, { method: 'POST' });
}
export function startReview(batchId: string): Promise<BatchResponse> {
return request<BatchResponse>(`/api/batch/${batchId}/review/start`, { method: 'POST' });
}
export function finishReview(batchId: string): Promise<BatchResponse> {
return request<BatchResponse>(`/api/batch/${batchId}/review/finish`, { method: 'POST' });
}
export function listExportFormats(
clientProfileId?: string
): Promise<ExportFormatResponse[]> {
const query = clientProfileId
? `?clientProfileId=${encodeURIComponent(clientProfileId)}`
: '';
return request<ExportFormatResponse[]>(`/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<unknown>;
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`,
};
}