// src/services/db/personalization.db.ts import type { Pool, PoolClient } from 'pg'; import { getPool } from './connection.db'; import { UniqueConstraintError, ForeignKeyConstraintError } from './errors.db'; import { logger } from '../logger.server'; import { MasterGroceryItem, PantryRecipe, RecommendedRecipe, WatchedItemDeal, PantryItemConversion, DietaryRestriction, Appliance, UserAppliance, Recipe, } from '../../types'; export class PersonalizationRepository { private db: Pool | PoolClient; constructor(db: Pool | PoolClient = getPool()) { this.db = db; } /** * Retrieves all master grocery items from the database. * @returns A promise that resolves to an array of MasterGroceryItem objects. */ async getAllMasterItems(): Promise { try { const res = await this.db.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. */ async 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 this.db.query(query, [userId]); return res.rows; } catch (error) { logger.error('Database error in getWatchedItems:', { error, userId }); throw new Error('Failed to retrieve watched items.'); } } /** * 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. */ async removeWatchedItem(userId: string, masterItemId: number): Promise { try { await this.db.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.'); } } /** * 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. */ async findPantryItemOwner(pantryItemId: number): Promise<{ user_id: string } | undefined> { try { const res = await this.db.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.'); } } /** * Adds an item to a user's watchlist. If the master item doesn't exist, it creates it. * This method should be wrapped in a transaction by the calling service if other operations depend on 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. */ async addWatchedItem(userId: string, itemName: string, categoryName: string): Promise { // This method assumes it might be part of a larger transaction, so it uses `this.db`. // The calling service is responsible for acquiring and releasing a client if needed. try { // Find category ID const categoryRes = await this.db.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 this.db.query('SELECT * FROM public.master_grocery_items WHERE name = $1', [itemName]); if (masterItemRes.rows.length > 0) { masterItem = masterItemRes.rows[0]; } else { const newMasterItemRes = await this.db.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 this.db.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] ); return masterItem; } catch (error) { if (error instanceof Error && 'code' in error) { if (error.code === '23505') { // unique_violation // 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.'); } else if (error.code === '23503') { // foreign_key_violation throw new ForeignKeyConstraintError('The specified user or category does not exist.'); } } logger.error('Database error in addWatchedItem:', { error }); throw new Error('Failed to add item to watchlist.'); } } /** * Calls a database function to get the best current sale prices for all watched items across all users. * This is much more efficient than calling getBestSalePricesForUser for each user individually. * @returns A promise that resolves to an array of deals, each augmented with user information. */ async getBestSalePricesForAllUsers(): Promise<(WatchedItemDeal & { user_id: string; email: string; full_name: string | null })[]> { try { // This function assumes a corresponding PostgreSQL function `get_best_sale_prices_for_all_users` exists. const res = await this.db.query<(WatchedItemDeal & { user_id: string; email: string; full_name: string | null })>('SELECT * FROM public.get_best_sale_prices_for_all_users()'); return res.rows; } catch (error) { logger.error('Database error in getBestSalePricesForAllUsers:', { error }); throw new Error('Failed to get best sale prices for all users.'); } } /** * Retrieves the master list of all available kitchen appliances. * @returns A promise that resolves to an array of Appliance objects. */ async getAppliances(): Promise { try { const res = await this.db.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 master list of all available dietary restrictions. * @returns A promise that resolves to an array of DietaryRestriction objects. */ async getDietaryRestrictions(): Promise { try { const res = await this.db.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. */ async 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 this.db.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. */ async setUserDietaryRestrictions(userId: string, restrictionIds: number[]): Promise { const client = await (this.db as Pool).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.'); } // The patch requested this specific error handling. if ((error as any).code === '23503') { throw new Error('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(); } } /** * 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. */ async setUserAppliances(userId: string, applianceIds: number[]): Promise { const client = await (this.db as Pool).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'); // The patch requested this specific error handling - check for foreign key violation if ((error as any).code === '23503') { throw new ForeignKeyConstraintError('Invalid appliance ID'); } logger.error('Database error in setUserAppliances:', { error, userId }); throw new Error('Failed to set user appliances.'); } finally { client.release(); } } /** * 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. */ async 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 this.db.query(query, [userId]); return res.rows; } catch (error) { logger.error('Database error in getUserAppliances:', { error, userId }); throw new Error('Failed to get user appliances.'); } } /** * 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. */ async findRecipesFromPantry(userId: string): Promise { try { const res = await this.db.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. */ async recommendRecipesForUser(userId: string, limit: number): Promise { try { const res = await this.db.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, limit }); 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. */ async getBestSalePricesForUser(userId: string): Promise { try { const res = await this.db.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. */ async suggestPantryItemConversions(pantryItemId: number): Promise { try { const res = await this.db.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.'); } } /** * 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. */ async getRecipesForUserDiets(userId: string): Promise { try { const res = await this.db.query('SELECT * FROM public.get_recipes_for_user_diets($1)', [userId]); // This is a standalone function, no change needed here. return res.rows; } catch (error) { logger.error('Database error in getRecipesForUserDiets:', { error, userId }); throw new Error('Failed to get recipes compatible with user diet.'); } } }