Files
inventory/inventory/src/services/apiv2.ts

325 lines
9.9 KiB
TypeScript

export interface SubmitNewProductsArgs {
products: Array<Record<string, unknown>>;
environment: "dev" | "prod";
useTestDataSource: boolean;
employeeId?: number;
}
export interface SubmitNewProductsResponse {
success: boolean;
message?: string;
data?: unknown;
error?: unknown;
}
export interface PoLineItemSubmit {
pid: number;
qty: number;
}
export interface SubmitNewPurchaseOrderArgs {
supplierId: number | string;
items: PoLineItemSubmit[];
}
export interface SubmitNewPurchaseOrderResponse {
success: boolean;
poId?: number;
message?: string;
error?: unknown;
raw?: unknown;
}
export interface CreateProductCategoryArgs {
masterCatId: string | number;
name: string;
environment?: "dev" | "prod";
image?: string;
nameForCustoms?: string;
taxCodeId?: string | number;
}
export interface CreateProductCategoryResponse {
success: boolean;
message?: string;
data?: unknown;
error?: unknown;
category?: unknown;
}
// Always use relative URLs - proxied by Vite in dev and Caddy in production
// Frontend calls /apiv2/* -> Caddy transforms to /api/* -> proxies to www.acherryontop.com
const DEV_ENDPOINT = "/apiv2-test/product/setup_new";
const DEV_CREATE_CATEGORY_ENDPOINT = "/apiv2-test/prod_cat/new";
const PROD_ENDPOINT = "/apiv2/product/setup_new";
const PROD_CREATE_CATEGORY_ENDPOINT = "/apiv2/prod_cat/new";
const isHtmlResponse = (payload: string) => {
const trimmed = payload.trim();
return trimmed.startsWith("<!DOCTYPE html") || trimmed.startsWith("<html");
};
export async function submitNewProducts({
products,
environment,
useTestDataSource,
}: SubmitNewProductsArgs): Promise<SubmitNewProductsResponse> {
const baseUrl = environment === "dev" ? DEV_ENDPOINT : PROD_ENDPOINT;
const targetUrl = useTestDataSource ? `${baseUrl}?use_test_data_source=1` : baseUrl;
const payload = new URLSearchParams();
const serializedProducts = JSON.stringify(products);
payload.append("products", serializedProducts);
let response: Response;
const fetchOptions: RequestInit = {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
},
body: payload,
};
// Authentication strategy depends on endpoint
if (environment === "dev") {
// Test endpoint: Use auth token in request body
const authToken = import.meta.env.VITE_APIV2_AUTH_TOKEN;
if (authToken) {
payload.append("auth", authToken);
fetchOptions.body = payload;
}
} else {
// Prod endpoint: Use cookies (proxied in both dev and production)
fetchOptions.credentials = "include";
}
try {
response = await fetch(targetUrl, fetchOptions);
} catch (networkError) {
throw new Error(networkError instanceof Error ? networkError.message : "Network request failed");
}
const rawBody = await response.text();
if (isHtmlResponse(rawBody)) {
throw new Error("Backend authentication required. Please ensure you are logged into the backend system.");
}
let parsed: unknown;
try {
parsed = JSON.parse(rawBody);
} catch {
const message = `Unexpected response from backend (${response.status}).`;
throw new Error(message);
}
if (!parsed || typeof parsed !== "object") {
throw new Error("Empty response from backend");
}
const parsedResponse = parsed as SubmitNewProductsResponse & Record<string, unknown>;
const extraFields = parsedResponse as Record<string, unknown>;
const normalizedResponse: SubmitNewProductsResponse = {
success: Boolean(parsedResponse.success),
message: typeof parsedResponse.message === "string" ? parsedResponse.message : undefined,
data: parsedResponse.data,
error: parsedResponse.error ?? extraFields.errors ?? extraFields.error_msg,
};
if (!response.ok || !normalizedResponse.success) {
return normalizedResponse;
}
return normalizedResponse;
}
export async function createProductCategory({
masterCatId,
name,
environment = "prod",
image,
nameForCustoms,
taxCodeId,
}: CreateProductCategoryArgs): Promise<CreateProductCategoryResponse> {
const targetUrl = environment === "dev" ? DEV_CREATE_CATEGORY_ENDPOINT : PROD_CREATE_CATEGORY_ENDPOINT;
const payload = new URLSearchParams();
payload.append("master_cat_id", masterCatId.toString());
payload.append("name", name);
if (nameForCustoms) {
payload.append("name_for_customs", nameForCustoms);
}
if (image) {
payload.append("image", image);
}
if (typeof taxCodeId !== "undefined" && taxCodeId !== null) {
payload.append("tax_code_id", taxCodeId.toString());
}
let response: Response;
const fetchOptions: RequestInit = {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
},
body: payload,
};
// Authentication strategy depends on endpoint
if (environment === "dev") {
// Test endpoint: Use auth token in request body
const authToken = import.meta.env.VITE_APIV2_AUTH_TOKEN;
if (authToken) {
payload.append("auth", authToken);
fetchOptions.body = payload;
}
} else {
// Prod endpoint: Use cookies (proxied in both dev and production)
fetchOptions.credentials = "include";
}
try {
response = await fetch(targetUrl, fetchOptions);
} catch (networkError) {
throw new Error(networkError instanceof Error ? networkError.message : "Network request failed");
}
const rawBody = await response.text();
if (isHtmlResponse(rawBody)) {
throw new Error("Backend authentication required. Please ensure you are logged into the backend system.");
}
let parsed: unknown;
try {
parsed = JSON.parse(rawBody);
} catch {
const message = `Unexpected response from backend (${response.status}).`;
throw new Error(message);
}
if (!parsed || typeof parsed !== "object") {
throw new Error("Empty response from backend");
}
const parsedRecord = parsed as Record<string, unknown>;
const normalizedResponse: CreateProductCategoryResponse = {
success: Boolean(parsedRecord.success ?? parsedRecord.status ?? parsedRecord.result),
message: typeof parsedRecord.message === "string" ? parsedRecord.message : undefined,
data: parsedRecord.data ?? parsedRecord.category ?? parsedRecord.result,
error: parsedRecord.error ?? parsedRecord.errors ?? parsedRecord.error_msg,
category: parsedRecord.category,
};
if (!response.ok || !normalizedResponse.success) {
return normalizedResponse;
}
return normalizedResponse;
}
/**
* Creates a new purchase order on the legacy PHP backend.
*
* Mirrors the auth/serialization pattern of `submitNewProducts`:
* - URL-encoded body
* - cookie-based auth (`credentials: 'include'`)
* - HTML response → "Backend authentication required" guard
*
* The endpoint accepts a JSON array of `{pid, qty}` items in the `items` body
* field, with the supplier ID as a path parameter. Returns `{po_id: number}`
* on success. Designed to be called from anywhere in the app — not just the
* Create PO page.
*/
export async function submitNewPurchaseOrder({
supplierId,
items,
}: SubmitNewPurchaseOrderArgs): Promise<SubmitNewPurchaseOrderResponse> {
if (!supplierId) {
throw new Error("supplierId is required");
}
if (!Array.isArray(items) || items.length === 0) {
throw new Error("At least one item is required");
}
const cleanItems = items
.map((i) => ({ pid: Number(i.pid), qty: Number(i.qty) }))
.filter((i) => Number.isInteger(i.pid) && i.pid > 0 && Number.isFinite(i.qty) && i.qty > 0);
if (cleanItems.length === 0) {
throw new Error("No valid items to submit");
}
const targetUrl = `/apiv2/po/new/${encodeURIComponent(String(supplierId))}`;
const payload = new URLSearchParams();
payload.append("items", JSON.stringify(cleanItems));
let response: Response;
try {
response = await fetch(targetUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
},
body: payload,
credentials: "include",
});
} catch (networkError) {
throw new Error(networkError instanceof Error ? networkError.message : "Network request failed");
}
const rawBody = await response.text();
if (isHtmlResponse(rawBody)) {
throw new Error("Backend authentication required. Please ensure you are logged into the backend system.");
}
let parsed: unknown;
try {
parsed = JSON.parse(rawBody);
} catch {
throw new Error(`Unexpected response from backend (${response.status}).`);
}
if (!parsed || typeof parsed !== "object") {
throw new Error("Empty response from backend");
}
const record = parsed as Record<string, unknown>;
// The PHP /apiv2 backend wraps successful responses in an envelope:
// {"success": true, "data": { "po_id": "32705" }}
// po_id can appear either at the top level (legacy shape) OR nested
// inside `data` (current shape), and the value is sometimes a string
// (e.g. "32705") rather than a number. We look in both locations and
// coerce to Number to handle both cases.
const data =
record.data && typeof record.data === "object"
? (record.data as Record<string, unknown>)
: {};
const rawPoId = record.po_id ?? record.poId ?? data.po_id ?? data.poId;
const poIdNum = typeof rawPoId === "number" ? rawPoId : Number(rawPoId);
const hasValidPoId = Number.isFinite(poIdNum) && poIdNum > 0;
// Trust the backend's own `success` flag when it's present — it's the
// authoritative signal. Fall back to "HTTP OK + valid po_id" for older
// response shapes that don't include the flag.
const backendSuccess =
record.success === true ||
record.success === "true" ||
record.success === 1;
const success = response.ok && hasValidPoId && (backendSuccess || record.success === undefined);
return {
success,
poId: success ? poIdNum : undefined,
message: typeof record.message === "string" ? record.message : undefined,
error: record.error ?? record.errors ?? record.error_msg,
raw: parsed,
};
}