From 1c08d2dab1ff8bbc7546420add45e8e7841c5a25 Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Mon, 24 Nov 2025 11:54:06 -0800 Subject: [PATCH] db to user_id --- server.ts | 4 +- sql/Initial_triggers_and_functions.sql | 172 +++--- sql/{ => helper_scripts}/generate_rollup.ps1 | 0 sql/{ => helper_scripts}/generate_rollup.sh | 0 sql/{ => helper_scripts}/verify_rollup.ps1 | 0 sql/{ => helper_scripts}/verify_rollup.sh | 0 sql/initial_data.sql | 126 ++-- sql/initial_schema.sql | 263 +++++---- sql/master_schema_rollup.sql | 544 +++++++++--------- src/App.tsx | 2 +- src/components/AdminBrandManager.tsx | 2 +- src/components/ExtractedDataTable.tsx | 4 +- src/components/FlyerList.tsx | 4 +- src/components/ShoppingList.tsx | 2 +- src/components/auth.integration.test.ts | 4 +- src/db/seed.ts | 34 +- src/db/seed_admin_account.ts | 12 +- src/routes/ai.ts | 18 +- src/routes/auth.ts | 31 +- src/routes/passport.ts | 14 +- src/routes/user.integration.test.ts | 8 +- src/routes/user.ts | 192 ++++--- src/services/db.integration.test.ts | 6 +- src/services/db/admin.ts | 36 +- src/services/db/flyer.ts | 40 +- src/services/db/personalization.ts | 14 +- src/services/db/recipe.ts | 2 +- src/services/db/shopping.ts | 28 +- src/services/db/user.ts | 70 +-- .../shopping-list.integration.test.ts | 4 +- src/types.ts | 86 +-- 31 files changed, 868 insertions(+), 854 deletions(-) rename sql/{ => helper_scripts}/generate_rollup.ps1 (100%) rename sql/{ => helper_scripts}/generate_rollup.sh (100%) rename sql/{ => helper_scripts}/verify_rollup.ps1 (100%) rename sql/{ => helper_scripts}/verify_rollup.sh (100%) diff --git a/server.ts b/server.ts index 271f310e..a58ecb43 100644 --- a/server.ts +++ b/server.ts @@ -30,7 +30,7 @@ logger.info(` Database: ${process.env.DB_DATABASE}`); // Query the users table to see what the server process sees on startup. // Corrected the query to be unambiguous by specifying the table alias for each column. // `id` and `email` come from the `users` table (u), and `role` comes from the `profiles` table (p). -getPool().query('SELECT u.id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.id = p.id') +getPool().query('SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id') .then(res => { logger.debug('[SERVER PROCESS] Users found in DB on startup:'); console.table(res.rows); @@ -71,7 +71,7 @@ const requestLogger = (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id?: string } | undefined; const durationInMilliseconds = getDurationInMilliseconds(start); const { statusCode } = res; - const userIdentifier = user?.id ? ` (User: ${user.id})` : ''; + const userIdentifier = user?.user_id ? ` (User: ${user.user_id})` : ''; const logMessage = `${method} ${originalUrl} ${statusCode} ${durationInMilliseconds.toFixed(2)}ms${userIdentifier}`; diff --git a/sql/Initial_triggers_and_functions.sql b/sql/Initial_triggers_and_functions.sql index 7fe9d5f5..cbb9f5e7 100644 --- a/sql/Initial_triggers_and_functions.sql +++ b/sql/Initial_triggers_and_functions.sql @@ -13,17 +13,17 @@ BEGIN -- The user's metadata (full_name, avatar_url) is passed via a temporary session variable. user_meta_data := current_setting('my_app.user_metadata', true)::JSONB; - INSERT INTO public.profiles (id, role, full_name, avatar_url) - VALUES (new.id, 'user', user_meta_data->>'full_name', user_meta_data->>'avatar_url') - RETURNING id INTO new_profile_id; + INSERT INTO public.profiles (user_id, role, full_name, avatar_url) + VALUES (new.user_id, 'user', user_meta_data->>'full_name', user_meta_data->>'avatar_url') + RETURNING user_id INTO new_profile_id; -- Also create a default shopping list for the new user. INSERT INTO public.shopping_lists (user_id, name) - VALUES (new_profile_id, 'Main Shopping List'); + VALUES (new.user_id, 'Main Shopping List'); -- Log the new user event INSERT INTO public.activity_log (user_id, action, display_text, icon, details) - VALUES (new.id, 'user_registered', + VALUES (new.user_id, 'user_registered', COALESCE(user_meta_data->>'full_name', new.email) || ' has registered.', 'user-plus', jsonb_build_object('email', new.email) @@ -123,7 +123,7 @@ BEGIN -- If the item could not be matched, add it to the unmatched queue for review. IF NEW.master_item_id IS NULL THEN INSERT INTO public.unmatched_flyer_items (flyer_item_id) - VALUES (NEW.id) + VALUES (NEW.flyer_item_id) ON CONFLICT (flyer_item_id) DO NOTHING; END IF; @@ -135,7 +135,7 @@ BEGIN -- Get the validity dates of the flyer and the store_id. SELECT valid_from, valid_to INTO flyer_valid_from, flyer_valid_to FROM public.flyers - WHERE id = NEW.flyer_id; + WHERE flyer_id = NEW.flyer_id; -- If the flyer dates are not set, we cannot proceed. IF flyer_valid_from IS NULL OR flyer_valid_to IS NULL THEN @@ -185,7 +185,7 @@ BEGIN -- Get the validity dates of the flyer. SELECT valid_from, valid_to INTO flyer_valid_from, flyer_valid_to FROM public.flyers - WHERE id = OLD.flyer_id; + WHERE flyer_id = OLD.flyer_id; -- If the flyer dates are not set, we cannot proceed. IF flyer_valid_from IS NULL OR flyer_valid_to IS NULL THEN @@ -201,11 +201,11 @@ BEGIN MIN(fi.price_in_cents) AS min_price, MAX(fi.price_in_cents) AS max_price, ROUND(AVG(fi.price_in_cents)) AS avg_price, - COUNT(fi.id) AS data_points + COUNT(fi.flyer_item_id) AS data_points INTO new_aggregates FROM public.flyer_items fi JOIN public.flyer_locations fl ON fi.flyer_id = fl.flyer_id - JOIN public.flyers f ON fi.flyer_id = f.id + JOIN public.flyers f ON fi.flyer_id = f.flyer_id WHERE fi.master_item_id = OLD.master_item_id AND fi.price_in_cents IS NOT NULL AND current_summary_date BETWEEN f.valid_from AND f.valid_to @@ -242,14 +242,14 @@ BEGIN avg_rating = ( SELECT AVG(rating) FROM public.recipe_ratings - WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id) + WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id) -- This is correct, no change needed ), rating_count = ( SELECT COUNT(*) FROM public.recipe_ratings - WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id) + WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id) -- This is correct, no change needed ) - WHERE id = COALESCE(NEW.recipe_id, OLD.recipe_id); + WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id); RETURN NULL; -- The result is ignored since this is an AFTER trigger. END; @@ -269,7 +269,7 @@ BEGIN VALUES ( NEW.user_id, 'recipe_created', - (SELECT full_name FROM public.profiles WHERE id = NEW.user_id) || ' created a new recipe: ' || NEW.name, + (SELECT full_name FROM public.profiles WHERE user_id = NEW.user_id) || ' created a new recipe: ' || NEW.name, 'chef-hat', jsonb_build_object('recipe_name', NEW.name) ); @@ -292,10 +292,10 @@ BEGIN INSERT INTO public.activity_log (action, display_text, icon, details) VALUES ( 'flyer_uploaded', - 'A new flyer for ' || (SELECT name FROM public.stores WHERE id = NEW.store_id) || ' has been uploaded.', + 'A new flyer for ' || (SELECT name FROM public.stores WHERE store_id = NEW.store_id) || ' has been uploaded.', 'file-text', jsonb_build_object( - 'store_name', (SELECT name FROM public.stores WHERE id = NEW.store_id), + 'store_name', (SELECT name FROM public.stores WHERE store_id = NEW.store_id), 'valid_from', to_char(NEW.valid_from, 'YYYY-MM-DD'), 'valid_to', to_char(NEW.valid_to, 'YYYY-MM-DD') ) @@ -318,7 +318,7 @@ BEGIN VALUES ( NEW.user_id, 'recipe_favorited', - (SELECT full_name FROM public.profiles WHERE id = NEW.user_id) || ' favorited the recipe: ' || (SELECT name FROM public.recipes WHERE id = NEW.recipe_id), + (SELECT full_name FROM public.profiles WHERE user_id = NEW.user_id) || ' favorited the recipe: ' || (SELECT name FROM public.recipes WHERE recipe_id = NEW.recipe_id), 'heart', jsonb_build_object( 'recipe_id', NEW.recipe_id @@ -342,10 +342,10 @@ BEGIN VALUES ( NEW.shared_by_user_id, 'list_shared', - (SELECT full_name FROM public.profiles WHERE id = NEW.shared_by_user_id) || ' shared a shopping list.', + (SELECT full_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id) || ' shared a shopping list.', 'share-2', jsonb_build_object( - 'list_name', (SELECT name FROM public.shopping_lists WHERE id = NEW.shopping_list_id), + 'list_name', (SELECT name FROM public.shopping_lists WHERE shopping_list_id = NEW.shopping_list_id), 'shared_with_user_id', NEW.shared_with_user_id ) ); @@ -388,7 +388,7 @@ BEGIN mgi.name AS item_name, fi.price_in_cents, s.name AS store_name, - f.id AS flyer_id, + f.flyer_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, @@ -396,10 +396,10 @@ BEGIN 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.master_grocery_items mgi ON uwi.master_item_id = mgi.master_grocery_item_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 + JOIN public.flyers f ON fi.flyer_id = f.flyer_id + JOIN public.stores s ON f.store_id = s.store_id WHERE uwi.user_id = p_user_id AND f.valid_from <= CURRENT_DATE AND f.valid_to >= CURRENT_DATE @@ -442,10 +442,10 @@ BEGIN (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.planned_meals pm ON mp.menu_plan_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 + JOIN public.recipes r ON pm.recipe_id = r.recipe_id + WHERE mp.menu_plan_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. @@ -458,7 +458,7 @@ BEGIN 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 + JOIN public.master_grocery_items mgi ON req.master_item_id = mgi.master_grocery_item_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"). @@ -490,8 +490,8 @@ AS $$ 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 + JOIN public.flyers f ON fi.flyer_id = f.flyer_id + JOIN public.stores s ON f.store_id = s.store_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 @@ -521,15 +521,15 @@ AS $$ 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.recipe_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 + JOIN EligibleRecipes er ON r.recipe_id = er.recipe_id -- Join with the filtered eligible recipes + JOIN public.recipe_ingredients ri ON r.recipe_id = ri.recipe_id + JOIN public.master_grocery_items mgi ON ri.master_item_id = mgi.master_grocery_item_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. @@ -575,7 +575,7 @@ 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; + WHERE shopping_list_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; @@ -614,7 +614,7 @@ 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 + JOIN public.flyers f ON fi.flyer_id = f.flyer_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 @@ -630,12 +630,12 @@ AS $$ ) -- Final Step: Select recipes that meet the minimum sale ingredient count and order them. SELECT - r.id, + r.recipe_id, r.name, r.description, ris.sale_ingredients_count FROM public.recipes r - JOIN RecipeIngredientStats ris ON r.id = ris.recipe_id + JOIN RecipeIngredientStats ris ON r.recipe_id = ris.recipe_id WHERE ris.sale_ingredients_count >= p_min_sale_ingredients ORDER BY ris.sale_ingredients_count DESC, @@ -659,9 +659,9 @@ AS $$ FROM public.flyer_items fi JOIN - public.flyers f ON fi.flyer_id = f.id + public.flyers f ON fi.flyer_id = f.flyer_id JOIN - public.master_grocery_items mgi ON fi.master_item_id = mgi.id + public.master_grocery_items mgi ON fi.master_item_id = mgi.master_grocery_item_id WHERE -- Only consider items linked to our master list fi.master_item_id IS NOT NULL @@ -669,7 +669,7 @@ AS $$ AND f.valid_to >= (CURRENT_DATE - (days_interval || ' days')::INTERVAL) AND f.valid_from <= CURRENT_DATE GROUP BY - mgi.id, mgi.name + mgi.master_grocery_item_id, mgi.name ORDER BY sale_occurrence_count DESC LIMIT result_limit; @@ -679,7 +679,7 @@ $$; -- 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, + recipe_id BIGINT, name TEXT, description TEXT, prep_time_minutes INTEGER, @@ -691,22 +691,22 @@ STABLE SECURITY INVOKER AS $$ SELECT - r.id, r.name, r.description, r.prep_time_minutes, r.cook_time_minutes, r.avg_rating + r.recipe_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 + JOIN public.master_grocery_items mgi ON ri.master_item_id = mgi.master_grocery_item_id + WHERE ri.recipe_id = r.recipe_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 + JOIN public.tags t ON rt.tag_id = t.tag_id + WHERE rt.recipe_id = r.recipe_id AND t.name = p_tag_name ) ORDER BY r.avg_rating DESC, r.name ASC; @@ -729,7 +729,7 @@ BEGIN WITH candidates AS ( -- Search for matches in the primary master_grocery_items table SELECT - id AS master_item_id, + master_grocery_item_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. @@ -781,12 +781,12 @@ UserWatchedItems AS ( RecipeScores AS ( -- CTE 3: Calculate a score for each recipe based on two factors. SELECT - r.id AS recipe_id, + r.recipe_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) + WHERE ri.recipe_id = r.recipe_id AND ri.master_item_id IN (SELECT master_item_id FROM UserWatchedItems) ) AS watched_item_score, -- Score from similarity to highly-rated recipes. ( @@ -796,18 +796,18 @@ RecipeScores AS ( 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 + WHERE ri1.recipe_id = r.recipe_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 + WHERE rt1.recipe_id = r.recipe_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. + WHERE uhr.recipe_id <> r.recipe_id -- Don't compare a recipe to itself. ) AS similarity_score FROM public.recipes r ), @@ -829,14 +829,14 @@ RankedRecommendations AS ( ) -- Final Selection: Join back to the recipes table to get full details and order by the final score. SELECT - r.id, + r.recipe_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 +JOIN public.recipes r ON rr.recipe_id = r.recipe_id ORDER BY rr.total_score DESC, r.avg_rating DESC, -- As a tie-breaker, prefer higher-rated recipes. @@ -859,7 +859,7 @@ 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'; + WHERE suggested_correction_id = p_correction_id AND status = 'pending'; IF NOT FOUND THEN RAISE EXCEPTION 'Correction with ID % not found or already processed.', p_correction_id; @@ -869,17 +869,17 @@ BEGIN 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; + WHERE flyer_item_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; + WHERE flyer_item_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; + WHERE suggested_correction_id = p_correction_id; END; $$; @@ -888,7 +888,7 @@ $$; -- in the specified user's pantry. CREATE OR REPLACE FUNCTION public.find_recipes_from_pantry(p_user_id UUID) RETURNS TABLE( - id BIGINT, + recipe_id BIGINT, name TEXT, description TEXT, prep_time_minutes INTEGER, @@ -927,7 +927,7 @@ AS $$ ) -- Final Step: Select recipes where the total ingredient count matches the pantry ingredient count. SELECT - r.id, + r.recipe_id, r.name, r.description, r.prep_time_minutes, @@ -935,7 +935,7 @@ AS $$ r.avg_rating, ris.missing_ingredients_count FROM public.recipes r - JOIN RecipeIngredientStats ris ON r.id = ris.recipe_id + JOIN RecipeIngredientStats ris ON r.recipe_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; @@ -963,7 +963,7 @@ AS $$ ON pi.master_item_id = uc.master_item_id AND pi.unit = uc.from_unit WHERE - pi.id = p_pantry_item_id + pi.pantry_item_id = p_pantry_item_id -- Exclude suggesting a conversion back to the same unit. AND pi.unit <> uc.to_unit; $$; @@ -971,7 +971,7 @@ $$; -- Function to get a user's favorite recipes. CREATE OR REPLACE FUNCTION public.get_user_favorite_recipes(p_user_id UUID) RETURNS TABLE ( - id BIGINT, + recipe_id BIGINT, name TEXT, description TEXT, avg_rating NUMERIC, @@ -982,9 +982,9 @@ STABLE SECURITY INVOKER AS $$ SELECT - r.id, r.name, r.description, r.avg_rating, r.photo_url + r.recipe_id, r.name, r.description, r.avg_rating, r.photo_url FROM public.recipes r - JOIN public.favorite_recipes fr ON r.id = fr.recipe_id + JOIN public.favorite_recipes fr ON r.recipe_id = fr.recipe_id WHERE fr.user_id = p_user_id ORDER BY r.name ASC; $$; @@ -992,7 +992,7 @@ $$; -- Function to get a paginated list of recent activities for the audit log. CREATE OR REPLACE FUNCTION public.get_activity_log(p_limit INTEGER DEFAULT 20, p_offset INTEGER DEFAULT 0) RETURNS TABLE ( - id BIGINT, + activity_log_id BIGINT, user_id UUID, action TEXT, display_text TEXT, @@ -1007,12 +1007,12 @@ STABLE SECURITY INVOKER AS $$ SELECT - al.id, al.user_id, al.action, al.display_text, al.icon, al.details, al.created_at, + al.activity_log_id, al.user_id, al.action, al.display_text, al.icon, al.details, al.created_at, p.full_name, p.avatar_url FROM public.activity_log al -- Join with profiles to get user details for display. -- LEFT JOIN is used because some activities might be system-generated (user_id is NULL). - LEFT JOIN public.profiles p ON al.user_id = p.id + LEFT JOIN public.profiles p ON al.user_id = p.user_id ORDER BY al.created_at DESC LIMIT p_limit @@ -1022,7 +1022,7 @@ $$; -- Function to get a user's profile by their ID, combining data from users and profiles tables. CREATE OR REPLACE FUNCTION public.get_user_profile_by_id(p_user_id UUID) RETURNS TABLE ( - id UUID, + user_id UUID, email TEXT, full_name TEXT, avatar_url TEXT, @@ -1036,7 +1036,7 @@ STABLE SECURITY INVOKER AS $$ SELECT - u.id, + u.user_id, u.email, p.full_name, p.avatar_url, @@ -1045,8 +1045,8 @@ AS $$ p.created_at, p.updated_at FROM public.users u - JOIN public.profiles p ON u.id = p.id - WHERE u.id = p_user_id; + JOIN public.profiles p ON u.user_id = p.user_id + WHERE u.user_id = p_user_id; $$; -- Function to get recipes that are compatible with a user's dietary restrictions (allergies). @@ -1059,10 +1059,10 @@ SECURITY INVOKER AS $$ WITH UserAllergens AS ( -- CTE 1: Find all master item IDs that are allergens for the given user. - SELECT mgi.id + SELECT mgi.master_grocery_item_id FROM public.master_grocery_items mgi JOIN public.dietary_restrictions dr ON mgi.allergy_info->>'type' = dr.name - JOIN public.user_dietary_restrictions udr ON dr.id = udr.restriction_id + JOIN public.user_dietary_restrictions udr ON dr.dietary_restriction_id = udr.restriction_id WHERE udr.user_id = p_user_id AND dr.type = 'allergy' AND mgi.is_allergen = true @@ -1071,12 +1071,12 @@ AS $$ -- CTE 2: Find all recipe IDs that contain one or more of the user's allergens. SELECT DISTINCT ri.recipe_id FROM public.recipe_ingredients ri - WHERE ri.master_item_id IN (SELECT id FROM UserAllergens) + WHERE ri.master_item_id IN (SELECT master_grocery_item_id FROM UserAllergens) ) -- Final Selection: Return all recipes that are NOT in the forbidden list. SELECT * FROM public.recipes r - WHERE r.id NOT IN (SELECT recipe_id FROM ForbiddenRecipes) + WHERE r.recipe_id NOT IN (SELECT recipe_id FROM ForbiddenRecipes) ORDER BY r.avg_rating DESC, r.name ASC; $$; @@ -1084,7 +1084,7 @@ $$; -- It aggregates recent activities from followed users. CREATE OR REPLACE FUNCTION public.get_user_feed(p_user_id UUID, p_limit INTEGER DEFAULT 20, p_offset INTEGER DEFAULT 0) RETURNS TABLE ( - id BIGINT, + activity_log_id BIGINT, user_id UUID, action TEXT, display_text TEXT, @@ -1104,10 +1104,10 @@ AS $$ ) -- Final Selection: Get activities from the log where the user_id is in the followed list. SELECT - al.id, al.user_id, al.action, al.display_text, al.icon, al.details, al.created_at, + al.activity_log_id, al.user_id, al.action, al.display_text, al.icon, al.details, al.created_at, p.full_name, p.avatar_url FROM public.activity_log al - JOIN public.profiles p ON al.user_id = p.id + JOIN public.profiles p ON al.user_id = p.user_id WHERE al.user_id IN (SELECT following_id FROM FollowedUsers) -- We can filter for specific action types to make the feed more relevant. @@ -1142,7 +1142,7 @@ 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; + WHERE shopping_list_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; @@ -1151,7 +1151,7 @@ BEGIN -- 1. Create a new shopping_trip record. INSERT INTO public.shopping_trips (user_id, shopping_list_id, total_spent_cents) VALUES (p_user_id, p_shopping_list_id, p_total_spent_cents) - RETURNING id INTO new_trip_id; + RETURNING shopping_trip_id INTO new_trip_id; -- 2. Copy purchased items from the shopping list to the new shopping_trip_items table. INSERT INTO public.shopping_trip_items (shopping_trip_id, master_item_id, custom_item_name, quantity) @@ -1187,12 +1187,12 @@ AS $$ WITH ReceiptItems AS ( -- CTE 1: Get all matched items from the specified receipt. SELECT - ri.id AS receipt_item_id, + ri.receipt_item_id AS receipt_item_id, ri.master_item_id, mgi.name AS item_name, ri.price_paid_cents FROM public.receipt_items ri - JOIN public.master_grocery_items mgi ON ri.master_item_id = mgi.id + JOIN public.master_grocery_items mgi ON ri.master_item_id = mgi.master_grocery_item_id WHERE ri.receipt_id = p_receipt_id AND ri.master_item_id IS NOT NULL ), @@ -1202,10 +1202,10 @@ AS $$ fi.master_item_id, fi.price_in_cents, s.name AS store_name, - f.id AS flyer_id + f.flyer_id AS flyer_id 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 + JOIN public.flyers f ON fi.flyer_id = f.flyer_id + JOIN public.stores s ON f.store_id = s.store_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 diff --git a/sql/generate_rollup.ps1 b/sql/helper_scripts/generate_rollup.ps1 similarity index 100% rename from sql/generate_rollup.ps1 rename to sql/helper_scripts/generate_rollup.ps1 diff --git a/sql/generate_rollup.sh b/sql/helper_scripts/generate_rollup.sh similarity index 100% rename from sql/generate_rollup.sh rename to sql/helper_scripts/generate_rollup.sh diff --git a/sql/verify_rollup.ps1 b/sql/helper_scripts/verify_rollup.ps1 similarity index 100% rename from sql/verify_rollup.ps1 rename to sql/helper_scripts/verify_rollup.ps1 diff --git a/sql/verify_rollup.sh b/sql/helper_scripts/verify_rollup.sh similarity index 100% rename from sql/verify_rollup.sh rename to sql/helper_scripts/verify_rollup.sh diff --git a/sql/initial_data.sql b/sql/initial_data.sql index 30aa5298..5a3b0711 100644 --- a/sql/initial_data.sql +++ b/sql/initial_data.sql @@ -15,22 +15,22 @@ DECLARE bc_cat_id BIGINT; ps_cat_id BIGINT; dpf_cat_id BIGINT; cg_cat_id BIGINT; cs_cat_id BIGINT; bkc_cat_id BIGINT; BEGIN - SELECT id INTO fv_cat_id FROM public.categories WHERE name = 'Fruits & Vegetables'; - SELECT id INTO ms_cat_id FROM public.categories WHERE name = 'Meat & Seafood'; - SELECT id INTO de_cat_id FROM public.categories WHERE name = 'Dairy & Eggs'; - SELECT id INTO bb_cat_id FROM public.categories WHERE name = 'Bakery & Bread'; - SELECT id INTO pdg_cat_id FROM public.categories WHERE name = 'Pantry & Dry Goods'; - SELECT id INTO bev_cat_id FROM public.categories WHERE name = 'Beverages'; - SELECT id INTO ff_cat_id FROM public.categories WHERE name = 'Frozen Foods'; - SELECT id INTO snk_cat_id FROM public.categories WHERE name = 'Snacks'; - SELECT id INTO hc_cat_id FROM public.categories WHERE name = 'Household & Cleaning'; - SELECT id INTO pch_cat_id FROM public.categories WHERE name = 'Personal Care & Health'; - SELECT id INTO bc_cat_id FROM public.categories WHERE name = 'Baby & Child'; - SELECT id INTO ps_cat_id FROM public.categories WHERE name = 'Pet Supplies'; - SELECT id INTO dpf_cat_id FROM public.categories WHERE name = 'Deli & Prepared Foods'; - SELECT id INTO cg_cat_id FROM public.categories WHERE name = 'Canned Goods'; - SELECT id INTO cs_cat_id FROM public.categories WHERE name = 'Condiments & Spices'; - SELECT id INTO bkc_cat_id FROM public.categories WHERE name = 'Breakfast & Cereal'; + SELECT category_id INTO fv_cat_id FROM public.categories WHERE name = 'Fruits & Vegetables'; + SELECT category_id INTO ms_cat_id FROM public.categories WHERE name = 'Meat & Seafood'; + SELECT category_id INTO de_cat_id FROM public.categories WHERE name = 'Dairy & Eggs'; + SELECT category_id INTO bb_cat_id FROM public.categories WHERE name = 'Bakery & Bread'; + SELECT category_id INTO pdg_cat_id FROM public.categories WHERE name = 'Pantry & Dry Goods'; + SELECT category_id INTO bev_cat_id FROM public.categories WHERE name = 'Beverages'; + SELECT category_id INTO ff_cat_id FROM public.categories WHERE name = 'Frozen Foods'; + SELECT category_id INTO snk_cat_id FROM public.categories WHERE name = 'Snacks'; + SELECT category_id INTO hc_cat_id FROM public.categories WHERE name = 'Household & Cleaning'; + SELECT category_id INTO pch_cat_id FROM public.categories WHERE name = 'Personal Care & Health'; + SELECT category_id INTO bc_cat_id FROM public.categories WHERE name = 'Baby & Child'; + SELECT category_id INTO ps_cat_id FROM public.categories WHERE name = 'Pet Supplies'; + SELECT category_id INTO dpf_cat_id FROM public.categories WHERE name = 'Deli & Prepared Foods'; + SELECT category_id INTO cg_cat_id FROM public.categories WHERE name = 'Canned Goods'; + SELECT category_id INTO cs_cat_id FROM public.categories WHERE name = 'Condiments & Spices'; + SELECT category_id INTO bkc_cat_id FROM public.categories WHERE name = 'Breakfast & Cereal'; INSERT INTO public.master_grocery_items (name, category_id) VALUES ('apples', fv_cat_id), ('bananas', fv_cat_id), ('oranges', fv_cat_id), ('grapes', fv_cat_id), ('strawberries', fv_cat_id), ('blueberries', fv_cat_id), ('raspberries', fv_cat_id), ('avocados', fv_cat_id), ('tomatoes', fv_cat_id), ('potatoes', fv_cat_id), ('onions', fv_cat_id), ('garlic', fv_cat_id), ('carrots', fv_cat_id), ('broccoli', fv_cat_id), ('spinach', fv_cat_id), ('lettuce', fv_cat_id), ('bell peppers', fv_cat_id), ('cucumbers', fv_cat_id), ('mushrooms', fv_cat_id), ('lemons', fv_cat_id), ('limes', fv_cat_id), ('celery', fv_cat_id), ('corn', fv_cat_id), ('sweet potatoes', fv_cat_id), ('zucchini', fv_cat_id), ('cauliflower', fv_cat_id), ('green beans', fv_cat_id), ('peas', fv_cat_id), ('asparagus', fv_cat_id), @@ -75,29 +75,29 @@ DECLARE BEGIN -- Insert a store for the store brands INSERT INTO public.stores (name) VALUES ('Loblaws') ON CONFLICT (name) DO NOTHING; - SELECT id INTO loblaws_id FROM public.stores WHERE name = 'Loblaws'; + SELECT store_id INTO loblaws_id FROM public.stores WHERE name = 'Loblaws'; -- Insert brands and get their IDs INSERT INTO public.brands (name) VALUES ('Coca-Cola'), ('Kraft'), ('Maple Leaf'), ('Dempster''s'), ('No Name'), ('President''s Choice') ON CONFLICT (name) DO NOTHING; - SELECT id INTO coke_id FROM public.brands WHERE name = 'Coca-Cola'; - SELECT id INTO kraft_id FROM public.brands WHERE name = 'Kraft'; - SELECT id INTO maple_leaf_id FROM public.brands WHERE name = 'Maple Leaf'; - SELECT id INTO dempsters_id FROM public.brands WHERE name = 'Dempster''s'; - SELECT id INTO no_name_id FROM public.brands WHERE name = 'No Name'; - SELECT id INTO pc_id FROM public.brands WHERE name = 'President''s Choice'; + SELECT brand_id INTO coke_id FROM public.brands WHERE name = 'Coca-Cola'; + SELECT brand_id INTO kraft_id FROM public.brands WHERE name = 'Kraft'; + SELECT brand_id INTO maple_leaf_id FROM public.brands WHERE name = 'Maple Leaf'; + SELECT brand_id INTO dempsters_id FROM public.brands WHERE name = 'Dempster''s'; + SELECT brand_id INTO no_name_id FROM public.brands WHERE name = 'No Name'; + SELECT brand_id INTO pc_id FROM public.brands WHERE name = 'President''s Choice'; -- Link store brands to their store UPDATE public.brands SET store_id = loblaws_id WHERE name = 'No Name'; UPDATE public.brands SET store_id = loblaws_id WHERE name = 'President''s Choice'; -- Get master item IDs - SELECT mgi.id INTO soda_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'soda'; - SELECT mgi.id INTO pasta_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'pasta'; - SELECT mgi.id INTO turkey_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'turkey'; - SELECT mgi.id INTO bread_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'bread'; - SELECT mgi.id INTO cheese_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'cheese'; + SELECT mgi.master_grocery_item_id INTO soda_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'soda'; + SELECT mgi.master_grocery_item_id INTO pasta_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'pasta'; + SELECT mgi.master_grocery_item_id INTO turkey_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'turkey'; + SELECT mgi.master_grocery_item_id INTO bread_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'bread'; + SELECT mgi.master_grocery_item_id INTO cheese_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'cheese'; -- Insert specific products, linking master items and brands INSERT INTO public.products (master_item_id, brand_id, name, size, upc_code) VALUES @@ -123,13 +123,13 @@ DECLARE toilet_paper_id BIGINT; BEGIN -- Get master item IDs - SELECT mgi.id INTO ground_beef_id FROM public.master_grocery_items mgi WHERE mgi.name = 'ground beef'; - SELECT mgi.id INTO chicken_breast_id FROM public.master_grocery_items mgi WHERE mgi.name = 'chicken breast'; - SELECT mgi.id INTO chicken_thighs_id FROM public.master_grocery_items mgi WHERE mgi.name = 'chicken thighs'; - SELECT mgi.id INTO bell_peppers_id FROM public.master_grocery_items mgi WHERE mgi.name = 'bell peppers'; - SELECT mgi.id INTO soda_id FROM public.master_grocery_items mgi WHERE mgi.name = 'soda'; - SELECT mgi.id INTO paper_towels_id FROM public.master_grocery_items mgi WHERE mgi.name = 'paper towels'; - SELECT mgi.id INTO toilet_paper_id FROM public.master_grocery_items mgi WHERE mgi.name = 'toilet paper'; + SELECT mgi.master_grocery_item_id INTO ground_beef_id FROM public.master_grocery_items mgi WHERE mgi.name = 'ground beef'; + SELECT mgi.master_grocery_item_id INTO chicken_breast_id FROM public.master_grocery_items mgi WHERE mgi.name = 'chicken breast'; + SELECT mgi.master_grocery_item_id INTO chicken_thighs_id FROM public.master_grocery_items mgi WHERE mgi.name = 'chicken thighs'; + SELECT mgi.master_grocery_item_id INTO bell_peppers_id FROM public.master_grocery_items mgi WHERE mgi.name = 'bell peppers'; + SELECT mgi.master_grocery_item_id INTO soda_id FROM public.master_grocery_items mgi WHERE mgi.name = 'soda'; + SELECT mgi.master_grocery_item_id INTO paper_towels_id FROM public.master_grocery_items mgi WHERE mgi.name = 'paper towels'; + SELECT mgi.master_grocery_item_id INTO toilet_paper_id FROM public.master_grocery_items mgi WHERE mgi.name = 'toilet paper'; -- Insert aliases, ignoring any that might already exist INSERT INTO public.master_item_aliases (master_item_id, alias) VALUES @@ -167,41 +167,41 @@ BEGIN ('Vegetable Stir-fry', 'A fast, flavorful, and vegetarian stir-fry loaded with fresh vegetables.', '1. Chop all vegetables. 2. Heat oil in a wok or large pan. 3. Stir-fry vegetables for 5-7 minutes until tender-crisp. 4. Add soy sauce and serve immediately.', 10, 10, 3) ON CONFLICT (name) WHERE user_id IS NULL DO NOTHING; - SELECT id INTO chicken_recipe_id FROM public.recipes WHERE name = 'Simple Chicken and Rice'; - SELECT id INTO bolognese_recipe_id FROM public.recipes WHERE name = 'Classic Spaghetti Bolognese'; - SELECT id INTO stir_fry_recipe_id FROM public.recipes WHERE name = 'Vegetable Stir-fry'; + SELECT recipe_id INTO chicken_recipe_id FROM public.recipes WHERE name = 'Simple Chicken and Rice'; + SELECT recipe_id INTO bolognese_recipe_id FROM public.recipes WHERE name = 'Classic Spaghetti Bolognese'; + SELECT recipe_id INTO stir_fry_recipe_id FROM public.recipes WHERE name = 'Vegetable Stir-fry'; -- Get ingredient IDs from master_grocery_items - SELECT mgi.id INTO chicken_breast_id FROM public.master_grocery_items mgi WHERE mgi.name = 'chicken breast'; - SELECT mgi.id INTO rice_id FROM public.master_grocery_items mgi WHERE mgi.name = 'rice'; - SELECT mgi.id INTO broccoli_id FROM public.master_grocery_items mgi WHERE mgi.name = 'broccoli'; - SELECT mgi.id INTO ground_beef_id FROM public.master_grocery_items mgi WHERE mgi.name = 'ground beef'; - SELECT mgi.id INTO pasta_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'pasta'; - SELECT mgi.id INTO tomatoes_id FROM public.master_grocery_items mgi WHERE mgi.name = 'tomatoes'; - SELECT mgi.id INTO onions_id FROM public.master_grocery_items mgi WHERE mgi.name = 'onions'; - SELECT mgi.id INTO garlic_id FROM public.master_grocery_items mgi WHERE mgi.name = 'garlic'; - SELECT mgi.id INTO bell_peppers_id FROM public.master_grocery_items mgi WHERE mgi.name = 'bell peppers'; - SELECT mgi.id INTO carrots_id FROM public.master_grocery_items mgi WHERE mgi.name = 'carrots'; - SELECT mgi.id INTO soy_sauce_id FROM public.master_grocery_items mgi WHERE mgi.name = 'soy sauce'; + SELECT mgi.master_grocery_item_id INTO chicken_breast_id FROM public.master_grocery_items mgi WHERE mgi.name = 'chicken breast'; + SELECT mgi.master_grocery_item_id INTO rice_id FROM public.master_grocery_items mgi WHERE mgi.name = 'rice'; + SELECT mgi.master_grocery_item_id INTO broccoli_id FROM public.master_grocery_items mgi WHERE mgi.name = 'broccoli'; + SELECT mgi.master_grocery_item_id INTO ground_beef_id FROM public.master_grocery_items mgi WHERE mgi.name = 'ground beef'; + SELECT mgi.master_grocery_item_id INTO pasta_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'pasta'; + SELECT mgi.master_grocery_item_id INTO tomatoes_id FROM public.master_grocery_items mgi WHERE mgi.name = 'tomatoes'; + SELECT mgi.master_grocery_item_id INTO onions_id FROM public.master_grocery_items mgi WHERE mgi.name = 'onions'; + SELECT mgi.master_grocery_item_id INTO garlic_id FROM public.master_grocery_items mgi WHERE mgi.name = 'garlic'; + SELECT mgi.master_grocery_item_id INTO bell_peppers_id FROM public.master_grocery_items mgi WHERE mgi.name = 'bell peppers'; + SELECT mgi.master_grocery_item_id INTO carrots_id FROM public.master_grocery_items mgi WHERE mgi.name = 'carrots'; + SELECT mgi.master_grocery_item_id INTO soy_sauce_id FROM public.master_grocery_items mgi WHERE mgi.name = 'soy sauce'; -- Insert ingredients for each recipe INSERT INTO public.recipe_ingredients (recipe_id, master_item_id, quantity, unit) VALUES (chicken_recipe_id, chicken_breast_id, 2, 'items'), (chicken_recipe_id, rice_id, 200, 'g'), (chicken_recipe_id, broccoli_id, 300, 'g'), (bolognese_recipe_id, ground_beef_id, 500, 'g'), (bolognese_recipe_id, pasta_item_id, 400, 'g'), (bolognese_recipe_id, tomatoes_id, 800, 'g'), (bolognese_recipe_id, onions_id, 1, 'items'), (bolognese_recipe_id, garlic_id, 2, 'cloves'), (stir_fry_recipe_id, broccoli_id, 200, 'g'), (stir_fry_recipe_id, bell_peppers_id, 1, 'items'), (stir_fry_recipe_id, carrots_id, 2, 'items'), (stir_fry_recipe_id, onions_id, 1, 'items'), (stir_fry_recipe_id, soy_sauce_id, 50, 'ml') - ON CONFLICT (id) DO NOTHING; + ON CONFLICT (recipe_ingredient_id) DO NOTHING; -- Insert tags and get their IDs INSERT INTO public.tags (name) VALUES ('Quick & Easy'), ('Healthy'), ('Chicken'), ('Family Friendly'), ('Beef'), ('Weeknight Dinner'), ('Vegetarian') ON CONFLICT (name) DO NOTHING; - SELECT id INTO quick_easy_tag FROM public.tags WHERE name = 'Quick & Easy'; - SELECT id INTO healthy_tag FROM public.tags WHERE name = 'Healthy'; - SELECT id INTO chicken_tag FROM public.tags WHERE name = 'Chicken'; - SELECT id INTO family_tag FROM public.tags WHERE name = 'Family Friendly'; - SELECT id INTO beef_tag FROM public.tags WHERE name = 'Beef'; - SELECT id INTO weeknight_tag FROM public.tags WHERE name = 'Weeknight Dinner'; - SELECT id INTO vegetarian_tag FROM public.tags WHERE name = 'Vegetarian'; + SELECT tag_id INTO quick_easy_tag FROM public.tags WHERE name = 'Quick & Easy'; + SELECT tag_id INTO healthy_tag FROM public.tags WHERE name = 'Healthy'; + SELECT tag_id INTO chicken_tag FROM public.tags WHERE name = 'Chicken'; + SELECT tag_id INTO family_tag FROM public.tags WHERE name = 'Family Friendly'; + SELECT tag_id INTO beef_tag FROM public.tags WHERE name = 'Beef'; + SELECT tag_id INTO weeknight_tag FROM public.tags WHERE name = 'Weeknight Dinner'; + SELECT tag_id INTO vegetarian_tag FROM public.tags WHERE name = 'Vegetarian'; -- Link tags to recipes INSERT INTO public.recipe_tags (recipe_id, tag_id) VALUES @@ -218,12 +218,12 @@ DECLARE flour_id BIGINT; sugar_id BIGINT; butter_id BIGINT; milk_id BIGINT; water_id BIGINT; rice_id BIGINT; BEGIN -- Get master item IDs - SELECT mgi.id INTO flour_id FROM public.master_grocery_items mgi WHERE mgi.name = 'flour'; - SELECT mgi.id INTO sugar_id FROM public.master_grocery_items mgi WHERE mgi.name = 'sugar'; - SELECT mgi.id INTO butter_id FROM public.master_grocery_items mgi WHERE mgi.name = 'butter'; - SELECT mgi.id INTO milk_id FROM public.master_grocery_items mgi WHERE mgi.name = 'milk'; - SELECT mgi.id INTO water_id FROM public.master_grocery_items mgi WHERE mgi.name = 'water'; - SELECT mgi.id INTO rice_id FROM public.master_grocery_items mgi WHERE mgi.name = 'rice'; + SELECT mgi.master_grocery_item_id INTO flour_id FROM public.master_grocery_items mgi WHERE mgi.name = 'flour'; + SELECT mgi.master_grocery_item_id INTO sugar_id FROM public.master_grocery_items mgi WHERE mgi.name = 'sugar'; + SELECT mgi.master_grocery_item_id INTO butter_id FROM public.master_grocery_items mgi WHERE mgi.name = 'butter'; + SELECT mgi.master_grocery_item_id INTO milk_id FROM public.master_grocery_items mgi WHERE mgi.name = 'milk'; + SELECT mgi.master_grocery_item_id INTO water_id FROM public.master_grocery_items mgi WHERE mgi.name = 'water'; + SELECT mgi.master_grocery_item_id INTO rice_id FROM public.master_grocery_items mgi WHERE mgi.name = 'rice'; -- Insert conversion factors INSERT INTO public.unit_conversions (master_item_id, from_unit, to_unit, factor) VALUES diff --git a/sql/initial_schema.sql b/sql/initial_schema.sql index fbe718c0..ae792de5 100644 --- a/sql/initial_schema.sql +++ b/sql/initial_schema.sql @@ -4,7 +4,7 @@ -- ============================================================================ -- 1. Users - This replaces the Supabase `auth.users` table. CREATE TABLE IF NOT EXISTS public.users ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), email TEXT NOT NULL UNIQUE, password_hash TEXT, refresh_token TEXT, @@ -23,8 +23,8 @@ CREATE INDEX IF NOT EXISTS idx_users_refresh_token ON public.users(refresh_token -- 2. Log key user activities for analytics. -- This needs to be created early as many triggers will insert into it. CREATE TABLE IF NOT EXISTS public.activity_log ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID REFERENCES public.users(id) ON DELETE SET NULL, + activity_log_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID REFERENCES public.users(user_id) ON DELETE SET NULL, action TEXT NOT NULL, display_text TEXT NOT NULL, icon TEXT, @@ -38,32 +38,31 @@ CREATE INDEX IF NOT EXISTS idx_activity_log_user_id ON public.activity_log(user_ -- 3. for public user profiles. -- This table is linked to the users table and stores non-sensitive user data. CREATE TABLE IF NOT EXISTS public.profiles ( - id UUID PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE, + user_id UUID PRIMARY KEY REFERENCES public.users(user_id) ON DELETE CASCADE, full_name TEXT, avatar_url TEXT, preferences JSONB, role TEXT CHECK (role IN ('admin', 'user')), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, - created_by UUID REFERENCES public.users(id) ON DELETE SET NULL, - updated_by UUID REFERENCES public.users(id) ON DELETE SET NULL + created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL, + updated_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL ); COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to the public.users table.'; -- 4. The 'stores' table for normalized store data. CREATE TABLE IF NOT EXISTS public.stores ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + store_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL UNIQUE, logo_url TEXT, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, - created_by UUID REFERENCES public.users(id) ON DELETE SET NULL -); + created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL COMMENT ON TABLE public.stores IS 'Stores metadata for grocery store chains (e.g., Safeway, Kroger).'; -- 5. The 'categories' table for normalized category data. CREATE TABLE IF NOT EXISTS public.categories ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + category_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL UNIQUE, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -72,11 +71,11 @@ COMMENT ON TABLE public.categories IS 'Stores a predefined list of grocery item -- 6. flyers' table CREATE TABLE IF NOT EXISTS public.flyers ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + flyer_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, file_name TEXT NOT NULL, image_url TEXT NOT NULL, checksum TEXT UNIQUE, - store_id BIGINT REFERENCES public.stores(id), + store_id BIGINT REFERENCES public.stores(store_id), valid_from DATE, valid_to DATE, store_address TEXT, @@ -95,23 +94,23 @@ COMMENT ON COLUMN public.flyers.store_address IS 'The physical store address if -- 7. The 'master_grocery_items' table. This is the master dictionary. CREATE TABLE IF NOT EXISTS public.master_grocery_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL UNIQUE, - category_id BIGINT REFERENCES public.categories(id), + category_id BIGINT REFERENCES public.categories(category_id), is_allergen BOOLEAN DEFAULT false, allergy_info JSONB, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, - created_by UUID REFERENCES public.users(id) ON DELETE SET NULL + created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL ); COMMENT ON TABLE public.master_grocery_items IS 'The master dictionary of canonical grocery items. Each item has a unique name and is linked to a category.'; CREATE INDEX IF NOT EXISTS idx_master_grocery_items_category_id ON public.master_grocery_items(category_id); -- 8. The 'user_watched_items' table. This links to the master list. CREATE TABLE IF NOT EXISTS public.user_watched_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, + user_watched_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, UNIQUE(user_id, master_item_id) @@ -121,19 +120,19 @@ CREATE INDEX IF NOT EXISTS idx_user_watched_items_master_item_id ON public.user_ -- 9. The 'flyer_items' table. This stores individual items from flyers. CREATE TABLE IF NOT EXISTS public.flyer_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - flyer_id BIGINT REFERENCES public.flyers(id) ON DELETE CASCADE, + flyer_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + flyer_id BIGINT REFERENCES public.flyers(flyer_id) ON DELETE CASCADE, item TEXT NOT NULL, price_display TEXT NOT NULL, price_in_cents INTEGER, quantity_num NUMERIC, quantity TEXT NOT NULL, - category_id BIGINT REFERENCES public.categories(id), + category_id BIGINT REFERENCES public.categories(category_id), category_name TEXT, unit_price JSONB, view_count INTEGER DEFAULT 0 NOT NULL, click_count INTEGER DEFAULT 0 NOT NULL, - master_item_id BIGINT REFERENCES public.master_grocery_items(id), + master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id), product_id BIGINT, created_at TIMESTAMPTZ DEFAULT now() NOT NULL ); @@ -159,8 +158,8 @@ CREATE INDEX IF NOT EXISTS flyer_items_item_trgm_idx ON public.flyer_items USING -- 10. For user-defined alerts on watched items. CREATE TABLE IF NOT EXISTS public.user_alerts ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_watched_item_id BIGINT NOT NULL REFERENCES public.user_watched_items(id) ON DELETE CASCADE, + user_alert_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_watched_item_id BIGINT NOT NULL REFERENCES public.user_watched_items(user_watched_item_id) ON DELETE CASCADE, alert_type TEXT NOT NULL CHECK (alert_type IN ('PRICE_BELOW', 'PERCENT_OFF_AVERAGE')), threshold_value NUMERIC NOT NULL, is_active BOOLEAN DEFAULT true NOT NULL, @@ -174,8 +173,8 @@ CREATE INDEX IF NOT EXISTS idx_user_alerts_user_watched_item_id ON public.user_a -- 11. Store notifications for users. CREATE TABLE IF NOT EXISTS public.notifications ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + notification_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, content TEXT NOT NULL, link_url TEXT, is_read BOOLEAN DEFAULT false NOT NULL, @@ -189,8 +188,8 @@ CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON public.notifications(use -- 12. Store individual store locations with geographic data. CREATE TABLE IF NOT EXISTS public.store_locations ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - store_id BIGINT NOT NULL REFERENCES public.stores(id) ON DELETE CASCADE, + store_location_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE, address TEXT NOT NULL, city TEXT, province_state TEXT, @@ -208,10 +207,10 @@ CREATE INDEX IF NOT EXISTS store_locations_geo_idx ON public.store_locations USI -- 13. For aggregated, historical price data for master items. CREATE TABLE IF NOT EXISTS public.item_price_history ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, + item_price_history_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, summary_date DATE NOT NULL, - store_location_id BIGINT REFERENCES public.store_locations(id) ON DELETE CASCADE, + store_location_id BIGINT REFERENCES public.store_locations(store_location_id) ON DELETE CASCADE, min_price_in_cents INTEGER, max_price_in_cents INTEGER, avg_price_in_cents INTEGER, @@ -231,8 +230,8 @@ CREATE INDEX IF NOT EXISTS idx_item_price_history_store_location_id ON public.it -- 14. Map various names to a single master grocery item. CREATE TABLE IF NOT EXISTS public.master_item_aliases ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, + master_item_alias_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, alias TEXT NOT NULL UNIQUE, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -243,8 +242,8 @@ CREATE INDEX IF NOT EXISTS idx_master_item_aliases_master_item_id ON public.mast -- 15. For user shopping lists. CREATE TABLE IF NOT EXISTS public.shopping_lists ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + shopping_list_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, name TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -254,9 +253,9 @@ CREATE INDEX IF NOT EXISTS idx_shopping_lists_user_id ON public.shopping_lists(u -- 16. For items in a user's shopping list. CREATE TABLE IF NOT EXISTS public.shopping_list_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(id) ON DELETE CASCADE, - master_item_id BIGINT REFERENCES public.master_grocery_items(id), + shopping_list_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(shopping_list_id) ON DELETE CASCADE, + master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id), custom_item_name TEXT, quantity NUMERIC DEFAULT 1 NOT NULL, is_purchased BOOLEAN DEFAULT false NOT NULL, @@ -273,10 +272,10 @@ CREATE INDEX IF NOT EXISTS idx_shopping_list_items_master_item_id ON public.shop -- 17. Manage shared access to shopping lists. CREATE TABLE IF NOT EXISTS public.shared_shopping_lists ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(id) ON DELETE CASCADE, - shared_by_user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - shared_with_user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + shared_shopping_list_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(shopping_list_id) ON DELETE CASCADE, + shared_by_user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + shared_with_user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, permission_level TEXT NOT NULL CHECK (permission_level IN ('view', 'edit')), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -289,8 +288,8 @@ CREATE INDEX IF NOT EXISTS idx_shared_shopping_lists_shared_with_user_id ON publ -- 18. Store a user's collection of planned meals for a date range. CREATE TABLE IF NOT EXISTS public.menu_plans ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + menu_plan_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, name TEXT NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, @@ -303,10 +302,10 @@ CREATE INDEX IF NOT EXISTS idx_menu_plans_user_id ON public.menu_plans(user_id); -- 19. Manage shared access to menu plans. CREATE TABLE IF NOT EXISTS public.shared_menu_plans ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - menu_plan_id BIGINT NOT NULL REFERENCES public.menu_plans(id) ON DELETE CASCADE, - shared_by_user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - shared_with_user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + shared_menu_plan_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + menu_plan_id BIGINT NOT NULL REFERENCES public.menu_plans(menu_plan_id) ON DELETE CASCADE, + shared_by_user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + shared_with_user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, permission_level TEXT NOT NULL CHECK (permission_level IN ('view', 'edit')), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -319,9 +318,9 @@ CREATE INDEX IF NOT EXISTS idx_shared_menu_plans_shared_with_user_id ON public.s -- 20. Store user-submitted corrections for flyer items. CREATE TABLE IF NOT EXISTS public.suggested_corrections ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES public.users(id), + suggested_correction_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(flyer_item_id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.users(user_id), correction_type TEXT NOT NULL, suggested_value TEXT NOT NULL, status TEXT DEFAULT 'pending' NOT NULL, @@ -339,10 +338,10 @@ CREATE INDEX IF NOT EXISTS idx_suggested_corrections_user_id ON public.suggested -- 21. For prices submitted directly by users from in-store. CREATE TABLE IF NOT EXISTS public.user_submitted_prices ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id), - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id), - store_id BIGINT NOT NULL REFERENCES public.stores(id), + user_submitted_price_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id), + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id), + store_id BIGINT NOT NULL REFERENCES public.stores(store_id), price_in_cents INTEGER NOT NULL, photo_url TEXT, upvotes INTEGER DEFAULT 0 NOT NULL, @@ -358,8 +357,8 @@ CREATE INDEX IF NOT EXISTS idx_user_submitted_prices_master_item_id ON public.us -- 22. Log flyer items that could not be automatically matched to a master item. CREATE TABLE IF NOT EXISTS public.unmatched_flyer_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(id) ON DELETE CASCADE, + unmatched_flyer_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(flyer_item_id) ON DELETE CASCADE, status TEXT DEFAULT 'pending' NOT NULL CHECK (status IN ('pending', 'reviewed', 'ignored')), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, reviewed_at TIMESTAMPTZ, @@ -371,10 +370,10 @@ CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_flyer_item_id ON public.unm -- 23. Store brand information. CREATE TABLE IF NOT EXISTS public.brands ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + brand_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL UNIQUE, logo_url TEXT, - store_id BIGINT REFERENCES public.stores(id) ON DELETE SET NULL, + store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL ); @@ -383,9 +382,9 @@ COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand ( -- 24. For specific products, linking a master item with a brand and size. CREATE TABLE IF NOT EXISTS public.products ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id), - brand_id BIGINT REFERENCES public.brands(id), + product_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id), + brand_id BIGINT REFERENCES public.brands(brand_id), name TEXT NOT NULL, description TEXT, size TEXT, @@ -403,8 +402,8 @@ CREATE INDEX IF NOT EXISTS idx_products_brand_id ON public.products(brand_id); -- 25. Linking table for when one flyer is valid for multiple locations. CREATE TABLE IF NOT EXISTS public.flyer_locations ( - flyer_id BIGINT NOT NULL REFERENCES public.flyers(id) ON DELETE CASCADE, - store_location_id BIGINT NOT NULL REFERENCES public.store_locations(id) ON DELETE CASCADE, + flyer_id BIGINT NOT NULL REFERENCES public.flyers(flyer_id) ON DELETE CASCADE, + store_location_id BIGINT NOT NULL REFERENCES public.store_locations(store_location_id) ON DELETE CASCADE, PRIMARY KEY (flyer_id, store_location_id), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -415,9 +414,9 @@ CREATE INDEX IF NOT EXISTS idx_flyer_locations_store_location_id ON public.flyer -- 26. Store recipes, which can be user-created or pre-populated. CREATE TABLE IF NOT EXISTS public.recipes ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID REFERENCES public.users(id) ON DELETE CASCADE, - original_recipe_id BIGINT REFERENCES public.recipes(id) ON DELETE SET NULL, + recipe_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID REFERENCES public.users(user_id) ON DELETE CASCADE, + original_recipe_id BIGINT REFERENCES public.recipes(recipe_id) ON DELETE SET NULL, name TEXT NOT NULL, description TEXT, instructions TEXT, @@ -452,9 +451,9 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_recipes_unique_system_recipe_name ON publi -- 27. For ingredients required for each recipe. CREATE TABLE IF NOT EXISTS public.recipe_ingredients ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id), + recipe_ingredient_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE, + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id), quantity NUMERIC NOT NULL, unit TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -467,9 +466,9 @@ CREATE INDEX IF NOT EXISTS idx_recipe_ingredients_master_item_id ON public.recip -- 28. Suggest ingredient substitutions for a recipe. CREATE TABLE IF NOT EXISTS public.recipe_ingredient_substitutions ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - recipe_ingredient_id BIGINT NOT NULL REFERENCES public.recipe_ingredients(id) ON DELETE CASCADE, - substitute_master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, + recipe_ingredient_substitution_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + recipe_ingredient_id BIGINT NOT NULL REFERENCES public.recipe_ingredients(recipe_ingredient_id) ON DELETE CASCADE, + substitute_master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, notes TEXT, UNIQUE(recipe_ingredient_id, substitute_master_item_id), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -481,7 +480,7 @@ CREATE INDEX IF NOT EXISTS idx_recipe_ingredient_substitutions_substitute_master -- 29. Store a predefined list of tags for recipes. CREATE TABLE IF NOT EXISTS public.tags ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + tag_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL UNIQUE, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -490,8 +489,8 @@ COMMENT ON TABLE public.tags IS 'Stores tags for categorizing recipes, e.g., "Ve -- 30. Associate multiple tags with a recipe. CREATE TABLE IF NOT EXISTS public.recipe_tags ( - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, - tag_id BIGINT NOT NULL REFERENCES public.tags(id) ON DELETE CASCADE, + recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE, + tag_id BIGINT NOT NULL REFERENCES public.tags(tag_id) ON DELETE CASCADE, PRIMARY KEY (recipe_id, tag_id), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -502,7 +501,7 @@ CREATE INDEX IF NOT EXISTS idx_recipe_tags_tag_id ON public.recipe_tags(tag_id); -- 31. Store a predefined list of kitchen appliances. CREATE TABLE IF NOT EXISTS public.appliances ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + appliance_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL UNIQUE, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -511,8 +510,8 @@ COMMENT ON TABLE public.appliances IS 'A predefined list of kitchen appliances ( -- 32. Associate recipes with required appliances. CREATE TABLE IF NOT EXISTS public.recipe_appliances ( - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, - appliance_id BIGINT NOT NULL REFERENCES public.appliances(id) ON DELETE CASCADE, + recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE, + appliance_id BIGINT NOT NULL REFERENCES public.appliances(appliance_id) ON DELETE CASCADE, PRIMARY KEY (recipe_id, appliance_id), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -523,9 +522,9 @@ CREATE INDEX IF NOT EXISTS idx_recipe_appliances_appliance_id ON public.recipe_a -- 33. Store individual user ratings for recipes. CREATE TABLE IF NOT EXISTS public.recipe_ratings ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + recipe_rating_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), comment TEXT, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -538,10 +537,10 @@ CREATE INDEX IF NOT EXISTS idx_recipe_ratings_user_id ON public.recipe_ratings(u -- 34. For user comments on recipes to enable discussion. CREATE TABLE IF NOT EXISTS public.recipe_comments ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - parent_comment_id BIGINT REFERENCES public.recipe_comments(id) ON DELETE CASCADE, + recipe_comment_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + parent_comment_id BIGINT REFERENCES public.recipe_comments(recipe_comment_id) ON DELETE CASCADE, content TEXT NOT NULL, status TEXT DEFAULT 'visible' NOT NULL CHECK (status IN ('visible', 'hidden', 'reported')), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -555,8 +554,8 @@ CREATE INDEX IF NOT EXISTS idx_recipe_comments_parent_comment_id ON public.recip -- 35. For users to define locations within their pantry. CREATE TABLE IF NOT EXISTS public.pantry_locations ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + pantry_location_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, name TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -567,9 +566,9 @@ CREATE INDEX IF NOT EXISTS idx_pantry_locations_user_id ON public.pantry_locatio -- 36. Associate a recipe with a specific date and meal type within a menu plan. CREATE TABLE IF NOT EXISTS public.planned_meals ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - menu_plan_id BIGINT NOT NULL REFERENCES public.menu_plans(id) ON DELETE CASCADE, - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, + planned_meal_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + menu_plan_id BIGINT NOT NULL REFERENCES public.menu_plans(menu_plan_id) ON DELETE CASCADE, + recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE, plan_date DATE NOT NULL, meal_type TEXT NOT NULL, servings_to_cook INTEGER, @@ -583,13 +582,13 @@ CREATE INDEX IF NOT EXISTS idx_planned_meals_recipe_id ON public.planned_meals(r -- 37. Track the grocery items a user currently has in their pantry. CREATE TABLE IF NOT EXISTS public.pantry_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, + pantry_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, quantity NUMERIC NOT NULL, unit TEXT, best_before_date DATE, - pantry_location_id BIGINT REFERENCES public.pantry_locations(id) ON DELETE SET NULL, + pantry_location_id BIGINT REFERENCES public.pantry_locations(pantry_location_id) ON DELETE SET NULL, notification_sent_at TIMESTAMPTZ, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, UNIQUE(user_id, master_item_id, unit) @@ -604,8 +603,8 @@ CREATE INDEX IF NOT EXISTS idx_pantry_items_pantry_location_id ON public.pantry_ -- 38. Store password reset tokens. CREATE TABLE IF NOT EXISTS public.password_reset_tokens ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + password_reset_token_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -619,8 +618,8 @@ CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token_hash ON public.passwo -- 39. Store unit conversion factors for specific master grocery items. CREATE TABLE IF NOT EXISTS public.unit_conversions ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, + unit_conversion_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, from_unit TEXT NOT NULL, to_unit TEXT NOT NULL, factor NUMERIC NOT NULL, @@ -634,9 +633,9 @@ CREATE INDEX IF NOT EXISTS idx_unit_conversions_master_item_id ON public.unit_co -- 40. For users to create their own private aliases for items. CREATE TABLE IF NOT EXISTS public.user_item_aliases ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, + user_item_alias_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, alias TEXT NOT NULL, UNIQUE(user_id, alias), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -648,8 +647,8 @@ CREATE INDEX IF NOT EXISTS idx_user_item_aliases_master_item_id ON public.user_i -- 41. For users to mark their favorite recipes. CREATE TABLE IF NOT EXISTS public.favorite_recipes ( - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, PRIMARY KEY (user_id, recipe_id), updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -660,8 +659,8 @@ CREATE INDEX IF NOT EXISTS idx_favorite_recipes_recipe_id ON public.favorite_rec -- 42. For users to mark their favorite stores. CREATE TABLE IF NOT EXISTS public.favorite_stores ( - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - store_id BIGINT NOT NULL REFERENCES public.stores(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, PRIMARY KEY (user_id, store_id), updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -672,8 +671,8 @@ CREATE INDEX IF NOT EXISTS idx_favorite_stores_store_id ON public.favorite_store -- 43. For users to group recipes into collections. CREATE TABLE IF NOT EXISTS public.recipe_collections ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + recipe_collection_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, name TEXT NOT NULL, description TEXT, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -684,8 +683,8 @@ CREATE INDEX IF NOT EXISTS idx_recipe_collections_user_id ON public.recipe_colle -- 44. Associate recipes with a user's collection. CREATE TABLE IF NOT EXISTS public.recipe_collection_items ( - collection_id BIGINT NOT NULL REFERENCES public.recipe_collections(id) ON DELETE CASCADE, - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, + collection_id BIGINT NOT NULL REFERENCES public.recipe_collections(recipe_collection_id) ON DELETE CASCADE, + recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE, added_at TIMESTAMPTZ DEFAULT now() NOT NULL, PRIMARY KEY (collection_id, recipe_id), updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -696,8 +695,8 @@ CREATE INDEX IF NOT EXISTS idx_recipe_collection_items_recipe_id ON public.recip -- 45. Log user search queries for analysis. CREATE TABLE IF NOT EXISTS public.search_queries ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID REFERENCES public.users(id) ON DELETE SET NULL, + search_query_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID REFERENCES public.users(user_id) ON DELETE SET NULL, query_text TEXT NOT NULL, result_count INTEGER, was_successful BOOLEAN, @@ -710,9 +709,9 @@ CREATE INDEX IF NOT EXISTS idx_search_queries_user_id ON public.search_queries(u -- 46. Store historical records of completed shopping trips. CREATE TABLE IF NOT EXISTS public.shopping_trips ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - shopping_list_id BIGINT REFERENCES public.shopping_lists(id) ON DELETE SET NULL, + shopping_trip_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + shopping_list_id BIGINT REFERENCES public.shopping_lists(shopping_list_id) ON DELETE SET NULL, completed_at TIMESTAMPTZ DEFAULT now() NOT NULL, total_spent_cents INTEGER, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -724,9 +723,9 @@ CREATE INDEX IF NOT EXISTS idx_shopping_trips_shopping_list_id ON public.shoppin -- 47. Store the items purchased during a specific shopping trip. CREATE TABLE IF NOT EXISTS public.shopping_trip_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(id) ON DELETE CASCADE, - master_item_id BIGINT REFERENCES public.master_grocery_items(id), + shopping_trip_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(shopping_trip_id) ON DELETE CASCADE, + master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id), custom_item_name TEXT, quantity NUMERIC NOT NULL, price_paid_cents INTEGER, @@ -741,7 +740,7 @@ CREATE INDEX IF NOT EXISTS idx_shopping_trip_items_master_item_id ON public.shop -- 48. Store predefined dietary restrictions (diets and allergies). CREATE TABLE IF NOT EXISTS public.dietary_restrictions ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + dietary_restriction_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL UNIQUE, type TEXT NOT NULL CHECK (type IN ('diet', 'allergy')), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -751,8 +750,8 @@ COMMENT ON TABLE public.dietary_restrictions IS 'A predefined list of common die -- 49. For a user's specific dietary restrictions. CREATE TABLE IF NOT EXISTS public.user_dietary_restrictions ( - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - restriction_id BIGINT NOT NULL REFERENCES public.dietary_restrictions(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + restriction_id BIGINT NOT NULL REFERENCES public.dietary_restrictions(dietary_restriction_id) ON DELETE CASCADE, PRIMARY KEY (user_id, restriction_id), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -763,8 +762,8 @@ CREATE INDEX IF NOT EXISTS idx_user_dietary_restrictions_restriction_id ON publi -- 50. For a user's owned kitchen appliances. CREATE TABLE IF NOT EXISTS public.user_appliances ( - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - appliance_id BIGINT NOT NULL REFERENCES public.appliances(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + appliance_id BIGINT NOT NULL REFERENCES public.appliances(appliance_id) ON DELETE CASCADE, PRIMARY KEY (user_id, appliance_id), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -775,8 +774,8 @@ CREATE INDEX IF NOT EXISTS idx_user_appliances_appliance_id ON public.user_appli -- 51. Manage the social graph (following relationships). CREATE TABLE IF NOT EXISTS public.user_follows ( - follower_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - following_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + follower_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + following_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, PRIMARY KEY (follower_id, following_id), updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -788,9 +787,9 @@ CREATE INDEX IF NOT EXISTS idx_user_follows_following_id ON public.user_follows( -- 52. Store uploaded user receipts for purchase tracking and analysis. CREATE TABLE IF NOT EXISTS public.receipts ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - store_id BIGINT REFERENCES public.stores(id), + receipt_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + store_id BIGINT REFERENCES public.stores(store_id), receipt_image_url TEXT NOT NULL, transaction_date TIMESTAMPTZ, total_amount_cents INTEGER, @@ -806,13 +805,13 @@ CREATE INDEX IF NOT EXISTS idx_receipts_store_id ON public.receipts(store_id); -- 53. Store individual line items extracted from a user receipt. CREATE TABLE IF NOT EXISTS public.receipt_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - receipt_id BIGINT NOT NULL REFERENCES public.receipts(id) ON DELETE CASCADE, + receipt_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + receipt_id BIGINT NOT NULL REFERENCES public.receipts(receipt_id) ON DELETE CASCADE, raw_item_description TEXT NOT NULL, quantity NUMERIC DEFAULT 1 NOT NULL, price_paid_cents INTEGER NOT NULL, - master_item_id BIGINT REFERENCES public.master_grocery_items(id), - product_id BIGINT REFERENCES public.products(id), + master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id), + product_id BIGINT REFERENCES public.products(product_id), status TEXT DEFAULT 'unmatched' NOT NULL CHECK (status IN ('unmatched', 'matched', 'needs_review', 'ignored')), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL diff --git a/sql/master_schema_rollup.sql b/sql/master_schema_rollup.sql index 54f1eb63..d3af7870 100644 --- a/sql/master_schema_rollup.sql +++ b/sql/master_schema_rollup.sql @@ -20,7 +20,7 @@ -- ============================================================================ -- 1. Users - This replaces the Supabase `auth.users` table. CREATE TABLE IF NOT EXISTS public.users ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), email TEXT NOT NULL UNIQUE, password_hash TEXT, refresh_token TEXT, @@ -39,8 +39,8 @@ CREATE INDEX IF NOT EXISTS idx_users_refresh_token ON public.users(refresh_token -- 2. Log key user activities for analytics. -- This needs to be created early as many triggers will insert into it. CREATE TABLE IF NOT EXISTS public.activity_log ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID REFERENCES public.users(id) ON DELETE SET NULL, + activity_log_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID REFERENCES public.users(user_id) ON DELETE SET NULL, action TEXT NOT NULL, display_text TEXT NOT NULL, icon TEXT, @@ -54,32 +54,32 @@ CREATE INDEX IF NOT EXISTS idx_activity_log_user_id ON public.activity_log(user_ -- 3. for public user profiles. -- This table is linked to the users table and stores non-sensitive user data. CREATE TABLE IF NOT EXISTS public.profiles ( - id UUID PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE, + user_id UUID PRIMARY KEY REFERENCES public.users(user_id) ON DELETE CASCADE, full_name TEXT, avatar_url TEXT, preferences JSONB, role TEXT CHECK (role IN ('admin', 'user')), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, - created_by UUID REFERENCES public.users(id) ON DELETE SET NULL, - updated_by UUID REFERENCES public.users(id) ON DELETE SET NULL + created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL, + updated_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL ); COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to the public.users table.'; -- 4. The 'stores' table for normalized store data. CREATE TABLE IF NOT EXISTS public.stores ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + store_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL UNIQUE, logo_url TEXT, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, - created_by UUID REFERENCES public.users(id) ON DELETE SET NULL + created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL ); COMMENT ON TABLE public.stores IS 'Stores metadata for grocery store chains (e.g., Safeway, Kroger).'; -- 5. The 'categories' table for normalized category data. CREATE TABLE IF NOT EXISTS public.categories ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + category_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL UNIQUE, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -88,11 +88,11 @@ COMMENT ON TABLE public.categories IS 'Stores a predefined list of grocery item -- 6. flyers' table CREATE TABLE IF NOT EXISTS public.flyers ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + flyer_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, file_name TEXT NOT NULL, image_url TEXT NOT NULL, checksum TEXT UNIQUE, - store_id BIGINT REFERENCES public.stores(id), + store_id BIGINT REFERENCES public.stores(store_id), valid_from DATE, valid_to DATE, store_address TEXT, @@ -111,23 +111,23 @@ COMMENT ON COLUMN public.flyers.store_address IS 'The physical store address if -- 7. The 'master_grocery_items' table. This is the master dictionary. CREATE TABLE IF NOT EXISTS public.master_grocery_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL UNIQUE, - category_id BIGINT REFERENCES public.categories(id), + category_id BIGINT REFERENCES public.categories(category_id), is_allergen BOOLEAN DEFAULT false, allergy_info JSONB, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, - created_by UUID REFERENCES public.users(id) ON DELETE SET NULL + created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL ); COMMENT ON TABLE public.master_grocery_items IS 'The master dictionary of canonical grocery items. Each item has a unique name and is linked to a category.'; CREATE INDEX IF NOT EXISTS idx_master_grocery_items_category_id ON public.master_grocery_items(category_id); -- 8. The 'user_watched_items' table. This links to the master list. CREATE TABLE IF NOT EXISTS public.user_watched_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, + user_watched_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, UNIQUE(user_id, master_item_id) @@ -137,19 +137,19 @@ CREATE INDEX IF NOT EXISTS idx_user_watched_items_master_item_id ON public.user_ -- 9. The 'flyer_items' table. This stores individual items from flyers. CREATE TABLE IF NOT EXISTS public.flyer_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - flyer_id BIGINT REFERENCES public.flyers(id) ON DELETE CASCADE, + flyer_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + flyer_id BIGINT REFERENCES public.flyers(flyer_id) ON DELETE CASCADE, item TEXT NOT NULL, price_display TEXT NOT NULL, price_in_cents INTEGER, quantity_num NUMERIC, quantity TEXT NOT NULL, - category_id BIGINT REFERENCES public.categories(id), + category_id BIGINT REFERENCES public.categories(category_id), category_name TEXT, unit_price JSONB, view_count INTEGER DEFAULT 0 NOT NULL, click_count INTEGER DEFAULT 0 NOT NULL, - master_item_id BIGINT REFERENCES public.master_grocery_items(id), + master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id), product_id BIGINT, created_at TIMESTAMPTZ DEFAULT now() NOT NULL ); @@ -175,8 +175,8 @@ CREATE INDEX IF NOT EXISTS flyer_items_item_trgm_idx ON public.flyer_items USING -- 10. For user-defined alerts on watched items. CREATE TABLE IF NOT EXISTS public.user_alerts ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_watched_item_id BIGINT NOT NULL REFERENCES public.user_watched_items(id) ON DELETE CASCADE, + user_alert_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_watched_item_id BIGINT NOT NULL REFERENCES public.user_watched_items(user_watched_item_id) ON DELETE CASCADE, alert_type TEXT NOT NULL CHECK (alert_type IN ('PRICE_BELOW', 'PERCENT_OFF_AVERAGE')), threshold_value NUMERIC NOT NULL, is_active BOOLEAN DEFAULT true NOT NULL, @@ -190,8 +190,8 @@ CREATE INDEX IF NOT EXISTS idx_user_alerts_user_watched_item_id ON public.user_a -- 11. Store notifications for users. CREATE TABLE IF NOT EXISTS public.notifications ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + notification_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, content TEXT NOT NULL, link_url TEXT, is_read BOOLEAN DEFAULT false NOT NULL, @@ -205,8 +205,8 @@ CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON public.notifications(use -- 12. Store individual store locations with geographic data. CREATE TABLE IF NOT EXISTS public.store_locations ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - store_id BIGINT NOT NULL REFERENCES public.stores(id) ON DELETE CASCADE, + store_location_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE, address TEXT NOT NULL, city TEXT, province_state TEXT, @@ -224,10 +224,10 @@ CREATE INDEX IF NOT EXISTS store_locations_geo_idx ON public.store_locations USI -- 13. For aggregated, historical price data for master items. CREATE TABLE IF NOT EXISTS public.item_price_history ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, + item_price_history_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, summary_date DATE NOT NULL, - store_location_id BIGINT REFERENCES public.store_locations(id) ON DELETE CASCADE, + store_location_id BIGINT REFERENCES public.store_locations(store_location_id) ON DELETE CASCADE, min_price_in_cents INTEGER, max_price_in_cents INTEGER, avg_price_in_cents INTEGER, @@ -247,8 +247,8 @@ CREATE INDEX IF NOT EXISTS idx_item_price_history_store_location_id ON public.it -- 14. Map various names to a single master grocery item. CREATE TABLE IF NOT EXISTS public.master_item_aliases ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, + master_item_alias_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, alias TEXT NOT NULL UNIQUE, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -259,8 +259,8 @@ CREATE INDEX IF NOT EXISTS idx_master_item_aliases_master_item_id ON public.mast -- 15. For user shopping lists. CREATE TABLE IF NOT EXISTS public.shopping_lists ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + shopping_list_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, name TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -270,9 +270,9 @@ CREATE INDEX IF NOT EXISTS idx_shopping_lists_user_id ON public.shopping_lists(u -- 16. For items in a user's shopping list. CREATE TABLE IF NOT EXISTS public.shopping_list_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(id) ON DELETE CASCADE, - master_item_id BIGINT REFERENCES public.master_grocery_items(id), + shopping_list_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(shopping_list_id) ON DELETE CASCADE, + master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id), custom_item_name TEXT, quantity NUMERIC DEFAULT 1 NOT NULL, is_purchased BOOLEAN DEFAULT false NOT NULL, @@ -289,10 +289,10 @@ CREATE INDEX IF NOT EXISTS idx_shopping_list_items_master_item_id ON public.shop -- 17. Manage shared access to shopping lists. CREATE TABLE IF NOT EXISTS public.shared_shopping_lists ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(id) ON DELETE CASCADE, - shared_by_user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - shared_with_user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + shared_shopping_list_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(shopping_list_id) ON DELETE CASCADE, + shared_by_user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + shared_with_user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, permission_level TEXT NOT NULL CHECK (permission_level IN ('view', 'edit')), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -305,8 +305,8 @@ CREATE INDEX IF NOT EXISTS idx_shared_shopping_lists_shared_with_user_id ON publ -- 18. Store a user's collection of planned meals for a date range. CREATE TABLE IF NOT EXISTS public.menu_plans ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + menu_plan_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, name TEXT NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, @@ -319,10 +319,10 @@ CREATE INDEX IF NOT EXISTS idx_menu_plans_user_id ON public.menu_plans(user_id); -- 19. Manage shared access to menu plans. CREATE TABLE IF NOT EXISTS public.shared_menu_plans ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - menu_plan_id BIGINT NOT NULL REFERENCES public.menu_plans(id) ON DELETE CASCADE, - shared_by_user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - shared_with_user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + shared_menu_plan_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + menu_plan_id BIGINT NOT NULL REFERENCES public.menu_plans(menu_plan_id) ON DELETE CASCADE, + shared_by_user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + shared_with_user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, permission_level TEXT NOT NULL CHECK (permission_level IN ('view', 'edit')), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -335,9 +335,9 @@ CREATE INDEX IF NOT EXISTS idx_shared_menu_plans_shared_with_user_id ON public.s -- 20. Store user-submitted corrections for flyer items. CREATE TABLE IF NOT EXISTS public.suggested_corrections ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES public.users(id), + suggested_correction_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(flyer_item_id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.users(user_id), correction_type TEXT NOT NULL, suggested_value TEXT NOT NULL, status TEXT DEFAULT 'pending' NOT NULL, @@ -355,10 +355,10 @@ CREATE INDEX IF NOT EXISTS idx_suggested_corrections_user_id ON public.suggested -- 21. For prices submitted directly by users from in-store. CREATE TABLE IF NOT EXISTS public.user_submitted_prices ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id), - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id), - store_id BIGINT NOT NULL REFERENCES public.stores(id), + user_submitted_price_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id), + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id), + store_id BIGINT NOT NULL REFERENCES public.stores(store_id), price_in_cents INTEGER NOT NULL, photo_url TEXT, upvotes INTEGER DEFAULT 0 NOT NULL, @@ -374,8 +374,8 @@ CREATE INDEX IF NOT EXISTS idx_user_submitted_prices_master_item_id ON public.us -- 22. Log flyer items that could not be automatically matched to a master item. CREATE TABLE IF NOT EXISTS public.unmatched_flyer_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(id) ON DELETE CASCADE, + unmatched_flyer_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(flyer_item_id) ON DELETE CASCADE, status TEXT DEFAULT 'pending' NOT NULL CHECK (status IN ('pending', 'reviewed', 'ignored')), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, reviewed_at TIMESTAMPTZ, @@ -387,10 +387,10 @@ CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_flyer_item_id ON public.unm -- 23. Store brand information. CREATE TABLE IF NOT EXISTS public.brands ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + brand_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL UNIQUE, logo_url TEXT, - store_id BIGINT REFERENCES public.stores(id) ON DELETE SET NULL, + store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL ); @@ -399,9 +399,9 @@ COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand ( -- 24. For specific products, linking a master item with a brand and size. CREATE TABLE IF NOT EXISTS public.products ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id), - brand_id BIGINT REFERENCES public.brands(id), + product_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id), + brand_id BIGINT REFERENCES public.brands(brand_id), name TEXT NOT NULL, description TEXT, size TEXT, @@ -419,8 +419,8 @@ CREATE INDEX IF NOT EXISTS idx_products_brand_id ON public.products(brand_id); -- 25. Linking table for when one flyer is valid for multiple locations. CREATE TABLE IF NOT EXISTS public.flyer_locations ( - flyer_id BIGINT NOT NULL REFERENCES public.flyers(id) ON DELETE CASCADE, - store_location_id BIGINT NOT NULL REFERENCES public.store_locations(id) ON DELETE CASCADE, + flyer_id BIGINT NOT NULL REFERENCES public.flyers(flyer_id) ON DELETE CASCADE, + store_location_id BIGINT NOT NULL REFERENCES public.store_locations(store_location_id) ON DELETE CASCADE, PRIMARY KEY (flyer_id, store_location_id), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -431,9 +431,9 @@ CREATE INDEX IF NOT EXISTS idx_flyer_locations_store_location_id ON public.flyer -- 26. Store recipes, which can be user-created or pre-populated. CREATE TABLE IF NOT EXISTS public.recipes ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID REFERENCES public.users(id) ON DELETE CASCADE, - original_recipe_id BIGINT REFERENCES public.recipes(id) ON DELETE SET NULL, + recipe_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID REFERENCES public.users(user_id) ON DELETE CASCADE, + original_recipe_id BIGINT REFERENCES public.recipes(recipe_id) ON DELETE SET NULL, name TEXT NOT NULL, description TEXT, instructions TEXT, @@ -468,9 +468,9 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_recipes_unique_system_recipe_name ON publi -- 27. For ingredients required for each recipe. CREATE TABLE IF NOT EXISTS public.recipe_ingredients ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id), + recipe_ingredient_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE, + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id), quantity NUMERIC NOT NULL, unit TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -483,9 +483,9 @@ CREATE INDEX IF NOT EXISTS idx_recipe_ingredients_master_item_id ON public.recip -- 28. Suggest ingredient substitutions for a recipe. CREATE TABLE IF NOT EXISTS public.recipe_ingredient_substitutions ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - recipe_ingredient_id BIGINT NOT NULL REFERENCES public.recipe_ingredients(id) ON DELETE CASCADE, - substitute_master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, + recipe_ingredient_substitution_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + recipe_ingredient_id BIGINT NOT NULL REFERENCES public.recipe_ingredients(recipe_ingredient_id) ON DELETE CASCADE, + substitute_master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, notes TEXT, UNIQUE(recipe_ingredient_id, substitute_master_item_id), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -497,7 +497,7 @@ CREATE INDEX IF NOT EXISTS idx_recipe_ingredient_substitutions_substitute_master -- 29. Store a predefined list of tags for recipes. CREATE TABLE IF NOT EXISTS public.tags ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + tag_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL UNIQUE, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -506,8 +506,8 @@ COMMENT ON TABLE public.tags IS 'Stores tags for categorizing recipes, e.g., "Ve -- 30. Associate multiple tags with a recipe. CREATE TABLE IF NOT EXISTS public.recipe_tags ( - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, - tag_id BIGINT NOT NULL REFERENCES public.tags(id) ON DELETE CASCADE, + recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE, + tag_id BIGINT NOT NULL REFERENCES public.tags(tag_id) ON DELETE CASCADE, PRIMARY KEY (recipe_id, tag_id), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -518,7 +518,7 @@ CREATE INDEX IF NOT EXISTS idx_recipe_tags_tag_id ON public.recipe_tags(tag_id); -- 31. Store a predefined list of kitchen appliances. CREATE TABLE IF NOT EXISTS public.appliances ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + appliance_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL UNIQUE, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -527,8 +527,8 @@ COMMENT ON TABLE public.appliances IS 'A predefined list of kitchen appliances ( -- 32. Associate recipes with required appliances. CREATE TABLE IF NOT EXISTS public.recipe_appliances ( - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, - appliance_id BIGINT NOT NULL REFERENCES public.appliances(id) ON DELETE CASCADE, + recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE, + appliance_id BIGINT NOT NULL REFERENCES public.appliances(appliance_id) ON DELETE CASCADE, PRIMARY KEY (recipe_id, appliance_id), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -539,9 +539,9 @@ CREATE INDEX IF NOT EXISTS idx_recipe_appliances_appliance_id ON public.recipe_a -- 33. Store individual user ratings for recipes. CREATE TABLE IF NOT EXISTS public.recipe_ratings ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + recipe_rating_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), comment TEXT, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -554,10 +554,10 @@ CREATE INDEX IF NOT EXISTS idx_recipe_ratings_user_id ON public.recipe_ratings(u -- 34. For user comments on recipes to enable discussion. CREATE TABLE IF NOT EXISTS public.recipe_comments ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - parent_comment_id BIGINT REFERENCES public.recipe_comments(id) ON DELETE CASCADE, + recipe_comment_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + parent_comment_id BIGINT REFERENCES public.recipe_comments(recipe_comment_id) ON DELETE CASCADE, content TEXT NOT NULL, status TEXT DEFAULT 'visible' NOT NULL CHECK (status IN ('visible', 'hidden', 'reported')), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -571,8 +571,8 @@ CREATE INDEX IF NOT EXISTS idx_recipe_comments_parent_comment_id ON public.recip -- 35. For users to define locations within their pantry. CREATE TABLE IF NOT EXISTS public.pantry_locations ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + pantry_location_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, name TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -583,9 +583,9 @@ CREATE INDEX IF NOT EXISTS idx_pantry_locations_user_id ON public.pantry_locatio -- 36. Associate a recipe with a specific date and meal type within a menu plan. CREATE TABLE IF NOT EXISTS public.planned_meals ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - menu_plan_id BIGINT NOT NULL REFERENCES public.menu_plans(id) ON DELETE CASCADE, - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, + planned_meal_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + menu_plan_id BIGINT NOT NULL REFERENCES public.menu_plans(menu_plan_id) ON DELETE CASCADE, + recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE, plan_date DATE NOT NULL, meal_type TEXT NOT NULL, servings_to_cook INTEGER, @@ -599,13 +599,13 @@ CREATE INDEX IF NOT EXISTS idx_planned_meals_recipe_id ON public.planned_meals(r -- 37. Track the grocery items a user currently has in their pantry. CREATE TABLE IF NOT EXISTS public.pantry_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, + pantry_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, quantity NUMERIC NOT NULL, unit TEXT, best_before_date DATE, - pantry_location_id BIGINT REFERENCES public.pantry_locations(id) ON DELETE SET NULL, + pantry_location_id BIGINT REFERENCES public.pantry_locations(pantry_location_id) ON DELETE SET NULL, notification_sent_at TIMESTAMPTZ, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, UNIQUE(user_id, master_item_id, unit) @@ -620,8 +620,8 @@ CREATE INDEX IF NOT EXISTS idx_pantry_items_pantry_location_id ON public.pantry_ -- 38. Store password reset tokens. CREATE TABLE IF NOT EXISTS public.password_reset_tokens ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + password_reset_token_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -635,8 +635,8 @@ CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token_hash ON public.passwo -- 39. Store unit conversion factors for specific master grocery items. CREATE TABLE IF NOT EXISTS public.unit_conversions ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, + unit_conversion_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, from_unit TEXT NOT NULL, to_unit TEXT NOT NULL, factor NUMERIC NOT NULL, @@ -650,9 +650,9 @@ CREATE INDEX IF NOT EXISTS idx_unit_conversions_master_item_id ON public.unit_co -- 40. For users to create their own private aliases for items. CREATE TABLE IF NOT EXISTS public.user_item_aliases ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, + user_item_alias_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, alias TEXT NOT NULL, UNIQUE(user_id, alias), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -664,8 +664,8 @@ CREATE INDEX IF NOT EXISTS idx_user_item_aliases_master_item_id ON public.user_i -- 41. For users to mark their favorite recipes. CREATE TABLE IF NOT EXISTS public.favorite_recipes ( - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, PRIMARY KEY (user_id, recipe_id), updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -676,8 +676,8 @@ CREATE INDEX IF NOT EXISTS idx_favorite_recipes_recipe_id ON public.favorite_rec -- 42. For users to mark their favorite stores. CREATE TABLE IF NOT EXISTS public.favorite_stores ( - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - store_id BIGINT NOT NULL REFERENCES public.stores(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, PRIMARY KEY (user_id, store_id), updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -688,8 +688,8 @@ CREATE INDEX IF NOT EXISTS idx_favorite_stores_store_id ON public.favorite_store -- 43. For users to group recipes into collections. CREATE TABLE IF NOT EXISTS public.recipe_collections ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + recipe_collection_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, name TEXT NOT NULL, description TEXT, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -700,8 +700,8 @@ CREATE INDEX IF NOT EXISTS idx_recipe_collections_user_id ON public.recipe_colle -- 44. Associate recipes with a user's collection. CREATE TABLE IF NOT EXISTS public.recipe_collection_items ( - collection_id BIGINT NOT NULL REFERENCES public.recipe_collections(id) ON DELETE CASCADE, - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, + collection_id BIGINT NOT NULL REFERENCES public.recipe_collections(recipe_collection_id) ON DELETE CASCADE, + recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE, added_at TIMESTAMPTZ DEFAULT now() NOT NULL, PRIMARY KEY (collection_id, recipe_id), updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -712,8 +712,8 @@ CREATE INDEX IF NOT EXISTS idx_recipe_collection_items_recipe_id ON public.recip -- 45. Log user search queries for analysis. CREATE TABLE IF NOT EXISTS public.search_queries ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID REFERENCES public.users(id) ON DELETE SET NULL, + search_query_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID REFERENCES public.users(user_id) ON DELETE SET NULL, query_text TEXT NOT NULL, result_count INTEGER, was_successful BOOLEAN, @@ -726,9 +726,9 @@ CREATE INDEX IF NOT EXISTS idx_search_queries_user_id ON public.search_queries(u -- 46. Store historical records of completed shopping trips. CREATE TABLE IF NOT EXISTS public.shopping_trips ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - shopping_list_id BIGINT REFERENCES public.shopping_lists(id) ON DELETE SET NULL, + shopping_trip_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + shopping_list_id BIGINT REFERENCES public.shopping_lists(shopping_list_id) ON DELETE SET NULL, completed_at TIMESTAMPTZ DEFAULT now() NOT NULL, total_spent_cents INTEGER, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -740,9 +740,9 @@ CREATE INDEX IF NOT EXISTS idx_shopping_trips_shopping_list_id ON public.shoppin -- 47. Store the items purchased during a specific shopping trip. CREATE TABLE IF NOT EXISTS public.shopping_trip_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(id) ON DELETE CASCADE, - master_item_id BIGINT REFERENCES public.master_grocery_items(id), + shopping_trip_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(shopping_trip_id) ON DELETE CASCADE, + master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id), custom_item_name TEXT, quantity NUMERIC NOT NULL, price_paid_cents INTEGER, @@ -757,7 +757,7 @@ CREATE INDEX IF NOT EXISTS idx_shopping_trip_items_master_item_id ON public.shop -- 48. Store predefined dietary restrictions (diets and allergies). CREATE TABLE IF NOT EXISTS public.dietary_restrictions ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + dietary_restriction_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL UNIQUE, type TEXT NOT NULL CHECK (type IN ('diet', 'allergy')), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -767,8 +767,8 @@ COMMENT ON TABLE public.dietary_restrictions IS 'A predefined list of common die -- 49. For a user's specific dietary restrictions. CREATE TABLE IF NOT EXISTS public.user_dietary_restrictions ( - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - restriction_id BIGINT NOT NULL REFERENCES public.dietary_restrictions(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + restriction_id BIGINT NOT NULL REFERENCES public.dietary_restrictions(dietary_restriction_id) ON DELETE CASCADE, PRIMARY KEY (user_id, restriction_id), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -779,8 +779,8 @@ CREATE INDEX IF NOT EXISTS idx_user_dietary_restrictions_restriction_id ON publi -- 50. For a user's owned kitchen appliances. CREATE TABLE IF NOT EXISTS public.user_appliances ( - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - appliance_id BIGINT NOT NULL REFERENCES public.appliances(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + appliance_id BIGINT NOT NULL REFERENCES public.appliances(appliance_id) ON DELETE CASCADE, PRIMARY KEY (user_id, appliance_id), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -791,8 +791,8 @@ CREATE INDEX IF NOT EXISTS idx_user_appliances_appliance_id ON public.user_appli -- 51. Manage the social graph (following relationships). CREATE TABLE IF NOT EXISTS public.user_follows ( - follower_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - following_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + follower_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + following_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, PRIMARY KEY (follower_id, following_id), updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -804,9 +804,9 @@ CREATE INDEX IF NOT EXISTS idx_user_follows_following_id ON public.user_follows( -- 52. Store uploaded user receipts for purchase tracking and analysis. CREATE TABLE IF NOT EXISTS public.receipts ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - store_id BIGINT REFERENCES public.stores(id), + receipt_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + store_id BIGINT REFERENCES public.stores(store_id), receipt_image_url TEXT NOT NULL, transaction_date TIMESTAMPTZ, total_amount_cents INTEGER, @@ -822,13 +822,13 @@ CREATE INDEX IF NOT EXISTS idx_receipts_store_id ON public.receipts(store_id); -- 53. Store individual line items extracted from a user receipt. CREATE TABLE IF NOT EXISTS public.receipt_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - receipt_id BIGINT NOT NULL REFERENCES public.receipts(id) ON DELETE CASCADE, + receipt_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + receipt_id BIGINT NOT NULL REFERENCES public.receipts(receipt_id) ON DELETE CASCADE, raw_item_description TEXT NOT NULL, quantity NUMERIC DEFAULT 1 NOT NULL, price_paid_cents INTEGER NOT NULL, - master_item_id BIGINT REFERENCES public.master_grocery_items(id), - product_id BIGINT REFERENCES public.products(id), + master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id), + product_id BIGINT REFERENCES public.products(product_id), status TEXT DEFAULT 'unmatched' NOT NULL CHECK (status IN ('unmatched', 'matched', 'needs_review', 'ignored')), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL @@ -858,22 +858,22 @@ DECLARE bc_cat_id BIGINT; ps_cat_id BIGINT; dpf_cat_id BIGINT; cg_cat_id BIGINT; cs_cat_id BIGINT; bkc_cat_id BIGINT; BEGIN - SELECT id INTO fv_cat_id FROM public.categories WHERE name = 'Fruits & Vegetables'; - SELECT id INTO ms_cat_id FROM public.categories WHERE name = 'Meat & Seafood'; - SELECT id INTO de_cat_id FROM public.categories WHERE name = 'Dairy & Eggs'; - SELECT id INTO bb_cat_id FROM public.categories WHERE name = 'Bakery & Bread'; - SELECT id INTO pdg_cat_id FROM public.categories WHERE name = 'Pantry & Dry Goods'; - SELECT id INTO bev_cat_id FROM public.categories WHERE name = 'Beverages'; - SELECT id INTO ff_cat_id FROM public.categories WHERE name = 'Frozen Foods'; - SELECT id INTO snk_cat_id FROM public.categories WHERE name = 'Snacks'; - SELECT id INTO hc_cat_id FROM public.categories WHERE name = 'Household & Cleaning'; - SELECT id INTO pch_cat_id FROM public.categories WHERE name = 'Personal Care & Health'; - SELECT id INTO bc_cat_id FROM public.categories WHERE name = 'Baby & Child'; - SELECT id INTO ps_cat_id FROM public.categories WHERE name = 'Pet Supplies'; - SELECT id INTO dpf_cat_id FROM public.categories WHERE name = 'Deli & Prepared Foods'; - SELECT id INTO cg_cat_id FROM public.categories WHERE name = 'Canned Goods'; - SELECT id INTO cs_cat_id FROM public.categories WHERE name = 'Condiments & Spices'; - SELECT id INTO bkc_cat_id FROM public.categories WHERE name = 'Breakfast & Cereal'; + SELECT category_id INTO fv_cat_id FROM public.categories WHERE name = 'Fruits & Vegetables'; + SELECT category_id INTO ms_cat_id FROM public.categories WHERE name = 'Meat & Seafood'; + SELECT category_id INTO de_cat_id FROM public.categories WHERE name = 'Dairy & Eggs'; + SELECT category_id INTO bb_cat_id FROM public.categories WHERE name = 'Bakery & Bread'; + SELECT category_id INTO pdg_cat_id FROM public.categories WHERE name = 'Pantry & Dry Goods'; + SELECT category_id INTO bev_cat_id FROM public.categories WHERE name = 'Beverages'; + SELECT category_id INTO ff_cat_id FROM public.categories WHERE name = 'Frozen Foods'; + SELECT category_id INTO snk_cat_id FROM public.categories WHERE name = 'Snacks'; + SELECT category_id INTO hc_cat_id FROM public.categories WHERE name = 'Household & Cleaning'; + SELECT category_id INTO pch_cat_id FROM public.categories WHERE name = 'Personal Care & Health'; + SELECT category_id INTO bc_cat_id FROM public.categories WHERE name = 'Baby & Child'; + SELECT category_id INTO ps_cat_id FROM public.categories WHERE name = 'Pet Supplies'; + SELECT category_id INTO dpf_cat_id FROM public.categories WHERE name = 'Deli & Prepared Foods'; + SELECT category_id INTO cg_cat_id FROM public.categories WHERE name = 'Canned Goods'; + SELECT category_id INTO cs_cat_id FROM public.categories WHERE name = 'Condiments & Spices'; + SELECT category_id INTO bkc_cat_id FROM public.categories WHERE name = 'Breakfast & Cereal'; INSERT INTO public.master_grocery_items (name, category_id) VALUES ('apples', fv_cat_id), ('bananas', fv_cat_id), ('oranges', fv_cat_id), ('grapes', fv_cat_id), ('strawberries', fv_cat_id), ('blueberries', fv_cat_id), ('raspberries', fv_cat_id), ('avocados', fv_cat_id), ('tomatoes', fv_cat_id), ('potatoes', fv_cat_id), ('onions', fv_cat_id), ('garlic', fv_cat_id), ('carrots', fv_cat_id), ('broccoli', fv_cat_id), ('spinach', fv_cat_id), ('lettuce', fv_cat_id), ('bell peppers', fv_cat_id), ('cucumbers', fv_cat_id), ('mushrooms', fv_cat_id), ('lemons', fv_cat_id), ('limes', fv_cat_id), ('celery', fv_cat_id), ('corn', fv_cat_id), ('sweet potatoes', fv_cat_id), ('zucchini', fv_cat_id), ('cauliflower', fv_cat_id), ('green beans', fv_cat_id), ('peas', fv_cat_id), ('asparagus', fv_cat_id), @@ -924,7 +924,7 @@ DECLARE BEGIN -- Insert a store for the store brands INSERT INTO public.stores (name) VALUES ('Loblaws') ON CONFLICT (name) DO NOTHING; - SELECT id INTO loblaws_id FROM public.stores WHERE name = 'Loblaws'; + SELECT store_id INTO loblaws_id FROM public.stores WHERE name = 'Loblaws'; -- Insert brands and get their IDs INSERT INTO public.brands (name) VALUES ('Coca-Cola'), ('Kraft'), ('Maple Leaf'), ('Dempster''s'), ('No Name'), ('President''s Choice') @@ -937,44 +937,44 @@ BEGIN ('Vegetable Stir-fry', 'A fast, flavorful, and vegetarian stir-fry loaded with fresh vegetables.', '1. Chop all vegetables. 2. Heat oil in a wok or large pan. 3. Stir-fry vegetables for 5-7 minutes until tender-crisp. 4. Add soy sauce and serve immediately.', 10, 10, 3) ON CONFLICT (name) WHERE user_id IS NULL DO NOTHING; - SELECT id INTO chicken_recipe_id FROM public.recipes WHERE name = 'Simple Chicken and Rice'; - SELECT id INTO bolognese_recipe_id FROM public.recipes WHERE name = 'Classic Spaghetti Bolognese'; - SELECT id INTO stir_fry_recipe_id FROM public.recipes WHERE name = 'Vegetable Stir-fry'; + SELECT recipe_id INTO chicken_recipe_id FROM public.recipes WHERE name = 'Simple Chicken and Rice'; + SELECT recipe_id INTO bolognese_recipe_id FROM public.recipes WHERE name = 'Classic Spaghetti Bolognese'; + SELECT recipe_id INTO stir_fry_recipe_id FROM public.recipes WHERE name = 'Vegetable Stir-fry'; -- Link store brands to their store UPDATE public.brands SET store_id = loblaws_id WHERE name = 'No Name'; UPDATE public.brands SET store_id = loblaws_id WHERE name = 'President''s Choice'; - SELECT id INTO coke_id FROM public.brands WHERE name = 'Coca-Cola'; - SELECT id INTO kraft_id FROM public.brands WHERE name = 'Kraft'; - SELECT id INTO maple_leaf_id FROM public.brands WHERE name = 'Maple Leaf'; - SELECT id INTO dempsters_id FROM public.brands WHERE name = 'Dempster''s'; - SELECT id INTO no_name_id FROM public.brands WHERE name = 'No Name'; - SELECT id INTO pc_id FROM public.brands WHERE name = 'President''s Choice'; + SELECT brand_id INTO coke_id FROM public.brands WHERE name = 'Coca-Cola'; + SELECT brand_id INTO kraft_id FROM public.brands WHERE name = 'Kraft'; + SELECT brand_id INTO maple_leaf_id FROM public.brands WHERE name = 'Maple Leaf'; + SELECT brand_id INTO dempsters_id FROM public.brands WHERE name = 'Dempster''s'; + SELECT brand_id INTO no_name_id FROM public.brands WHERE name = 'No Name'; + SELECT brand_id INTO pc_id FROM public.brands WHERE name = 'President''s Choice'; -- Get ingredient IDs from master_grocery_items - SELECT mgi.id INTO chicken_breast_id FROM public.master_grocery_items mgi WHERE mgi.name = 'chicken breast'; - SELECT mgi.id INTO rice_id FROM public.master_grocery_items mgi WHERE mgi.name = 'rice'; - SELECT mgi.id INTO broccoli_id FROM public.master_grocery_items mgi WHERE mgi.name = 'broccoli'; - SELECT mgi.id INTO ground_beef_id FROM public.master_grocery_items mgi WHERE mgi.name = 'ground beef'; - SELECT mgi.id INTO pasta_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'pasta'; - SELECT mgi.id INTO tomatoes_id FROM public.master_grocery_items mgi WHERE mgi.name = 'tomatoes'; - SELECT mgi.id INTO onions_id FROM public.master_grocery_items mgi WHERE mgi.name = 'onions'; - SELECT mgi.id INTO garlic_id FROM public.master_grocery_items mgi WHERE mgi.name = 'garlic'; - SELECT mgi.id INTO bell_peppers_id FROM public.master_grocery_items mgi WHERE mgi.name = 'bell peppers'; - SELECT mgi.id INTO carrots_id FROM public.master_grocery_items mgi WHERE mgi.name = 'carrots'; - SELECT mgi.id INTO soy_sauce_id FROM public.master_grocery_items mgi WHERE mgi.name = 'soy sauce'; - SELECT mgi.id INTO soda_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'soda'; - SELECT mgi.id INTO turkey_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'turkey'; - SELECT mgi.id INTO bread_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'bread'; - SELECT mgi.id INTO cheese_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'cheese'; + SELECT mgi.master_grocery_item_id INTO chicken_breast_id FROM public.master_grocery_items mgi WHERE mgi.name = 'chicken breast'; + SELECT mgi.master_grocery_item_id INTO rice_id FROM public.master_grocery_items mgi WHERE mgi.name = 'rice'; + SELECT mgi.master_grocery_item_id INTO broccoli_id FROM public.master_grocery_items mgi WHERE mgi.name = 'broccoli'; + SELECT mgi.master_grocery_item_id INTO ground_beef_id FROM public.master_grocery_items mgi WHERE mgi.name = 'ground beef'; + SELECT mgi.master_grocery_item_id INTO pasta_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'pasta'; + SELECT mgi.master_grocery_item_id INTO tomatoes_id FROM public.master_grocery_items mgi WHERE mgi.name = 'tomatoes'; + SELECT mgi.master_grocery_item_id INTO onions_id FROM public.master_grocery_items mgi WHERE mgi.name = 'onions'; + SELECT mgi.master_grocery_item_id INTO garlic_id FROM public.master_grocery_items mgi WHERE mgi.name = 'garlic'; + SELECT mgi.master_grocery_item_id INTO bell_peppers_id FROM public.master_grocery_items mgi WHERE mgi.name = 'bell peppers'; + SELECT mgi.master_grocery_item_id INTO carrots_id FROM public.master_grocery_items mgi WHERE mgi.name = 'carrots'; + SELECT mgi.master_grocery_item_id INTO soy_sauce_id FROM public.master_grocery_items mgi WHERE mgi.name = 'soy sauce'; + SELECT mgi.master_grocery_item_id INTO soda_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'soda'; + SELECT mgi.master_grocery_item_id INTO turkey_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'turkey'; + SELECT mgi.master_grocery_item_id INTO bread_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'bread'; + SELECT mgi.master_grocery_item_id INTO cheese_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'cheese'; -- Insert ingredients for each recipe INSERT INTO public.recipe_ingredients (recipe_id, master_item_id, quantity, unit) VALUES (chicken_recipe_id, chicken_breast_id, 2, 'items'), (chicken_recipe_id, rice_id, 200, 'g'), (chicken_recipe_id, broccoli_id, 300, 'g'), (bolognese_recipe_id, ground_beef_id, 500, 'g'), (bolognese_recipe_id, pasta_item_id, 400, 'g'), (bolognese_recipe_id, tomatoes_id, 800, 'g'), (bolognese_recipe_id, onions_id, 1, 'items'), (bolognese_recipe_id, garlic_id, 2, 'cloves'), (stir_fry_recipe_id, broccoli_id, 200, 'g'), (stir_fry_recipe_id, bell_peppers_id, 1, 'items'), (stir_fry_recipe_id, carrots_id, 2, 'items'), (stir_fry_recipe_id, onions_id, 1, 'items'), (stir_fry_recipe_id, soy_sauce_id, 50, 'ml') - ON CONFLICT (id) DO NOTHING; + ON CONFLICT (recipe_ingredient_id) DO NOTHING; -- Insert specific products, linking master items and brands INSERT INTO public.products (master_item_id, brand_id, name, size, upc_code) VALUES @@ -990,13 +990,13 @@ BEGIN INSERT INTO public.tags (name) VALUES ('Quick & Easy'), ('Healthy'), ('Chicken'), ('Family Friendly'), ('Beef'), ('Weeknight Dinner'), ('Vegetarian') ON CONFLICT (name) DO NOTHING; - SELECT id INTO quick_easy_tag FROM public.tags WHERE name = 'Quick & Easy'; - SELECT id INTO healthy_tag FROM public.tags WHERE name = 'Healthy'; - SELECT id INTO chicken_tag FROM public.tags WHERE name = 'Chicken'; - SELECT id INTO family_tag FROM public.tags WHERE name = 'Family Friendly'; - SELECT id INTO beef_tag FROM public.tags WHERE name = 'Beef'; - SELECT id INTO weeknight_tag FROM public.tags WHERE name = 'Weeknight Dinner'; - SELECT id INTO vegetarian_tag FROM public.tags WHERE name = 'Vegetarian'; + SELECT tag_id INTO quick_easy_tag FROM public.tags WHERE name = 'Quick & Easy'; + SELECT tag_id INTO healthy_tag FROM public.tags WHERE name = 'Healthy'; + SELECT tag_id INTO chicken_tag FROM public.tags WHERE name = 'Chicken'; + SELECT tag_id INTO family_tag FROM public.tags WHERE name = 'Family Friendly'; + SELECT tag_id INTO beef_tag FROM public.tags WHERE name = 'Beef'; + SELECT tag_id INTO weeknight_tag FROM public.tags WHERE name = 'Weeknight Dinner'; + SELECT tag_id INTO vegetarian_tag FROM public.tags WHERE name = 'Vegetarian'; -- Link tags to recipes INSERT INTO public.recipe_tags (recipe_id, tag_id) VALUES @@ -1012,12 +1012,12 @@ DECLARE flour_id BIGINT; sugar_id BIGINT; butter_id BIGINT; milk_id BIGINT; water_id BIGINT; rice_id BIGINT; BEGIN -- Get master item IDs - SELECT mgi.id INTO flour_id FROM public.master_grocery_items mgi WHERE mgi.name = 'flour'; - SELECT mgi.id INTO sugar_id FROM public.master_grocery_items mgi WHERE mgi.name = 'sugar'; - SELECT mgi.id INTO butter_id FROM public.master_grocery_items mgi WHERE mgi.name = 'butter'; - SELECT mgi.id INTO milk_id FROM public.master_grocery_items mgi WHERE mgi.name = 'milk'; - SELECT mgi.id INTO water_id FROM public.master_grocery_items mgi WHERE mgi.name = 'water'; - SELECT mgi.id INTO rice_id FROM public.master_grocery_items mgi WHERE mgi.name = 'rice'; + SELECT mgi.master_grocery_item_id INTO flour_id FROM public.master_grocery_items mgi WHERE mgi.name = 'flour'; + SELECT mgi.master_grocery_item_id INTO sugar_id FROM public.master_grocery_items mgi WHERE mgi.name = 'sugar'; + SELECT mgi.master_grocery_item_id INTO butter_id FROM public.master_grocery_items mgi WHERE mgi.name = 'butter'; + SELECT mgi.master_grocery_item_id INTO milk_id FROM public.master_grocery_items mgi WHERE mgi.name = 'milk'; + SELECT mgi.master_grocery_item_id INTO water_id FROM public.master_grocery_items mgi WHERE mgi.name = 'water'; + SELECT mgi.master_grocery_item_id INTO rice_id FROM public.master_grocery_items mgi WHERE mgi.name = 'rice'; -- Insert conversion factors INSERT INTO public.unit_conversions (master_item_id, from_unit, to_unit, factor) VALUES @@ -1092,7 +1092,7 @@ BEGIN mgi.name AS item_name, fi.price_in_cents, s.name AS store_name, - f.id AS flyer_id, + f.flyer_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, @@ -1100,10 +1100,10 @@ BEGIN 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.master_grocery_items mgi ON uwi.master_item_id = mgi.master_grocery_item_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 + JOIN public.flyers f ON fi.flyer_id = f.flyer_id + JOIN public.stores s ON f.store_id = s.store_id WHERE uwi.user_id = p_user_id AND f.valid_from <= CURRENT_DATE AND f.valid_to >= CURRENT_DATE @@ -1146,10 +1146,10 @@ BEGIN (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.planned_meals pm ON mp.menu_plan_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 + JOIN public.recipes r ON pm.recipe_id = r.recipe_id + WHERE mp.menu_plan_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. @@ -1162,7 +1162,7 @@ BEGIN 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 + JOIN public.master_grocery_items mgi ON req.master_item_id = mgi.master_grocery_item_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"). @@ -1194,8 +1194,8 @@ AS $$ 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 + JOIN public.flyers f ON fi.flyer_id = f.flyer_id + JOIN public.stores s ON f.store_id = s.store_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 @@ -1223,15 +1223,15 @@ AS $$ 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.recipe_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 the ingredient is not on sale. bcp.store_name FROM public.recipes r - JOIN EligibleRecipes er ON r.id = er.recipe_id - JOIN public.recipe_ingredients ri ON r.id = ri.recipe_id - JOIN public.master_grocery_items mgi ON ri.master_item_id = mgi.id + JOIN EligibleRecipes er ON r.recipe_id = er.recipe_id + JOIN public.recipe_ingredients ri ON r.recipe_id = ri.recipe_id + JOIN public.master_grocery_items mgi ON ri.master_item_id = mgi.master_grocery_item_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. @@ -1277,7 +1277,7 @@ 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; + WHERE shopping_list_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; @@ -1316,7 +1316,7 @@ 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 + JOIN public.flyers f ON fi.flyer_id = f.flyer_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 @@ -1332,12 +1332,12 @@ AS $$ ) -- Final Step: Select recipes that meet the minimum sale ingredient count and order them. SELECT - r.id, + r.recipe_id, r.name, r.description, ris.sale_ingredients_count FROM public.recipes r - JOIN RecipeIngredientStats ris ON r.id = ris.recipe_id + JOIN RecipeIngredientStats ris ON r.recipe_id = ris.recipe_id WHERE ris.sale_ingredients_count >= p_min_sale_ingredients ORDER BY ris.sale_ingredients_count DESC, @@ -1361,16 +1361,16 @@ AS $$ FROM public.flyer_items fi JOIN - public.flyers f ON fi.flyer_id = f.id + public.flyers f ON fi.flyer_id = f.flyer_id JOIN - public.master_grocery_items mgi ON fi.master_item_id = mgi.id + public.master_grocery_items mgi ON fi.master_item_id = mgi.master_grocery_item_id WHERE 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 + mgi.master_grocery_item_id, mgi.name ORDER BY sale_occurrence_count DESC LIMIT result_limit; @@ -1380,7 +1380,7 @@ $$; -- 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, + recipe_id BIGINT, name TEXT, description TEXT, prep_time_minutes INTEGER, @@ -1392,22 +1392,22 @@ STABLE SECURITY INVOKER AS $$ SELECT - r.id, r.name, r.description, r.prep_time_minutes, r.cook_time_minutes, r.avg_rating + r.recipe_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 + JOIN public.master_grocery_items mgi ON ri.master_item_id = mgi.master_grocery_item_id + WHERE ri.recipe_id = r.recipe_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 + JOIN public.tags t ON rt.tag_id = t.tag_id + WHERE rt.recipe_id = r.recipe_id AND t.name = p_tag_name ) ORDER BY r.avg_rating DESC, r.name ASC; @@ -1430,7 +1430,7 @@ BEGIN WITH candidates AS ( -- Search for matches in the primary master_grocery_items table SELECT - id AS master_item_id, + master_grocery_item_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. @@ -1456,7 +1456,7 @@ $$; -- in the specified user's pantry. CREATE OR REPLACE FUNCTION public.find_recipes_from_pantry(p_user_id UUID) RETURNS TABLE( - id BIGINT, + recipe_id BIGINT, name TEXT, description TEXT, prep_time_minutes INTEGER, @@ -1495,7 +1495,7 @@ AS $$ ) -- Final Step: Select recipes where the total ingredient count matches the pantry ingredient count. SELECT - r.id, + r.recipe_id, r.name, r.description, r.prep_time_minutes, @@ -1503,7 +1503,7 @@ AS $$ r.avg_rating, ris.missing_ingredients_count FROM public.recipes r - JOIN RecipeIngredientStats ris ON r.id = ris.recipe_id + JOIN RecipeIngredientStats ris ON r.recipe_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; @@ -1531,7 +1531,7 @@ AS $$ ON pi.master_item_id = uc.master_item_id AND pi.unit = uc.from_unit WHERE - pi.id = p_pantry_item_id + pi.pantry_item_id = p_pantry_item_id -- Exclude suggesting a conversion back to the same unit. AND pi.unit <> uc.to_unit; $$; @@ -1567,12 +1567,12 @@ UserWatchedItems AS ( RecipeScores AS ( -- CTE 3: Calculate a score for each recipe based on two factors. SELECT - r.id AS recipe_id, + r.recipe_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) + WHERE ri.recipe_id = r.recipe_id AND ri.master_item_id IN (SELECT master_item_id FROM UserWatchedItems) ) AS watched_item_score, -- Score from similarity to highly-rated recipes. ( @@ -1582,18 +1582,18 @@ RecipeScores AS ( 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 + WHERE ri1.recipe_id = r.recipe_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 + WHERE rt1.recipe_id = r.recipe_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. + WHERE uhr.recipe_id <> r.recipe_id -- Don't compare a recipe to itself. ) AS similarity_score FROM public.recipes r ), @@ -1615,14 +1615,14 @@ RankedRecommendations AS ( ) -- Final Selection: Join back to the recipes table to get full details and order by the final score. SELECT - r.id, + r.recipe_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 +JOIN public.recipes r ON rr.recipe_id = r.recipe_id ORDER BY rr.total_score DESC, r.avg_rating DESC, -- As a tie-breaker, prefer higher-rated recipes. @@ -1634,7 +1634,7 @@ $$; -- Function to get a user's favorite recipes. CREATE OR REPLACE FUNCTION public.get_user_favorite_recipes(p_user_id UUID) RETURNS TABLE ( - id BIGINT, + recipe_id BIGINT, name TEXT, description TEXT, avg_rating NUMERIC, @@ -1645,9 +1645,9 @@ STABLE SECURITY INVOKER AS $$ SELECT - r.id, r.name, r.description, r.avg_rating, r.photo_url + r.recipe_id, r.name, r.description, r.avg_rating, r.photo_url FROM public.recipes r - JOIN public.favorite_recipes fr ON r.id = fr.recipe_id + JOIN public.favorite_recipes fr ON r.recipe_id = fr.recipe_id WHERE fr.user_id = p_user_id ORDER BY r.name ASC; $$; @@ -1658,7 +1658,7 @@ DROP FUNCTION IF EXISTS public.get_activity_log(integer, integer); CREATE OR REPLACE FUNCTION public.get_activity_log(p_limit INTEGER DEFAULT 20, p_offset INTEGER DEFAULT 0) RETURNS TABLE ( - id BIGINT, + activity_log_id BIGINT, user_id UUID, action TEXT, -- Changed from activity_type display_text TEXT, -- Added @@ -1673,10 +1673,10 @@ STABLE SECURITY INVOKER AS $$ SELECT - al.id, al.user_id, al.action, al.display_text, al.icon, al.details, al.created_at, + al.activity_log_id, al.user_id, al.action, al.display_text, al.icon, al.details, al.created_at, p.full_name, p.avatar_url FROM public.activity_log al - LEFT JOIN public.profiles p ON al.user_id = p.id + LEFT JOIN public.profiles p ON al.user_id = p.user_id ORDER BY al.created_at DESC LIMIT p_limit OFFSET p_offset; @@ -1685,7 +1685,7 @@ $$; -- Function to get a user's profile by their ID, combining data from users and profiles tables. CREATE OR REPLACE FUNCTION public.get_user_profile_by_id(p_user_id UUID) RETURNS TABLE ( - id UUID, + user_id UUID, email TEXT, full_name TEXT, avatar_url TEXT, @@ -1699,7 +1699,7 @@ STABLE SECURITY INVOKER AS $$ SELECT - u.id, + u.user_id, u.email, p.full_name, p.avatar_url, @@ -1708,8 +1708,8 @@ AS $$ p.created_at, p.updated_at FROM public.users u - JOIN public.profiles p ON u.id = p.id - WHERE u.id = p_user_id; + JOIN public.profiles p ON u.user_id = p.user_id + WHERE u.user_id = p_user_id; $$; -- Function to get recipes that are compatible with a user's dietary restrictions (allergies). @@ -1722,10 +1722,10 @@ SECURITY INVOKER AS $$ WITH UserAllergens AS ( -- CTE 1: Find all master item IDs that are allergens for the given user. - SELECT mgi.id + SELECT mgi.master_grocery_item_id FROM public.master_grocery_items mgi JOIN public.dietary_restrictions dr ON mgi.allergy_info->>'type' = dr.name - JOIN public.user_dietary_restrictions udr ON dr.id = udr.restriction_id + JOIN public.user_dietary_restrictions udr ON dr.dietary_restriction_id = udr.restriction_id WHERE udr.user_id = p_user_id AND dr.type = 'allergy' AND mgi.is_allergen = true @@ -1734,12 +1734,12 @@ AS $$ -- CTE 2: Find all recipe IDs that contain one or more of the user's allergens. SELECT DISTINCT ri.recipe_id FROM public.recipe_ingredients ri - WHERE ri.master_item_id IN (SELECT id FROM UserAllergens) + WHERE ri.master_item_id IN (SELECT master_grocery_item_id FROM UserAllergens) ) -- Final Selection: Return all recipes that are NOT in the forbidden list. SELECT * FROM public.recipes r - WHERE r.id NOT IN (SELECT recipe_id FROM ForbiddenRecipes) + WHERE r.recipe_id NOT IN (SELECT recipe_id FROM ForbiddenRecipes) ORDER BY r.avg_rating DESC, r.name ASC; $$; @@ -1747,7 +1747,7 @@ $$; -- It aggregates recent activities from followed users. CREATE OR REPLACE FUNCTION public.get_user_feed(p_user_id UUID, p_limit INTEGER DEFAULT 20, p_offset INTEGER DEFAULT 0) RETURNS TABLE ( - id BIGINT, + activity_log_id BIGINT, user_id UUID, action TEXT, display_text TEXT, @@ -1767,10 +1767,10 @@ AS $$ ) -- Final Selection: Get activities from the log where the user_id is in the followed list. SELECT - al.id, al.user_id, al.action, al.display_text, al.icon, al.details, al.created_at, + al.activity_log_id, al.user_id, al.action, al.display_text, al.icon, al.details, al.created_at, p.full_name, p.avatar_url FROM public.activity_log al - JOIN public.profiles p ON al.user_id = p.id + JOIN public.profiles p ON al.user_id = p.user_id WHERE al.user_id IN (SELECT following_id FROM FollowedUsers) -- We can filter for specific action types to make the feed more relevant. @@ -1805,7 +1805,7 @@ 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; + WHERE shopping_list_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; @@ -1814,7 +1814,7 @@ BEGIN -- 1. Create a new shopping_trip record. INSERT INTO public.shopping_trips (user_id, shopping_list_id, total_spent_cents) VALUES (p_user_id, p_shopping_list_id, p_total_spent_cents) - RETURNING id INTO new_trip_id; + RETURNING shopping_trip_id INTO new_trip_id; -- 2. Copy purchased items from the shopping list to the new shopping_trip_items table. INSERT INTO public.shopping_trip_items (shopping_trip_id, master_item_id, custom_item_name, quantity) @@ -1850,12 +1850,12 @@ AS $$ WITH ReceiptItems AS ( -- CTE 1: Get all matched items from the specified receipt. SELECT - ri.id AS receipt_item_id, + ri.receipt_item_id AS receipt_item_id, ri.master_item_id, mgi.name AS item_name, ri.price_paid_cents FROM public.receipt_items ri - JOIN public.master_grocery_items mgi ON ri.master_item_id = mgi.id + JOIN public.master_grocery_items mgi ON ri.master_item_id = mgi.master_grocery_item_id WHERE ri.receipt_id = p_receipt_id AND ri.master_item_id IS NOT NULL ), @@ -1865,10 +1865,10 @@ AS $$ fi.master_item_id, fi.price_in_cents, s.name AS store_name, - f.id AS flyer_id + f.flyer_id AS flyer_id 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 + JOIN public.flyers f ON fi.flyer_id = f.flyer_id + JOIN public.stores s ON f.store_id = s.store_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 @@ -1905,7 +1905,7 @@ 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'; + WHERE suggested_correction_id = p_correction_id AND status = 'pending'; IF NOT FOUND THEN RAISE EXCEPTION 'Correction with ID % not found or already processed.', p_correction_id; @@ -1915,17 +1915,17 @@ BEGIN 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; + WHERE flyer_item_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; + WHERE flyer_item_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; + WHERE suggested_correction_id = p_correction_id; END; $$; @@ -1965,17 +1965,17 @@ BEGIN -- The user's metadata (full_name, avatar_url) is passed via a temporary session variable. user_meta_data := current_setting('my_app.user_metadata', true)::JSONB; - INSERT INTO public.profiles (id, role, full_name, avatar_url) - VALUES (new.id, 'user', user_meta_data->>'full_name', user_meta_data->>'avatar_url') - RETURNING id INTO new_profile_id; + INSERT INTO public.profiles (user_id, role, full_name, avatar_url) + VALUES (new.user_id, 'user', user_meta_data->>'full_name', user_meta_data->>'avatar_url') + RETURNING user_id INTO new_profile_id; -- Also create a default shopping list for the new user. INSERT INTO public.shopping_lists (user_id, name) - VALUES (new_profile_id, 'Main Shopping List'); + VALUES (new.user_id, 'Main Shopping List'); -- Log the new user event INSERT INTO public.activity_log (user_id, action, display_text, icon, details) - VALUES (new.id, 'user_registered', + VALUES (new.user_id, 'user_registered', COALESCE(user_meta_data->>'full_name', new.email) || ' has registered.', 'user-plus', jsonb_build_object('email', new.email) @@ -2083,7 +2083,7 @@ BEGIN -- If the item could not be matched, add it to the unmatched queue for review. IF NEW.master_item_id IS NULL THEN INSERT INTO public.unmatched_flyer_items (flyer_item_id) - VALUES (NEW.id) + VALUES (NEW.flyer_item_id) ON CONFLICT (flyer_item_id) DO NOTHING; END IF; @@ -2095,7 +2095,7 @@ BEGIN -- Get the validity dates of the flyer and the store_id. SELECT valid_from, valid_to INTO flyer_valid_from, flyer_valid_to FROM public.flyers - WHERE id = NEW.flyer_id; + WHERE flyer_id = NEW.flyer_id; -- If the flyer dates are not set, we cannot proceed. IF flyer_valid_from IS NULL OR flyer_valid_to IS NULL THEN @@ -2145,7 +2145,7 @@ BEGIN -- Get the validity dates of the flyer. SELECT valid_from, valid_to INTO flyer_valid_from, flyer_valid_to FROM public.flyers - WHERE id = OLD.flyer_id; + WHERE flyer_id = OLD.flyer_id; -- If the flyer dates are not set, we cannot proceed. IF flyer_valid_from IS NULL OR flyer_valid_to IS NULL THEN @@ -2161,11 +2161,11 @@ BEGIN MIN(fi.price_in_cents) AS min_price, MAX(fi.price_in_cents) AS max_price, ROUND(AVG(fi.price_in_cents)) AS avg_price, - COUNT(fi.id) AS data_points + COUNT(fi.flyer_item_id) AS data_points INTO new_aggregates FROM public.flyer_items fi JOIN public.flyer_locations fl ON fi.flyer_id = fl.flyer_id - JOIN public.flyers f ON fi.flyer_id = f.id + JOIN public.flyers f ON fi.flyer_id = f.flyer_id WHERE fi.master_item_id = OLD.master_item_id AND fi.price_in_cents IS NOT NULL AND current_summary_date BETWEEN f.valid_from AND f.valid_to @@ -2202,14 +2202,14 @@ BEGIN avg_rating = ( SELECT AVG(rating) FROM public.recipe_ratings - WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id) + WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id) -- This is correct, no change needed ), rating_count = ( SELECT COUNT(*) FROM public.recipe_ratings - WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id) + WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id) -- This is correct, no change needed ) - WHERE id = COALESCE(NEW.recipe_id, OLD.recipe_id); + WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id); RETURN NULL; -- The result is ignored since this is an AFTER trigger. END; @@ -2229,7 +2229,7 @@ BEGIN VALUES ( NEW.user_id, 'recipe_created', - (SELECT full_name FROM public.profiles WHERE id = NEW.user_id) || ' created a new recipe: ' || NEW.name, + (SELECT full_name FROM public.profiles WHERE user_id = NEW.user_id) || ' created a new recipe: ' || NEW.name, 'chef-hat', jsonb_build_object('recipe_name', NEW.name) ); @@ -2252,10 +2252,10 @@ BEGIN INSERT INTO public.activity_log (action, display_text, icon, details) VALUES ( 'flyer_uploaded', - 'A new flyer for ' || (SELECT name FROM public.stores WHERE id = NEW.store_id) || ' has been uploaded.', + 'A new flyer for ' || (SELECT name FROM public.stores WHERE store_id = NEW.store_id) || ' has been uploaded.', 'file-text', jsonb_build_object( - 'store_name', (SELECT name FROM public.stores WHERE id = NEW.store_id), + 'store_name', (SELECT name FROM public.stores WHERE store_id = NEW.store_id), 'valid_from', to_char(NEW.valid_from, 'YYYY-MM-DD'), 'valid_to', to_char(NEW.valid_to, 'YYYY-MM-DD') ) @@ -2278,7 +2278,7 @@ BEGIN VALUES ( NEW.user_id, 'recipe_favorited', - (SELECT full_name FROM public.profiles WHERE id = NEW.user_id) || ' favorited the recipe: ' || (SELECT name FROM public.recipes WHERE id = NEW.recipe_id), + (SELECT full_name FROM public.profiles WHERE user_id = NEW.user_id) || ' favorited the recipe: ' || (SELECT name FROM public.recipes WHERE recipe_id = NEW.recipe_id), 'heart', jsonb_build_object( 'recipe_id', NEW.recipe_id @@ -2296,10 +2296,10 @@ BEGIN VALUES ( NEW.shared_by_user_id, 'list_shared', - (SELECT full_name FROM public.profiles WHERE id = NEW.shared_by_user_id) || ' shared a shopping list.', + (SELECT full_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id) || ' shared a shopping list.', 'share-2', jsonb_build_object( - 'list_name', (SELECT name FROM public.shopping_lists WHERE id = NEW.shopping_list_id), + 'list_name', (SELECT name FROM public.shopping_lists WHERE shopping_list_id = NEW.shopping_list_id), 'shared_with_user_id', NEW.shared_with_user_id ) ); diff --git a/src/App.tsx b/src/App.tsx index 88651597..6fdc6691 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -654,7 +654,7 @@ function App() { try { const updatedOrNewItem = await apiAddWatchedItem(itemName, category); setWatchedItems(prevItems => { - const itemExists = prevItems.some(item => item.id === updatedOrNewItem.id); + const itemExists = prevItems.some(item => item.item_id === updatedOrNewItem.item_id); if (!itemExists) { const newItems = [...prevItems, updatedOrNewItem]; return newItems.sort((a,b) => a.name.localeCompare(b.name)); diff --git a/src/components/AdminBrandManager.tsx b/src/components/AdminBrandManager.tsx index 749a2e71..38d80579 100644 --- a/src/components/AdminBrandManager.tsx +++ b/src/components/AdminBrandManager.tsx @@ -50,7 +50,7 @@ export const AdminBrandManager: React.FC = () => { // Update the state to show the new logo immediately setBrands(prevBrands => prevBrands.map(brand => - brand.id === brandId ? { ...brand, logo_url: logoUrl } : brand + brand.brand_id === brandId ? { ...brand, logo_url: logoUrl } : brand ) ); } catch (e) { diff --git a/src/components/ExtractedDataTable.tsx b/src/components/ExtractedDataTable.tsx index ec9e7975..8c688ca1 100644 --- a/src/components/ExtractedDataTable.tsx +++ b/src/components/ExtractedDataTable.tsx @@ -25,7 +25,7 @@ export const ExtractedDataTable: React.FC = ({ items, t const activeShoppingListItems = useMemo(() => { if (!activeListId) return new Set(); - const activeList = shoppingLists.find(list => list.id === activeListId); + const activeList = shoppingLists.find(list => list.list_id === activeListId); return new Set(activeList?.items.map(item => item.master_item_id)); }, [shoppingLists, activeListId]); @@ -115,7 +115,7 @@ export const ExtractedDataTable: React.FC = ({ items, t const formattedUnitPrice = formatUnitPrice(item.unit_price, unitSystem); return ( - +
{item.item}
diff --git a/src/components/FlyerList.tsx b/src/components/FlyerList.tsx index 0a5aa441..a6c9638c 100644 --- a/src/components/FlyerList.tsx +++ b/src/components/FlyerList.tsx @@ -35,9 +35,9 @@ export const FlyerList: React.FC = ({ flyers, onFlyerSelect, sel return (
  • onFlyerSelect(flyer)} - className={`p-4 flex items-center space-x-3 cursor-pointer transition-colors duration-200 ${selectedFlyerId === flyer.id ? 'bg-brand-light dark:bg-brand-dark/30' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`} + className={`p-4 flex items-center space-x-3 cursor-pointer transition-colors duration-200 ${selectedFlyerId === flyer.flyer_id ? 'bg-brand-light dark:bg-brand-dark/30' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`} >
    diff --git a/src/components/ShoppingList.tsx b/src/components/ShoppingList.tsx index 224f30be..cc47a8db 100644 --- a/src/components/ShoppingList.tsx +++ b/src/components/ShoppingList.tsx @@ -27,7 +27,7 @@ export const ShoppingListComponent: React.FC = ({ us const [isAddingCustom, setIsAddingCustom] = useState(false); const [isReadingAloud, setIsReadingAloud] = useState(false); - const activeList = useMemo(() => lists.find(list => list.id === activeListId), [lists, activeListId]); + const activeList = useMemo(() => lists.find(list => list.list_id === activeListId), [lists, activeListId]); const { neededItems, purchasedItems } = useMemo(() => { if (!activeList) return { neededItems: [], purchasedItems: [] }; const neededItems: ShoppingListItem[] = []; diff --git a/src/components/auth.integration.test.ts b/src/components/auth.integration.test.ts index 48a174ca..e8b1d4ab 100644 --- a/src/components/auth.integration.test.ts +++ b/src/components/auth.integration.test.ts @@ -16,7 +16,7 @@ import { getPool } from '../services/db/connection'; describe('Authentication API Integration', () => { // --- START DEBUG LOGGING --- // Query the DB from within the test file to see its state. - getPool().query('SELECT u.id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.id = p.id').then(res => { + getPool().query('SELECT u.id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.id').then(res => { console.log('\n--- [auth.integration.test.ts] Users found in DB from TEST perspective: ---'); console.table(res.rows); console.log('--------------------------------------------------------------------------\n'); @@ -47,7 +47,7 @@ describe('Authentication API Integration', () => { expect(response).toBeDefined(); expect(response.user).toBeDefined(); expect(response.user.email).toBe(adminEmail); - expect(response.user.id).toBeTypeOf('string'); + expect(response.user.user_id).toBeTypeOf('string'); expect(response.token).toBeTypeOf('string'); }); diff --git a/src/db/seed.ts b/src/db/seed.ts index 28db8243..af58c440 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -53,16 +53,16 @@ async function main() { // 2. Seed Categories logger.info('--- Seeding Categories... ---'); const categoryQuery = `INSERT INTO public.categories (name) VALUES ${CATEGORIES.map((_, i) => `($${i + 1})`).join(', ')} RETURNING id, name`; - const seededCategories = (await client.query(categoryQuery, CATEGORIES)).rows; - const categoryMap = new Map(seededCategories.map(c => [c.name, c.id])); + const seededCategories = (await client.query<{category_id: number, name: string}>(categoryQuery, CATEGORIES)).rows; + const categoryMap = new Map(seededCategories.map(c => [c.name, c.category_id])); logger.info(`Seeded ${seededCategories.length} categories.`); // 3. Seed Stores logger.info('--- Seeding Stores... ---'); const stores = ['Safeway', 'No Frills', 'Costco', 'Superstore']; const storeQuery = `INSERT INTO public.stores (name) VALUES ${stores.map((_, i) => `($${i + 1})`).join(', ')} RETURNING id, name`; - const seededStores = (await client.query(storeQuery, stores)).rows; - const storeMap = new Map(seededStores.map(s => [s.name, s.id])); + const seededStores = (await client.query<{store_id: number, name: string}>(storeQuery, stores)).rows; + const storeMap = new Map(seededStores.map(s => [s.name, s.store_id])); logger.info(`Seeded ${seededStores.length} stores.`); // 4. Seed Master Grocery Items @@ -85,8 +85,8 @@ async function main() { ]; const masterItemValues = masterItems.map(item => `('${item.name.replace(/'/g, "''")}', ${categoryMap.get(item.category)})`).join(', '); const masterItemQuery = `INSERT INTO public.master_grocery_items (name, category_id) VALUES ${masterItemValues} RETURNING id, name`; - const seededMasterItems = (await client.query(masterItemQuery)).rows; - const masterItemMap = new Map(seededMasterItems.map(item => [item.name, item.id])); + const seededMasterItems = (await client.query<{master_grocery_item_id: number, name: string}>(masterItemQuery)).rows; + const masterItemMap = new Map(seededMasterItems.map(item => [item.name, item.master_grocery_item_id])); logger.info(`Seeded ${seededMasterItems.length} master grocery items.`); // 5. Seed Users & Profiles @@ -98,17 +98,17 @@ async function main() { // Admin User await client.query("SELECT set_config('my_app.user_metadata', $1, true)", [JSON.stringify({ full_name: 'Admin User', role: 'admin' })]); // The trigger will create a profile with the 'user' role. We capture the ID to update it. - const adminRes = await client.query('INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING id', ['admin@example.com', adminPassHash]); - const adminId = adminRes.rows[0].id; + const adminRes = await client.query('INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id', ['admin@example.com', adminPassHash]); + const adminId = adminRes.rows[0].user_id; // Explicitly update the role to 'admin' for the newly created user. - await client.query("UPDATE public.profiles SET role = 'admin' WHERE id = $1", [adminId]); + await client.query("UPDATE public.profiles SET role = 'admin' WHERE user_id = $1", [adminId]); logger.info('Seeded admin user (admin@example.com / adminpass)'); logger.info(`> Role for ${adminId} set to 'admin'.`); // Regular User await client.query("SELECT set_config('my_app.user_metadata', $1, true)", [JSON.stringify({ full_name: 'Test User' })]); - const userRes = await client.query('INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING id', ['user@example.com', userPassHash]); - const userId = userRes.rows[0].id; + const userRes = await client.query('INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id', ['user@example.com', userPassHash]); + const userId = userRes.rows[0].user_id; logger.info('Seeded regular user (user@example.com / userpass)'); // 6. Seed a Flyer @@ -122,10 +122,10 @@ async function main() { const flyerQuery = ` INSERT INTO public.flyers (file_name, image_url, checksum, store_id, valid_from, valid_to) VALUES ('safeway-flyer.jpg', '/sample-assets/safeway-flyer.jpg', 'sample-checksum-123', ${storeMap.get('Safeway')}, $1, $2) - RETURNING id; + RETURNING flyer_id; `; - const flyerRes = await client.query(flyerQuery, [validFrom.toISOString().split('T')[0], validTo.toISOString().split('T')[0]]); - const flyerId = flyerRes.rows[0].id; + const flyerRes = await client.query<{flyer_id: number}>(flyerQuery, [validFrom.toISOString().split('T')[0], validTo.toISOString().split('T')[0]]); + const flyerId = flyerRes.rows[0].flyer_id; logger.info(`Seeded flyer for Safeway (ID: ${flyerId}).`); // 7. Seed Flyer Items @@ -161,8 +161,8 @@ async function main() { // 9. Seed a Shopping List logger.info('--- Seeding a Shopping List... ---'); - const listRes = await client.query('INSERT INTO public.shopping_lists (user_id, name) VALUES ($1, $2) RETURNING id', [userId, 'Weekly Groceries']); - const listId = listRes.rows[0].id; + const listRes = await client.query<{shopping_list_id: number}>('INSERT INTO public.shopping_lists (user_id, name) VALUES ($1, $2) RETURNING shopping_list_id', [userId, 'Weekly Groceries']); + const listId = listRes.rows[0].shopping_list_id; const shoppingListItems = [ { master_item_id: masterItemMap.get('Milk, 2%'), quantity: 1 }, @@ -181,7 +181,7 @@ async function main() { // --- SEED SCRIPT DEBUG LOGGING --- // Corrected the query to be unambiguous by specifying the table alias for each column. // `id` and `email` come from the `users` table (u), and `role` comes from the `profiles` table (p). - const allUsersInDb = await client.query('SELECT u.id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.id = p.id'); + const allUsersInDb = await client.query('SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id'); logger.debug('[SEED SCRIPT] Final state of users table after seeding:'); console.table(allUsersInDb.rows); // --- END DEBUG LOGGING --- diff --git a/src/db/seed_admin_account.ts b/src/db/seed_admin_account.ts index 12381ca4..30b8ccf1 100644 --- a/src/db/seed_admin_account.ts +++ b/src/db/seed_admin_account.ts @@ -24,14 +24,14 @@ async function seedAdminUser() { try { // Check if the admin user already exists - const existingUserRes = await client.query('SELECT id FROM public.users WHERE email = $1', [ADMIN_EMAIL]); + const existingUserRes = await client.query('SELECT user_id FROM public.users WHERE email = $1', [ADMIN_EMAIL]); if (existingUserRes.rows.length > 0) { - const userId = existingUserRes.rows[0].id; + const userId = existingUserRes.rows[0].user_id; console.log(`Admin user '${ADMIN_EMAIL}' already exists with ID: ${userId}.`); // Ensure the user has the 'admin' role - const profileRes = await client.query("SELECT role FROM public.profiles WHERE id = $1", [userId]); + const profileRes = await client.query("SELECT role FROM public.profiles WHERE user_id = $1", [userId]); if (profileRes.rows.length === 0 || profileRes.rows[0].role !== 'admin') { await client.query("UPDATE public.profiles SET role = 'admin' WHERE id = $1", [userId]); console.log(`Updated role to 'admin' for user ${userId}.`); @@ -48,16 +48,16 @@ async function seedAdminUser() { // Insert into the users table. The `handle_new_user` trigger will create the profile. const newUserRes = await client.query( - 'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING id', + 'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id', [ADMIN_EMAIL, hashedPassword] ); - const newUserId = newUserRes.rows[0].id; + const newUserId = newUserRes.rows[0].user_id; console.log(`Successfully created user with ID: ${newUserId}.`); // The trigger creates a profile with the 'user' role. We now update it to 'admin'. await client.query( - "UPDATE public.profiles SET role = 'admin' WHERE id = $1", + "UPDATE public.profiles SET role = 'admin' WHERE user_id = $1", [newUserId] ); diff --git a/src/routes/ai.ts b/src/routes/ai.ts index c0a143b2..57321a03 100644 --- a/src/routes/ai.ts +++ b/src/routes/ai.ts @@ -47,7 +47,7 @@ router.post('/process-flyer', optionalAuth, upload.array('flyerImages'), async ( logger.debug(`[API /ai/process-flyer] Processing image paths:`, { imagePaths }); const user = req.user as UserProfile | undefined; - const logIdentifier = user ? `user ID: ${user.id}` : 'anonymous user'; + const logIdentifier = user ? `user ID: ${user.user_id}` : 'anonymous user'; logger.info(`Starting AI flyer data extraction for ${imagePaths.length} image(s) for ${logIdentifier}.`); @@ -87,12 +87,12 @@ router.post('/flyers/process', optionalAuth, upload.single('flyerImage'), async // Find or create the store to get its ID, which is required by `createFlyerAndItems`. let storeId: number; - const storeRes = await db.getPool().query<{ id: number }>('SELECT id FROM public.stores WHERE name = $1', [extractedData.store_name]); + const storeRes = await db.getPool().query<{ store_id: number }>('SELECT store_id FROM public.stores WHERE name = $1', [extractedData.store_name]); if (storeRes.rows.length > 0) { - storeId = storeRes.rows[0].id; + storeId = storeRes.rows[0].store_id; } else { - const newStoreRes = await db.getPool().query<{ id: number }>('INSERT INTO public.stores (name) VALUES ($1) RETURNING id', [extractedData.store_name]); - storeId = newStoreRes.rows[0].id; + const newStoreRes = await db.getPool().query<{ store_id: number }>('INSERT INTO public.stores (name) VALUES ($1) RETURNING store_id', [extractedData.store_name]); + storeId = newStoreRes.rows[0].store_id; } // 2. Prepare flyer data for insertion @@ -105,20 +105,20 @@ router.post('/flyers/process', optionalAuth, upload.single('flyerImage'), async valid_from: extractedData.valid_from, valid_to: extractedData.valid_to, store_address: extractedData.store_address, - uploaded_by: user?.id, // Associate with user if logged in + uploaded_by: user?.user_id, // Associate with user if logged in }; // 3. Create flyer and its items in a transaction const newFlyer = await db.createFlyerAndItems(flyerData, extractedData.items); - logger.info(`Successfully processed and saved new flyer: ${newFlyer.file_name} (ID: ${newFlyer.id})`); + logger.info(`Successfully processed and saved new flyer: ${newFlyer.file_name} (ID: ${newFlyer.flyer_id})`); // Log this significant event await db.logActivity({ - userId: user?.id, + userId: user?.user_id, action: 'flyer_processed', displayText: `Processed a new flyer for ${flyerData.store_name}.`, - details: { flyerId: newFlyer.id, storeName: flyerData.store_name } + details: { flyerId: newFlyer.flyer_id, storeName: flyerData.store_name } }); res.status(201).json({ message: 'Flyer processed and saved successfully.', flyer: newFlyer }); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index ca17ef5c..75f69f8f 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -63,21 +63,21 @@ router.post('/register', async (req: Request, res: Response, next: NextFunction) logger.info(`Hashing password for new user: ${email}`); const newUser = await db.createUser(email, hashedPassword, { full_name, avatar_url }); - logger.info(`Successfully created new user in DB: ${newUser.email} (ID: ${newUser.id})`); + logger.info(`Successfully created new user in DB: ${newUser.email} (ID: ${newUser.user_id})`); // Use the new standardized logging function await db.logActivity({ - userId: newUser.id, + userId: newUser.user_id, action: 'user_registered', displayText: `${newUser.email} has registered.`, icon: 'user-plus', }); - const payload = { id: newUser.id, email: newUser.email }; + const payload = { user_id: newUser.user_id, email: newUser.email }; const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' }); const refreshToken = crypto.randomBytes(64).toString('hex'); - await db.saveRefreshToken(newUser.id, refreshToken); + await db.saveRefreshToken(newUser.user_id, refreshToken); res.cookie('refreshToken', refreshToken, { httpOnly: true, @@ -102,7 +102,7 @@ router.post('/login', (req: Request, res: Response, next: NextFunction) => { if (user) logger.info('[API /login] Passport reported USER FOUND.', { user }); try { - const allUsersInDb = await db.getPool().query('SELECT u.id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.id = p.id'); + const allUsersInDb = await getPool().query('SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id'); logger.debug('[API /login] Current users in DB from SERVER perspective:'); console.table(allUsersInDb.rows); } catch (dbError) { @@ -118,12 +118,12 @@ router.post('/login', (req: Request, res: Response, next: NextFunction) => { return res.status(401).json({ message: info.message || 'Login failed' }); } - const typedUser = user as { id: string; email: string }; - const payload = { id: typedUser.id, email: typedUser.email }; + const typedUser = user as { user_id: string; email: string }; + const payload = { user_id: typedUser.user_id, email: typedUser.email }; const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' }); const refreshToken = crypto.randomBytes(64).toString('hex'); - db.saveRefreshToken(typedUser.id, refreshToken).then(() => { + db.saveRefreshToken(typedUser.user_id, refreshToken).then(() => { logger.info(`JWT and refresh token issued for user: ${typedUser.email}`); const cookieOptions = { @@ -133,7 +133,10 @@ router.post('/login', (req: Request, res: Response, next: NextFunction) => { }; res.cookie('refreshToken', refreshToken, cookieOptions); - return res.json({ user: payload, token: accessToken }); + // The user object in the response should match the User type. + const userResponse = { user_id: typedUser.user_id, email: typedUser.email }; + + return res.json({ user: userResponse, token: accessToken }); }).catch(tokenErr => { logger.error('Failed to save refresh token during login:', { error: tokenErr }); return next(tokenErr); @@ -152,7 +155,7 @@ router.post('/forgot-password', forgotPasswordLimiter, async (req: Request, res: logger.debug(`[API /forgot-password] Received request for email: ${email}`); const user = await db.findUserByEmail(email); let token: string | undefined; - logger.debug(`[API /forgot-password] Database search result for ${email}:`, { user: user ? { id: user.id, email: user.email } : 'NOT FOUND' }); + logger.debug(`[API /forgot-password] Database search result for ${email}:`, { user: user ? { user_id: user.user_id, email: user.email } : 'NOT FOUND' }); if (user) { token = crypto.randomBytes(32).toString('hex'); @@ -160,7 +163,7 @@ router.post('/forgot-password', forgotPasswordLimiter, async (req: Request, res: const tokenHash = await bcrypt.hash(token, saltRounds); const expiresAt = new Date(Date.now() + 3600000); // 1 hour - await db.createPasswordResetToken(user.id, tokenHash, expiresAt); + await db.createPasswordResetToken(user.user_id, tokenHash, expiresAt); const resetLink = `${process.env.FRONTEND_URL || 'http://localhost:5173'}/reset-password/${token}`; @@ -247,7 +250,7 @@ router.post('/refresh-token', async (req: Request, res: Response) => { return res.status(403).json({ message: 'Invalid refresh token.' }); } - const payload = { id: user.id, email: user.email }; + const payload = { user_id: user.user_id, email: user.email }; const newAccessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' }); res.json({ token: newAccessToken }); @@ -256,8 +259,8 @@ router.post('/refresh-token', async (req: Request, res: Response) => { // --- OAuth Routes --- // const handleOAuthCallback = (req: Request, res: Response) => { -// const user = req.user as { id: string; email: string }; -// const payload = { id: user.id, email: user.email }; +// const user = req.user as { user_id: string; email: string }; +// const payload = { user_id: user.user_id, email: user.email }; // const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' }); // const refreshToken = crypto.randomBytes(64).toString('hex'); diff --git a/src/routes/passport.ts b/src/routes/passport.ts index 4876e12b..a8e1343a 100644 --- a/src/routes/passport.ts +++ b/src/routes/passport.ts @@ -62,11 +62,11 @@ passport.use(new LocalStrategy( logger.warn(`Login attempt failed for user ${email} due to incorrect password.`); // Increment failed attempts - await db.incrementFailedLoginAttempts(user.id); + await db.incrementFailedLoginAttempts(user.user_id); // Log this security event. await db.logActivity({ - userId: user.id, + userId: user.user_id, action: 'login_failed_password', displayText: `Failed login attempt for user ${user.email}.`, icon: 'shield-alert', @@ -77,7 +77,7 @@ passport.use(new LocalStrategy( // 3. Success! Return the user object (without password_hash for security). // Reset failed login attempts upon successful login. - await db.resetFailedLoginAttempts(user.id); + await db.resetFailedLoginAttempts(user.user_id); // The password_hash is intentionally destructured and discarded for security. // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -206,12 +206,12 @@ const jwtOptions = { passport.use(new JwtStrategy(jwtOptions, async (jwt_payload, done) => { logger.debug('[JWT Strategy] Verifying token payload:', { jwt_payload }); try { - // The jwt_payload contains the data you put into the token during login (e.g., { id: user.id, email: user.email }). + // The jwt_payload contains the data you put into the token during login (e.g., { user_id: user.user_id, email: user.email }). // We re-fetch the user from the database here to ensure they are still active and valid. - const userProfile = await db.findUserProfileById(jwt_payload.id); + const userProfile = await db.findUserProfileById(jwt_payload.user_id); // --- JWT STRATEGY DEBUG LOGGING --- - logger.debug(`[JWT Strategy] DB lookup for user ID ${jwt_payload.id} result: ${userProfile ? 'FOUND' : 'NOT FOUND'}`); + logger.debug(`[JWT Strategy] DB lookup for user ID ${jwt_payload.user_id} result: ${userProfile ? 'FOUND' : 'NOT FOUND'}`); if (userProfile) { return done(null, userProfile); // User profile object will be available as req.user in protected routes @@ -233,7 +233,7 @@ export const isAdmin = (req: Request, res: Response, next: NextFunction) => { if (userProfile && userProfile.role === 'admin') { next(); } else { - logger.warn(`Admin access denied for user: ${userProfile?.id}`); + logger.warn(`Admin access denied for user: ${userProfile?.user_id}`); res.status(403).json({ message: 'Forbidden: Administrator access required.' }); } }; diff --git a/src/routes/user.integration.test.ts b/src/routes/user.integration.test.ts index 8daa2aa7..65eb7a21 100644 --- a/src/routes/user.integration.test.ts +++ b/src/routes/user.integration.test.ts @@ -32,7 +32,7 @@ describe('User API Routes Integration Tests', () => { // --- START DEBUG LOGGING --- // Query the DB from within the test file to see its state. beforeAll(async () => { - const res = await getPool().query('SELECT u.id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.id = p.id'); + const res = await getPool().query('SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id'); console.log('\n--- [user.integration.test.ts] Users found in DB from TEST perspective (beforeAll): ---'); console.table(res.rows); console.log('-------------------------------------------------------------------------------------\n'); @@ -50,7 +50,7 @@ describe('User API Routes Integration Tests', () => { // After all tests, clean up by deleting the created user. afterAll(async () => { if (testUser) { - logger.debug(`[user.integration.test.ts afterAll] Cleaning up user ID: ${testUser.id}`); + logger.debug(`[user.integration.test.ts afterAll] Cleaning up user ID: ${testUser.user_id}`); // This requires an authenticated call to delete the account. await apiClient.deleteUserAccount(TEST_PASSWORD, authToken); } @@ -62,7 +62,7 @@ describe('User API Routes Integration Tests', () => { // Assert: Verify the profile data matches the created user. expect(profile).toBeDefined(); - expect(profile.id).toBe(testUser.id); + expect(profile.user_id).toBe(testUser.user_id); expect(profile.user.email).toBe(testUser.email); expect(profile.full_name).toBe('Test User'); expect(profile.role).toBe('user'); @@ -155,6 +155,6 @@ describe('User API Routes Integration Tests', () => { // Act 3 & Assert 3 (Verification): Log in with the NEW password to confirm the change. const loginResponse = await apiClient.loginUser(resetEmail, newPassword, false); expect(loginResponse.user).toBeDefined(); - expect(loginResponse.user.id).toBe(resetUser.id); + expect(loginResponse.user.user_id).toBe(resetUser.user_id); }); }); \ No newline at end of file diff --git a/src/routes/user.ts b/src/routes/user.ts index f93f4d30..59eb4b3a 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -9,7 +9,7 @@ import * as bcrypt from 'bcrypt'; import * as db from '../services/db'; import * as aiService from '../services/aiService.server'; import { logger } from '../services/logger'; -import { ShoppingListItem } from '../types'; +import { ReceiptItem, ShoppingListItem } from '../types'; const router = Router(); @@ -36,16 +36,16 @@ router.use((req, res, next) => { }); router.get('/profile', async (req: Request, res: Response) => { - const authenticatedUser = req.user as { id: string; email: string }; + const authenticatedUser = req.user as { user_id: string; email: string }; logger.info(`Profile requested for user: ${authenticatedUser.email}`); try { // Fetch both profile and user data to construct the full UserProfile object. - const profileData = await db.findUserProfileById(authenticatedUser.id); - const userData = await db.findUserById(authenticatedUser.id); + const profileData = await db.findUserProfileById(authenticatedUser.user_id); + const userData = await db.findUserById(authenticatedUser.user_id); if (!profileData || !userData) { - logger.warn(`No profile or user found for authenticated user ID: ${authenticatedUser.id}`); + logger.warn(`No profile or user found for authenticated user ID: ${authenticatedUser.user_id}`); return res.status(404).json({ message: 'Profile not found for this user.' }); } @@ -59,7 +59,7 @@ router.get('/profile', async (req: Request, res: Response) => { }); router.put('/profile', async (req: Request, res: Response) => { - const authenticatedUser = req.user as { id: string; email: string }; + const authenticatedUser = req.user as { user_id: string; email: string }; const { full_name, avatar_url } = req.body; if (typeof full_name === 'undefined' && typeof avatar_url === 'undefined') { @@ -69,7 +69,7 @@ router.put('/profile', async (req: Request, res: Response) => { logger.info(`Profile update requested for user: ${authenticatedUser.email}`, { fullName: full_name, avatarUrl: avatar_url }); try { - const updatedProfile = await db.updateUserProfile(authenticatedUser.id, { full_name, avatar_url }); + const updatedProfile = await db.updateUserProfile(authenticatedUser.user_id, { full_name, avatar_url }); res.json(updatedProfile); } catch (error) { logger.error('Error updating profile in /api/users/profile:', { error }); @@ -78,11 +78,11 @@ router.put('/profile', async (req: Request, res: Response) => { }); router.get('/data-export', async (req: Request, res: Response) => { - const authenticatedUser = req.user as { id: string; email: string }; + const authenticatedUser = req.user as { user_id: string; email: string }; logger.info(`Data export requested for user: ${authenticatedUser.email}`); try { - const userData = await db.exportUserData(authenticatedUser.id); + const userData = await db.exportUserData(authenticatedUser.user_id); const date = new Date().toISOString().split('T')[0]; res.setHeader('Content-Disposition', `attachment; filename="flyer-crawler-data-export-${date}.json"`); @@ -95,7 +95,7 @@ router.get('/data-export', async (req: Request, res: Response) => { }); router.put('/profile/preferences', async (req: Request, res: Response) => { - const authenticatedUser = req.user as { id: string; email: string }; + const authenticatedUser = req.user as { user_id: string; email: string }; const newPreferences = req.body; if (!newPreferences || typeof newPreferences !== 'object') { @@ -106,9 +106,9 @@ router.put('/profile/preferences', async (req: Request, res: Response) => { try { // Fetch the full user profile after updating preferences to ensure the response is complete. - await db.updateUserPreferences(authenticatedUser.id, newPreferences); - const profileData = await db.findUserProfileById(authenticatedUser.id); - const userData = await db.findUserById(authenticatedUser.id); + await db.updateUserPreferences(authenticatedUser.user_id, newPreferences); + const profileData = await db.findUserProfileById(authenticatedUser.user_id); + const userData = await db.findUserById(authenticatedUser.user_id); if (!profileData || !userData) { return res.status(404).json({ message: 'Profile not found after update.' }); } @@ -121,7 +121,7 @@ router.put('/profile/preferences', async (req: Request, res: Response) => { }); router.put('/profile/password', async (req: Request, res: Response, next: NextFunction) => { - const authenticatedUser = req.user as { id: string; email: string }; + const authenticatedUser = req.user as { user_id: string; email: string }; const { newPassword } = req.body; if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 6) { @@ -131,7 +131,7 @@ router.put('/profile/password', async (req: Request, res: Response, next: NextFu const MIN_PASSWORD_SCORE = 3; const strength = zxcvbn(newPassword); if (strength.score < MIN_PASSWORD_SCORE) { - logger.warn(`Weak password update rejected for user: ${authenticatedUser.email}. Score: ${strength.score}`, { userId: authenticatedUser.id }); + logger.warn(`Weak password update rejected for user: ${authenticatedUser.email}. Score: ${strength.score}`, { userId: authenticatedUser.user_id }); const feedback = strength.feedback.warning || (strength.feedback.suggestions && strength.feedback.suggestions[0]); return res.status(400).json({ message: `New password is too weak. ${feedback || 'Please choose a stronger password.'}`.trim() }); } @@ -139,13 +139,13 @@ router.put('/profile/password', async (req: Request, res: Response, next: NextFu try { const saltRounds = 10; const hashedPassword = await bcrypt.hash(newPassword, saltRounds); - logger.info(`Hashing new, validated password for user: ${authenticatedUser.email}`, { userId: authenticatedUser.id }); + logger.info(`Hashing new, validated password for user: ${authenticatedUser.email}`, { userId: authenticatedUser.user_id }); - await db.updateUserPassword(authenticatedUser.id, hashedPassword); + await db.updateUserPassword(authenticatedUser.user_id, hashedPassword); logger.info(`Successfully updated password for user: ${authenticatedUser.email}`); res.status(200).json({ message: 'Password updated successfully.' }); } catch (error) { - logger.error('Error during password update:', { error, userId: authenticatedUser.id }); + logger.error('Error during password update:', { error, userId: authenticatedUser.user_id }); next(error); } }); @@ -153,7 +153,7 @@ router.put('/profile/password', async (req: Request, res: Response, next: NextFu router.delete('/account', async (req: Request, res: Response) => { // The `req.user` object from the JWT strategy is the user's profile. // It reliably contains the user's ID. - const authenticatedUser = req.user as { id: string; email: string }; + const authenticatedUser = req.user as { user_id: string; email: string }; const { password } = req.body; // --- DELETE ACCOUNT DEBUG LOGGING --- @@ -164,13 +164,13 @@ router.delete('/account', async (req: Request, res: Response) => { return res.status(400).json({ message: 'Password is required for account deletion.' }); } - logger.info(`[API /users/account] Account deletion requested for user ID: ${authenticatedUser.id}`); + logger.info(`[API /users/account] Account deletion requested for user ID: ${authenticatedUser.user_id}`); try { - const userWithHash = await db.findUserWithPasswordHashById(authenticatedUser.id); + const userWithHash = await db.findUserWithPasswordHashById(authenticatedUser.user_id); // --- DELETE ACCOUNT DEBUG LOGGING --- - logger.debug(`[API /users/account] DB search result for user ID ${authenticatedUser.id}:`, { user: userWithHash ? 'FOUND' : 'NOT FOUND' }); + logger.debug(`[API /users/account] DB search result for user ID ${authenticatedUser.user_id}:`, { user: userWithHash ? 'FOUND' : 'NOT FOUND' }); // --- END DEBUG LOGGING --- if (!userWithHash || !userWithHash.password_hash) { @@ -183,7 +183,7 @@ router.delete('/account', async (req: Request, res: Response) => { return res.status(403).json({ message: 'Incorrect password.' }); } - await db.deleteUserById(authenticatedUser.id); + await db.deleteUserById(authenticatedUser.user_id); logger.warn(`User account deleted successfully: ${authenticatedUser.email}`); res.status(200).json({ message: 'Account deleted successfully.' }); } catch (error) { @@ -195,9 +195,9 @@ router.delete('/account', async (req: Request, res: Response) => { // --- Watched Items Routes --- router.get('/watched-items', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; try { - const items = await db.getWatchedItems(user.id); + const items = await db.getWatchedItems(user.user_id); res.json(items); } catch (error) { next(error); @@ -205,10 +205,10 @@ router.get('/watched-items', async (req: Request, res: Response, next: NextFunct }); router.post('/watched-items', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; const { itemName, category } = req.body; try { - const newItem = await db.addWatchedItem(user.id, itemName, category); + const newItem = await db.addWatchedItem(user.user_id, itemName, category); res.status(201).json(newItem); } catch (error) { next(error); @@ -216,10 +216,10 @@ router.post('/watched-items', async (req: Request, res: Response, next: NextFunc }); router.delete('/watched-items/:masterItemId', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; const { masterItemId } = req.params; try { - await db.removeWatchedItem(user.id, parseInt(masterItemId, 10)); + await db.removeWatchedItem(user.user_id, parseInt(masterItemId, 10)); res.status(204).send(); } catch (error) { next(error); @@ -229,9 +229,9 @@ router.delete('/watched-items/:masterItemId', async (req: Request, res: Response // --- Shopping List Routes --- router.get('/shopping-lists', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; try { - const lists = await db.getShoppingLists(user.id); + const lists = await db.getShoppingLists(user.user_id); res.json(lists); } catch (error) { next(error); @@ -239,10 +239,10 @@ router.get('/shopping-lists', async (req: Request, res: Response, next: NextFunc }); router.post('/shopping-lists', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; const { name } = req.body; try { - const newList = await db.createShoppingList(user.id, name); + const newList = await db.createShoppingList(user.user_id, name); res.status(201).json(newList); } catch (error) { next(error); @@ -250,10 +250,10 @@ router.post('/shopping-lists', async (req: Request, res: Response, next: NextFun }); router.delete('/shopping-lists/:listId', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; const { listId } = req.params; try { - await db.deleteShoppingList(parseInt(listId, 10), user.id); + await db.deleteShoppingList(parseInt(listId, 10), user.user_id); res.status(204).send(); } catch (error) { next(error); @@ -290,12 +290,12 @@ router.delete('/shopping-lists/items/:itemId', async (req: Request, res: Respons }); router.post('/shopping-lists/:id/complete', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; const shoppingListId = parseInt(req.params.id, 10); const { totalSpentCents } = req.body; try { - const newTripId = await db.completeShoppingList(shoppingListId, user.id, totalSpentCents); + const newTripId = await db.completeShoppingList(shoppingListId, user.user_id, totalSpentCents); res.status(201).json({ message: 'Shopping list completed and archived.', newTripId }); } catch (error) { next(error); @@ -304,8 +304,11 @@ router.post('/shopping-lists/:id/complete', async (req: Request, res: Response, // --- Receipt Processing Routes --- +// Type for creating a new receipt item, excluding DB-generated fields. +type NewReceiptItem = Omit; + router.post('/receipts/upload', upload.single('receiptImage'), async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; if (!req.file) { return res.status(400).json({ message: 'Receipt image file is required.' }); @@ -314,14 +317,23 @@ router.post('/receipts/upload', upload.single('receiptImage'), async (req: Reque let newReceipt; try { const receiptImageUrl = `/assets/${req.file.filename}`; - newReceipt = await db.createReceipt(user.id, receiptImageUrl); + newReceipt = await db.createReceipt(user.user_id, receiptImageUrl); try { logger.info(`Starting AI receipt processing for receipt ID: ${newReceipt.id}`); - const extractedItems = await aiService.extractItemsFromReceiptImage(req.file.path, req.file.mimetype); + // The AI service might not return quantity, so we ensure it's added. + const extractedItemsFromAI: { raw_item_description: string; price_paid_cents: number; quantity?: number }[] = + await aiService.extractItemsFromReceiptImage(req.file.path, req.file.mimetype); - await db.processReceiptItems(newReceipt.id, extractedItems); - logger.info(`Completed AI receipt processing for receipt ID: ${newReceipt.id}. Found ${extractedItems.length} items.`); + const itemsToProcess: NewReceiptItem[] = extractedItemsFromAI.map(item => ({ + receipt_item_id: -1, // Placeholder to satisfy type, will be generated by DB + raw_item_description: item.raw_item_description, + price_paid_cents: item.price_paid_cents, + quantity: item.quantity || 1, // Default quantity to 1 if not provided + })); + + await db.processReceiptItems(newReceipt.id, itemsToProcess); + logger.info(`Completed AI receipt processing for receipt ID: ${newReceipt.id}. Found ${itemsToProcess.length} items.`); } catch (processingError) { logger.error(`Receipt processing failed for receipt ID: ${newReceipt.id}.`, { error: processingError }); @@ -339,12 +351,12 @@ router.post('/receipts/upload', upload.single('receiptImage'), async (req: Reque }); router.get('/receipts/:id/deals', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; const receiptId = parseInt(req.params.id, 10); try { const owner = await db.findReceiptOwner(receiptId); - if (!owner || owner.user_id !== user.id) { - logger.warn(`User ${user.id} attempted to access unauthorized receipt ${receiptId}.`); + if (!owner || owner.user_id !== user.user_id) { + logger.warn(`User ${user.user_id} attempted to access unauthorized receipt ${receiptId}.`); return res.status(403).json({ message: 'Forbidden: You do not have access to this receipt.' }); } @@ -357,10 +369,10 @@ router.get('/receipts/:id/deals', async (req: Request, res: Response, next: Next // --- Personalization Routes --- -router.get('/users/pantry-recipes', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; +router.get('/pantry-recipes', async (req: Request, res: Response, next: NextFunction) => { + const user = req.user as { user_id: string }; try { - const recipes = await db.findRecipesFromPantry(user.id); + const recipes = await db.findRecipesFromPantry(user.user_id); res.json(recipes); } catch (error) { next(error); @@ -368,10 +380,10 @@ router.get('/users/pantry-recipes', async (req: Request, res: Response, next: Ne }); router.get('/users/recommended-recipes', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; const limit = parseInt(req.query.limit as string || '10', 10); try { - const recipes = await db.recommendRecipesForUser(user.id, limit); + const recipes = await db.recommendRecipesForUser(user.user_id, limit); res.json(recipes); } catch (error) { next(error); @@ -379,9 +391,9 @@ router.get('/users/recommended-recipes', async (req: Request, res: Response, nex }); router.get('/users/best-sale-prices', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; try { - const deals = await db.getBestSalePricesForUser(user.id); + const deals = await db.getBestSalePricesForUser(user.user_id); res.json(deals); } catch (error) { next(error); @@ -389,11 +401,11 @@ router.get('/users/best-sale-prices', async (req: Request, res: Response, next: }); router.get('/pantry-items/:id/conversions', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; const pantryItemId = parseInt(req.params.id, 10); try { const owner = await db.findPantryItemOwner(pantryItemId); - if (!owner || owner.user_id !== user.id) { + if (!owner || owner.user_id !== user.user_id) { return res.status(403).json({ message: 'Forbidden: You do not have access to this item.' }); } const conversions = await db.suggestPantryItemConversions(pantryItemId); @@ -404,10 +416,10 @@ router.get('/pantry-items/:id/conversions', async (req: Request, res: Response, }); router.get('/menu-plans/:id/shopping-list', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; const menuPlanId = parseInt(req.params.id, 10); try { - const shoppingListItems = await db.generateShoppingListForMenuPlan(menuPlanId, user.id); + const shoppingListItems = await db.generateShoppingListForMenuPlan(menuPlanId, user.user_id); res.json(shoppingListItems); } catch (error) { next(error); @@ -415,11 +427,11 @@ router.get('/menu-plans/:id/shopping-list', async (req: Request, res: Response, }); router.post('/shopping-lists/:listId/add-from-menu-plan', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; const shoppingListId = parseInt(req.params.listId, 10); const { menuPlanId } = req.body; try { - const addedItems = await db.addMenuPlanToShoppingList(menuPlanId, shoppingListId, user.id); + const addedItems = await db.addMenuPlanToShoppingList(menuPlanId, shoppingListId, user.user_id); res.status(201).json(addedItems); } catch (error) { next(error); @@ -427,9 +439,9 @@ router.post('/shopping-lists/:listId/add-from-menu-plan', async (req: Request, r }); router.get('/users/me/dietary-restrictions', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; try { - const restrictions = await db.getUserDietaryRestrictions(user.id); + const restrictions = await db.getUserDietaryRestrictions(user.user_id); res.json(restrictions); } catch (error) { next(error); @@ -437,10 +449,10 @@ router.get('/users/me/dietary-restrictions', async (req: Request, res: Response, }); router.put('/users/me/dietary-restrictions', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; const { restrictionIds } = req.body; try { - await db.setUserDietaryRestrictions(user.id, restrictionIds); + await db.setUserDietaryRestrictions(user.user_id, restrictionIds); res.status(204).send(); } catch (error) { next(error); @@ -448,9 +460,9 @@ router.put('/users/me/dietary-restrictions', async (req: Request, res: Response, }); router.get('/users/me/appliances', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; try { - const appliances = await db.getUserAppliances(user.id); + const appliances = await db.getUserAppliances(user.user_id); res.json(appliances); } catch (error) { next(error); @@ -458,10 +470,10 @@ router.get('/users/me/appliances', async (req: Request, res: Response, next: Nex }); router.put('/users/me/appliances', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; const { applianceIds } = req.body; try { - await db.setUserAppliances(user.id, applianceIds); + await db.setUserAppliances(user.user_id, applianceIds); res.status(204).send(); } catch (error) { next(error); @@ -469,9 +481,9 @@ router.put('/users/me/appliances', async (req: Request, res: Response, next: Nex }); router.get('/users/me/compatible-recipes', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; try { - const recipes = await db.getRecipesForUserDiets(user.id); + const recipes = await db.getRecipesForUserDiets(user.user_id); res.json(recipes); } catch (error) { next(error); @@ -479,11 +491,11 @@ router.get('/users/me/compatible-recipes', async (req: Request, res: Response, n }); router.get('/users/me/feed', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; const limit = parseInt(req.query.limit as string, 10) || 20; const offset = parseInt(req.query.offset as string, 10) || 0; try { - const feedItems = await db.getUserFeed(user.id, limit, offset); + const feedItems = await db.getUserFeed(user.user_id, limit, offset); res.json(feedItems); } catch (error) { next(error); @@ -491,10 +503,10 @@ router.get('/users/me/feed', async (req: Request, res: Response, next: NextFunct }); router.post('/recipes/:id/fork', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; const originalRecipeId = parseInt(req.params.id, 10); try { - const forkedRecipe = await db.forkRecipe(user.id, originalRecipeId); + const forkedRecipe = await db.forkRecipe(user.user_id, originalRecipeId); res.status(201).json(forkedRecipe); } catch (error) { next(error); @@ -502,9 +514,9 @@ router.post('/recipes/:id/fork', async (req: Request, res: Response, next: NextF }); router.get('/users/favorite-recipes', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; try { - const recipes = await db.getUserFavoriteRecipes(user.id); + const recipes = await db.getUserFavoriteRecipes(user.user_id); res.json(recipes); } catch (error) { next(error); @@ -512,10 +524,10 @@ router.get('/users/favorite-recipes', async (req: Request, res: Response, next: }); router.post('/users/favorite-recipes', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; const { recipeId } = req.body; try { - const favorite = await db.addFavoriteRecipe(user.id, recipeId); + const favorite = await db.addFavoriteRecipe(user.user_id, recipeId); res.status(201).json(favorite); } catch (error) { next(error); @@ -523,10 +535,10 @@ router.post('/users/favorite-recipes', async (req: Request, res: Response, next: }); router.delete('/users/favorite-recipes/:recipeId', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; const recipeId = parseInt(req.params.recipeId, 10); try { - await db.removeFavoriteRecipe(user.id, recipeId); + await db.removeFavoriteRecipe(user.user_id, recipeId); res.status(204).send(); } catch (error) { next(error); @@ -534,10 +546,10 @@ router.delete('/users/favorite-recipes/:recipeId', async (req: Request, res: Res }); router.post('/users/:id/follow', async (req: Request, res: Response, next: NextFunction) => { - const follower = req.user as { id: string }; + const follower = req.user as { user_id: string }; const followingId = req.params.id; try { - await db.followUser(follower.id, followingId); + await db.followUser(follower.user_id, followingId); res.status(204).send(); } catch (error) { next(error); @@ -545,10 +557,10 @@ router.post('/users/:id/follow', async (req: Request, res: Response, next: NextF }); router.delete('/users/:id/follow', async (req: Request, res: Response, next: NextFunction) => { - const follower = req.user as { id: string }; + const follower = req.user as { user_id: string }; const followingId = req.params.id; try { - await db.unfollowUser(follower.id, followingId); + await db.unfollowUser(follower.user_id, followingId); res.status(204).send(); } catch (error) { next(error); @@ -585,14 +597,14 @@ router.post('/price-history', async (req: Request, res: Response, next: NextFunc }); router.post('/recipes/:recipeId/comments', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; const recipeId = parseInt(req.params.recipeId, 10); const { content, parentCommentId } = req.body; if (!content) { return res.status(400).json({ message: 'Comment content is required.' }); } try { - const newComment = await db.addRecipeComment(recipeId, user.id, content, parentCommentId); + const newComment = await db.addRecipeComment(recipeId, user.user_id, content, parentCommentId); res.status(201).json(newComment); } catch (error) { next(error); @@ -600,13 +612,13 @@ router.post('/recipes/:recipeId/comments', async (req: Request, res: Response, n }); router.post('/pantry/locations', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; const { name } = req.body; if (!name) { return res.status(400).json({ message: 'Location name is required.' }); } try { - const newLocation = await db.createPantryLocation(user.id, name); + const newLocation = await db.createPantryLocation(user.user_id, name); res.status(201).json(newLocation); } catch (error) { next(error); @@ -614,10 +626,10 @@ router.post('/pantry/locations', async (req: Request, res: Response, next: NextF }); router.post('/search/log', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; const { queryText, resultCount, wasSuccessful } = req.body; try { - db.logSearchQuery({ userId: user.id, queryText, resultCount, wasSuccessful }); + db.logSearchQuery({ userId: user.user_id, queryText, resultCount, wasSuccessful }); res.status(202).send(); } catch (error) { next(error); @@ -625,9 +637,9 @@ router.post('/search/log', async (req: Request, res: Response, next: NextFunctio }); router.get('/shopping-history', async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; + const user = req.user as { user_id: string }; try { - const history = await db.getShoppingTripHistory(user.id); + const history = await db.getShoppingTripHistory(user.user_id); res.json(history); } catch (error) { next(error); diff --git a/src/services/db.integration.test.ts b/src/services/db.integration.test.ts index 42bbf3de..78fce26a 100644 --- a/src/services/db.integration.test.ts +++ b/src/services/db.integration.test.ts @@ -17,18 +17,18 @@ describe('Database Service Integration Tests', () => { // Assert: Check that the user was created with the correct details expect(createdUser).toBeDefined(); expect(createdUser.email).toBe(email); - expect(createdUser.id).toBeTypeOf('string'); + expect(createdUser.user_id).toBeTypeOf('string'); // Act: Try to find the user we just created const foundUser = await db.findUserByEmail(email); // Assert: Check that the found user matches the created user expect(foundUser).toBeDefined(); - expect(foundUser?.id).toBe(createdUser.id); + expect(foundUser?.user_id).toBe(createdUser.user_id); expect(foundUser?.email).toBe(email); // Also, verify the profile was created by the trigger - const profile = await db.findUserProfileById(createdUser.id); + const profile = await db.findUserProfileById(createdUser.user_id); expect(profile).toBeDefined(); expect(profile?.full_name).toBe(fullName); }); diff --git a/src/services/db/admin.ts b/src/services/db/admin.ts index 6e99ede1..c57517ec 100644 --- a/src/services/db/admin.ts +++ b/src/services/db/admin.ts @@ -12,7 +12,7 @@ export async function getSuggestedCorrections(): Promise try { const query = ` SELECT - sc.id, + sc.suggested_correction_id, sc.flyer_item_id, sc.user_id, sc.correction_type, @@ -23,8 +23,8 @@ export async function getSuggestedCorrections(): Promise fi.price_display as flyer_item_price_display, u.email as user_email FROM public.suggested_corrections sc - JOIN public.flyer_items fi ON sc.flyer_item_id = fi.id - LEFT JOIN public.users u ON sc.user_id = u.id + JOIN public.flyer_items fi ON sc.flyer_item_id = fi.flyer_item_id + LEFT JOIN public.users u ON sc.user_id = u.user_id WHERE sc.status = 'pending' ORDER BY sc.created_at ASC; `; @@ -63,7 +63,7 @@ export async function approveCorrection(correctionId: number): Promise { export async function rejectCorrection(correctionId: number): Promise { try { const res = await getPool().query( - "UPDATE public.suggested_corrections SET status = 'rejected' WHERE id = $1 AND status = 'pending' RETURNING id", + "UPDATE public.suggested_corrections SET status = 'rejected' WHERE suggested_correction_id = $1 AND status = 'pending' RETURNING suggested_correction_id", [correctionId] ); if (res.rowCount === 0) { @@ -89,7 +89,7 @@ export async function rejectCorrection(correctionId: number): Promise { export async function updateSuggestedCorrection(correctionId: number, newSuggestedValue: string): Promise { try { const res = await getPool().query( - "UPDATE public.suggested_corrections SET suggested_value = $1 WHERE id = $2 AND status = 'pending' RETURNING *", + "UPDATE public.suggested_corrections SET suggested_value = $1 WHERE suggested_correction_id = $2 AND status = 'pending' RETURNING *", [newSuggestedValue, correctionId] ); if (res.rowCount === 0) { @@ -214,7 +214,7 @@ export async function getMostFrequentSaleItems(days: number, limit: number): Pro export async function updateRecipeCommentStatus(commentId: number, status: 'visible' | 'hidden' | 'reported'): Promise { try { const res = await getPool().query( - 'UPDATE public.recipe_comments SET status = $1 WHERE id = $2 RETURNING *', + 'UPDATE public.recipe_comments SET status = $1 WHERE recipe_comment_id = $2 RETURNING *', [status, commentId] ); if (res.rowCount === 0) { @@ -235,18 +235,18 @@ export async function getUnmatchedFlyerItems(): Promise { try { const query = ` SELECT - ufi.id, + ufi.unmatched_flyer_item_id, ufi.status, ufi.created_at, - fi.id as flyer_item_id, + fi.flyer_item_id as flyer_item_id, fi.item as flyer_item_name, fi.price_display, - f.id as flyer_id, + f.flyer_id as flyer_id, s.name as store_name FROM public.unmatched_flyer_items ufi - JOIN public.flyer_items fi ON ufi.flyer_item_id = fi.id - JOIN public.flyers f ON fi.flyer_id = f.id - JOIN public.stores s ON f.store_id = s.id + JOIN public.flyer_items fi ON ufi.flyer_item_id = fi.flyer_item_id + JOIN public.flyers f ON fi.flyer_id = f.flyer_id + JOIN public.stores s ON f.store_id = s.store_id WHERE ufi.status = 'pending' ORDER BY ufi.created_at ASC; `; @@ -267,7 +267,7 @@ export async function getUnmatchedFlyerItems(): Promise { export async function updateRecipeStatus(recipeId: number, status: 'private' | 'pending_review' | 'public' | 'rejected'): Promise { try { const res = await getPool().query( - 'UPDATE public.recipes SET status = $1 WHERE id = $2 RETURNING *', + 'UPDATE public.recipes SET status = $1 WHERE recipe_id = $2 RETURNING *', [status, recipeId] ); if (res.rowCount === 0) { @@ -336,8 +336,8 @@ export async function incrementFailedLoginAttempts(userId: string): Promise { try { await getPool().query( - `UPDATE public.users SET failed_login_attempts = 0, last_failed_login = NULL WHERE id = $1`, + `UPDATE public.users SET failed_login_attempts = 0, last_failed_login = NULL WHERE user_id = $1`, [userId] ); } catch (error) { @@ -369,7 +369,7 @@ export async function resetFailedLoginAttempts(userId: string): Promise { export async function updateBrandLogo(brandId: number, logoUrl: string): Promise { try { await getPool().query( - 'UPDATE public.brands SET logo_url = $1 WHERE id = $2', + 'UPDATE public.brands SET logo_url = $1 WHERE brand_id = $2', [logoUrl, brandId] ); } catch (error) { @@ -387,7 +387,7 @@ export async function updateBrandLogo(brandId: number, logoUrl: string): Promise export async function updateReceiptStatus(receiptId: number, status: 'pending' | 'processing' | 'completed' | 'failed'): Promise { try { const res = await getPool().query( - `UPDATE public.receipts SET status = $1, processed_at = CASE WHEN $1 IN ('completed', 'failed') THEN now() ELSE processed_at END WHERE id = $2 RETURNING *`, + `UPDATE public.receipts SET status = $1, processed_at = CASE WHEN $1 IN ('completed', 'failed') THEN now() ELSE processed_at END WHERE receipt_id = $2 RETURNING *`, [status, receiptId] ); if (res.rowCount === 0) { diff --git a/src/services/db/flyer.ts b/src/services/db/flyer.ts index fce62c19..92a7e6aa 100644 --- a/src/services/db/flyer.ts +++ b/src/services/db/flyer.ts @@ -11,7 +11,7 @@ export async function getFlyers(): Promise { try { const query = ` SELECT - f.id, + f.flyer_id, f.created_at, f.file_name, f.image_url, @@ -21,12 +21,12 @@ export async function getFlyers(): Promise { f.valid_to, f.store_address, json_build_object( - 'id', s.id, + 'store_id', s.store_id, 'name', s.name, 'logo_url', s.logo_url ) as store FROM public.flyers f - LEFT JOIN public.stores s ON f.store_id = s.id + LEFT JOIN public.stores s ON f.store_id = s.store_id ORDER BY f.valid_to DESC, s.name ASC; `; const res = await getPool().query(query); @@ -45,9 +45,9 @@ export async function getFlyers(): Promise { export async function getAllBrands(): Promise { try { const query = ` - SELECT b.id, b.name, b.logo_url, b.store_id, s.name as store_name + SELECT b.brand_id, b.name, b.logo_url, b.store_id, s.name as store_name FROM public.brands b - LEFT JOIN public.stores s ON b.store_id = s.id + LEFT JOIN public.stores s ON b.store_id = s.store_id ORDER BY b.name ASC; `; const res = await getPool().query(query); @@ -67,13 +67,13 @@ export async function getAllMasterItems(): Promise { try { const query = ` SELECT - m.id, + m.master_grocery_item_id, m.created_at, m.name, m.category_id, c.name as category_name FROM public.master_grocery_items m - LEFT JOIN public.categories c ON m.category_id = c.id + LEFT JOIN public.categories c ON m.category_id = c.category_id ORDER BY m.name ASC; `; const res = await getPool().query(query); @@ -89,12 +89,12 @@ export async function getAllMasterItems(): Promise { * @returns A promise that resolves to an array of Category objects. */ // prettier-ignore -export async function getAllCategories(): Promise<{id: number, name: string}[]> { +export async function getAllCategories(): Promise<{category_id: number, name: string}[]> { try { const query = ` - SELECT id, name FROM public.categories ORDER BY name ASC; + SELECT category_id, name FROM public.categories ORDER BY name ASC; `; - const res = await getPool().query<{id: number, name: string}>(query); + const res = await getPool().query<{category_id: number, name: string}>(query); return res.rows; } catch (error) { logger.error('Database error in getAllCategories:', { error }); @@ -126,8 +126,8 @@ export async function findFlyerByChecksum(checksum: string): Promise & { store_name: string }, - items: Omit[] + flyerData: Omit & { store_name: string }, + items: Omit[] ): Promise { const client = await getPool().connect(); logger.debug('[DB createFlyerAndItems] Starting transaction to create flyer.', { flyerData: { name: flyerData.file_name, store: flyerData.store_name }, itemCount: items.length }); @@ -137,12 +137,12 @@ export async function createFlyerAndItems( // Find or create the store let storeId: number; - const storeRes = await client.query<{ id: number }>('SELECT id FROM public.stores WHERE name = $1', [flyerData.store_name]); + const storeRes = await client.query<{ store_id: number }>('SELECT store_id FROM public.stores WHERE name = $1', [flyerData.store_name]); if (storeRes.rows.length > 0) { - storeId = storeRes.rows[0].id; + storeId = storeRes.rows[0].store_id; } else { - const newStoreRes = await client.query<{ id: number }>('INSERT INTO public.stores (name) VALUES ($1) RETURNING id', [flyerData.store_name]); - storeId = newStoreRes.rows[0].id; + const newStoreRes = await client.query<{ store_id: number }>('INSERT INTO public.stores (name) VALUES ($1) RETURNING store_id', [flyerData.store_name]); + storeId = newStoreRes.rows[0].store_id; } // Create the flyer record @@ -172,7 +172,7 @@ export async function createFlyerAndItems( // This is more efficient than making a separate DB call for each item to get the suggestion. for (const item of items) { const itemValues = [ - newFlyer.id, + newFlyer.flyer_id, item.item, item.price_display, item.price_in_cents, @@ -185,7 +185,7 @@ export async function createFlyerAndItems( } await client.query('COMMIT'); - logger.debug(`[DB createFlyerAndItems] COMMIT transaction successful for new flyer ID: ${newFlyer.id}.`); + logger.debug(`[DB createFlyerAndItems] COMMIT transaction successful for new flyer ID: ${newFlyer.flyer_id}.`); return newFlyer; } catch (error) { await client.query('ROLLBACK'); @@ -208,7 +208,7 @@ export async function getFlyerItems(flyerId: number): Promise { const query = ` SELECT * FROM public.flyer_items WHERE flyer_id = $1 - ORDER BY id ASC; + ORDER BY flyer_item_id ASC; `; const res = await getPool().query(query, [flyerId]); return res.rows; @@ -282,7 +282,7 @@ export async function trackFlyerItemInteraction(itemId: number, type: 'view' | ' try { const column = type === 'view' ? 'view_count' : 'click_count'; // Use the || operator to concatenate the column name safely into the query. - const query = `UPDATE public.flyer_items SET ${column} = ${column} + 1 WHERE id = $1`; + const query = `UPDATE public.flyer_items SET ${column} = ${column} + 1 WHERE flyer_item_id = $1`; await getPool().query(query, [itemId]); } catch (error) { logger.error('Database error in trackFlyerItemInteraction:', { error, itemId, type }); diff --git a/src/services/db/personalization.ts b/src/services/db/personalization.ts index c87b89c2..a2fbf47d 100644 --- a/src/services/db/personalization.ts +++ b/src/services/db/personalization.ts @@ -23,7 +23,7 @@ export async function getWatchedItems(userId: string): Promise('SELECT id FROM public.categories WHERE name = $1', [categoryName]); - const categoryId = categoryRes.rows[0]?.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.`); } @@ -71,7 +71,7 @@ export async function addWatchedItem(userId: string, itemName: string, categoryN // 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.id] + [userId, masterItem.master_grocery_item_id] ); await client.query('COMMIT'); @@ -170,7 +170,7 @@ export async function suggestPantryItemConversions(pantryItemId: number): Promis export async function findPantryItemOwner(pantryItemId: number): Promise<{ user_id: string } | undefined> { try { const res = await getPool().query<{ user_id: string }>( - 'SELECT user_id FROM public.pantry_items WHERE id = $1', + 'SELECT user_id FROM public.pantry_items WHERE pantry_item_id = $1', [pantryItemId] ); return res.rows[0]; @@ -203,7 +203,7 @@ export async function getUserDietaryRestrictions(userId: string): Promise(query, [userId]); @@ -264,7 +264,7 @@ export async function getUserAppliances(userId: string): Promise { try { const query = ` SELECT a.* FROM public.appliances a - JOIN public.user_appliances ua ON a.id = ua.appliance_id + JOIN public.user_appliances ua ON a.appliance_id = ua.appliance_id WHERE ua.user_id = $1 ORDER BY a.name; `; const res = await getPool().query(query, [userId]); diff --git a/src/services/db/recipe.ts b/src/services/db/recipe.ts index b9fcdaab..90eb6541 100644 --- a/src/services/db/recipe.ts +++ b/src/services/db/recipe.ts @@ -109,7 +109,7 @@ export async function getRecipeComments(recipeId: number): Promise // which is generally more performant than using a correlated subquery for each row. const query = ` SELECT - sl.id, sl.name, sl.created_at, + sl.shopping_list_id, sl.name, sl.created_at, -- Aggregate all joined shopping list items into a single JSON array for each list. -- The FILTER clause ensures that if a list has no items, we get an empty array '[]' -- instead of an array with a single null value '[null]'. COALESCE( (SELECT json_agg( json_build_object( - 'id', sli.id, + 'shopping_list_item_id', sli.shopping_list_item_id, 'shopping_list_id', sli.shopping_list_id, 'master_item_id', sli.master_item_id, 'custom_item_name', sli.custom_item_name, @@ -39,14 +39,14 @@ export async function getShoppingLists(userId: string): Promise 'added_at', sli.added_at, 'master_item', json_build_object('name', mgi.name) ) ORDER BY sli.added_at ASC - ) FILTER (WHERE sli.id IS NOT NULL)), + ) FILTER (WHERE sli.shopping_list_item_id IS NOT NULL)), '[]'::json ) as items FROM public.shopping_lists sl - LEFT JOIN public.shopping_list_items sli ON sl.id = sli.shopping_list_id - LEFT JOIN public.master_grocery_items mgi ON sli.master_item_id = mgi.id + LEFT JOIN public.shopping_list_items sli ON sl.shopping_list_id = sli.shopping_list_id + LEFT JOIN public.master_grocery_items mgi ON sli.master_item_id = mgi.master_grocery_item_id WHERE sl.user_id = $1 - GROUP BY sl.id, sl.name, sl.created_at + GROUP BY sl.shopping_list_id, sl.name, sl.created_at ORDER BY sl.created_at ASC; `; const res = await getPool().query(query, [userId]); @@ -66,7 +66,7 @@ export async function getShoppingLists(userId: string): Promise export async function createShoppingList(userId: string, name: string): Promise { try { const res = await getPool().query( - 'INSERT INTO public.shopping_lists (user_id, name) VALUES ($1, $2) RETURNING id, user_id, name, created_at', + 'INSERT INTO public.shopping_lists (user_id, name) VALUES ($1, $2) RETURNING shopping_list_id, user_id, name, created_at', [userId, name] ); // Return a complete ShoppingList object with an empty items array @@ -85,7 +85,7 @@ export async function createShoppingList(userId: string, name: string): Promise< export async function deleteShoppingList(listId: number, userId: string): Promise { try { // The user_id check ensures a user can only delete their own list. - await getPool().query('DELETE FROM public.shopping_lists WHERE id = $1 AND user_id = $2', [listId, userId]); + await getPool().query('DELETE FROM public.shopping_lists WHERE shopping_list_id = $1 AND user_id = $2', [listId, userId]); } catch (error) { logger.error('Database error in deleteShoppingList:', { error }); throw new Error('Failed to delete shopping list.'); @@ -117,7 +117,7 @@ export async function addShoppingListItem(listId: number, item: { masterItemId?: */ export async function removeShoppingListItem(itemId: number): Promise { try { - await getPool().query('DELETE FROM public.shopping_list_items WHERE id = $1', [itemId]); + await getPool().query('DELETE FROM public.shopping_list_items WHERE shopping_list_item_id = $1', [itemId]); } catch (error) { logger.error('Database error in removeShoppingListItem:', { error }); throw new Error('Failed to remove item from shopping list.'); @@ -222,7 +222,7 @@ export async function updateShoppingListItem(itemId: number, updates: Partial(query, values); return res.rows[0]; @@ -261,11 +261,11 @@ export async function getShoppingTripHistory(userId: string): Promise { try { const res = await getPool().query<{ user_id: string }>( - 'SELECT user_id FROM public.receipts WHERE id = $1', + 'SELECT user_id FROM public.receipts WHERE receipt_id = $1', [receiptId] ); return res.rows[0]; diff --git a/src/services/db/user.ts b/src/services/db/user.ts index 889746f4..bdad377a 100644 --- a/src/services/db/user.ts +++ b/src/services/db/user.ts @@ -9,7 +9,7 @@ import { getWatchedItems } from './personalization'; * Defines the structure of a user object as returned from the database. */ interface DbUser { - id: string; // UUID + user_id: string; // UUID email: string; password_hash: string; refresh_token?: string | null; @@ -26,11 +26,11 @@ export async function findUserByEmail(email: string): Promise( - 'SELECT id, email, password_hash, refresh_token, failed_login_attempts, last_failed_login FROM public.users WHERE email = $1', + 'SELECT user_id, email, password_hash, refresh_token, failed_login_attempts, last_failed_login FROM public.users WHERE email = $1', [email] ); const userFound = res.rows[0]; - logger.debug(`[DB findUserByEmail] Query for ${email} result: ${userFound ? `FOUND user ID ${userFound.id}` : 'NOT FOUND'}`); + logger.debug(`[DB findUserByEmail] Query for ${email} result: ${userFound ? `FOUND user ID ${userFound.user_id}` : 'NOT FOUND'}`); return res.rows[0]; } catch (error) { logger.error('Database error in findUserByEmail:', { error }); @@ -49,7 +49,7 @@ export async function createUser( email: string, passwordHash: string | null, profileData: { full_name?: string; avatar_url?: string } -): Promise<{ id: string; email: string }> { +): Promise<{ user_id: string; email: string }> { // Use a client from the pool to run multiple queries in a transaction const client = await getPool().connect(); @@ -69,8 +69,8 @@ export async function createUser( await client.query("SELECT set_config('my_app.user_metadata', $1, true)", [JSON.stringify(profileData)]); // Insert the new user into the 'users' table. This will fire the trigger. - const res = await client.query<{ id: string; email: string }>( - 'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING id, email', + const res = await client.query<{ user_id: string; email: string }>( + 'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id, email', [email, passwordHash] ); @@ -101,11 +101,11 @@ export async function createUser( * @returns A promise that resolves to the user object (id, email) or undefined if not found. */ // prettier-ignore -export async function findUserById(id: string): Promise<{ id: string; email: string } | undefined> { +export async function findUserById(userId: string): Promise<{ user_id: string; email: string } | undefined> { try { - const res = await getPool().query<{ id: string; email: string }>( - 'SELECT id, email FROM public.users WHERE id = $1', - [id] + const res = await getPool().query<{ user_id: string; email: string }>( + 'SELECT user_id, email FROM public.users WHERE user_id = $1', + [userId] ); return res.rows[0]; } catch (error) { @@ -121,11 +121,11 @@ export async function findUserById(id: string): Promise<{ id: string; email: str * @returns A promise that resolves to the user object (id, email, password_hash) or undefined if not found. */ // prettier-ignore -export async function findUserWithPasswordHashById(id: string): Promise<{ id: string; email: string; password_hash: string | null } | undefined> { +export async function findUserWithPasswordHashById(userId: string): Promise<{ user_id: string; email: string; password_hash: string | null } | undefined> { try { - const res = await getPool().query<{ id: string; email: string; password_hash: string | null }>( - 'SELECT id, email, password_hash FROM public.users WHERE id = $1', - [id] + const res = await getPool().query<{ user_id: string; email: string; password_hash: string | null }>( + 'SELECT user_id, email, password_hash FROM public.users WHERE user_id = $1', + [userId] ); return res.rows[0]; } catch (error) { @@ -140,12 +140,12 @@ export async function findUserWithPasswordHashById(id: string): Promise<{ id: st * @returns A promise that resolves to the user's profile object or undefined if not found. */ // prettier-ignore -export async function findUserProfileById(id: string): Promise { +export async function findUserProfileById(userId: string): Promise { try { // This query assumes your 'profiles' table has a foreign key 'id' referencing 'users.id' const res = await getPool().query( - 'SELECT id, full_name, avatar_url, preferences, role FROM public.profiles WHERE id = $1', - [id] + 'SELECT user_id, full_name, avatar_url, preferences, role FROM public.profiles WHERE user_id = $1', + [userId] ); return res.rows[0]; } catch (error) { @@ -161,16 +161,16 @@ export async function findUserProfileById(id: string): Promise { +export async function updateUserProfile(userId: string, profileData: { full_name?: string; avatar_url?: string }): Promise { try { const res = await getPool().query( `UPDATE public.profiles SET full_name = COALESCE($1, full_name), avatar_url = COALESCE($2, avatar_url), updated_by = $3 - WHERE id = $3 -- Use the same parameter for updated_by and the WHERE clause - RETURNING id, full_name, avatar_url, preferences, role`, - [profileData.full_name, profileData.avatar_url, id] + WHERE user_id = $3 -- Use the same parameter for updated_by and the WHERE clause + RETURNING user_id, full_name, avatar_url, preferences, role`, + [profileData.full_name, profileData.avatar_url, userId] ); return res.rows[0]; } catch (error) { @@ -188,16 +188,16 @@ export async function updateUserProfile(id: string, profileData: { full_name?: s * @returns A promise that resolves to the updated profile object. */ // prettier-ignore -export async function updateUserPreferences(id: string, preferences: Profile['preferences']): Promise { +export async function updateUserPreferences(userId: string, preferences: Profile['preferences']): Promise { try { const res = await getPool().query( `UPDATE public.profiles -- The '||' operator correctly merges the new preferences JSONB object -- with the existing one, overwriting keys in the original with new values. SET preferences = COALESCE(preferences, '{}'::jsonb) || $1, updated_at = now() - WHERE id = $2 - RETURNING id, full_name, avatar_url, preferences, role`, - [preferences, id] + WHERE user_id = $2 + RETURNING user_id, full_name, avatar_url, preferences, role`, + [preferences, userId] ); return res.rows[0]; } catch (error) { @@ -212,11 +212,11 @@ export async function updateUserPreferences(id: string, preferences: Profile['pr * @param passwordHash The new bcrypt hashed password. */ // prettier-ignore -export async function updateUserPassword(id: string, passwordHash: string): Promise { +export async function updateUserPassword(userId: string, passwordHash: string): Promise { try { await getPool().query( - 'UPDATE public.users SET password_hash = $1 WHERE id = $2', - [passwordHash, id] + 'UPDATE public.users SET password_hash = $1 WHERE user_id = $2', + [passwordHash, userId] ); } catch (error) { logger.error('Database error in updateUserPassword:', { error }); @@ -229,9 +229,9 @@ export async function updateUserPassword(id: string, passwordHash: string): Prom * @param id The UUID of the user to delete. */ // prettier-ignore -export async function deleteUserById(id: string): Promise { +export async function deleteUserById(userId: string): Promise { try { - await getPool().query('DELETE FROM public.users WHERE id = $1', [id]); + await getPool().query('DELETE FROM public.users WHERE user_id = $1', [userId]); } catch (error) { logger.error('Database error in deleteUserById:', { error }); throw new Error('Failed to delete user from database.'); @@ -248,7 +248,7 @@ export async function saveRefreshToken(userId: string, refreshToken: string): Pr try { // For simplicity, we store one token per user. For multi-device support, a separate table is better. await getPool().query( - 'UPDATE public.users SET refresh_token = $1 WHERE id = $2', + 'UPDATE public.users SET refresh_token = $1 WHERE user_id = $2', [refreshToken, userId] ); } catch (error) { @@ -263,10 +263,10 @@ export async function saveRefreshToken(userId: string, refreshToken: string): Pr * @returns A promise that resolves to the user object (id, email) or undefined if not found. */ // prettier-ignore -export async function findUserByRefreshToken(refreshToken: string): Promise<{ id: string; email: string } | undefined> { +export async function findUserByRefreshToken(refreshToken: string): Promise<{ user_id: string; email: string } | undefined> { try { - const res = await getPool().query<{ id: string; email: string }>( - 'SELECT id, email FROM public.users WHERE refresh_token = $1', + const res = await getPool().query<{ user_id: string; email: string }>( + 'SELECT user_id, email FROM public.users WHERE refresh_token = $1', [refreshToken] ); return res.rows[0]; @@ -370,7 +370,7 @@ export async function exportUserData(userId: string): Promise<{ profile: Profile */ export async function followUser(followerId: string, followingId: string): Promise { if (followerId === followingId) { - throw new Error('User cannot follow themselves.'); + throw new Error('A user cannot follow themselves.'); } try { await getPool().query( diff --git a/src/services/shopping-list.integration.test.ts b/src/services/shopping-list.integration.test.ts index a5833686..f1a6a5f8 100644 --- a/src/services/shopping-list.integration.test.ts +++ b/src/services/shopping-list.integration.test.ts @@ -8,7 +8,7 @@ describe('Shopping List DB Service Tests', () => { const email = `list-user-${Date.now()}@example.com`; const passwordHash = await bcrypt.hash('password123', 10); const user = await db.createUser(email, passwordHash, { full_name: 'List Test User' }); - const testUserId = user.id; + const testUserId = user.user_id; // Arrange: The `beforeEach` hook already creates a user, which in turn // triggers the creation of a "Main Shopping List". We then create two more. @@ -31,7 +31,7 @@ describe('Shopping List DB Service Tests', () => { const email = `privacy-user-${Date.now()}@example.com`; const passwordHash = await bcrypt.hash('password123', 10); const user = await db.createUser(email, passwordHash, { full_name: 'Privacy Test User' }); - const testUserId = user.id; + const testUserId = user.user_id; // Arrange: Create a list for our primary test user await db.createShoppingList(testUserId, 'My Private List'); diff --git a/src/types.ts b/src/types.ts index 54d208b1..f4f0dca2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,12 +1,12 @@ export interface Store { - id: number; + store_id: number; created_at: string; name: string; logo_url?: string | null; } export interface Flyer { - id: number; + flyer_id: number; created_at: string; file_name: string; image_url: string; @@ -25,7 +25,7 @@ export interface UnitPrice { } export interface FlyerItem { - id?: number; + flyer_item_id?: number; flyer_id?: number; created_at?: string; item: string; @@ -44,7 +44,7 @@ export interface FlyerItem { } export interface MasterGroceryItem { - id: number; + master_grocery_item_id: number; created_at: string; name: string; category_id?: number | null; @@ -52,12 +52,12 @@ export interface MasterGroceryItem { } export interface Category { - id: number; + category_id: number; name: string; } export interface Brand { - id: number; + brand_id: number; name: string; logo_url?: string | null; store_id?: number | null; @@ -65,7 +65,7 @@ export interface Brand { } export interface Product { - id: number; + product_id: number; master_item_id: number; brand_id?: number | null; name: string; @@ -86,12 +86,12 @@ export interface DealItem { // User-specific types export interface User { - id: string; // UUID + user_id: string; // UUID email: string; } export interface Profile { - id: string; // UUID + user_id: string; // UUID updated_at?: string; full_name?: string | null; avatar_url?: string | null; @@ -109,7 +109,7 @@ export interface Profile { export type UserProfile = Profile & { user: User }; export interface SuggestedCorrection { - id: number; + suggested_correction_id: number; flyer_item_id: number; user_id: string; correction_type: string; @@ -137,7 +137,7 @@ export interface UserDataExport { } export interface UserAlert { - id: number; + user_alert_id: number; user_watched_item_id: number; alert_type: 'PRICE_BELOW' | 'PERCENT_OFF_AVERAGE'; threshold_value: number; @@ -146,7 +146,7 @@ export interface UserAlert { } export interface Notification { - id: number; + notification_id: number; user_id: string; // UUID content: string; link_url?: string | null; @@ -155,7 +155,7 @@ export interface Notification { } export interface ShoppingList { - id: number; + shopping_list_id: number; user_id: string; // UUID name: string; created_at: string; @@ -163,7 +163,7 @@ export interface ShoppingList { } export interface ShoppingListItem { - id: number; + shopping_list_item_id: number; shopping_list_id: number; master_item_id?: number | null; custom_item_name?: string | null; @@ -178,7 +178,7 @@ export interface ShoppingListItem { } export interface UserSubmittedPrice { - id: number; + user_submitted_price_id: number; user_id: string; // UUID master_item_id: number; store_id: number; @@ -190,7 +190,7 @@ export interface UserSubmittedPrice { } export interface ItemPriceHistory { - id: number; + item_price_history_id: number; master_item_id: number; summary_date: string; // DATE min_price_in_cents?: number | null; @@ -200,13 +200,13 @@ export interface ItemPriceHistory { } export interface MasterItemAlias { - id: number; + master_item_alias_id: number; master_item_id: number; alias: string; } export interface Recipe { - id: number; + recipe_id: number; user_id?: string | null; // UUID original_recipe_id?: number | null; name: string; @@ -227,7 +227,7 @@ export interface Recipe { } export interface RecipeIngredient { - id: number; + recipe_ingredient_id: number; recipe_id: number; master_item_id: number; quantity: number; @@ -235,7 +235,7 @@ export interface RecipeIngredient { } export interface RecipeIngredientSubstitution { - id: number; + recipe_ingredient_substitution_id: number; recipe_ingredient_id: number; substitute_master_item_id: number; notes?: string | null; @@ -243,7 +243,7 @@ export interface RecipeIngredientSubstitution { export interface Tag { - id: number; + tag_id: number; name: string; } @@ -253,7 +253,7 @@ export interface RecipeTag { } export interface RecipeRating { - id: number; + recipe_rating_id: number; recipe_id: number; user_id: string; // UUID rating: number; @@ -262,7 +262,7 @@ export interface RecipeRating { } export interface RecipeComment { - id: number; + recipe_comment_id: number; recipe_id: number; user_id: string; // UUID parent_comment_id?: number | null; @@ -275,7 +275,7 @@ export interface RecipeComment { } export interface MenuPlan { - id: number; + menu_plan_id: number; user_id: string; // UUID name: string; start_date: string; // DATE @@ -284,7 +284,7 @@ export interface MenuPlan { } export interface SharedMenuPlan { - id: number; + shared_menu_plan_id: number; menu_plan_id: number; shared_by_user_id: string; // UUID shared_with_user_id: string; // UUID @@ -293,7 +293,7 @@ export interface SharedMenuPlan { } export interface PlannedMeal { - id: number; + planned_meal_id: number; menu_plan_id: number; recipe_id: number; plan_date: string; // DATE @@ -302,7 +302,7 @@ export interface PlannedMeal { } export interface PantryItem { - id: number; + pantry_item_id: number; user_id: string; // UUID master_item_id: number; quantity: number; @@ -314,7 +314,7 @@ export interface PantryItem { } export interface UserItemAlias { - id: number; + user_item_alias_id: number; user_id: string; // UUID master_item_id: number; alias: string; @@ -333,7 +333,7 @@ export interface FavoriteStore { } export interface RecipeCollection { - id: number; + recipe_collection_id: number; user_id: string; // UUID name: string; description?: string | null; @@ -347,7 +347,7 @@ export interface RecipeCollectionItem { } export interface SharedShoppingList { - id: number; + shared_shopping_list_id: number; shopping_list_id: number; shared_by_user_id: string; // UUID shared_with_user_id: string; // UUID @@ -356,7 +356,7 @@ export interface SharedShoppingList { } export interface DietaryRestriction { - id: number; + dietary_restriction_id: number; name: string; type: 'diet' | 'allergy'; } @@ -367,7 +367,7 @@ export interface UserDietaryRestriction { } export interface Appliance { - id: number; + appliance_id: number; name: string; } @@ -389,7 +389,7 @@ export interface UserFollow { export interface UnmatchedFlyerItem { - id: number; + unmatched_flyer_item_id: number; status: 'pending' | 'resolved' | 'ignored'; created_at: string; flyer_item_id: number; @@ -411,7 +411,7 @@ export type ActivityLogDetails = Record[]; + items: Omit[]; } /** @@ -651,7 +651,7 @@ export interface MenuPlanShoppingListItem { * Returned by `getUnmatchedFlyerItems`. */ export interface UnmatchedFlyerItem { - id: number; + unmatched_flyer_item_id: number; status: 'pending' | 'resolved' | 'ignored'; created_at: string; // Date string flyer_item_id: number;