// src/services/apiClient.ts import { Profile, ShoppingListItem, SearchQuery, Budget, Address } from '../types'; import { logger } from './logger.client'; import { eventBus } from './eventBus'; // 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 refreshTokenPromise: 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 refreshToken = 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. * All authenticated API calls should use this function. * @param url The URL to fetch. * @param options The fetch options. * @returns A promise that resolves to the fetch Response. */ 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(`apiFetch: ${options.method || 'GET'} ${fullUrl}`); // 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 (!refreshTokenPromise) { refreshTokenPromise = refreshToken(); } // Wait for the existing or new refresh operation to complete. const newToken = await refreshTokenPromise; 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. refreshTokenPromise = null; } } // --- DEBUG LOGGING for failed requests --- if (!response.ok) { const responseText = await response.clone().text(); logger.error({ url: fullUrl, status: response.status, body: responseText }, 'apiFetch: Request failed', ); } // --- END DEBUG LOGGING --- return response; }; /** * 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 = async (): Promise => { // This should return the response for the caller to handle. return fetch(`${API_BASE_URL}/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 = async (): Promise => { return fetch(`${API_BASE_URL}/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 = async (): Promise => { return fetch(`${API_BASE_URL}/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 = async (): Promise => { return fetch(`${API_BASE_URL}/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 = async (): Promise => { return fetch(`${API_BASE_URL}/health/redis`); }; /** * Checks the status of the application process managed by PM2. * This is intended for development and diagnostic purposes. */ export const checkPm2Status = async (): Promise => { // This is a public health check, so we can use standard fetch. return fetch(`${API_BASE_URL}/system/pm2-status`); }; /** * Fetches all flyers from the backend. * @returns A promise that resolves to an array of Flyer objects. */ export const fetchFlyers = async (): Promise => { return fetch(`${API_BASE_URL}/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 = async (flyerId: number): Promise => { return fetch(`${API_BASE_URL}/flyers/${flyerId}`); }; /** * Fetches all master grocery items from the backend. * @returns A promise that resolves to an array of MasterGroceryItem objects. */ export const fetchMasterItems = async (): Promise => { return fetch(`${API_BASE_URL}/personalization/master-items`); }; /** * Fetches all categories from the backend. * @returns A promise that resolves to an array of Category objects. */ export const fetchCategories = async (): Promise => { return fetch(`${API_BASE_URL}/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 = async ( file: File, checksum: string, tokenOverride?: string, ): Promise => { const formData = new FormData(); formData.append('flyerFile', file); formData.append('checksum', checksum); return apiFetch( '/ai/upload-and-process', { method: 'POST', body: formData, }, { tokenOverride }, ); }; // --- Flyer Item API Functions --- export const fetchFlyerItems = async (flyerId: number): Promise => { return fetch(`${API_BASE_URL}/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 fetch(`${API_BASE_URL}/flyers/items/batch-fetch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ 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 fetch(`${API_BASE_URL}/flyers/items/batch-count`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ 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 = async ( storeId: number, logoImage: File, tokenOverride?: string, ): Promise => { const formData = new FormData(); formData.append('logoImage', logoImage); // Use apiFetch to ensure the user is authenticated to perform this action. return apiFetch( `/stores/${storeId}/logo`, { method: 'POST', body: formData, // Do not set Content-Type for FormData, browser handles it. }, { 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 = async ( brandId: number, logoImage: File, tokenOverride?: string, ): Promise => { const formData = new FormData(); formData.append('logoImage', logoImage); // Use apiFetch to ensure the user is an authenticated admin. return apiFetch( `/admin/brands/${brandId}/logo`, { method: 'POST', body: formData, // Do not set Content-Type for FormData, browser handles it. }, { 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 apiFetch( `/price-history`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ masterItemIds }), }, { tokenOverride }, ); }; // --- Watched Items API Functions --- export const fetchWatchedItems = async (tokenOverride?: string): Promise => { return apiFetch(`/users/watched-items`, {}, { tokenOverride }); }; export const addWatchedItem = async ( itemName: string, category: string, tokenOverride?: string, ): Promise => { return apiFetch( `/users/watched-items`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemName, category }), }, { tokenOverride }, ); }; export const removeWatchedItem = async ( masterItemId: number, tokenOverride?: string, ): Promise => { return apiFetch( `/users/watched-items/${masterItemId}`, { method: 'DELETE', }, { 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 = async (tokenOverride?: string): Promise => { // This endpoint assumes an authenticated user session. return apiFetch(`/users/deals/best-watched-prices`, {}, { tokenOverride }); }; // --- Shopping List API Functions --- export const fetchShoppingLists = async (tokenOverride?: string): Promise => { return apiFetch(`/users/shopping-lists`, {}, { tokenOverride }); }; export const fetchShoppingListById = async ( listId: number, tokenOverride?: string, ): Promise => { return apiFetch(`/users/shopping-lists/${listId}`, {}, { tokenOverride }); }; export const createShoppingList = async ( name: string, tokenOverride?: string, ): Promise => { return apiFetch( `/users/shopping-lists`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }, { tokenOverride }, ); }; export const deleteShoppingList = async ( listId: number, tokenOverride?: string, ): Promise => { return apiFetch( `/users/shopping-lists/${listId}`, { method: 'DELETE', }, { tokenOverride }, ); }; export const addShoppingListItem = async ( listId: number, item: { masterItemId?: number; customItemName?: string }, tokenOverride?: string, ): Promise => { return apiFetch( `/users/shopping-lists/${listId}/items`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(item), }, { tokenOverride }, ); }; export const updateShoppingListItem = async ( itemId: number, updates: Partial, tokenOverride?: string, ): Promise => { return apiFetch( `/users/shopping-lists/items/${itemId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), }, { tokenOverride }, ); }; export const removeShoppingListItem = async ( itemId: number, tokenOverride?: string, ): Promise => { return apiFetch( `/users/shopping-lists/items/${itemId}`, { method: 'DELETE', }, { 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 = async (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 apiFetch( `/users/profile`, { method: 'GET', }, options, ); }; export async function loginUser( email: string, password: string, rememberMe: boolean, ): Promise { // This function already returns a Response, so it's correct. // I'm just simplifying it slightly. return fetch(`${API_BASE_URL}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ 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 = async ( receiptImage: File, tokenOverride?: string, ): Promise => { const formData = new FormData(); formData.append('receiptImage', receiptImage); // Use apiFetch, which now correctly handles FormData. return apiFetch( `/receipts/upload`, { method: 'POST', body: 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 = async ( receiptId: number, tokenOverride?: string, ): Promise => { return apiFetch(`/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 = async (tokenOverride?: string): Promise => { return apiFetch(`/pantry/locations`, {}, { tokenOverride }); }; export const createPantryLocation = async ( name: string, tokenOverride?: string, ): Promise => { return apiFetch( `/pantry/locations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }, { tokenOverride }, ); }; export const completeShoppingList = async ( shoppingListId: number, totalSpentCents?: number, tokenOverride?: string, ): Promise => { return apiFetch( `/users/shopping-lists/${shoppingListId}/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ totalSpentCents }), }, { tokenOverride }, ); }; export const getShoppingTripHistory = async (tokenOverride?: string): Promise => { return apiFetch(`/users/shopping-history`, {}, { tokenOverride }); }; // --- Personalization & Social API Functions --- export const getDietaryRestrictions = async (): Promise => { return fetch(`${API_BASE_URL}/personalization/dietary-restrictions`); }; export const getAppliances = async (): Promise => { return fetch(`${API_BASE_URL}/personalization/appliances`); }; export const getUserDietaryRestrictions = async (tokenOverride?: string): Promise => { return apiFetch(`/users/me/dietary-restrictions`, {}, { tokenOverride }); }; export const setUserDietaryRestrictions = async ( restrictionIds: number[], tokenOverride?: string, ): Promise => { return apiFetch( `/users/me/dietary-restrictions`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ restrictionIds }), }, { tokenOverride }, ); }; export const getCompatibleRecipes = async (tokenOverride?: string): Promise => { return apiFetch(`/users/me/compatible-recipes`, {}, { tokenOverride }); }; export const getUserFeed = async ( limit: number = 20, offset: number = 0, tokenOverride?: string, ): Promise => { return apiFetch(`/users/feed?limit=${limit}&offset=${offset}`, {}, { tokenOverride }); }; export const forkRecipe = async ( originalRecipeId: number, tokenOverride?: string, ): Promise => { return apiFetch( `/recipes/${originalRecipeId}/fork`, { method: 'POST', }, { tokenOverride }, ); }; export const followUser = async ( userIdToFollow: string, tokenOverride?: string, ): Promise => { return apiFetch( `/users/${userIdToFollow}/follow`, { method: 'POST', }, { tokenOverride }, ); }; export const unfollowUser = async ( userIdToUnfollow: string, tokenOverride?: string, ): Promise => { return apiFetch( `/users/${userIdToUnfollow}/follow`, { method: 'DELETE', }, { 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 = async ( limit: number = 20, offset: number = 0, tokenOverride?: string, ): Promise => { return apiFetch(`/admin/activity-log?limit=${limit}&offset=${offset}`, {}, { tokenOverride }); }; // --- Favorite Recipes API Functions --- /** * Retrieves a list of the currently authenticated user's favorite recipes. * @param {string} [tokenOverride] Optional token for testing purposes. * @returns {Promise} A promise that resolves to the API response. */ export const getUserFavoriteRecipes = async (tokenOverride?: string): Promise => { return apiFetch(`/users/me/favorite-recipes`, {}, { tokenOverride }); }; export const addFavoriteRecipe = async ( recipeId: number, tokenOverride?: string, ): Promise => { return apiFetch( `/users/me/favorite-recipes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ recipeId }), }, { tokenOverride }, ); }; export const removeFavoriteRecipe = async ( recipeId: number, tokenOverride?: string, ): Promise => { return apiFetch( `/users/me/favorite-recipes/${recipeId}`, { method: 'DELETE', }, { tokenOverride }, ); }; // --- Recipe Comments API Functions --- export const getRecipeComments = async (recipeId: number): Promise => { // This is a public endpoint, so we can use standard fetch. return fetch(`${API_BASE_URL}/recipes/${recipeId}/comments`); // This was a duplicate, fixed. }; /** * 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 = async (recipeId: number): Promise => { return fetch(`${API_BASE_URL}/recipes/${recipeId}`); }; export const addRecipeComment = async ( recipeId: number, content: string, parentCommentId?: number, tokenOverride?: string, ): Promise => { return apiFetch( `/recipes/${recipeId}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, parentCommentId }), }, { 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 = async (recipeId: number, tokenOverride?: string): Promise => { return apiFetch(`/recipes/${recipeId}`, { method: 'DELETE' }, { tokenOverride }); }; // --- Admin API Functions for New Features --- export const getUnmatchedFlyerItems = async (tokenOverride?: string): Promise => { return apiFetch(`${API_BASE_URL}/admin/unmatched-items`, {}, { tokenOverride }); }; export const updateRecipeStatus = async ( recipeId: number, status: 'private' | 'pending_review' | 'public' | 'rejected', tokenOverride?: string, ): Promise => { return apiFetch( `/admin/recipes/${recipeId}/status`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }), }, { tokenOverride }, ); }; export const updateRecipeCommentStatus = async ( commentId: number, status: 'visible' | 'hidden' | 'reported', tokenOverride?: string, ): Promise => { return apiFetch( `/admin/comments/${commentId}/status`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ 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 = async (tokenOverride?: string): Promise => { return apiFetch(`/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 = async (tokenOverride?: string): Promise => { return apiFetch(`${API_BASE_URL}/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 = async (tokenOverride?: string): Promise => { return apiFetch(`${API_BASE_URL}/admin/stats`, {}, { tokenOverride }); }; // --- Admin Correction API Functions --- export const getSuggestedCorrections = async (tokenOverride?: string): Promise => { return apiFetch(`${API_BASE_URL}/admin/corrections`, {}, { tokenOverride }); }; export const approveCorrection = async ( correctionId: number, tokenOverride?: string, ): Promise => { return apiFetch( `/admin/corrections/${correctionId}/approve`, { method: 'POST', }, { tokenOverride }, ); }; export const rejectCorrection = async ( correctionId: number, tokenOverride?: string, ): Promise => { return apiFetch( `/admin/corrections/${correctionId}/reject`, { method: 'POST', }, { tokenOverride }, ); }; export const updateSuggestedCorrection = async ( correctionId: number, newSuggestedValue: string, tokenOverride?: string, ): Promise => { return apiFetch( `/admin/corrections/${correctionId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ 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 = async ( flyerId: number, tokenOverride?: string, ): Promise => { return apiFetch(`/admin/flyers/${flyerId}/cleanup`, { method: 'POST' }, { 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 = async (tokenOverride?: string): Promise => { // This is an admin-only endpoint, so we use apiFetch to include the auth token. return apiFetch(`/admin/trigger/failing-job`, { method: 'POST' }, { 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 = async (jobId: string, tokenOverride?: string): Promise => { return apiFetch(`/ai/jobs/${jobId}/status`, {}, { tokenOverride }); }; /** * Triggers the clearing of the geocoding cache on the server. * Requires admin privileges. * @param tokenOverride Optional token for testing. */ export const clearGeocodeCache = async (tokenOverride?: string): Promise => { return apiFetch(`/admin/system/clear-geocode-cache`, { method: 'POST' }, { tokenOverride }); }; export async function registerUser( email: string, password: string, fullName?: string, avatarUrl?: string, ): Promise { return fetch(`${API_BASE_URL}/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ 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 async function requestPasswordReset(email: string): Promise { return fetch(`${API_BASE_URL}/auth/forgot-password`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ 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 async function resetPassword(token: string, newPassword: string): Promise { return fetch(`${API_BASE_URL}/auth/reset-password`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ 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 apiFetch( `${API_BASE_URL}/users/profile/preferences`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(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 async function updateUserProfile( profileData: Partial, apiOptions: ApiOptions = {}, ): Promise { return apiFetch( `${API_BASE_URL}/users/profile`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(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 async function exportUserData(apiOptions: ApiOptions = {}): Promise { return apiFetch( `${API_BASE_URL}/users/data-export`, { method: 'GET', }, apiOptions, ); } export const getUserAppliances = async (tokenOverride?: string): Promise => { return apiFetch(`/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 = async ( applianceIds: number[], apiOptions: ApiOptions = {}, ): Promise => { return apiFetch( `/users/appliances`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ 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 = async ( address: string, apiOptions: ApiOptions = {}, ): Promise => { return apiFetch( `/system/geocode`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ 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 = async ( addressId: number, apiOptions: ApiOptions = {}, ): Promise => { return apiFetch(`/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 = async ( addressData: Partial
, apiOptions: ApiOptions = {}, ): Promise => { return apiFetch( `/users/profile/address`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(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 apiFetch( `/users/profile/password`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ 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 apiFetch( `/admin/users/${userId}/role`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ 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 apiFetch( `/users/account`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ 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 = async ( limit: number = 20, offset: number = 0, tokenOverride?: string, ): Promise => { return apiFetch(`/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 = async (tokenOverride?: string): Promise => { return apiFetch(`/users/notifications/mark-all-read`, { method: 'POST' }, { 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 = async ( notificationId: number, tokenOverride?: string, ): Promise => { return apiFetch( `/users/notifications/${notificationId}/mark-read`, { method: 'POST' }, { 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 = async (tokenOverride?: string): Promise => { return apiFetch(`/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 = async ( budgetData: Omit, tokenOverride?: string, ): Promise => { return apiFetch( `/budgets`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(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 = async ( budgetId: number, budgetData: Partial>, tokenOverride?: string, ): Promise => { return apiFetch( `/budgets/${budgetId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(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 = async (budgetId: number, tokenOverride?: string): Promise => { return apiFetch(`/budgets/${budgetId}`, { method: 'DELETE' }, { 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 = async ( startDate: string, endDate: string, tokenOverride?: string, ): Promise => { return apiFetch( `/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 = async (): Promise => { // This is a public endpoint, so we can use standard fetch. return fetch(`${API_BASE_URL}/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 = async (tokenOverride?: string): Promise => { return apiFetch(`/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 = async (limit: number = 10): Promise => { // This is a public endpoint, so we can use standard fetch. return fetch(`${API_BASE_URL}/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 = async (avatarFile: File, tokenOverride?: string): Promise => { const formData = new FormData(); formData.append('avatar', avatarFile); // Use apiFetch, which now correctly handles FormData. return apiFetch('/users/profile/avatar', { method: 'POST', body: formData }, { tokenOverride }); };