-- Function to find the best current sale price for a user's watched items. -- This function queries all currently active flyers to find the lowest price -- for each item on a specific user's watchlist. CREATE OR REPLACE FUNCTION public.get_best_sale_prices_for_user(p_user_id UUID) RETURNS TABLE ( master_item_id BIGINT, item_name TEXT, best_price_in_cents INTEGER, store_name TEXT, flyer_id BIGINT, flyer_image_url TEXT, flyer_valid_from DATE, flyer_valid_to DATE ) LANGUAGE plpgsql SECURITY INVOKER -- Runs with the privileges of the calling user. AS $$ BEGIN RETURN QUERY WITH UserWatchedSales AS ( -- This CTE gathers all sales from active flyers that match the user's watched items. SELECT uwi.master_item_id, mgi.name AS item_name, fi.price_in_cents, s.name AS store_name, f.id AS flyer_id, f.image_url AS flyer_image_url, f.valid_from AS flyer_valid_from, f.valid_to AS flyer_valid_to, -- We use ROW_NUMBER to rank sales for the same item, prioritizing the lowest price. ROW_NUMBER() OVER (PARTITION BY uwi.master_item_id ORDER BY fi.price_in_cents ASC, f.valid_to DESC, s.name ASC) as rn FROM public.user_watched_items uwi JOIN public.master_grocery_items mgi ON uwi.master_item_id = mgi.id JOIN public.flyer_items fi ON uwi.master_item_id = fi.master_item_id JOIN public.flyers f ON fi.flyer_id = f.id JOIN public.stores s ON f.store_id = s.id WHERE uwi.user_id = p_user_id AND f.valid_from <= CURRENT_DATE AND f.valid_to >= CURRENT_DATE AND fi.price_in_cents IS NOT NULL ) -- The final select returns only the top-ranked sale (rn = 1) for each item. SELECT uws.master_item_id, uws.item_name, uws.price_in_cents, uws.store_name, uws.flyer_id, uws.flyer_image_url, uws.flyer_valid_from, uws.flyer_valid_to FROM UserWatchedSales uws WHERE uws.rn = 1; END; $$; -- Function to generate a smart shopping list from a menu plan, subtracting pantry items. -- This function calculates the total ingredients needed for a user's menu plan, -- scales them by desired servings, and then subtracts what the user already has -- in their pantry to determine what needs to be bought. CREATE OR REPLACE FUNCTION public.generate_shopping_list_for_menu_plan(p_menu_plan_id BIGINT, p_user_id UUID) RETURNS TABLE ( master_item_id BIGINT, item_name TEXT, required_quantity NUMERIC, pantry_quantity NUMERIC, shopping_list_quantity NUMERIC, unit TEXT ) LANGUAGE plpgsql SECURITY INVOKER -- Runs with the privileges of the calling user. AS $$ BEGIN RETURN QUERY WITH RequiredIngredients AS ( -- This CTE calculates the total quantity of each ingredient needed for the menu plan. -- It accounts for scaling the recipe based on the number of servings the user plans to cook. SELECT ri.master_item_id, ri.unit, SUM( ri.quantity * -- The base ingredient quantity from the recipe -- Calculate the scaling factor. Default to 1 if servings_to_cook is not set. (COALESCE(pm.servings_to_cook, r.servings)::NUMERIC / NULLIF(r.servings, 0)::NUMERIC) ) AS total_required FROM public.menu_plans mp JOIN public.planned_meals pm ON mp.id = pm.menu_plan_id JOIN public.recipe_ingredients ri ON pm.recipe_id = ri.recipe_id JOIN public.recipes r ON pm.recipe_id = r.id -- Join to get the recipe's base servings WHERE mp.id = p_menu_plan_id AND mp.user_id = p_user_id GROUP BY ri.master_item_id, ri.unit ) -- This final select compares the required ingredients with the user's pantry. SELECT req.master_item_id, mgi.name AS item_name, req.total_required AS required_quantity, COALESCE(pi.quantity, 0) AS pantry_quantity, -- Calculate the amount to buy. If pantry has enough, this will be 0 or less, so GREATEST(0, ...) ensures we don't get negative values. GREATEST(0, req.total_required - COALESCE(pi.quantity, 0)) AS shopping_list_quantity, req.unit FROM RequiredIngredients req JOIN public.master_grocery_items mgi ON req.master_item_id = mgi.id LEFT JOIN public.pantry_items pi ON req.master_item_id = pi.master_item_id AND req.unit = pi.unit -- Critical: only subtract if units match to avoid errors (e.g., subtracting 2 "items" from 500 "grams"). AND pi.user_id = p_user_id WHERE -- Only include items that actually need to be purchased. GREATEST(0, req.total_required - COALESCE(pi.quantity, 0)) > 0; END; $$; -- Function to find recipes based on the percentage of their ingredients that are currently on sale. -- For example, you can ask for recipes where at least 50% of the ingredients are on sale. CREATE OR REPLACE FUNCTION public.get_recipes_by_sale_percentage(p_min_sale_percentage NUMERIC DEFAULT 100.0) RETURNS TABLE (recipe_details JSONB) LANGUAGE sql STABLE -- Indicates the function cannot modify the database and is safe for read-only queries. SECURITY INVOKER AS $$ WITH BestCurrentPrices AS ( -- CTE 1: For every distinct item on sale, find its single best price and the store offering it. SELECT bcp.master_item_id, bcp.price_in_cents, bcp.store_name FROM ( SELECT fi.master_item_id, fi.price_in_cents, s.name as store_name, ROW_NUMBER() OVER(PARTITION BY fi.master_item_id ORDER BY fi.price_in_cents ASC, f.valid_to DESC) as rn FROM public.flyer_items fi JOIN public.flyers f ON fi.flyer_id = f.id JOIN public.stores s ON f.store_id = s.id WHERE fi.master_item_id IS NOT NULL AND fi.price_in_cents IS NOT NULL AND CURRENT_DATE BETWEEN f.valid_from AND f.valid_to ) bcp WHERE bcp.rn = 1 ), RecipeIngredientStats AS ( -- CTE 2: For each recipe, count its total ingredients and how many of them are on sale. SELECT ri.recipe_id, COUNT(ri.master_item_id) AS total_ingredients, COUNT(bcp.master_item_id) AS sale_ingredients -- COUNT(column) only counts non-NULL values. FROM public.recipe_ingredients ri LEFT JOIN BestCurrentPrices bcp ON ri.master_item_id = bcp.master_item_id -- Join to count how many ingredients are on sale LEFT JOIN BestCurrentPrices bcp ON ri.master_item_id = bcp.master_item_id GROUP BY ri.recipe_id ), EligibleRecipes AS ( -- CTE 3: Filter recipes based on the minimum sale percentage provided as an argument. SELECT ris.recipe_id, ris.total_ingredients, ris.sale_ingredients FROM RecipeIngredientStats ris WHERE ris.total_ingredients > 0 -- Avoid division by zero and recipes with no ingredients AND (ris.sale_ingredients * 100.0 / ris.total_ingredients) >= p_min_sale_percentage ), RecipeSaleDetails AS ( -- CTE 4: Gather details for the eligible recipes and ALL their ingredients, noting which are on sale. SELECT r.id AS recipe_id, r.name AS recipe_name, mgi.name AS item_name, bcp.price_in_cents AS best_price_in_cents, -- This will be NULL if not on sale bcp.store_name -- This will be NULL if not on sale FROM public.recipes r JOIN EligibleRecipes er ON r.id = er.recipe_id -- Join with the filtered eligible recipes JOIN public.recipe_ingredients ri ON r.id = ri.recipe_id JOIN public.master_grocery_items mgi ON ri.master_item_id = mgi.id LEFT JOIN BestCurrentPrices bcp ON ri.master_item_id = bcp.master_item_id -- LEFT JOIN to include all ingredients, not just sale ones. ) -- Final Step: Aggregate the details into a single JSON object for each recipe. SELECT jsonb_build_object( 'id', rsd.recipe_id, 'name', rsd.recipe_name, 'ingredients', jsonb_agg( jsonb_build_object( 'item_name', rsd.item_name, 'on_sale', (rsd.best_price_in_cents IS NOT NULL), 'best_price_in_cents', rsd.best_price_in_cents, 'store_name', rsd.store_name ) ORDER BY (rsd.best_price_in_cents IS NOT NULL) DESC, rsd.item_name ASC -- Show sale items first in the list. ) ) FROM RecipeSaleDetails rsd GROUP BY rsd.recipe_id, rsd.recipe_name; $$; -- Function to add items generated from a menu plan directly to a user's shopping list. -- This acts as a utility function to chain `generate_shopping_list_for_menu_plan` with an INSERT action. CREATE OR REPLACE FUNCTION public.add_menu_plan_to_shopping_list( p_menu_plan_id BIGINT, p_shopping_list_id BIGINT, p_user_id UUID ) RETURNS TABLE ( master_item_id BIGINT, item_name TEXT, quantity_added NUMERIC ) LANGUAGE plpgsql -- SECURITY DEFINER is used here to perform actions with elevated privileges, -- but it's safe because we first perform a strict ownership check inside the function. SECURITY DEFINER AS $$ DECLARE list_owner_id UUID; item_to_add RECORD; BEGIN -- Security Check: Ensure the user calling this function owns the target shopping list. SELECT user_id INTO list_owner_id FROM public.shopping_lists WHERE id = p_shopping_list_id; IF list_owner_id IS NULL OR list_owner_id <> p_user_id THEN RAISE EXCEPTION 'Permission denied: You do not own shopping list %', p_shopping_list_id; END IF; -- Loop through the items generated by the smart shopping list function. FOR item_to_add IN SELECT * FROM public.generate_shopping_list_for_menu_plan(p_menu_plan_id, p_user_id) LOOP -- Insert the item into the shopping list. If it already exists, add to the quantity. INSERT INTO public.shopping_list_items (shopping_list_id, master_item_id, quantity) VALUES (p_shopping_list_id, item_to_add.master_item_id, item_to_add.shopping_list_quantity) ON CONFLICT (shopping_list_id, master_item_id) DO UPDATE SET quantity = shopping_list_items.quantity + EXCLUDED.quantity; -- Return the details of the item that was added/updated. RETURN QUERY SELECT item_to_add.master_item_id, item_to_add.item_name, item_to_add.shopping_list_quantity; END LOOP; END; $$; -- Function to find recipes that have at least a specified number of ingredients currently on sale. CREATE OR REPLACE FUNCTION public.get_recipes_by_min_sale_ingredients(p_min_sale_ingredients INTEGER) RETURNS TABLE ( recipe_id BIGINT, recipe_name TEXT, description TEXT, sale_ingredients_count BIGINT ) LANGUAGE sql STABLE SECURITY INVOKER AS $$ WITH CurrentSaleItems AS ( -- CTE 1: Get a distinct list of all master item IDs that are currently on sale. SELECT DISTINCT fi.master_item_id FROM public.flyer_items fi JOIN public.flyers f ON fi.flyer_id = f.id WHERE fi.master_item_id IS NOT NULL AND fi.price_in_cents IS NOT NULL AND CURRENT_DATE BETWEEN f.valid_from AND f.valid_to ), RecipeIngredientStats AS ( -- CTE 2: For each recipe, count how many of its ingredients are on the sale list. SELECT ri.recipe_id, COUNT(csi.master_item_id) AS sale_ingredients_count FROM public.recipe_ingredients ri LEFT JOIN CurrentSaleItems csi ON ri.master_item_id = csi.master_item_id GROUP BY ri.recipe_id ) -- Final Step: Select recipes that meet the minimum sale ingredient count and order them. SELECT r.id, r.name, r.description, ris.sale_ingredients_count FROM public.recipes r JOIN RecipeIngredientStats ris ON r.id = ris.recipe_id WHERE ris.sale_ingredients_count >= p_min_sale_ingredients ORDER BY ris.sale_ingredients_count DESC, r.avg_rating DESC; $$; -- Function to find the most frequently advertised items in a given period. -- This helps identify which items go on sale most often. CREATE OR REPLACE FUNCTION public.get_most_frequent_sale_items(days_interval INTEGER, result_limit INTEGER) RETURNS TABLE ( item_name TEXT, sale_occurrence_count BIGINT ) LANGUAGE sql STABLE SECURITY INVOKER AS $$ SELECT mgi.name AS item_name, COUNT(DISTINCT fi.flyer_id) AS sale_occurrence_count -- Count distinct flyers the item appeared in FROM public.flyer_items fi JOIN public.flyers f ON fi.flyer_id = f.id JOIN public.master_grocery_items mgi ON fi.master_item_id = mgi.id WHERE -- Only consider items linked to our master list fi.master_item_id IS NOT NULL -- Filter for flyers that have been active in the last X days AND f.valid_to >= (CURRENT_DATE - (days_interval || ' days')::INTERVAL) AND f.valid_from <= CURRENT_DATE GROUP BY mgi.id, mgi.name ORDER BY sale_occurrence_count DESC LIMIT result_limit; $$; -- Function to find recipes by a specific ingredient AND a specific tag. -- This allows for more refined recipe searching, e.g., "Find me a quick & easy recipe with chicken breast". CREATE OR REPLACE FUNCTION public.find_recipes_by_ingredient_and_tag(p_ingredient_name TEXT, p_tag_name TEXT) RETURNS TABLE ( id BIGINT, name TEXT, description TEXT, prep_time_minutes INTEGER, cook_time_minutes INTEGER, avg_rating NUMERIC ) LANGUAGE sql STABLE SECURITY INVOKER AS $$ SELECT r.id, r.name, r.description, r.prep_time_minutes, r.cook_time_minutes, r.avg_rating FROM public.recipes r WHERE -- Check that the recipe has the required ingredient using an EXISTS subquery. EXISTS ( SELECT 1 FROM public.recipe_ingredients ri JOIN public.master_grocery_items mgi ON ri.master_item_id = mgi.id WHERE ri.recipe_id = r.id AND mgi.name = p_ingredient_name ) AND -- Check that the recipe has the required tag using another EXISTS subquery. EXISTS ( SELECT 1 FROM public.recipe_tags rt JOIN public.tags t ON rt.tag_id = t.id WHERE rt.recipe_id = r.id AND t.name = p_tag_name ) ORDER BY r.avg_rating DESC, r.name ASC; $$; -- Function to suggest a master_item_id for a given flyer item name. -- This function uses trigram similarity to find the best match from both the -- master_grocery_items table and the master_item_aliases table. CREATE OR REPLACE FUNCTION public.suggest_master_item_for_flyer_item(p_flyer_item_name TEXT) RETURNS BIGINT LANGUAGE plpgsql STABLE -- This function does not modify the database. AS $$ DECLARE suggested_id BIGINT; -- A similarity score between 0 and 1. A higher value means a better match. -- This threshold can be adjusted based on observed performance. 0.4 is a reasonable starting point. similarity_threshold REAL := 0.4; BEGIN WITH candidates AS ( -- Search for matches in the primary master_grocery_items table SELECT id AS master_item_id, similarity(name, p_flyer_item_name) AS score FROM public.master_grocery_items WHERE name % p_flyer_item_name -- The '%' operator uses the trigram index for pre-filtering, making the search much faster. UNION ALL -- Search for matches in the master_item_aliases table SELECT master_item_id, similarity(alias, p_flyer_item_name) AS score FROM public.master_item_aliases WHERE alias % p_flyer_item_name ) -- Select the master_item_id with the highest similarity score, provided it's above our threshold. SELECT master_item_id INTO suggested_id FROM candidates WHERE score >= similarity_threshold ORDER BY score DESC, master_item_id LIMIT 1; RETURN suggested_id; END; $$; -- Function to recommend recipes to a user based on their watched items and highly-rated recipes. -- It calculates a score based on ingredient matches from the user's watchlist and similarity -- to other recipes the user has liked. CREATE OR REPLACE FUNCTION public.recommend_recipes_for_user(p_user_id UUID, p_limit INTEGER DEFAULT 10) RETURNS TABLE ( recipe_id BIGINT, recipe_name TEXT, recipe_description TEXT, avg_rating NUMERIC, recommendation_score NUMERIC, recommendation_reason TEXT ) LANGUAGE sql STABLE SECURITY INVOKER AS $$ WITH UserHighRatedRecipes AS ( -- CTE 1: Get recipes the user has rated 4 stars or higher. SELECT rr.recipe_id, rr.rating FROM public.recipe_ratings rr WHERE rr.user_id = p_user_id AND rr.rating >= 4 ), UserWatchedItems AS ( -- CTE 2: Get the user's watchlist of grocery items. SELECT uwi.master_item_id FROM public.user_watched_items uwi WHERE uwi.user_id = p_user_id ), RecipeScores AS ( -- CTE 3: Calculate a score for each recipe based on two factors. SELECT r.id AS recipe_id, -- Score from watched items: +5 points for each watched ingredient in the recipe. ( SELECT 5 * COUNT(*) FROM public.recipe_ingredients ri WHERE ri.recipe_id = r.id AND ri.master_item_id IN (SELECT master_item_id FROM UserWatchedItems) ) AS watched_item_score, -- Score from similarity to highly-rated recipes. ( SELECT COALESCE(SUM( -- +2 points for each shared ingredient with a highly-rated recipe. ( SELECT 2 * COUNT(*) FROM public.recipe_ingredients ri1 JOIN public.recipe_ingredients ri2 ON ri1.master_item_id = ri2.master_item_id WHERE ri1.recipe_id = r.id AND ri2.recipe_id = uhr.recipe_id ) + -- +3 points for each shared tag with a highly-rated recipe. ( SELECT 3 * COUNT(*) FROM public.recipe_tags rt1 JOIN public.recipe_tags rt2 ON rt1.tag_id = rt2.tag_id WHERE rt1.recipe_id = r.id AND rt2.recipe_id = uhr.recipe_id ) ), 0) FROM UserHighRatedRecipes uhr WHERE uhr.recipe_id <> r.id -- Don't compare a recipe to itself. ) AS similarity_score FROM public.recipes r ), RankedRecommendations AS ( -- CTE 4: Combine scores and generate a human-readable reason for the recommendation. SELECT rs.recipe_id, rs.watched_item_score + rs.similarity_score AS total_score, -- Create a reason string based on which score is higher. CASE WHEN rs.watched_item_score > rs.similarity_score THEN 'Contains items from your watchlist' WHEN rs.similarity_score > 0 THEN 'Similar to recipes you''ve liked' ELSE 'A popular recipe you might like' END AS reason FROM RecipeScores rs WHERE rs.watched_item_score + rs.similarity_score > 0 -- Exclude recipes the user has already rated to avoid recommending things they've already seen. AND rs.recipe_id NOT IN (SELECT recipe_id FROM public.recipe_ratings WHERE user_id = p_user_id) ) -- Final Selection: Join back to the recipes table to get full details and order by the final score. SELECT r.id, r.name, r.description, r.avg_rating, rr.total_score, rr.reason FROM RankedRecommendations rr JOIN public.recipes r ON rr.recipe_id = r.id ORDER BY rr.total_score DESC, r.avg_rating DESC, -- As a tie-breaker, prefer higher-rated recipes. r.rating_count DESC, r.name ASC LIMIT p_limit; $$; -- Function to approve a suggested correction and apply it. -- This is a SECURITY DEFINER function to allow an admin to update tables -- they might not have direct RLS access to. CREATE OR REPLACE FUNCTION public.approve_correction(p_correction_id BIGINT) RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $$ DECLARE correction_record RECORD; BEGIN -- 1. Fetch the correction details, ensuring it's still pending. SELECT * INTO correction_record FROM public.suggested_corrections WHERE id = p_correction_id AND status = 'pending'; IF NOT FOUND THEN RAISE EXCEPTION 'Correction with ID % not found or already processed.', p_correction_id; END IF; -- 2. Apply the correction based on its type. IF correction_record.correction_type = 'INCORRECT_ITEM_LINK' THEN UPDATE public.flyer_items SET master_item_id = correction_record.suggested_value::BIGINT WHERE id = correction_record.flyer_item_id; ELSIF correction_record.correction_type = 'WRONG_PRICE' THEN UPDATE public.flyer_items SET price_in_cents = correction_record.suggested_value::INTEGER WHERE id = correction_record.flyer_item_id; END IF; -- 3. Update the correction status to 'approved'. UPDATE public.suggested_corrections SET status = 'approved', reviewed_at = now() WHERE id = p_correction_id; END; $$; -- Function to find recipes that can be made entirely from items in a user's pantry. -- This function checks each recipe and returns it only if every ingredient is present -- in the specified user's pantry. CREATE OR REPLACE FUNCTION public.find_recipes_from_pantry(p_user_id UUID) RETURNS TABLE( id BIGINT, name TEXT, description TEXT, prep_time_minutes INTEGER, cook_time_minutes INTEGER, avg_rating NUMERIC, missing_ingredients_count BIGINT ) LANGUAGE sql STABLE SECURITY INVOKER AS $$ WITH UserPantryItems AS ( -- CTE 1: Get a distinct set of master item IDs from the user's pantry. SELECT master_item_id, quantity, unit FROM public.pantry_items WHERE user_id = p_user_id AND quantity > 0 ), RecipeIngredientStats AS ( -- CTE 2: For each recipe, count its total ingredients and how many of those are in the user's pantry. SELECT ri.recipe_id, -- Count how many ingredients DO NOT meet the pantry requirements. -- An ingredient is missing if it's not in the pantry OR if the quantity is insufficient. -- The filter condition handles this logic. COUNT(*) FILTER ( WHERE upi.master_item_id IS NULL -- The item is not in the pantry at all OR upi.quantity < ri.quantity -- The user has the item, but not enough of it ) AS missing_ingredients_count FROM public.recipe_ingredients ri -- LEFT JOIN to the user's pantry on both item and unit. -- We only compare quantities if the units match (e.g., 'g' vs 'g'). LEFT JOIN UserPantryItems upi ON ri.master_item_id = upi.master_item_id AND ri.unit = upi.unit GROUP BY ri.recipe_id ) -- Final Step: Select recipes where the total ingredient count matches the pantry ingredient count. SELECT r.id, r.name, r.description, r.prep_time_minutes, r.cook_time_minutes, r.avg_rating, ris.missing_ingredients_count FROM public.recipes r JOIN RecipeIngredientStats ris ON r.id = ris.recipe_id -- Order by recipes with the fewest missing ingredients first, then by rating. -- Recipes with 0 missing ingredients are the ones that can be made. ORDER BY ris.missing_ingredients_count ASC, r.avg_rating DESC, r.name ASC; $$; -- Function to suggest alternative units for a given pantry item. -- For example, if a user has 500g of flour, this function might suggest "4.1 cups". CREATE OR REPLACE FUNCTION public.suggest_pantry_item_conversions(p_pantry_item_id BIGINT) RETURNS TABLE ( suggested_quantity NUMERIC, suggested_unit TEXT ) LANGUAGE sql STABLE SECURITY INVOKER AS $$ SELECT -- Calculate the converted quantity by multiplying the original quantity by the conversion factor. -- Round to 2 decimal places for readability. ROUND(pi.quantity * uc.factor, 2) AS suggested_quantity, uc.to_unit AS suggested_unit FROM public.pantry_items pi -- Join with the unit_conversions table to find available conversion rules. JOIN public.unit_conversions uc ON pi.master_item_id = uc.master_item_id AND pi.unit = uc.from_unit WHERE pi.id = p_pantry_item_id -- Exclude suggesting a conversion back to the same unit. AND pi.unit <> uc.to_unit; $$;