Files
Torben Sorensen 5879328b67
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m34s
fixing categories 3rd normal form
2026-01-19 19:13:30 -08:00

1180 lines
42 KiB
TypeScript

// 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<string> | 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<string> => {
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<Response> => {
// 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<Response> => {
return fetch(`${API_BASE_URL}${endpoint}`);
};
/** Helper for public POST requests with a JSON body */
export const publicPost = <T>(endpoint: string, body: T): Promise<Response> => {
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<Response> => {
return apiFetch(endpoint, { method: 'GET' }, options);
};
/** Helper for authenticated POST requests with a JSON body */
export const authedPost = <T>(
endpoint: string,
body: T,
options: ApiOptions = {},
): Promise<Response> => {
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<Response> => {
return apiFetch(endpoint, { method: 'POST' }, options);
};
/** Helper for authenticated POST requests with FormData */
export const authedPostForm = (
endpoint: string,
formData: FormData,
options: ApiOptions = {},
): Promise<Response> => {
return apiFetch(endpoint, { method: 'POST', body: formData }, options);
};
/** Helper for authenticated PUT requests with a JSON body */
export const authedPut = <T>(
endpoint: string,
body: T,
options: ApiOptions = {},
): Promise<Response> => {
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<Response> => {
return apiFetch(endpoint, { method: 'DELETE' }, options);
};
/** Helper for authenticated DELETE requests with a JSON body */
export const authedDeleteWithBody = <T>(
endpoint: string,
body: T,
options: ApiOptions = {},
): Promise<Response> => {
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<Response> => 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<Response> => 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<Response> => 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<Response> => 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<Response> => 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<Response> => 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<Response> => 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<Response> => {
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<Response> =>
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<Response> => {
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<Response> => 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<Response> => {
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<Response> =>
publicGet(`/flyers/${flyerId}/items`);
export const fetchFlyerItemsForFlyers = async (flyerIds: number[]): Promise<Response> => {
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<Response> => {
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<Response> => {
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<Response> => {
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<Response> => {
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<Response> =>
authedGet('/users/watched-items', { tokenOverride });
export const addWatchedItem = (
itemName: string,
category_id: number,
tokenOverride?: string,
): Promise<Response> =>
authedPost('/users/watched-items', { itemName, category_id }, { tokenOverride });
export const removeWatchedItem = (
masterItemId: number,
tokenOverride?: string,
): Promise<Response> => 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<Response> =>
authedGet('/users/deals/best-watched-prices', { tokenOverride });
// --- Shopping List API Functions ---
export const fetchShoppingLists = (tokenOverride?: string): Promise<Response> =>
authedGet('/users/shopping-lists', { tokenOverride });
export const fetchShoppingListById = (listId: number, tokenOverride?: string): Promise<Response> =>
authedGet(`/users/shopping-lists/${listId}`, { tokenOverride });
export const createShoppingList = (name: string, tokenOverride?: string): Promise<Response> =>
authedPost('/users/shopping-lists', { name }, { tokenOverride });
export const deleteShoppingList = (listId: number, tokenOverride?: string): Promise<Response> =>
authedDelete(`/users/shopping-lists/${listId}`, { tokenOverride });
export const addShoppingListItem = (
listId: number,
item: { masterItemId?: number; customItemName?: string },
tokenOverride?: string,
): Promise<Response> =>
authedPost(`/users/shopping-lists/${listId}/items`, item, { tokenOverride });
export const updateShoppingListItem = (
itemId: number,
updates: Partial<ShoppingListItem>,
tokenOverride?: string,
): Promise<Response> =>
authedPut(`/users/shopping-lists/items/${itemId}`, updates, { tokenOverride });
export const removeShoppingListItem = (itemId: number, tokenOverride?: string): Promise<Response> =>
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<Response> => {
// 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<Response> {
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<Response> => {
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<Response> =>
authedGet(`/receipts/${receiptId}/deals`, { tokenOverride });
// --- Analytics & Shopping Enhancement API Functions ---
export const trackFlyerItemInteraction = async (
itemId: number,
type: 'view' | 'click',
): Promise<void> => {
// 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<void>
.catch((error) => logger.warn('Failed to track flyer item interaction', { error }));
};
export const logSearchQuery = async (
query: Omit<SearchQuery, 'search_query_id' | 'id' | 'created_at' | 'user_id'>,
tokenOverride?: string,
): Promise<void> => {
// Add 'return' here
return apiFetch(
`/search/log`,
{
method: 'POST',
body: JSON.stringify(query),
keepalive: true,
},
{ tokenOverride },
)
.then(() => {}) // Ensure return type is Promise<void>
.catch((error) => logger.warn('Failed to log search query', { error }));
};
export const getPantryLocations = (tokenOverride?: string): Promise<Response> =>
authedGet('/pantry/locations', { tokenOverride });
export const createPantryLocation = (name: string, tokenOverride?: string): Promise<Response> =>
authedPost('/pantry/locations', { name }, { tokenOverride });
export const completeShoppingList = (
shoppingListId: number,
totalSpentCents?: number,
tokenOverride?: string,
): Promise<Response> =>
authedPost(
`/users/shopping-lists/${shoppingListId}/complete`,
{ totalSpentCents },
{ tokenOverride },
);
export const getShoppingTripHistory = (tokenOverride?: string): Promise<Response> =>
authedGet('/users/shopping-history', { tokenOverride });
// --- Personalization & Social API Functions ---
export const getDietaryRestrictions = (): Promise<Response> =>
publicGet('/personalization/dietary-restrictions');
export const getAppliances = (): Promise<Response> => publicGet('/personalization/appliances');
export const getUserDietaryRestrictions = (tokenOverride?: string): Promise<Response> =>
authedGet('/users/me/dietary-restrictions', { tokenOverride });
export const setUserDietaryRestrictions = (
restrictionIds: number[],
tokenOverride?: string,
): Promise<Response> =>
authedPut('/users/me/dietary-restrictions', { restrictionIds }, { tokenOverride });
export const getCompatibleRecipes = (tokenOverride?: string): Promise<Response> =>
authedGet('/users/me/compatible-recipes', { tokenOverride });
export const getUserFeed = (
limit: number = 20,
offset: number = 0,
tokenOverride?: string,
): Promise<Response> => authedGet(`/users/feed?limit=${limit}&offset=${offset}`, { tokenOverride });
export const forkRecipe = (originalRecipeId: number, tokenOverride?: string): Promise<Response> =>
authedPostEmpty(`/recipes/${originalRecipeId}/fork`, { tokenOverride });
export const followUser = (userIdToFollow: string, tokenOverride?: string): Promise<Response> =>
authedPostEmpty(`/users/${userIdToFollow}/follow`, { tokenOverride });
export const unfollowUser = (userIdToUnfollow: string, tokenOverride?: string): Promise<Response> =>
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<Response> =>
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<Response> =>
authedGet('/users/me/favorite-recipes', { tokenOverride });
export const addFavoriteRecipe = (recipeId: number, tokenOverride?: string): Promise<Response> =>
authedPost('/users/me/favorite-recipes', { recipeId }, { tokenOverride });
export const removeFavoriteRecipe = (recipeId: number, tokenOverride?: string): Promise<Response> =>
authedDelete(`/users/me/favorite-recipes/${recipeId}`, { tokenOverride });
// --- Recipe Comments API Functions ---
export const getRecipeComments = (recipeId: number): Promise<Response> =>
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<Response> =>
publicGet(`/recipes/${recipeId}`);
export const addRecipeComment = (
recipeId: number,
content: string,
parentCommentId?: number,
tokenOverride?: string,
): Promise<Response> =>
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<Response> => {
// 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<Response>} A promise that resolves to the API response.
*/
export const deleteRecipe = (recipeId: number, tokenOverride?: string): Promise<Response> =>
authedDelete(`/recipes/${recipeId}`, { tokenOverride });
// --- Admin API Functions for New Features ---
export const getUnmatchedFlyerItems = (tokenOverride?: string): Promise<Response> =>
authedGet('/admin/unmatched-items', { tokenOverride });
export const updateRecipeStatus = (
recipeId: number,
status: 'private' | 'pending_review' | 'public' | 'rejected',
tokenOverride?: string,
): Promise<Response> =>
authedPut(`/admin/recipes/${recipeId}/status`, { status }, { tokenOverride });
export const updateRecipeCommentStatus = (
commentId: number,
status: 'visible' | 'hidden' | 'reported',
tokenOverride?: string,
): Promise<Response> =>
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<Response> =>
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<Response> =>
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<Response> =>
authedGet('/admin/stats', { tokenOverride });
// --- Admin Correction API Functions ---
export const getSuggestedCorrections = (tokenOverride?: string): Promise<Response> =>
authedGet('/admin/corrections', { tokenOverride });
export const getFlyersForReview = (tokenOverride?: string): Promise<Response> => {
logger.debug('apiClient: calling getFlyersForReview');
return authedGet('/admin/review/flyers', { tokenOverride });
};
export const approveCorrection = (
correctionId: number,
tokenOverride?: string,
): Promise<Response> =>
authedPostEmpty(`/admin/corrections/${correctionId}/approve`, { tokenOverride });
export const rejectCorrection = (correctionId: number, tokenOverride?: string): Promise<Response> =>
authedPostEmpty(`/admin/corrections/${correctionId}/reject`, { tokenOverride });
export const updateSuggestedCorrection = (
correctionId: number,
newSuggestedValue: string,
tokenOverride?: string,
): Promise<Response> =>
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<Response> =>
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<Response> =>
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<Response> =>
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<Response> =>
authedPostEmpty('/admin/system/clear-geocode-cache', { tokenOverride });
export function registerUser(
email: string,
password: string,
fullName?: string,
avatarUrl?: string,
): Promise<Response> {
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<Response> {
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<Response> {
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<Profile['preferences']>,
apiOptions: ApiOptions = {},
): Promise<Response> {
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<Profile>,
apiOptions: ApiOptions = {},
): Promise<Response> {
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<Response> {
return authedGet('/users/data-export', apiOptions);
}
export const getUserAppliances = (tokenOverride?: string): Promise<Response> =>
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<Response> => 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<Response> =>
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<Response> =>
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<Address>,
apiOptions: ApiOptions = {},
): Promise<Response> => 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<Response> {
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<Response> {
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<Response> {
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<Response> =>
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<Response> =>
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<Response> =>
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<Response> =>
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<Budget, 'budget_id' | 'user_id' | 'created_at' | 'updated_at'>,
tokenOverride?: string,
): Promise<Response> => 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<Omit<Budget, 'budget_id' | 'user_id' | 'created_at' | 'updated_at'>>,
tokenOverride?: string,
): Promise<Response> => 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<Response> =>
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<Response> =>
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<Response> => 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<Response> =>
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<Response> =>
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<Response> => {
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<Response> =>
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<Response> => 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<Response> => 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<Response> => 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<Response> =>
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<Response> => 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<Response> =>
authedDelete(`/stores/${storeId}/locations/${locationId}`, { tokenOverride });