Files
flyer-crawler.projectium.com/sql/functions.sql

608 lines
24 KiB
PL/PgSQL

-- 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;
$$;