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 };