Files
flyer-crawler.projectium.com/src/services/db/recipe.db.ts
Torben Sorensen 748ac9e049
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 51s
more db
2026-01-01 02:30:06 -08:00

380 lines
13 KiB
TypeScript

// src/services/db/recipe.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { NotFoundError, UniqueConstraintError, handleDbError } from './errors.db';
import type { Logger } from 'pino';
import type { Recipe, FavoriteRecipe, RecipeComment } from '../../types';
export class RecipeRepository {
private db: Pool | PoolClient;
constructor(db: Pool | PoolClient = getPool()) {
this.db = db;
}
/**
* Calls a database function to get recipes based on the percentage of their ingredients on sale.
* @param minPercentage The minimum percentage of ingredients that must be on sale.
* @returns A promise that resolves to an array of recipes.
*/
async getRecipesBySalePercentage(minPercentage: number, logger: Logger): Promise<Recipe[]> {
try {
const res = await this.db.query<Recipe>(
'SELECT * FROM public.get_recipes_by_sale_percentage($1)',
[minPercentage],
);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getRecipesBySalePercentage', { minPercentage }, {
defaultMessage: 'Failed to get recipes by sale percentage.',
});
}
}
/**
* Calls a database function to get recipes by the minimum number of sale ingredients.
* @param minIngredients The minimum number of ingredients that must be on sale.
* @returns A promise that resolves to an array of recipes.
*/
async getRecipesByMinSaleIngredients(minIngredients: number, logger: Logger): Promise<Recipe[]> {
try {
const res = await this.db.query<Recipe>(
'SELECT * FROM public.get_recipes_by_min_sale_ingredients($1)',
[minIngredients],
);
return res.rows;
} catch (error) {
handleDbError(
error,
logger,
'Database error in getRecipesByMinSaleIngredients',
{ minIngredients },
{ defaultMessage: 'Failed to get recipes by minimum sale ingredients.' },
);
}
}
/**
* Calls a database function to find recipes by a specific ingredient and tag.
* @param ingredient The name of the ingredient to search for.
* @param tag The name of the tag to search for.
* @returns A promise that resolves to an array of matching recipes.
*/
async findRecipesByIngredientAndTag(
ingredient: string,
tag: string,
logger: Logger,
): Promise<Recipe[]> {
try {
const res = await this.db.query<Recipe>(
'SELECT * FROM public.find_recipes_by_ingredient_and_tag($1, $2)',
[ingredient, tag],
);
return res.rows;
} catch (error) {
handleDbError(
error,
logger,
'Database error in findRecipesByIngredientAndTag',
{ ingredient, tag },
{ defaultMessage: 'Failed to find recipes by ingredient and tag.' },
);
}
}
/**
* Calls a database function to get a user's favorite recipes.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of the user's favorite recipes.
*/
async getUserFavoriteRecipes(userId: string, logger: Logger): Promise<Recipe[]> {
try {
const res = await this.db.query<Recipe>(
'SELECT * FROM public.get_user_favorite_recipes($1)',
[userId],
);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getUserFavoriteRecipes', { userId }, {
defaultMessage: 'Failed to get favorite recipes.',
});
}
}
/**
* Adds a recipe to a user's favorites.
* @param userId The ID of the user.
* @param recipeId The ID of the recipe to favorite.
* @returns A promise that resolves to the created favorite record.
*/
async addFavoriteRecipe(
userId: string,
recipeId: number,
logger: Logger,
): Promise<FavoriteRecipe> {
// The ON CONFLICT DO NOTHING clause prevents duplicates but also causes the query to return
// zero rows if the favorite already exists. We need to handle this case.
try {
const res = await this.db.query<FavoriteRecipe>(
'INSERT INTO public.favorite_recipes (user_id, recipe_id) VALUES ($1, $2) ON CONFLICT (user_id, recipe_id) DO NOTHING RETURNING *',
[userId, recipeId],
);
if (res.rowCount === 0) {
throw new UniqueConstraintError("This recipe is already in the user's favorites.");
}
return res.rows[0];
} catch (error) {
handleDbError(error, logger, 'Database error in addFavoriteRecipe', { userId, recipeId }, {
fkMessage: 'The specified user or recipe does not exist.',
defaultMessage: 'Failed to add favorite recipe.',
});
}
}
/**
* Removes a recipe from a user's favorites.
* @param userId The ID of the user.
* @param recipeId The ID of the recipe to unfavorite.
*/
async removeFavoriteRecipe(userId: string, recipeId: number, logger: Logger): Promise<void> {
try {
const res = await this.db.query(
'DELETE FROM public.favorite_recipes WHERE user_id = $1 AND recipe_id = $2',
[userId, recipeId],
);
if (res.rowCount === 0) {
throw new NotFoundError('Favorite recipe not found for this user.');
}
} catch (error) {
handleDbError(error, logger, 'Database error in removeFavoriteRecipe', { userId, recipeId }, {
defaultMessage: 'Failed to remove favorite recipe.',
});
}
}
/**
* Deletes a recipe, ensuring ownership.
* @param recipeId The ID of the recipe to delete.
* @param userId The ID of the user attempting to delete the recipe.
* @param isAdmin A boolean indicating if the user is an administrator.
*/
async deleteRecipe(
recipeId: number,
userId: string,
isAdmin: boolean,
logger: Logger,
): Promise<void> {
try {
let query = 'DELETE FROM public.recipes WHERE recipe_id = $1 AND user_id = $2';
const params = [recipeId, userId];
if (isAdmin) {
query = 'DELETE FROM public.recipes WHERE recipe_id = $1';
params.pop(); // Remove userId from params if admin
}
const res = await this.db.query(query, params);
if (res.rowCount === 0) {
throw new NotFoundError('Recipe not found or user does not have permission to delete.');
}
} catch (error) {
handleDbError(error, logger, 'Database error in deleteRecipe', { recipeId, userId, isAdmin }, {
defaultMessage: 'Failed to delete recipe.',
});
}
}
/**
* Updates a recipe, ensuring ownership.
* @param recipeId The ID of the recipe to update.
* @param userId The ID of the user attempting the update.
* @param updates An object containing the fields to update.
* @returns A promise that resolves to the updated Recipe object.
*/
async updateRecipe(
recipeId: number,
userId: string,
updates: Partial<
Pick<
Recipe,
| 'name'
| 'description'
| 'instructions'
| 'prep_time_minutes'
| 'cook_time_minutes'
| 'servings'
| 'photo_url'
>
>,
logger: Logger,
): Promise<Recipe> {
try {
const setClauses = [];
const values = [];
let paramIndex = 1;
// Dynamically build the SET clause based on provided updates
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined) {
setClauses.push(`${key} = $${paramIndex++}`);
values.push(value);
}
}
if (setClauses.length === 0) {
throw new Error('No fields provided to update.');
}
values.push(recipeId, userId);
const query = `
UPDATE public.recipes
SET ${setClauses.join(', ')}
WHERE recipe_id = $${paramIndex++} AND user_id = $${paramIndex++}
RETURNING *;
`;
const res = await this.db.query<Recipe>(query, values);
if (res.rowCount === 0) {
throw new NotFoundError('Recipe not found or user does not have permission to update.');
}
return res.rows[0];
} catch (error) {
// Explicitly re-throw the "No fields" error before it gets caught by the generic handler.
if (error instanceof Error && error.message === 'No fields provided to update.') {
throw error;
}
handleDbError(error, logger, 'Database error in updateRecipe', { recipeId, userId, updates }, {
defaultMessage: 'Failed to update recipe.',
});
}
}
/**
* Retrieves a single recipe by its ID, including its ingredients and tags.
* @param recipeId The ID of the recipe to retrieve.
* @returns A promise that resolves to the Recipe object or undefined if not found.
*/
async getRecipeById(recipeId: number, logger: Logger): Promise<Recipe> {
try {
const query = `
SELECT
r.*,
COALESCE(json_agg(DISTINCT jsonb_build_object(
'recipe_ingredient_id', ri.recipe_ingredient_id,
'master_item_name', mgi.name,
'quantity', ri.quantity,
'unit', ri.unit,
'created_at', ri.created_at,
'updated_at', ri.updated_at
)) FILTER (WHERE ri.recipe_ingredient_id IS NOT NULL), '[]') AS ingredients,
COALESCE(json_agg(DISTINCT jsonb_build_object(
'tag_id', t.tag_id,
'name', t.name,
'created_at', t.created_at,
'updated_at', t.updated_at
)) FILTER (WHERE t.tag_id IS NOT NULL), '[]') AS tags
FROM public.recipes r
LEFT JOIN public.recipe_ingredients ri ON r.recipe_id = ri.recipe_id
LEFT JOIN public.master_grocery_items mgi ON ri.master_item_id = mgi.master_grocery_item_id
LEFT JOIN public.recipe_tags rt ON r.recipe_id = rt.recipe_id
LEFT JOIN public.tags t ON rt.tag_id = t.tag_id
WHERE r.recipe_id = $1
GROUP BY r.recipe_id;
`;
const res = await this.db.query<Recipe>(query, [recipeId]);
if (res.rowCount === 0) {
throw new NotFoundError(`Recipe with ID ${recipeId} not found`);
}
return res.rows[0];
} catch (error) {
handleDbError(error, logger, 'Database error in getRecipeById', { recipeId }, {
defaultMessage: 'Failed to retrieve recipe.',
});
}
}
/**
* Retrieves all comments for a specific recipe.
* @param recipeId The ID of the recipe.
* @returns A promise that resolves to an array of RecipeComment objects.
*/
async getRecipeComments(recipeId: number, logger: Logger): Promise<RecipeComment[]> {
try {
const query = `
SELECT
rc.*,
p.full_name as user_full_name,
p.avatar_url as user_avatar_url
FROM public.recipe_comments rc
LEFT JOIN public.profiles p ON rc.user_id = p.user_id
WHERE rc.recipe_id = $1
ORDER BY rc.created_at ASC;
`;
const res = await this.db.query<RecipeComment>(query, [recipeId]);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getRecipeComments', { recipeId }, {
defaultMessage: 'Failed to get recipe comments.',
});
}
}
/**
* Adds a new comment to a recipe.
* @param recipeId The ID of the recipe to comment on.
* @param userId The ID of the user posting the comment.
* @param content The text content of the comment.
* @param parentCommentId Optional ID of the parent comment for threaded replies.
* @returns A promise that resolves to the newly created RecipeComment object.
*/
async addRecipeComment(
recipeId: number,
userId: string,
content: string,
logger: Logger,
parentCommentId?: number,
): Promise<RecipeComment> {
try {
const res = await this.db.query<RecipeComment>(
'INSERT INTO public.recipe_comments (recipe_id, user_id, content, parent_comment_id) VALUES ($1, $2, $3, $4) RETURNING *',
[recipeId, userId, content, parentCommentId],
);
return res.rows[0];
} catch (error) {
handleDbError(
error,
logger,
'Database error in addRecipeComment',
{ recipeId, userId, parentCommentId },
{ fkMessage: 'The specified recipe, user, or parent comment does not exist.', defaultMessage: 'Failed to add recipe comment.' },
);
}
}
/**
* Creates a personal, editable copy (a "fork") of a public recipe for a user.
* @param userId The ID of the user forking the recipe.
* @param originalRecipeId The ID of the recipe to fork.
* @returns A promise that resolves to the newly created forked Recipe object.
*/
async forkRecipe(userId: string, originalRecipeId: number, logger: Logger): Promise<Recipe> {
try {
const res = await this.db.query<Recipe>('SELECT * FROM public.fork_recipe($1, $2)', [
userId,
originalRecipeId,
]);
return res.rows[0];
} catch (error) {
// The fork_recipe function could fail if the original recipe doesn't exist or isn't public.
if (error instanceof Error && 'code' in error && error.code === 'P0001') {
// raise_exception
throw new Error(error.message); // Re-throw the user-friendly message from the DB function.
}
handleDbError(error, logger, 'Database error in forkRecipe', { userId, originalRecipeId }, {
fkMessage: 'The specified user or original recipe does not exist.',
defaultMessage: 'Failed to fork recipe.',
});
}
}
}