Files
flyer-crawler.projectium.com/src/services/db/personalization.db.ts
Torben Sorensen 2e72ee81dd
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 41s
maybe a few too many fixes
2025-12-28 21:38:31 -08:00

436 lines
16 KiB
TypeScript

// src/services/db/personalization.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool, withTransaction } from './connection.db';
import { ForeignKeyConstraintError } 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 all master grocery items from the database.
* @returns A promise that resolves to an array of MasterGroceryItem objects.
*/
async getAllMasterItems(logger: Logger): Promise<MasterGroceryItem[]> {
try {
const 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 res = await this.db.query<MasterGroceryItem>(query);
return res.rows;
} catch (error) {
logger.error({ err: error }, 'Database error in getAllMasterItems');
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, 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) {
logger.error({ err: error, userId }, 'Database error in getWatchedItems');
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, 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) {
logger.error({ err: error, userId, masterItemId }, 'Database error in removeWatchedItem');
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,
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) {
logger.error({ err: error, pantryItemId }, 'Database error in findPantryItemOwner');
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,
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) {
// The withTransaction helper will handle rollback. We just need to handle specific errors.
if (error instanceof Error && 'code' in error) {
if (error.code === '23503') {
// foreign_key_violation
throw new ForeignKeyConstraintError('The specified user or category does not exist.');
}
}
logger.error(
{ err: error, userId, itemName, categoryName },
'Transaction error in addWatchedItem',
);
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(
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) {
logger.error({ err: error }, 'Database error in getBestSalePricesForAllUsers');
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(logger: Logger): Promise<Appliance[]> {
try {
const res = await this.db.query<Appliance>('SELECT * FROM public.appliances ORDER BY name');
return res.rows;
} catch (error) {
logger.error({ err: error }, 'Database error in getAppliances');
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(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) {
logger.error({ err: error }, 'Database error in getDietaryRestrictions');
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, 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) {
logger.error({ err: error, userId }, 'Database error in getUserDietaryRestrictions');
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[],
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) {
// Check for a foreign key violation, which would mean an invalid ID was provided.
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(
{ err: error, userId, restrictionIds },
'Database error in setUserDietaryRestrictions',
);
throw new Error('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) {
// Check for a foreign key violation, which would mean an invalid ID was provided.
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('Invalid appliance ID');
}
logger.error({ err: error, userId, applianceIds }, 'Database error in setUserAppliances');
throw new Error('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) {
logger.error({ err: error, userId }, 'Database error in getUserAppliances');
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, 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) {
logger.error({ err: error, userId }, 'Database error in findRecipesFromPantry');
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,
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) {
logger.error({ err: error, userId, limit }, 'Database error in recommendRecipesForUser');
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, 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) {
logger.error({ err: error, userId }, 'Database error in getBestSalePricesForUser');
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,
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) {
logger.error({ err: error, pantryItemId }, 'Database error in suggestPantryItemConversions');
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, 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) {
logger.error({ err: error, userId }, 'Database error in getRecipesForUserDiets');
throw new Error('Failed to get recipes compatible with user diet.');
}
}
}