432 lines
18 KiB
JavaScript
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 };
|