All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m42s
564 lines
18 KiB
TypeScript
564 lines
18 KiB
TypeScript
// src/services/db/personalization.db.ts
|
|
import type { Pool, PoolClient } from 'pg';
|
|
import { getPool, withTransaction } from './connection.db';
|
|
import { handleDbError } from './errors.db';
|
|
import type { Logger } from 'pino';
|
|
import {
|
|
MasterGroceryItem,
|
|
PantryRecipe,
|
|
RecommendedRecipe,
|
|
WatchedItemDeal,
|
|
PantryItemConversion,
|
|
DietaryRestriction,
|
|
Appliance,
|
|
UserAppliance,
|
|
Recipe,
|
|
} from '../../types';
|
|
|
|
export class PersonalizationRepository {
|
|
// The repository only needs an object with a `query` method, matching the Pool/PoolClient interface.
|
|
// Using `Pick` makes this dependency explicit and simplifies testing by reducing the mock surface.
|
|
private db: Pick<Pool | PoolClient, 'query'>;
|
|
|
|
constructor(db: Pick<Pool | PoolClient, 'query'> = getPool()) {
|
|
this.db = db;
|
|
}
|
|
|
|
/**
|
|
* Retrieves master grocery items from the database with optional pagination.
|
|
* @param logger The logger instance.
|
|
* @param limit Optional limit for pagination. If not provided, returns all items.
|
|
* @param offset Optional offset for pagination.
|
|
* @returns A promise that resolves to an object with items array and total count.
|
|
*/
|
|
async getAllMasterItems(
|
|
logger: Logger,
|
|
limit?: number,
|
|
offset?: number,
|
|
): Promise<{ items: MasterGroceryItem[]; total: number }> {
|
|
try {
|
|
// Get total count
|
|
const countRes = await this.db.query<{ count: string }>(
|
|
'SELECT COUNT(*) FROM public.master_grocery_items',
|
|
);
|
|
const total = parseInt(countRes.rows[0].count, 10);
|
|
|
|
// Build query with optional pagination
|
|
let query = `
|
|
SELECT
|
|
mgi.*,
|
|
c.name as category_name
|
|
FROM public.master_grocery_items mgi
|
|
LEFT JOIN public.categories c ON mgi.category_id = c.category_id
|
|
ORDER BY mgi.name ASC`;
|
|
|
|
const params: number[] = [];
|
|
if (limit !== undefined) {
|
|
query += ` LIMIT $${params.length + 1}`;
|
|
params.push(limit);
|
|
}
|
|
if (offset !== undefined) {
|
|
query += ` OFFSET $${params.length + 1}`;
|
|
params.push(offset);
|
|
}
|
|
|
|
const res = await this.db.query<MasterGroceryItem>(
|
|
query,
|
|
params.length > 0 ? params : undefined,
|
|
);
|
|
return { items: res.rows, total };
|
|
} catch (error) {
|
|
handleDbError(
|
|
error,
|
|
logger,
|
|
'Database error in getAllMasterItems',
|
|
{},
|
|
{
|
|
defaultMessage: '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, logger: Logger): Promise<MasterGroceryItem[]> {
|
|
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<MasterGroceryItem>(query, [userId]);
|
|
return res.rows;
|
|
} catch (error) {
|
|
handleDbError(
|
|
error,
|
|
logger,
|
|
'Database error in getWatchedItems',
|
|
{ userId },
|
|
{
|
|
defaultMessage: '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, logger: Logger): Promise<void> {
|
|
try {
|
|
await this.db.query(
|
|
'DELETE FROM public.user_watched_items WHERE user_id = $1 AND master_item_id = $2',
|
|
[userId, masterItemId],
|
|
);
|
|
} catch (error) {
|
|
handleDbError(
|
|
error,
|
|
logger,
|
|
'Database error in removeWatchedItem',
|
|
{ userId, masterItemId },
|
|
{
|
|
defaultMessage: '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,
|
|
logger: Logger,
|
|
): 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) {
|
|
handleDbError(
|
|
error,
|
|
logger,
|
|
'Database error in findPantryItemOwner',
|
|
{ pantryItemId },
|
|
{
|
|
defaultMessage: '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,
|
|
logger: Logger,
|
|
): Promise<MasterGroceryItem> {
|
|
try {
|
|
return await withTransaction(async (client) => {
|
|
// 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<MasterGroceryItem>(
|
|
'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<MasterGroceryItem>(
|
|
'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],
|
|
);
|
|
|
|
return masterItem;
|
|
});
|
|
} catch (error) {
|
|
handleDbError(
|
|
error,
|
|
logger,
|
|
'Transaction error in addWatchedItem',
|
|
{ userId, itemName, categoryName },
|
|
{
|
|
fkMessage: 'The specified user or category does not exist.',
|
|
uniqueMessage: 'A master grocery item with this name was created by another process.',
|
|
defaultMessage: '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(
|
|
logger: Logger,
|
|
): 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) {
|
|
handleDbError(
|
|
error,
|
|
logger,
|
|
'Database error in getBestSalePricesForAllUsers',
|
|
{},
|
|
{
|
|
defaultMessage: '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(logger: Logger): Promise<Appliance[]> {
|
|
try {
|
|
const res = await this.db.query<Appliance>('SELECT * FROM public.appliances ORDER BY name');
|
|
return res.rows;
|
|
} catch (error) {
|
|
handleDbError(
|
|
error,
|
|
logger,
|
|
'Database error in getAppliances',
|
|
{},
|
|
{
|
|
defaultMessage: '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(logger: Logger): Promise<DietaryRestriction[]> {
|
|
try {
|
|
const res = await this.db.query<DietaryRestriction>(
|
|
'SELECT * FROM public.dietary_restrictions ORDER BY type, name',
|
|
);
|
|
return res.rows;
|
|
} catch (error) {
|
|
handleDbError(
|
|
error,
|
|
logger,
|
|
'Database error in getDietaryRestrictions',
|
|
{},
|
|
{
|
|
defaultMessage: '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, logger: Logger): Promise<DietaryRestriction[]> {
|
|
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<DietaryRestriction>(query, [userId]);
|
|
return res.rows;
|
|
} catch (error) {
|
|
handleDbError(
|
|
error,
|
|
logger,
|
|
'Database error in getUserDietaryRestrictions',
|
|
{ userId },
|
|
{
|
|
defaultMessage: '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[],
|
|
logger: Logger,
|
|
): Promise<void> {
|
|
try {
|
|
await withTransaction(async (client) => {
|
|
// 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]);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
handleDbError(
|
|
error,
|
|
logger,
|
|
'Database error in setUserDietaryRestrictions',
|
|
{ userId, restrictionIds },
|
|
{
|
|
fkMessage: 'One or more of the specified restriction IDs are invalid.',
|
|
defaultMessage: 'Failed to set user dietary restrictions.',
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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[],
|
|
logger: Logger,
|
|
): Promise<UserAppliance[]> {
|
|
try {
|
|
return await withTransaction(async (client) => {
|
|
// 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<UserAppliance>(insertQuery, [userId, applianceIds]);
|
|
newAppliances = res.rows;
|
|
}
|
|
return newAppliances;
|
|
});
|
|
} catch (error) {
|
|
handleDbError(
|
|
error,
|
|
logger,
|
|
'Database error in setUserAppliances',
|
|
{ userId, applianceIds },
|
|
{
|
|
fkMessage: 'Invalid appliance ID',
|
|
defaultMessage: 'Failed to set user 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.
|
|
*/
|
|
async getUserAppliances(userId: string, logger: Logger): Promise<Appliance[]> {
|
|
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<Appliance>(query, [userId]);
|
|
return res.rows;
|
|
} catch (error) {
|
|
handleDbError(
|
|
error,
|
|
logger,
|
|
'Database error in getUserAppliances',
|
|
{ userId },
|
|
{
|
|
defaultMessage: '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, logger: Logger): Promise<PantryRecipe[]> {
|
|
try {
|
|
const res = await this.db.query<PantryRecipe>(
|
|
'SELECT * FROM public.find_recipes_from_pantry($1)',
|
|
[userId],
|
|
);
|
|
return res.rows;
|
|
} catch (error) {
|
|
handleDbError(
|
|
error,
|
|
logger,
|
|
'Database error in findRecipesFromPantry',
|
|
{ userId },
|
|
{
|
|
defaultMessage: '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,
|
|
logger: Logger,
|
|
): Promise<RecommendedRecipe[]> {
|
|
try {
|
|
const res = await this.db.query<RecommendedRecipe>(
|
|
'SELECT * FROM public.recommend_recipes_for_user($1, $2)',
|
|
[userId, limit],
|
|
);
|
|
return res.rows;
|
|
} catch (error) {
|
|
handleDbError(
|
|
error,
|
|
logger,
|
|
'Database error in recommendRecipesForUser',
|
|
{ userId, limit },
|
|
{
|
|
defaultMessage: '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, logger: Logger): Promise<WatchedItemDeal[]> {
|
|
try {
|
|
const res = await this.db.query<WatchedItemDeal>(
|
|
'SELECT * FROM public.get_best_sale_prices_for_user($1)',
|
|
[userId],
|
|
);
|
|
return res.rows;
|
|
} catch (error) {
|
|
handleDbError(
|
|
error,
|
|
logger,
|
|
'Database error in getBestSalePricesForUser',
|
|
{ userId },
|
|
{
|
|
defaultMessage: '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,
|
|
logger: Logger,
|
|
): Promise<PantryItemConversion[]> {
|
|
try {
|
|
const res = await this.db.query<PantryItemConversion>(
|
|
'SELECT * FROM public.suggest_pantry_item_conversions($1)',
|
|
[pantryItemId],
|
|
);
|
|
return res.rows;
|
|
} catch (error) {
|
|
handleDbError(
|
|
error,
|
|
logger,
|
|
'Database error in suggestPantryItemConversions',
|
|
{ pantryItemId },
|
|
{
|
|
defaultMessage: '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, logger: Logger): Promise<Recipe[]> {
|
|
try {
|
|
const res = await this.db.query<Recipe>(
|
|
'SELECT * FROM public.get_recipes_for_user_diets($1)',
|
|
[userId],
|
|
); // This is a standalone function, no change needed here.
|
|
return res.rows;
|
|
} catch (error) {
|
|
handleDbError(
|
|
error,
|
|
logger,
|
|
'Database error in getRecipesForUserDiets',
|
|
{ userId },
|
|
{
|
|
defaultMessage: 'Failed to get recipes compatible with user diet.',
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|