// src/services/apiClient.ts import { Profile, ShoppingListItem, SearchQuery, Budget, Address } from '../types'; import { logger } from './logger.client'; import { eventBus } from './eventBus'; // Sentry integration is optional - only used if @sentry/browser is installed let Sentry: { setTag?: (key: string, value: string) => void } | null = null; try { // Dynamic import would be cleaner but this keeps the code synchronous // eslint-disable-next-line @typescript-eslint/no-require-imports Sentry = require('@sentry/browser'); } catch { // Sentry not installed, skip error tracking integration } // This constant should point to your backend API. // It's often a good practice to store this in an environment variable. // Using a relative path '/api' is the most robust method for production. // It makes API calls to the same host that served the frontend files, // which is then handled by the Nginx reverse proxy. const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'; export interface ApiOptions { tokenOverride?: string; signal?: AbortSignal; } // --- API Fetch Wrapper with Token Refresh Logic --- /** * Helper to safely join a base URL and a path, ensuring exactly one slash between them. * This preserves path segments in the base URL (e.g. '/api') which `new URL()` would strip * if the path argument started with a slash. */ const joinUrl = (base: string, path: string): string => { const cleanBase = base.replace(/\/+$/, ''); const cleanPath = path.replace(/^\/+/, ''); return `${cleanBase}/${cleanPath}`; }; /** * A promise that holds the in-progress token refresh operation. * This prevents multiple parallel refresh requests. */ let performTokenRefreshPromise: Promise | null = null; /** * Attempts to refresh the access token using the HttpOnly refresh token cookie. * @returns A promise that resolves to the new access token. */ const _performTokenRefresh = async (): Promise => { logger.info('Attempting to refresh access token...'); try { // Use the joinUrl helper for consistency, though usually this is a relative fetch in browser const refreshUrl = typeof window === 'undefined' && !API_BASE_URL.startsWith('http') ? joinUrl(API_BASE_URL, '/auth/refresh-token') : `${API_BASE_URL}/auth/refresh-token`; const response = await fetch(refreshUrl, { method: 'POST', // This endpoint relies on the HttpOnly cookie, so no body is needed. headers: { 'Content-Type': 'application/json' }, }); const data = await response.json(); if (!response.ok) { throw new Error(data.message || 'Failed to refresh token.'); } // On successful refresh, store the new access token. if (typeof window !== 'undefined') { localStorage.setItem('authToken', data.token); } logger.info('Successfully refreshed access token.'); return data.token; } catch (error) { logger.error({ error }, 'Failed to refresh token. User session has expired.'); // Only perform browser-specific actions if in the browser environment. if (typeof window !== 'undefined') { localStorage.removeItem('authToken'); // Dispatch a global event that the UI layer can listen for to handle session expiry. eventBus.dispatch('sessionExpired'); } throw error; } }; /** * A custom fetch wrapper that handles automatic token refreshing for authenticated API calls. * If a request fails with a 401 Unauthorized status, it attempts to refresh the access token * using the refresh token cookie. If successful, it retries the original request with the new token. * All authenticated API calls should use this function or one of its helpers (e.g., `authedGet`). * * @param url The endpoint path (e.g., '/users/profile') or a full URL. * @param options Standard `fetch` options (method, body, etc.). * @param apiOptions Custom options for the API client, such as `tokenOverride` for testing or an `AbortSignal`. * @returns A promise that resolves to the final `Response` object from the fetch call. */ export const apiFetch = async ( url: string, options: RequestInit = {}, apiOptions: ApiOptions = {}, ): Promise => { // Always construct the full URL from the base and the provided path, // unless the path is already a full URL. This works for both browser and Node.js. const fullUrl = url.startsWith('http') ? url : joinUrl(API_BASE_URL, url); logger.debug({ method: options.method || 'GET', url: fullUrl }, 'apiFetch: Request'); // Create a new headers object to avoid mutating the original options. const headers = new Headers(options.headers || {}); // Use the token override if provided (for testing), otherwise get it from localStorage. // The `typeof window` check prevents errors in the Node.js test environment. const token = apiOptions.tokenOverride ?? (typeof window !== 'undefined' ? localStorage.getItem('authToken') : null); if (token) { headers.set('Authorization', `Bearer ${token}`); } // Do not set Content-Type for FormData. The browser must set it with the // correct multipart boundary. For all other requests, default to application/json. if (!(options.body instanceof FormData) && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } const newOptions = { ...options, headers, signal: apiOptions.signal || options.signal }; let response = await fetch(fullUrl, newOptions); if (response.status === 401) { // Prevent an infinite loop if the refresh token endpoint itself returns 401. if (fullUrl.includes('/auth/refresh-token')) { return response; } try { logger.info(`apiFetch: Received 401 for ${fullUrl}. Attempting token refresh.`); // If no refresh is in progress, start one. if (!performTokenRefreshPromise) { performTokenRefreshPromise = _performTokenRefresh(); } // Wait for the existing or new refresh operation to complete. const newToken = await performTokenRefreshPromise; logger.info(`apiFetch: Token refreshed. Retrying original request to ${fullUrl}.`); // Retry the original request with the new token. headers.set('Authorization', `Bearer ${newToken}`); response = await fetch(fullUrl, { ...options, headers }); } catch (refreshError) { // If refreshToken() fails, it will redirect, but we re-throw just in case. return Promise.reject(refreshError); } finally { // Clear the promise so the next 401 will trigger a new refresh. performTokenRefreshPromise = null; } } // --- DEBUG LOGGING for failed requests --- if (!response.ok) { const requestId = response.headers.get('x-request-id'); if (requestId && Sentry?.setTag) { Sentry.setTag('api_request_id', requestId); } const responseText = await response.clone().text(); logger.error( { url: fullUrl, status: response.status, body: responseText, requestId }, 'apiFetch: Request failed', ); } // --- END DEBUG LOGGING --- return response; }; // --- API Helper Functions --- /** Helper for public GET requests */ export const publicGet = (endpoint: string): Promise => { return fetch(`${API_BASE_URL}${endpoint}`); }; /** Helper for public POST requests with a JSON body */ export const publicPost = (endpoint: string, body: T): Promise => { return fetch(`${API_BASE_URL}${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); }; /** Helper for authenticated GET requests */ export const authedGet = (endpoint: string, options: ApiOptions = {}): Promise => { return apiFetch(endpoint, { method: 'GET' }, options); }; /** Helper for authenticated POST requests with a JSON body */ export const authedPost = ( endpoint: string, body: T, options: ApiOptions = {}, ): Promise => { return apiFetch( endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, options, ); }; /** Helper for authenticated POST requests with an empty body */ export const authedPostEmpty = (endpoint: string, options: ApiOptions = {}): Promise => { return apiFetch(endpoint, { method: 'POST' }, options); }; /** Helper for authenticated POST requests with FormData */ export const authedPostForm = ( endpoint: string, formData: FormData, options: ApiOptions = {}, ): Promise => { return apiFetch(endpoint, { method: 'POST', body: formData }, options); }; /** Helper for authenticated PUT requests with a JSON body */ export const authedPut = ( endpoint: string, body: T, options: ApiOptions = {}, ): Promise => { return apiFetch( endpoint, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, options, ); }; /** Helper for authenticated DELETE requests */ export const authedDelete = (endpoint: string, options: ApiOptions = {}): Promise => { return apiFetch(endpoint, { method: 'DELETE' }, options); }; /** Helper for authenticated DELETE requests with a JSON body */ export const authedDeleteWithBody = ( endpoint: string, body: T, options: ApiOptions = {}, ): Promise => { return apiFetch( endpoint, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }, options, ); }; /** * Pings the backend server to check if it's running and reachable. * @returns A promise that resolves to true if the server responds with 'pong'. */ export const pingBackend = (): Promise => publicGet('/health/ping'); /** * Checks the backend's database schema. * @returns A promise that resolves to an object with success status and a message. */ export const checkDbSchema = (): Promise => publicGet('/health/db-schema'); /** * Checks the backend's storage directory. * @returns A promise that resolves to an object with success status and a message. */ export const checkStorage = (): Promise => publicGet('/health/storage'); /** * Checks the backend's database connection pool health. * @returns A promise that resolves to an object with success status and a message. */ export const checkDbPoolHealth = (): Promise => publicGet('/health/db-pool'); /** * Checks the backend's Redis connection health. * @returns A promise that resolves to an object with success status and a message. */ export const checkRedisHealth = (): Promise => publicGet('/health/redis'); /** * Fetches the health status of the background job queues. * @returns A promise that resolves to the queue status object. */ export const getQueueHealth = (): Promise => publicGet('/health/queues'); /** * Checks the status of the application process managed by PM2. * This is intended for development and diagnostic purposes. */ export const checkPm2Status = (): Promise => publicGet('/system/pm2-status'); /** * Fetches flyers from the backend with pagination support. * @param limit - Maximum number of flyers to fetch (default: 20) * @param offset - Number of flyers to skip (default: 0) * @returns A promise that resolves to a paginated response of Flyer objects. */ export const fetchFlyers = (limit?: number, offset?: number): Promise => { const params = new URLSearchParams(); if (limit !== undefined) params.append('limit', limit.toString()); if (offset !== undefined) params.append('offset', offset.toString()); const queryString = params.toString(); return publicGet(queryString ? `/flyers?${queryString}` : '/flyers'); }; /** * Fetches a single flyer by its ID. * @param flyerId The ID of the flyer to fetch. * @returns A promise that resolves to the API response. */ export const fetchFlyerById = (flyerId: number): Promise => publicGet(`/flyers/${flyerId}`); /** * Fetches all master grocery items from the backend. * @returns A promise that resolves to an array of MasterGroceryItem objects. */ export const fetchMasterItems = (): Promise => { logger.debug('apiClient: fetchMasterItems called'); return publicGet('/personalization/master-items'); }; /** * Fetches all categories from the backend. * @returns A promise that resolves to an array of Category objects. */ export const fetchCategories = (): Promise => publicGet('/categories'); // --- Flyer Processing API Function --- /** * Uploads a flyer file to the backend to be processed asynchronously. * @param file The flyer file (PDF or image). * @param checksum The SHA-256 checksum of the file. * @param tokenOverride Optional token for testing. * @returns A promise that resolves to the API response, which should contain a `jobId`. */ export const uploadAndProcessFlyer = ( file: File, checksum: string, tokenOverride?: string, ): Promise => { const formData = new FormData(); formData.append('flyerFile', file); formData.append('checksum', checksum); return authedPostForm('/ai/upload-and-process', formData, { tokenOverride }); }; // --- Flyer Item API Functions --- export const fetchFlyerItems = (flyerId: number): Promise => publicGet(`/flyers/${flyerId}/items`); export const fetchFlyerItemsForFlyers = async (flyerIds: number[]): Promise => { if (flyerIds.length === 0) { return new Response(JSON.stringify([]), { headers: { 'Content-Type': 'application/json' } }); } return publicPost('/flyers/items/batch-fetch', { flyerIds }); }; export const countFlyerItemsForFlyers = async (flyerIds: number[]): Promise => { if (flyerIds.length === 0) { return new Response(JSON.stringify({ count: 0 }), { headers: { 'Content-Type': 'application/json' }, }); } return publicPost('/flyers/items/batch-count', { flyerIds }); }; // --- Store API Functions --- /** * Uploads a new logo for a store. * @param storeId The ID of the store to update. * @param logoImage The logo image file. * @returns A promise that resolves with the new logo URL. */ export const uploadLogoAndUpdateStore = ( storeId: number, logoImage: File, tokenOverride?: string, ): Promise => { const formData = new FormData(); formData.append('logoImage', logoImage); return authedPostForm(`/stores/${storeId}/logo`, formData, { tokenOverride }); }; /** * Uploads a new logo for a brand. Requires admin privileges. * @param brandId The ID of the brand to update. * @param logoImage The logo image file. * @returns A promise that resolves with the new logo URL. */ export const uploadBrandLogo = ( brandId: number, logoImage: File, tokenOverride?: string, ): Promise => { const formData = new FormData(); formData.append('logoImage', logoImage); return authedPostForm(`/admin/brands/${brandId}/logo`, formData, { tokenOverride }); }; /** * Fetches historical price data for a given list of master item IDs. * @param masterItemIds An array of master grocery item IDs. * @returns A promise that resolves to an array of historical price records. */ export const fetchHistoricalPriceData = async ( masterItemIds: number[], tokenOverride?: string, ): Promise => { if (masterItemIds.length === 0) { // Return a Response with an empty array return new Response(JSON.stringify([]), { headers: { 'Content-Type': 'application/json' } }); } return authedPost('/price-history', { masterItemIds }, { tokenOverride }); }; // --- Watched Items API Functions --- export const fetchWatchedItems = (tokenOverride?: string): Promise => authedGet('/users/watched-items', { tokenOverride }); export const addWatchedItem = ( itemName: string, category_id: number, tokenOverride?: string, ): Promise => authedPost('/users/watched-items', { itemName, category_id }, { tokenOverride }); export const removeWatchedItem = ( masterItemId: number, tokenOverride?: string, ): Promise => authedDelete(`/users/watched-items/${masterItemId}`, { tokenOverride }); /** * Fetches the best current sale prices for all of the user's watched items. * @param tokenOverride Optional token for testing. * @returns A promise that resolves to an array of WatchedItemDeal objects. */ export const fetchBestSalePrices = (tokenOverride?: string): Promise => authedGet('/users/deals/best-watched-prices', { tokenOverride }); // --- Shopping List API Functions --- export const fetchShoppingLists = (tokenOverride?: string): Promise => authedGet('/users/shopping-lists', { tokenOverride }); export const fetchShoppingListById = (listId: number, tokenOverride?: string): Promise => authedGet(`/users/shopping-lists/${listId}`, { tokenOverride }); export const createShoppingList = (name: string, tokenOverride?: string): Promise => authedPost('/users/shopping-lists', { name }, { tokenOverride }); export const deleteShoppingList = (listId: number, tokenOverride?: string): Promise => authedDelete(`/users/shopping-lists/${listId}`, { tokenOverride }); export const addShoppingListItem = ( listId: number, item: { masterItemId?: number; customItemName?: string }, tokenOverride?: string, ): Promise => authedPost(`/users/shopping-lists/${listId}/items`, item, { tokenOverride }); export const updateShoppingListItem = ( itemId: number, updates: Partial, tokenOverride?: string, ): Promise => authedPut(`/users/shopping-lists/items/${itemId}`, updates, { tokenOverride }); export const removeShoppingListItem = (itemId: number, tokenOverride?: string): Promise => authedDelete(`/users/shopping-lists/items/${itemId}`, { tokenOverride }); /** * Fetches the full profile for the currently authenticated user. * It retrieves the auth token from local storage and sends it in the Authorization header. * @returns A promise that resolves to the user's combined UserProfile object. * @throws An error if the request fails or if the user is not authenticated. */ export const getAuthenticatedUserProfile = (options: ApiOptions = {}): Promise => { // The token is now passed to apiFetch, which handles the Authorization header. // If no token is provided (in browser context), apiFetch will get it from localStorage. return authedGet('/users/profile', options); }; export async function loginUser( email: string, password: string, rememberMe: boolean, ): Promise { return publicPost('/auth/login', { email, password, rememberMe }); } // --- Receipt Processing API Functions --- /** * Uploads a receipt image to the backend for processing. * @param receiptImage The image file of the receipt. * @returns A promise that resolves with the backend's response, including the newly created receipt record. */ export const uploadReceipt = (receiptImage: File, tokenOverride?: string): Promise => { const formData = new FormData(); formData.append('receiptImage', receiptImage); return authedPostForm('/receipts/upload', formData, { tokenOverride }); }; /** * Fetches the deals found for a specific processed receipt. * @param receiptId The ID of the processed receipt. * @returns A promise that resolves to an array of ReceiptDeal objects. */ export const getDealsForReceipt = (receiptId: number, tokenOverride?: string): Promise => authedGet(`/receipts/${receiptId}/deals`, { tokenOverride }); // --- Analytics & Shopping Enhancement API Functions --- export const trackFlyerItemInteraction = async ( itemId: number, type: 'view' | 'click', ): Promise => { // Add 'return' here so the promise chain is returned to the caller return apiFetch(`/flyer-items/${itemId}/track`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type }), keepalive: true, // Helps ensure the request is sent even if the page is closing }) .then(() => {}) // Ensure return type is Promise .catch((error) => logger.warn('Failed to track flyer item interaction', { error })); }; export const logSearchQuery = async ( query: Omit, tokenOverride?: string, ): Promise => { // Add 'return' here return apiFetch( `/search/log`, { method: 'POST', body: JSON.stringify(query), keepalive: true, }, { tokenOverride }, ) .then(() => {}) // Ensure return type is Promise .catch((error) => logger.warn('Failed to log search query', { error })); }; export const getPantryLocations = (tokenOverride?: string): Promise => authedGet('/pantry/locations', { tokenOverride }); export const createPantryLocation = (name: string, tokenOverride?: string): Promise => authedPost('/pantry/locations', { name }, { tokenOverride }); export const completeShoppingList = ( shoppingListId: number, totalSpentCents?: number, tokenOverride?: string, ): Promise => authedPost( `/users/shopping-lists/${shoppingListId}/complete`, { totalSpentCents }, { tokenOverride }, ); export const getShoppingTripHistory = (tokenOverride?: string): Promise => authedGet('/users/shopping-history', { tokenOverride }); // --- Personalization & Social API Functions --- export const getDietaryRestrictions = (): Promise => publicGet('/personalization/dietary-restrictions'); export const getAppliances = (): Promise => publicGet('/personalization/appliances'); export const getUserDietaryRestrictions = (tokenOverride?: string): Promise => authedGet('/users/me/dietary-restrictions', { tokenOverride }); export const setUserDietaryRestrictions = ( restrictionIds: number[], tokenOverride?: string, ): Promise => authedPut('/users/me/dietary-restrictions', { restrictionIds }, { tokenOverride }); export const getCompatibleRecipes = (tokenOverride?: string): Promise => authedGet('/users/me/compatible-recipes', { tokenOverride }); export const getUserFeed = ( limit: number = 20, offset: number = 0, tokenOverride?: string, ): Promise => authedGet(`/users/feed?limit=${limit}&offset=${offset}`, { tokenOverride }); export const forkRecipe = (originalRecipeId: number, tokenOverride?: string): Promise => authedPostEmpty(`/recipes/${originalRecipeId}/fork`, { tokenOverride }); export const followUser = (userIdToFollow: string, tokenOverride?: string): Promise => authedPostEmpty(`/users/${userIdToFollow}/follow`, { tokenOverride }); export const unfollowUser = (userIdToUnfollow: string, tokenOverride?: string): Promise => authedDelete(`/users/${userIdToUnfollow}/follow`, { tokenOverride }); // --- Activity Log API Function --- /** * Fetches a paginated list of recent activities from the backend. * @param limit The number of items to fetch. * @param offset The starting offset for pagination. * @returns A promise that resolves to an array of ActivityLogItem objects. */ export const fetchActivityLog = ( limit: number = 20, offset: number = 0, tokenOverride?: string, ): Promise => authedGet(`/admin/activity-log?limit=${limit}&offset=${offset}`, { tokenOverride }); // --- Favorite Recipes API Functions --- /** * Retrieves a list of the currently authenticated user's favorite recipes. * @param tokenOverride Optional token for testing purposes. * @returns A promise that resolves to the API response. */ export const getUserFavoriteRecipes = (tokenOverride?: string): Promise => authedGet('/users/me/favorite-recipes', { tokenOverride }); export const addFavoriteRecipe = (recipeId: number, tokenOverride?: string): Promise => authedPost('/users/me/favorite-recipes', { recipeId }, { tokenOverride }); export const removeFavoriteRecipe = (recipeId: number, tokenOverride?: string): Promise => authedDelete(`/users/me/favorite-recipes/${recipeId}`, { tokenOverride }); // --- Recipe Comments API Functions --- export const getRecipeComments = (recipeId: number): Promise => publicGet(`/recipes/${recipeId}/comments`); /** * Fetches a single recipe by its ID. * @param recipeId The ID of the recipe to fetch. * @returns A promise that resolves to the API response. */ export const getRecipeById = (recipeId: number): Promise => publicGet(`/recipes/${recipeId}`); export const addRecipeComment = ( recipeId: number, content: string, parentCommentId?: number, tokenOverride?: string, ): Promise => authedPost(`/recipes/${recipeId}/comments`, { content, parentCommentId }, { tokenOverride }); /** * Requests a simple recipe suggestion from the AI based on a list of ingredients. * @param ingredients An array of ingredient strings. * @param tokenOverride Optional token for testing. * @returns A promise that resolves to the API response containing the suggestion. */ export const suggestRecipe = (ingredients: string[], tokenOverride?: string): Promise => { // This is a protected endpoint, so we use authedPost. return authedPost('/recipes/suggest', { ingredients }, { tokenOverride }); }; /** * Deletes a recipe. * @param recipeId The ID of the recipe to delete. * @param tokenOverride Optional token for testing purposes. * @returns {Promise} A promise that resolves to the API response. */ export const deleteRecipe = (recipeId: number, tokenOverride?: string): Promise => authedDelete(`/recipes/${recipeId}`, { tokenOverride }); // --- Admin API Functions for New Features --- export const getUnmatchedFlyerItems = (tokenOverride?: string): Promise => authedGet('/admin/unmatched-items', { tokenOverride }); export const updateRecipeStatus = ( recipeId: number, status: 'private' | 'pending_review' | 'public' | 'rejected', tokenOverride?: string, ): Promise => authedPut(`/admin/recipes/${recipeId}/status`, { status }, { tokenOverride }); export const updateRecipeCommentStatus = ( commentId: number, status: 'visible' | 'hidden' | 'reported', tokenOverride?: string, ): Promise => authedPut(`/admin/comments/${commentId}/status`, { status }, { tokenOverride }); /** * Fetches all brands from the backend. Requires admin privileges. * @returns A promise that resolves to an array of Brand objects. */ export const fetchAllBrands = (tokenOverride?: string): Promise => authedGet('/admin/brands', { tokenOverride }); export interface AppStats { // This interface should ideally be in types.ts flyerCount: number; userCount: number; flyerItemCount: number; storeCount: number; pendingCorrectionCount: number; recipeCount: number; } export interface DailyStat { date: string; new_users: number; new_flyers: number; } /** * Fetches daily user registration and flyer upload stats for the last 30 days. * @returns A promise that resolves to an array of daily stat objects. */ export const getDailyStats = (tokenOverride?: string): Promise => authedGet('/admin/stats/daily', { tokenOverride }); /** * Fetches application-wide statistics. Requires admin privileges. * @returns A promise that resolves to an object containing app stats. */ export const getApplicationStats = (tokenOverride?: string): Promise => authedGet('/admin/stats', { tokenOverride }); // --- Admin Correction API Functions --- export const getSuggestedCorrections = (tokenOverride?: string): Promise => authedGet('/admin/corrections', { tokenOverride }); export const getFlyersForReview = (tokenOverride?: string): Promise => { logger.debug('apiClient: calling getFlyersForReview'); return authedGet('/admin/review/flyers', { tokenOverride }); }; export const approveCorrection = ( correctionId: number, tokenOverride?: string, ): Promise => authedPostEmpty(`/admin/corrections/${correctionId}/approve`, { tokenOverride }); export const rejectCorrection = (correctionId: number, tokenOverride?: string): Promise => authedPostEmpty(`/admin/corrections/${correctionId}/reject`, { tokenOverride }); export const updateSuggestedCorrection = ( correctionId: number, newSuggestedValue: string, tokenOverride?: string, ): Promise => authedPut( `/admin/corrections/${correctionId}`, { suggested_value: newSuggestedValue }, { tokenOverride }, ); /** * Enqueues a job to clean up the files associated with a specific flyer. * Requires admin privileges. * @param flyerId The ID of the flyer to clean up. * @param tokenOverride Optional token for testing. */ export const cleanupFlyerFiles = (flyerId: number, tokenOverride?: string): Promise => authedPostEmpty(`/admin/flyers/${flyerId}/cleanup`, { tokenOverride }); /** * Enqueues a test job designed to fail, for testing the Bull Board UI. * Requires admin privileges. * @param tokenOverride Optional token for testing. */ export const triggerFailingJob = (tokenOverride?: string): Promise => authedPostEmpty('/admin/trigger/failing-job', { tokenOverride }); /** * Fetches the status of a background processing job. * @param jobId The ID of the job to check. * @param tokenOverride Optional token for testing. * @returns A promise that resolves to the API response with the job's status. */ export const getJobStatus = (jobId: string, tokenOverride?: string): Promise => authedGet(`/ai/jobs/${jobId}/status`, { tokenOverride }); /** * Refreshes an access token using a refresh token cookie. * This is intended for use in Node.js test environments where cookies must be set manually. * @param cookie The full 'Cookie' header string (e.g., "refreshToken=..."). * @returns A promise that resolves to the fetch Response. */ export async function refreshToken(cookie: string) { const url = joinUrl(API_BASE_URL, '/auth/refresh-token'); const options: RequestInit = { method: 'POST', headers: { 'Content-Type': 'application/json', // The browser would handle this automatically, but in Node.js tests we must set it manually. Cookie: cookie, }, }; return fetch(url, options); } /** * Triggers the clearing of the geocoding cache on the server. * Requires admin privileges. * @param tokenOverride Optional token for testing. */ export const clearGeocodeCache = (tokenOverride?: string): Promise => authedPostEmpty('/admin/system/clear-geocode-cache', { tokenOverride }); export function registerUser( email: string, password: string, fullName?: string, avatarUrl?: string, ): Promise { return publicPost('/auth/register', { email, password, full_name: fullName, avatar_url: avatarUrl, }); } /** * Sends a password reset request for the given email to the backend. * @param email The user's email address. * @returns A promise that resolves with a success message. */ export function requestPasswordReset(email: string): Promise { return publicPost('/auth/forgot-password', { email }); } /** * Sends the password reset token and new password to the backend. * @param token The password reset token from the URL. * @param newPassword The user's new password. * @returns A promise that resolves with a success message. */ export function resetPassword(token: string, newPassword: string): Promise { return publicPost('/auth/reset-password', { token, newPassword }); } /** * Sends updated user preferences to the backend. * @param preferences A partial object of the user's preferences to update. * @returns A promise that resolves to the user's full, updated profile object. */ export async function updateUserPreferences( preferences: Partial, apiOptions: ApiOptions = {}, ): Promise { return authedPut('/users/profile/preferences', preferences, apiOptions); } /** * Sends updated user profile data (name, avatar) to the backend. * @param profileData An object containing the full_name and/or avatar_url to update. * @returns A promise that resolves to the user's full, updated profile object. */ export function updateUserProfile( profileData: Partial, apiOptions: ApiOptions = {}, ): Promise { return authedPut('/users/profile', profileData, apiOptions); } /** * Fetches a complete export of the user's data from the backend. * @returns A promise that resolves to a JSON object of the user's data. */ export function exportUserData(apiOptions: ApiOptions = {}): Promise { return authedGet('/users/data-export', apiOptions); } export const getUserAppliances = (tokenOverride?: string): Promise => authedGet('/users/appliances', { tokenOverride }); /** * Sets the kitchen appliances for the currently authenticated user. * This will replace all existing appliances with the new set. * @param applianceIds An array of numbers representing the IDs of the selected appliances. */ export const setUserAppliances = ( applianceIds: number[], apiOptions: ApiOptions = {}, ): Promise => authedPut('/users/appliances', { applianceIds }, apiOptions); /** * Sends an address string to the backend to be geocoded. * @param address The full address string. * @param tokenOverride Optional token for testing. */ export const geocodeAddress = (address: string, apiOptions: ApiOptions = {}): Promise => authedPost('/system/geocode', { address }, apiOptions); /** * Fetches a specific address by its ID. * @param addressId The ID of the address to fetch. * @param tokenOverride Optional token for testing. */ export const getUserAddress = (addressId: number, apiOptions: ApiOptions = {}): Promise => authedGet(`/users/addresses/${addressId}`, apiOptions); /** * Creates or updates the authenticated user's primary address. * @param addressData The full address object. * @param tokenOverride Optional token for testing. */ export const updateUserAddress = ( addressData: Partial
, apiOptions: ApiOptions = {}, ): Promise => authedPut('/users/profile/address', addressData, apiOptions); /** * Sends a new password to the backend to be updated. * @param newPassword The user's new password. * @returns A promise that resolves on success. */ export async function updateUserPassword( newPassword: string, apiOptions: ApiOptions = {}, ): Promise { return authedPut('/users/profile/password', { newPassword }, apiOptions); } /** * Updates the role of a specific user. * @param userId The ID of the user to update. * @param role The new role to assign ('user' or 'admin'). * @returns A promise that resolves to the updated Profile object. */ export async function updateUserRole( userId: string, role: 'user' | 'admin', apiOptions: ApiOptions = {}, ): Promise { return authedPut(`/admin/users/${userId}/role`, { role }, apiOptions); } /** * Sends a request to delete the user's account, verifying with their current password. * @param password The user's current password for verification. * @returns A promise that resolves on success. */ export async function deleteUserAccount( password: string, apiOptions: ApiOptions = {}, ): Promise { return authedDeleteWithBody('/users/account', { password }, apiOptions); } // --- Notification API Functions --- /** * Fetches notifications for the authenticated user. * @param limit The number of notifications to fetch. * @param offset The number of notifications to skip for pagination. * @returns A promise that resolves to the API response. */ export const getNotifications = ( limit: number = 20, offset: number = 0, tokenOverride?: string, ): Promise => authedGet(`/users/notifications?limit=${limit}&offset=${offset}`, { tokenOverride }); /** * Marks all of the user's unread notifications as read. * @returns A promise that resolves to the API response. */ export const markAllNotificationsAsRead = (tokenOverride?: string): Promise => authedPostEmpty('/users/notifications/mark-all-read', { tokenOverride }); /** * Marks a single notification as read. * @param notificationId The ID of the notification to mark as read. * @returns A promise that resolves to the API response. */ export const markNotificationAsRead = ( notificationId: number, tokenOverride?: string, ): Promise => authedPostEmpty(`/users/notifications/${notificationId}/mark-read`, { tokenOverride }); // --- Budgeting and Spending Analysis API Functions --- /** * Fetches all budgets for the authenticated user. * @returns A promise that resolves to the API response. */ export const getBudgets = (tokenOverride?: string): Promise => authedGet('/budgets', { tokenOverride }); /** * Creates a new budget for the authenticated user. * @param budgetData The data for the new budget. * @returns A promise that resolves to the API response. */ export const createBudget = ( budgetData: Omit, tokenOverride?: string, ): Promise => authedPost('/budgets', budgetData, { tokenOverride }); /** * Updates an existing budget for the authenticated user. * @param budgetId The ID of the budget to update. * @param budgetData The data to update. * @returns A promise that resolves to the API response. */ export const updateBudget = ( budgetId: number, budgetData: Partial>, tokenOverride?: string, ): Promise => authedPut(`/budgets/${budgetId}`, budgetData, { tokenOverride }); /** * Deletes a budget for the authenticated user. * @param budgetId The ID of the budget to delete. * @returns A promise that resolves to the API response. */ export const deleteBudget = (budgetId: number, tokenOverride?: string): Promise => authedDelete(`/budgets/${budgetId}`, { tokenOverride }); /** * Fetches the user's spending analysis for a given date range. * @param startDate The start date of the analysis period (YYYY-MM-DD). * @param endDate The end date of the analysis period (YYYY-MM-DD). * @returns A promise that resolves to the API response. */ export const getSpendingAnalysis = ( startDate: string, endDate: string, tokenOverride?: string, ): Promise => authedGet(`/budgets/spending-analysis?startDate=${startDate}&endDate=${endDate}`, { tokenOverride, }); // --- Gamification API Functions --- /** * Fetches the master list of all available achievements in the system. * This is a public endpoint. * @returns A promise that resolves to the API response. */ export const getAchievements = (): Promise => publicGet('/achievements'); /** * Fetches all achievements earned by the currently authenticated user. * @param tokenOverride Optional token for testing purposes. * @returns A promise that resolves to the API response. */ export const getUserAchievements = (tokenOverride?: string): Promise => authedGet('/achievements/me', { tokenOverride }); /** * Fetches the public leaderboard of top users by points. * @param limit The number of users to fetch. Defaults to 10. * @returns A promise that resolves to the API response. */ export const fetchLeaderboard = (limit: number = 10): Promise => publicGet(`/achievements/leaderboard?limit=${limit}`); /** * Uploads a new avatar image for the authenticated user. * @param avatarFile The image file to upload. * @param tokenOverride Optional token for testing purposes. * @returns A promise that resolves to the API response containing the updated profile. */ export const uploadAvatar = (avatarFile: File, tokenOverride?: string): Promise => { const formData = new FormData(); formData.append('avatar', avatarFile); return authedPostForm('/users/profile/avatar', formData, { tokenOverride }); }; // --- Store Management API Functions --- /** * Fetches all stores with optional location data. * @param includeLocations Whether to include store locations and addresses. * @returns A promise that resolves to the API response. */ export const getStores = (includeLocations: boolean = false): Promise => publicGet(`/stores${includeLocations ? '?includeLocations=true' : ''}`); /** * Fetches a single store by ID with its locations. * @param storeId The store ID to fetch. * @returns A promise that resolves to the API response. */ export const getStoreById = (storeId: number): Promise => publicGet(`/stores/${storeId}`); /** * Creates a new store with optional address. * @param storeData The store data (name, optional logo_url, optional address). * @param tokenOverride Optional token for testing purposes. * @returns A promise that resolves to the API response containing the created store. */ export const createStore = ( storeData: { name: string; logo_url?: string; address?: { address_line_1: string; city: string; province_state: string; postal_code: string; country?: string; }; }, tokenOverride?: string, ): Promise => authedPost('/stores', storeData, { tokenOverride }); /** * Updates an existing store's name and/or logo. * @param storeId The store ID to update. * @param updates The fields to update (name and/or logo_url). * @param tokenOverride Optional token for testing purposes. * @returns A promise that resolves to the API response. */ export const updateStore = ( storeId: number, updates: { name?: string; logo_url?: string }, tokenOverride?: string, ): Promise => authedPut(`/stores/${storeId}`, updates, { tokenOverride }); /** * Deletes a store (admin only). * @param storeId The store ID to delete. * @param tokenOverride Optional token for testing purposes. * @returns A promise that resolves to the API response. */ export const deleteStore = (storeId: number, tokenOverride?: string): Promise => authedDelete(`/stores/${storeId}`, { tokenOverride }); /** * Adds a new location to an existing store. * @param storeId The store ID to add a location to. * @param address The address data for the new location. * @param tokenOverride Optional token for testing purposes. * @returns A promise that resolves to the API response. */ export const addStoreLocation = ( storeId: number, address: { address_line_1: string; city: string; province_state: string; postal_code: string; country?: string; }, tokenOverride?: string, ): Promise => authedPost(`/stores/${storeId}/locations`, { address }, { tokenOverride }); /** * Removes a location from a store. * @param storeId The store ID. * @param locationId The store_location_id to remove. * @param tokenOverride Optional token for testing purposes. * @returns A promise that resolves to the API response. */ export const deleteStoreLocation = ( storeId: number, locationId: number, tokenOverride?: string, ): Promise => authedDelete(`/stores/${storeId}/locations/${locationId}`, { tokenOverride });