// src/services/db/personalization.db.ts import { getPool } from './connection.db'; import { UniqueConstraintError, ForeignKeyConstraintError } from './errors.db'; import { logger } from '../logger'; import { MasterGroceryItem, PantryRecipe, RecommendedRecipe, WatchedItemDeal, PantryItemConversion, DietaryRestriction, Appliance, UserAppliance, Recipe, } from '../../types'; /** * Retrieves all master grocery items from the database. * This is used to provide a list of known items to the AI for better matching. * @returns A promise that resolves to an array of MasterGroceryItem objects. */ export async function getAllMasterItems(): Promise { try { const res = await getPool().query('SELECT * FROM public.master_grocery_items ORDER BY name ASC'); return res.rows; } catch (error) { logger.error('Database error in getAllMasterItems:', { error }); throw new Error('Failed to retrieve master grocery items.'); } } /** * Retrieves all watched master items for a specific user. * @param userId The UUID of the user. * @returns A promise that resolves to an array of MasterGroceryItem objects. */ // prettier-ignore export async function getWatchedItems(userId: string): Promise { try { const query = ` SELECT mgi.* FROM public.master_grocery_items mgi JOIN public.user_watched_items uwi ON mgi.master_grocery_item_id = uwi.master_item_id WHERE uwi.user_id = $1 ORDER BY mgi.name ASC; `; const res = await getPool().query(query, [userId]); return res.rows; } catch (error) { logger.error('Database error in getWatchedItems:', { error, userId }); throw new Error('Failed to retrieve watched items.'); } } /** * Adds an item to a user's watchlist. If the master item doesn't exist, it creates it. * @param userId The UUID of the user. * @param itemName The name of the item to watch. * @param categoryName The category of the item. * @returns A promise that resolves to the MasterGroceryItem that was added to the watchlist. */ // prettier-ignore export async function addWatchedItem(userId: string, itemName: string, categoryName: string): Promise { const client = await getPool().connect(); try { await client.query('BEGIN'); // Find category ID const categoryRes = await client.query<{ category_id: number }>('SELECT category_id FROM public.categories WHERE name = $1', [categoryName]); const categoryId = categoryRes.rows[0]?.category_id; if (!categoryId) { throw new Error(`Category '${categoryName}' not found.`); } // Find or create master item let masterItem: MasterGroceryItem; const masterItemRes = await client.query('SELECT * FROM public.master_grocery_items WHERE name = $1', [itemName]); if (masterItemRes.rows.length > 0) { masterItem = masterItemRes.rows[0]; } else { const newMasterItemRes = await client.query( 'INSERT INTO public.master_grocery_items (name, category_id) VALUES ($1, $2) RETURNING *', [itemName, categoryId] ); masterItem = newMasterItemRes.rows[0]; } // Add to user's watchlist, ignoring if it's already there. await client.query( 'INSERT INTO public.user_watched_items (user_id, master_item_id) VALUES ($1, $2) ON CONFLICT (user_id, master_item_id) DO NOTHING', [userId, masterItem.master_grocery_item_id] ); await client.query('COMMIT'); return masterItem; } catch (error) { if (error instanceof Error && 'code' in error && error.code === '23505') { // This case is handled by ON CONFLICT, but it's good practice for other functions. throw new UniqueConstraintError('This item is already in the watchlist.'); } await client.query('ROLLBACK'); logger.error('Database transaction error in addWatchedItem:', { error }); throw new Error('Failed to add item to watchlist.'); } finally { client.release(); } } /** * Removes an item from a user's watchlist. * @param userId The UUID of the user. * @param masterItemId The ID of the master item to remove. */ // prettier-ignore export async function removeWatchedItem(userId: string, masterItemId: number): Promise { try { await getPool().query('DELETE FROM public.user_watched_items WHERE user_id = $1 AND master_item_id = $2', [userId, masterItemId]); } catch (error) { logger.error('Database error in removeWatchedItem:', { error }); throw new Error('Failed to remove item from watchlist.'); } } /** * Calls a database function to find recipes that can be made from a user's pantry. * @param userId The ID of the user. * @returns A promise that resolves to an array of recipes. */ export async function findRecipesFromPantry(userId: string): Promise { try { const res = await getPool().query('SELECT * FROM public.find_recipes_from_pantry($1)', [userId]); return res.rows; } catch (error) { logger.error('Database error in findRecipesFromPantry:', { error, userId }); throw new Error('Failed to find recipes from pantry.'); } } /** * Calls a database function to recommend recipes for a user. * @param userId The ID of the user. * @param limit The maximum number of recipes to recommend. * @returns A promise that resolves to an array of recommended recipes. */ export async function recommendRecipesForUser(userId: string, limit: number): Promise { try { const res = await getPool().query('SELECT * FROM public.recommend_recipes_for_user($1, $2)', [userId, limit]); return res.rows; } catch (error) { logger.error('Database error in recommendRecipesForUser:', { error, userId }); throw new Error('Failed to recommend recipes.'); } } /** * Calls a database function to get the best current sale prices for a user's watched items. * @param userId The ID of the user. * @returns A promise that resolves to an array of the best deals. */ export async function getBestSalePricesForUser(userId: string): Promise { try { const res = await getPool().query('SELECT * FROM public.get_best_sale_prices_for_user($1)', [userId]); return res.rows; } catch (error) { logger.error('Database error in getBestSalePricesForUser:', { error, userId }); throw new Error('Failed to get best sale prices.'); } } /** * Calls a database function to suggest unit conversions for a pantry item. * @param pantryItemId The ID of the pantry item. * @returns A promise that resolves to an array of suggested conversions. */ export async function suggestPantryItemConversions(pantryItemId: number): Promise { try { const res = await getPool().query('SELECT * FROM public.suggest_pantry_item_conversions($1)', [pantryItemId]); return res.rows; } catch (error) { logger.error('Database error in suggestPantryItemConversions:', { error, pantryItemId }); throw new Error('Failed to suggest pantry item conversions.'); } } /** * Finds the owner of a specific pantry item. * @param pantryItemId The ID of the pantry item. * @returns A promise that resolves to an object containing the user_id, or undefined if not found. */ // prettier-ignore export async function findPantryItemOwner(pantryItemId: number): Promise<{ user_id: string } | undefined> { try { const res = await getPool().query<{ user_id: string }>( 'SELECT user_id FROM public.pantry_items WHERE pantry_item_id = $1', [pantryItemId] ); return res.rows[0]; } catch (error) { logger.error('Database error in findPantryItemOwner:', { error, pantryItemId }); throw new Error('Failed to retrieve pantry item owner from database.'); } } /** * Retrieves the master list of all available dietary restrictions. * @returns A promise that resolves to an array of DietaryRestriction objects. */ export async function getDietaryRestrictions(): Promise { try { const res = await getPool().query('SELECT * FROM public.dietary_restrictions ORDER BY type, name'); return res.rows; } catch (error) { logger.error('Database error in getDietaryRestrictions:', { error }); throw new Error('Failed to get dietary restrictions.'); } } /** * Retrieves the dietary restrictions for a specific user. * @param userId The ID of the user. * @returns A promise that resolves to an array of the user's selected DietaryRestriction objects. */ export async function getUserDietaryRestrictions(userId: string): Promise { try { const query = ` SELECT dr.* FROM public.dietary_restrictions dr JOIN public.user_dietary_restrictions udr ON dr.dietary_restriction_id = udr.restriction_id WHERE udr.user_id = $1 ORDER BY dr.type, dr.name; `; const res = await getPool().query(query, [userId]); return res.rows; } catch (error) { logger.error('Database error in getUserDietaryRestrictions:', { error, userId }); throw new Error('Failed to get user dietary restrictions.'); } } /** * Sets the dietary restrictions for a user, replacing any existing ones. * @param userId The ID of the user. * @param restrictionIds An array of IDs for the selected dietary restrictions. * @returns A promise that resolves when the operation is complete. */ export async function setUserDietaryRestrictions(userId: string, restrictionIds: number[]): Promise { const client = await getPool().connect(); try { await client.query('BEGIN'); // 1. Clear existing restrictions for the user await client.query('DELETE FROM public.user_dietary_restrictions WHERE user_id = $1', [userId]); // 2. Insert new ones if any are provided if (restrictionIds.length > 0) { // Using unnest is safer than string concatenation and prevents SQL injection. const insertQuery = `INSERT INTO public.user_dietary_restrictions (user_id, restriction_id) SELECT $1, unnest($2::int[])`; await client.query(insertQuery, [userId, restrictionIds]); } // 3. Commit the transaction await client.query('COMMIT'); } catch (error) { await client.query('ROLLBACK'); if (error instanceof Error && 'code' in error && error.code === '23503') { throw new ForeignKeyConstraintError('One or more of the specified restriction IDs are invalid.'); } logger.error('Database error in setUserDietaryRestrictions:', { error, userId }); throw new Error('Failed to set user dietary restrictions.'); } finally { client.release(); } } /** * Retrieves the master list of all available kitchen appliances. * @returns A promise that resolves to an array of Appliance objects. */ export async function getAppliances(): Promise { try { const res = await getPool().query('SELECT * FROM public.appliances ORDER BY name'); return res.rows; } catch (error) { logger.error('Database error in getAppliances:', { error }); throw new Error('Failed to get appliances.'); } } /** * Retrieves the kitchen appliances for a specific user. * @param userId The ID of the user. * @returns A promise that resolves to an array of the user's selected Appliance objects. */ export async function getUserAppliances(userId: string): Promise { try { const query = ` SELECT a.* FROM public.appliances a JOIN public.user_appliances ua ON a.appliance_id = ua.appliance_id WHERE ua.user_id = $1 ORDER BY a.name; `; const res = await getPool().query(query, [userId]); return res.rows; } catch (error) { logger.error('Database error in getUserAppliances:', { error, userId }); throw new Error('Failed to get user appliances.'); } } /** * Sets the kitchen appliances for a user, replacing any existing ones. * @param userId The ID of the user. * @param applianceIds An array of IDs for the selected appliances. * @returns A promise that resolves when the operation is complete. */ export async function setUserAppliances(userId: string, applianceIds: number[]): Promise { const client = await getPool().connect(); try { await client.query('BEGIN'); // Start transaction // 1. Clear existing appliances for the user await client.query('DELETE FROM public.user_appliances WHERE user_id = $1', [userId]); let newAppliances: UserAppliance[] = []; // 2. Insert new ones if any are provided if (applianceIds.length > 0) { const insertQuery = `INSERT INTO public.user_appliances (user_id, appliance_id) SELECT $1, unnest($2::int[]) RETURNING *`; const res = await client.query(insertQuery, [userId, applianceIds]); newAppliances = res.rows; } // 3. Commit the transaction await client.query('COMMIT'); return newAppliances; } catch (error) { await client.query('ROLLBACK'); if (error instanceof Error && 'code' in error && error.code === '23503') { throw new ForeignKeyConstraintError('One or more of the specified appliance IDs are invalid.'); } logger.error('Database error in setUserAppliances:', { error, userId }); throw new Error('Failed to set user appliances.'); } finally { client.release(); } } /** * Calls a database function to get recipes that are compatible with a user's dietary restrictions. * @param userId The ID of the user. * @returns A promise that resolves to an array of compatible Recipe objects. */ export async function getRecipesForUserDiets(userId: string): Promise { try { const res = await getPool().query('SELECT * FROM public.get_recipes_for_user_diets($1)', [userId]); return res.rows; } catch (error) { logger.error('Database error in getRecipesForUserDiets:', { error, userId }); throw new Error('Failed to get recipes compatible with user diet.'); } }