Files
catalogue_frontend/src/api/client.js
2026-08-29 14:51:10 +05:30

432 lines
18 KiB
JavaScript

const BASE = import.meta.env.VITE_API_BASE_URL || '';
// Where the access token lives. sessionStorage, not localStorage: the token is
// a bearer credential, and a tab-scoped store means closing the tab ends the
// session rather than leaving a working credential on disk.
export const TOKEN_STORAGE_KEY = 'app_access_token';
// Read at module load so a page refresh is already authenticated before
// AuthContext mounts and the first request goes out.
let authToken = (() => {
try {
return sessionStorage.getItem(TOKEN_STORAGE_KEY);
} catch {
return null;
}
})();
let onUnauthorized = null;
/** Called by AuthContext on login/logout. Pass null to clear. */
export function setAuthToken(token) {
authToken = token || null;
try {
if (token) sessionStorage.setItem(TOKEN_STORAGE_KEY, token);
else sessionStorage.removeItem(TOKEN_STORAGE_KEY);
} catch {
/* private browsing with storage disabled - the in-memory copy still works */
}
}
export function getAuthToken() {
return authToken;
}
/**
* Registered by AuthContext so an expired token anywhere in the app drops the
* session once, rather than leaving every panel to render its own 401 error.
*/
export function setUnauthorizedHandler(fn) {
onUnauthorized = fn;
}
function authHeaders() {
return authToken ? { Authorization: `Bearer ${authToken}` } : {};
}
class ApiError extends Error {
constructor(message, status) {
super(message);
this.status = status;
}
}
async function request(path, options = {}) {
let res;
try {
res = await fetch(`${BASE}${path}`, {
headers: {
'Content-Type': 'application/json',
...authHeaders(),
...(options.headers || {}),
},
...options,
});
} catch {
throw new ApiError(
`Could not reach the API at ${BASE || '(same origin)'}${path}. Is the backend running ` +
`(uvicorn app.main:app) and reachable?`,
0
);
}
if (!res.ok) {
let detail = `Request failed (${res.status})`;
try {
const body = await res.json();
detail = body.detail || JSON.stringify(body);
} catch {
/* ignore parse errors, keep generic message */
}
// 401 means the token is missing, expired or invalid - the session is over.
// 403 is a live session lacking a permission, so it must NOT log you out.
if (res.status === 401 && onUnauthorized) onUnauthorized(detail);
throw new ApiError(detail, res.status);
}
if (res.status === 204) return null;
return res.json();
}
/**
* POST a file as multipart/form-data.
*
* Deliberately not routed through request(): the browser must set its own
* Content-Type so it can append the multipart boundary, and request()'s JSON
* default would corrupt the body. Everything else request() does still has to
* happen by hand here - most importantly the Authorization header, whose
* absence is exactly what used to make every upload in the app 401.
*/
async function upload(path, file, { fieldName = 'file' } = {}) {
const formData = new FormData();
formData.append(fieldName, file);
let res;
try {
res = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: authHeaders(),
body: formData,
});
} catch {
throw new ApiError(
`Could not reach the API at ${BASE || '(same origin)'}${path}. Is the backend running ` +
`and reachable?`,
0
);
}
if (!res.ok) {
let detail = `Upload failed (${res.status})`;
try {
const body = await res.json();
detail = body.detail || JSON.stringify(body);
} catch {
/* ignore parse errors, keep generic message */
}
if (res.status === 401 && onUnauthorized) onUnauthorized(detail);
throw new ApiError(detail, res.status);
}
return res.json();
}
/**
* Post several files under one field name.
*
* Separate from `upload` rather than a flag on it: `upload` is used by six
* call sites that send exactly one file, and widening its signature would put
* an array check in front of all of them for the benefit of one.
*
* FastAPI reads `files: List[UploadFile]` from repeated parts sharing a name,
* which is why every file is appended under the same key. As with `upload`,
* Content-Type is deliberately left unset so the browser writes the multipart
* boundary itself.
*/
async function uploadMany(path, files, { fieldName = 'files' } = {}) {
const formData = new FormData();
Array.from(files || []).forEach((file) => formData.append(fieldName, file));
let res;
try {
res = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: authHeaders(),
body: formData,
});
} catch {
throw new ApiError(
`Could not reach the API at ${BASE || '(same origin)'}${path}. Is the backend running ` +
`and reachable?`,
0
);
}
if (!res.ok) {
let detail = `Upload failed (${res.status})`;
try {
const body = await res.json();
detail = body.detail || JSON.stringify(body);
} catch {
/* ignore parse errors, keep generic message */
}
if (res.status === 401 && onUnauthorized) onUnauthorized(detail);
throw new ApiError(detail, res.status);
}
return res.json();
}
function qs(params = {}) {
const usp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') usp.set(k, v);
});
const s = usp.toString();
return s ? `?${s}` : '';
}
// Browse endpoints intentionally request a very high `limit` explicitly
// (rather than relying on the backend's default) so that the UI always
// shows the FULL catalog for a brand / for "All brands", however many
// thousands of products that ends up being. Do not lower this to a small
// page size unless you also add real pagination controls to the grid -
// silently truncating the product list here was the exact bug this
// constant is guarding against.
const SHOW_ALL_PRODUCTS_LIMIT = 100000;
export const api = {
getHealth: () => request('/api/health'),
// --- Auth ---
login: (username, password) =>
request('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
}),
getMe: () => request('/api/auth/me'),
getRoles: () => request('/api/auth/roles'),
getBrands: () => request('/api/brands'),
// Richer per-brand summaries for the home page card grid. `getBrands` above
// still returns plain names and is what the sidebar and admin pages use.
getBrandCards: ({ refresh = false } = {}) =>
request(`/api/brands/overview${qs({ refresh: refresh || undefined })}`),
getBrandCategories: (brand) => request(`/api/brands/${encodeURIComponent(brand)}/categories`),
getBrandProducts: (brand, { category, limit = SHOW_ALL_PRODUCTS_LIMIT, offset = 0 } = {}) =>
request(`/api/brands/${encodeURIComponent(brand)}/products${qs({ category, limit, offset })}`),
getAllProducts: ({ category, limit = SHOW_ALL_PRODUCTS_LIMIT, offset = 0 } = {}) =>
request(`/api/products${qs({ category, limit, offset })}`),
getProductDetail: (brand, imageId) =>
request(`/api/brands/${encodeURIComponent(brand)}/products/${encodeURIComponent(imageId)}`),
// Same reasoning as SHOW_ALL_PRODUCTS_LIMIT above: searching a brand name must
// return that brand's whole catalog, not a page of it. The old default of 50
// was in any case ignored - the backend clamped every search to 15.
search: (q, { brand, category, top_k = SHOW_ALL_PRODUCTS_LIMIT, offset = 0 } = {}) =>
request(`/api/search${qs({ q, brand, category, top_k, offset })}`),
// Autocomplete for the catalog search box. Answers from in-process caches,
// so it is safe to call per keystroke (debounced in useSuggestions).
suggest: (q, { limit = 8 } = {}) => request(`/api/suggest${qs({ q, limit })}`),
chat: ({ query, brand, category, top_k, history }) =>
request('/api/chat', {
method: 'POST',
body: JSON.stringify({ query, brand, category, top_k, history }),
}),
generateCatalog: ({ brand, max_products = 50 }) =>
request('/api/catalog/generate', {
method: 'POST',
body: JSON.stringify({ brand, max_products }),
}),
getJobStatus: (jobId) => request(`/api/catalog/jobs/${jobId}`),
// --- Store Intelligence (v3.0) ---
getStores: () => request('/api/stores'),
getStore: (storeId) => request(`/api/stores/${encodeURIComponent(storeId)}`),
getStoreProducts: (storeId, { category, in_stock_only, limit = 500, offset = 0 } = {}) =>
request(`/api/stores/${encodeURIComponent(storeId)}/products${qs({ category, in_stock_only, limit, offset })}`),
getProductAcrossStores: (brand, imageId) =>
request(`/api/products/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}/stores`),
getStoreDiscounts: (storeId) => request(`/api/stores/${encodeURIComponent(storeId)}/discounts`),
getProductDiscount: (storeId, brand, imageId) =>
request(`/api/stores/${encodeURIComponent(storeId)}/discounts/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}`),
getTrending: ({ window = 'weekly', scope = 'overall', scope_value, top_k = 10 } = {}) =>
request(`/api/trending${qs({ window, scope, scope_value, top_k })}`),
getRecommendations: (brand, imageId, topK = 5) =>
request(`/api/recommendations/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}${qs({ top_k: topK })}`),
getStoreAnalytics: (storeId) => request(`/api/analytics/store/${encodeURIComponent(storeId)}`),
getStoreComparison: () => request('/api/analytics/compare'),
getProductAnalytics: (brand, imageId) => request(`/api/analytics/product/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}`),
getTopProducts: ({ by = 'revenue', order = 'top', limit = 10 } = {}) =>
request(`/api/analytics/top-products${qs({ by, order, limit })}`),
seedStoreIntelligence: ({ reset_orders = true, days = 90, seed = 42 } = {}) =>
request('/api/admin/store-intelligence/seed', { method: 'POST', body: JSON.stringify({ reset_orders, days, seed }) }),
trainStoreModels: ({ models } = {}) =>
request('/api/admin/store-intelligence/train', { method: 'POST', body: JSON.stringify({ models: models || null }) }),
getStoreIntelligenceJob: (jobId) => request(`/api/admin/store-intelligence/jobs/${jobId}`),
// --- AI Nutritional Intelligence Module ---
getNutrition: (brand, imageId) =>
request(`/api/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}`),
getNutritionHealthScore: (brand, imageId) =>
request(`/api/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}/health-score`),
getNutritionInsights: (brand, imageId) =>
request(`/api/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}/insights`),
getNutritionAlternatives: (brand, imageId, topK = 5) =>
request(`/api/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}/alternatives${qs({ top_k: topK })}`),
getNutritionSimilar: (brand, imageId, topK = 5) =>
request(`/api/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}/similar${qs({ top_k: topK })}`),
compareNutrition: (products) => request(`/api/nutrition/compare${qs({ products: products.join(',') })}`),
getDietCompatibleProducts: (tag, { category, exclude_allergen, limit = 20, offset = 0 } = {}) =>
request(`/api/nutrition/diet/${encodeURIComponent(tag)}${qs({ category, exclude_allergen, limit, offset })}`),
getHighProteinProducts: ({ category, limit = 20, offset = 0 } = {}) =>
request(`/api/nutrition/high-protein${qs({ category, limit, offset })}`),
getLowSugarProducts: ({ category, limit = 20, offset = 0 } = {}) =>
request(`/api/nutrition/low-sugar${qs({ category, limit, offset })}`),
getHighFiberProducts: ({ category, limit = 20, offset = 0 } = {}) =>
request(`/api/nutrition/high-fiber${qs({ category, limit, offset })}`),
filterNutritionProducts: ({ sort_by = 'health_score', order = 'desc', category, diet_tag, exclude_allergen, limit = 20, offset = 0 } = {}) =>
request(`/api/nutrition/products${qs({ sort_by, order, category, diet_tag, exclude_allergen, limit, offset })}`),
getNutritionAnalyticsDashboard: (limit = 10) => request(`/api/nutrition/analytics/dashboard${qs({ limit })}`),
getNutritionLeaderboards: (limit = 10) => request(`/api/nutrition/analytics/leaderboards${qs({ limit })}`),
getNutritionRankings: (limit = 10) => request(`/api/nutrition/analytics/rankings${qs({ limit })}`),
getNutritionDistribution: () => request('/api/nutrition/analytics/distribution'),
getPersonalizedNutritionRecommendations: (customerId, topK = 8) =>
request(`/api/nutrition/recommendations/${encodeURIComponent(customerId)}${qs({ top_k: topK })}`),
enrichNutrition: ({ skip_if_verified = true, generate_narrative = true, max_products } = {}) =>
request('/api/admin/nutrition-intelligence/enrich', {
method: 'POST', body: JSON.stringify({ skip_if_verified, generate_narrative, max_products: max_products || null }),
}),
trainNutritionModels: () => request('/api/admin/nutrition-intelligence/train', { method: 'POST', body: JSON.stringify({}) }),
getNutritionIntelligenceJob: (jobId) => request(`/api/admin/nutrition-intelligence/jobs/${jobId}`),
getNutritionEnrichmentStatus: () => request('/api/admin/nutrition-intelligence/status'),
// --- Excel / CSV Upload API ---
uploadFile: (tabType, file) => upload(`/api/upload/${encodeURIComponent(tabType)}`, file),
getTemplateUrl: (tabType) => `${BASE}/api/upload/template/${encodeURIComponent(tabType)}`,
// --- Admin: training datasets & discount allocation ---
// Guarded by `upload_train_test` / `allocate_discounts` on the server, so
// these must carry the bearer token - see app/api/routers/admin_train.py.
getProjectDetails: () => request('/api/admin/training/project-details'),
uploadTrainingDataset: (file) => upload('/api/admin/training/upload-dataset', file),
allocateDiscounts: ({ rules, store_id = null }) =>
request('/api/admin/training/allocate-discounts', {
method: 'POST',
body: JSON.stringify({ store_id, rules }),
}),
// --- Admin: store-catalog ingestion (Excel -> 11-stage pipeline -> brand tables) ---
// See app/api/routers/store_catalog.py. `preview` parses only, so the
// operator can check the column mapping before paying for a full scrape.
previewStoreCatalog: (file) => upload('/api/admin/store-catalog/preview', file),
// The two flags are query params, not form fields: the endpoint takes them
// as plain query arguments alongside the multipart body.
ingestStoreCatalog: (file, { use_llm = true, fetch_images = true } = {}) =>
upload(`/api/admin/store-catalog/ingest${qs({ use_llm, fetch_images })}`, file),
getStoreCatalogJob: (jobId) =>
request(`/api/admin/store-catalog/jobs/${encodeURIComponent(jobId)}`),
// --- Admin: batch catalog ingestion (many spreadsheets -> one batch) ---
// The multi-file sibling of the block above. See
// app/api/routers/batch_catalog.py.
//
// NOTE the flag defaults are the OPPOSITE of ingestStoreCatalog's. One file
// with image search on is a considered trade; twenty files is thousands of
// outbound requests and a Playwright subprocess per row, on a single-vCPU
// container that is also serving the API. A batch opts in.
previewCatalogBatch: (files) =>
uploadMany('/api/admin/catalog-batch/preview', files),
ingestCatalogBatch: (files, { use_llm = false, fetch_images = false } = {}) =>
uploadMany(`/api/admin/catalog-batch/ingest${qs({ use_llm, fetch_images })}`, files),
getCatalogBatch: (batchId) =>
request(`/api/admin/catalog-batch/batches/${encodeURIComponent(batchId)}`),
listCatalogBatches: (limit = 20) =>
request(`/api/admin/catalog-batch/batches${qs({ limit })}`),
resumeCatalogBatch: (batchId) =>
request(`/api/admin/catalog-batch/batches/${encodeURIComponent(batchId)}/resume`, {
method: 'POST',
}),
cancelCatalogBatch: (batchId) =>
request(`/api/admin/catalog-batch/batches/${encodeURIComponent(batchId)}/cancel`, {
method: 'POST',
}),
// --- Admin: the review inbox (a colleague dropped files; you decide) ---
// The uploader half is POST /api/uploads/catalog, which the browser never
// calls - a colleague hits it with an X-API-Key from a script.
listInbox: () => request('/api/admin/catalog-batch/inbox'),
// `runner` picks the executor: 'inprocess' (this container's worker thread,
// the default and the only one that exists in production) or 'dagster'
// (staged and left for the orchestrator to claim). See InboxStartRequest in
// app/api/routers/batch_catalog.py.
startBatchFromInbox: (
fileIds,
{ use_llm = false, fetch_images = false, runner = 'inprocess' } = {},
) =>
request('/api/admin/catalog-batch/from-inbox', {
method: 'POST',
body: JSON.stringify({ file_ids: fileIds, use_llm, fetch_images, runner }),
}),
dismissInboxFiles: (fileIds) =>
request('/api/admin/catalog-batch/inbox/dismiss', {
method: 'POST',
body: JSON.stringify({ file_ids: fileIds }),
}),
// --- User workspace: single + batch product entry ---
// Guarded by `add_product` / `upload_batch_products` - see
// app/api/routers/user_products.py.
addUserProduct: (payload) =>
request('/api/user/products/add', {
method: 'POST',
body: JSON.stringify(payload),
}),
uploadUserProductsFile: (file) => upload('/api/user/products/upload-file', file),
// --- MCP (Model Context Protocol) ---
// The /mcp endpoint itself speaks streamable HTTP with sessions, which the
// browser can't talk to without a full MCP client. These two REST endpoints
// expose the same server for the admin page - see app/api/routers/mcp_info.py.
getMcpInfo: () => request('/api/mcp/info'),
invokeMcpTool: (toolName, args = {}) =>
request(`/api/mcp/tools/${encodeURIComponent(toolName)}`, {
method: 'POST',
body: JSON.stringify(args),
}),
};
export { ApiError };