From f830a120973d8308224c103717f3086ed09579e0 Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Thu, 20 Nov 2025 00:45:53 -0800 Subject: [PATCH] database expansion prior to creating on server --- package.json | 1 - server.ts | 139 +++++++--- sql/drop_tables.sql | 22 +- sql/functions.sql | 158 ----------- sql/initial.sql | 116 +++++++- sql/initial_data.sql | 20 +- sql/master_schema_rollup.sql | 385 +++++++++------------------ sql/triggers.sql | 49 ---- src/App.tsx | 4 + src/components/AdminBrandManager.tsx | 111 ++++++++ src/components/AdminPage.tsx | 2 + src/components/ProfileManager.tsx | 12 +- src/services/apiClient.ts | 64 +++-- src/services/db.ts | 160 ++++++----- src/services/geminiService.ts | 149 +++++++---- src/types.ts | 3 + 16 files changed, 732 insertions(+), 663 deletions(-) create mode 100644 src/components/AdminBrandManager.tsx diff --git a/package.json b/package.json index 2ecd2bb0..db321c83 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ "@tailwindcss/postcss": "4.1.17", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", - "@types/bcrypt": "^6.0.0", "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.5", "@types/jsonwebtoken": "^9.0.10", diff --git a/server.ts b/server.ts index 7d25d962..fba79071 100644 --- a/server.ts +++ b/server.ts @@ -13,7 +13,7 @@ import fs from 'fs/promises'; import multer from 'multer'; import * as db from './src/services/db'; import { logger } from './src/services/logger'; -import { extractItemsFromReceiptImage } from './src/services/geminiService'; +import { extractItemsFromReceiptImage, extractCoreDataFromFlyerImage } from './src/services/geminiService'; import { Profile, UserProfile, ShoppingListItem, ReceiptItem } from './src/types'; // Load environment variables from a .env file at the root of your project @@ -499,9 +499,18 @@ app.get('/api/users/best-sale-prices', passport.authenticate('jwt', { session: f }); app.get('/api/pantry-items/:id/conversions', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { + const user = req.user as { id: string }; const pantryItemId = parseInt(req.params.id, 10); - // TODO: Add an ownership check to ensure the user owns this pantry item. try { + // --- Ownership Check --- + const owner = await db.findPantryItemOwner(pantryItemId); + if (!owner) { + return res.status(404).json({ message: 'Pantry item not found.' }); + } + if (owner.user_id !== user.id) { + logger.warn(`User ${user.id} attempted to access unauthorized pantry item ${pantryItemId}.`); + return res.status(403).json({ message: 'Forbidden: You do not have access to this item.' }); + } const conversions = await db.suggestPantryItemConversions(pantryItemId); res.json(conversions); } catch (error) { @@ -581,6 +590,30 @@ app.put('/api/users/me/dietary-restrictions', passport.authenticate('jwt', { ses } }); +app.get('/api/users/me/appliances', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { + const user = req.user as { id: string }; + try { + const appliances = await db.getUserAppliances(user.id); + res.json(appliances); + } catch (error) { + next(error); + } +}); + +app.put('/api/users/me/appliances', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { + const user = req.user as { id: string }; + const { applianceIds } = req.body; + if (!Array.isArray(applianceIds)) { + return res.status(400).json({ message: 'applianceIds must be an array of numbers.' }); + } + try { + await db.setUserAppliances(user.id, applianceIds); + res.status(204).send(); + } catch (error) { + next(error); + } +}); + app.get('/api/users/me/compatible-recipes', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; try { @@ -1153,7 +1186,7 @@ app.put('/api/users/profile/preferences', passport.authenticate('jwt', { session }); // Protected Route to update user password -app.put('/api/users/profile/password', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => { +app.put('/api/users/profile/password', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const authenticatedUser = req.user as { id: string; email: string }; const { newPassword } = req.body; @@ -1161,30 +1194,28 @@ app.put('/api/users/profile/password', passport.authenticate('jwt', { session: f return res.status(400).json({ message: 'Password must be a string of at least 6 characters.' }); } - // Hash the password before updating it - const saltRounds = 10; - const hashedPassword = await bcrypt.hash(newPassword, saltRounds); - logger.info(`Hashing new password for user: ${authenticatedUser.email}`); - - // We pass the hashed password to the db function - await db.updateUserPassword(authenticatedUser.id, hashedPassword); - // --- Password Strength Check --- const MIN_PASSWORD_SCORE = 3; // Require a 'Good' or 'Strong' password (score 3 or 4) const strength = zxcvbn(newPassword); if (strength.score < MIN_PASSWORD_SCORE) { - logger.warn(`Weak password update rejected for user: ${authenticatedUser.email}. Score: ${strength.score}`); + logger.warn(`Weak password update rejected for user: ${authenticatedUser.email}. Score: ${strength.score}`, { userId: authenticatedUser.id }); // Provide the user with helpful feedback from the strength analysis. 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() }); } try { + // Hash the password only after it has passed the strength check + const saltRounds = 10; + const hashedPassword = await bcrypt.hash(newPassword, saltRounds); + logger.info(`Hashing new, validated password for user: ${authenticatedUser.email}`, { userId: authenticatedUser.id }); + + await db.updateUserPassword(authenticatedUser.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 }); - res.status(500).json({ message: 'Failed to update password.' }); + logger.error('Error during password update:', { error, userId: authenticatedUser.id }); + next(error); } }); @@ -1233,6 +1264,41 @@ app.get('/api/admin/corrections', passport.authenticate('jwt', { session: false } }); +app.get('/api/admin/brands', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => { + try { + const brands = await db.getAllBrands(); + res.json(brands); + } catch (error) { + logger.error('Error fetching brands in /api/admin/brands:', { error }); + next(error); + } +}); + +app.post('/api/ai/process-flyer', passport.authenticate('jwt', { session: false }), upload.array('flyerImages'), async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.files || !Array.isArray(req.files) || req.files.length === 0) { + return res.status(400).json({ message: 'Flyer image files are required.' }); + } + + // Master items are sent as a JSON string in a separate field + const masterItems = JSON.parse(req.body.masterItems); + + const imagePaths = req.files.map(file => ({ + path: file.path, + mimetype: file.mimetype + })); + + logger.info(`Starting AI flyer data extraction for ${imagePaths.length} image(s).`); + const extractedData = await extractCoreDataFromFlyerImage(imagePaths, masterItems); + logger.info(`Completed AI flyer data extraction. Found ${extractedData.items.length} items.`); + + res.status(200).json({ data: extractedData }); + } catch (error) { + logger.error('Error in /api/ai/process-flyer endpoint:', { error }); + next(error); + } +}); + app.get('/api/admin/stats', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => { try { const stats = await db.getApplicationStats(); @@ -1304,6 +1370,23 @@ app.put('/api/admin/recipes/:id/status', passport.authenticate('jwt', { session: } }); +app.post('/api/admin/brands/:id/logo', passport.authenticate('jwt', { session: false }), isAdmin, upload.single('logoImage'), async (req: Request, res: Response, next: NextFunction) => { + const brandId = parseInt(req.params.id, 10); + try { + if (!req.file) { + return res.status(400).json({ message: 'Logo image file is required.' }); + } + + const logoUrl = `/assets/${req.file.filename}`; + await db.updateBrandLogo(brandId, logoUrl); + + logger.info(`Brand logo updated for brand ID: ${brandId}`, { brandId, logoUrl }); + res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl }); + } catch (error) { + next(error); + } +}); + app.get('/api/admin/unmatched-items', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => { try { @@ -1315,21 +1398,6 @@ app.get('/api/admin/unmatched-items', passport.authenticate('jwt', { session: fa } }); -app.put('/api/admin/recipes/:id/status', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => { - const recipeId = parseInt(req.params.id, 10); - const { status } = req.body; - - if (!status || !['private', 'pending_review', 'public'].includes(status)) { - return res.status(400).json({ message: 'A valid status (private, pending_review, public) is required.' }); - } - try { - const updatedRecipe = await db.updateRecipeStatus(recipeId, status); - res.status(200).json(updatedRecipe); - } catch (error) { - next(error); - } -}); - app.put('/api/admin/comments/:id/status', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => { const commentId = parseInt(req.params.id, 10); const { status } = req.body; @@ -1378,10 +1446,17 @@ app.post('/api/receipts/upload', passport.authenticate('jwt', { session: false } app.get('/api/receipts/:id/deals', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; const receiptId = parseInt(req.params.id, 10); - - // TODO: Add an ownership check to ensure the user owns this receipt. - try { + // --- Ownership Check --- + const owner = await db.findReceiptOwner(receiptId); + if (!owner) { + return res.status(404).json({ message: 'Receipt not found.' }); + } + if (owner.user_id !== user.id) { + logger.warn(`User ${user.id} attempted to access unauthorized receipt ${receiptId}.`); + return res.status(403).json({ message: 'Forbidden: You do not have access to this receipt.' }); + } + const deals = await db.findDealsForReceipt(receiptId); res.json(deals); } catch (error) { diff --git a/sql/drop_tables.sql b/sql/drop_tables.sql index cc8db780..e1d60c91 100644 --- a/sql/drop_tables.sql +++ b/sql/drop_tables.sql @@ -7,16 +7,6 @@ DROP TABLE IF EXISTS public.user_follows CASCADE; DROP TABLE IF EXISTS public.user_appliances CASCADE; -DROP TABLE IF EXISTS public.appliances CASCADE; -DROP TABLE IF EXISTS public.user_dietary_restrictions CASCADE; -DROP TABLE IF EXISTS public.dietary_restrictions CASCADE; -DROP TABLE IF EXISTS public.recipe_collection_items CASCADE; -DROP TABLE IF EXISTS public.receipt_items CASCADE; -DROP TABLE IF EXISTS public.receipts CASCADE; -DROP TABLE IF EXISTS public.shopping_trip_items CASCADE; -DROP TABLE IF EXISTS public.shopping_trips CASCADE; -DROP TABLE IF EXISTS public.search_queries CASCADE; -DROP TABLE IF EXISTS public.pantry_locations CASCADE; DROP TABLE IF EXISTS public.recipe_collections CASCADE; DROP TABLE IF EXISTS public.user_activity_log CASCADE; DROP TABLE IF EXISTS public.favorite_stores CASCADE; @@ -24,6 +14,7 @@ DROP TABLE IF EXISTS public.favorite_recipes CASCADE; DROP TABLE IF EXISTS public.user_item_aliases CASCADE; DROP TABLE IF EXISTS public.recipe_appliances CASCADE; DROP TABLE IF EXISTS public.shared_menu_plans CASCADE; +DROP TABLE IF EXISTS public.unit_conversions CASCADE; DROP TABLE IF EXISTS public.recipe_ingredient_substitutions CASCADE; DROP TABLE IF EXISTS public.recipe_comments CASCADE; DROP TABLE IF EXISTS public.pantry_items CASCADE; @@ -32,12 +23,19 @@ DROP TABLE IF EXISTS public.menu_plans CASCADE; DROP TABLE IF EXISTS public.recipe_ratings CASCADE; DROP TABLE IF EXISTS public.recipe_tags CASCADE; DROP TABLE IF EXISTS public.tags CASCADE; +DROP TABLE IF EXISTS public.recipe_collection_items CASCADE; DROP TABLE IF EXISTS public.shared_shopping_lists CASCADE; DROP TABLE IF EXISTS public.recipe_ingredients CASCADE; DROP TABLE IF EXISTS public.recipes CASCADE; DROP TABLE IF EXISTS public.flyer_locations CASCADE; DROP TABLE IF EXISTS public.store_locations CASCADE; DROP TABLE IF EXISTS public.user_submitted_prices CASCADE; +DROP TABLE IF EXISTS public.receipt_items CASCADE; +DROP TABLE IF EXISTS public.receipts CASCADE; +DROP TABLE IF EXISTS public.shopping_trip_items CASCADE; +DROP TABLE IF EXISTS public.shopping_trips CASCADE; +DROP TABLE IF EXISTS public.search_queries CASCADE; +DROP TABLE IF EXISTS public.pantry_locations CASCADE; DROP TABLE IF EXISTS public.suggested_corrections CASCADE; DROP TABLE IF EXISTS public.shopping_list_items CASCADE; DROP TABLE IF EXISTS public.shopping_lists CASCADE; @@ -49,10 +47,14 @@ DROP TABLE IF EXISTS public.unmatched_flyer_items CASCADE; DROP TABLE IF EXISTS public.item_price_history CASCADE; DROP TABLE IF EXISTS public.flyer_items CASCADE; DROP TABLE IF EXISTS public.products CASCADE; +DROP TABLE IF EXISTS public.products CASCADE; DROP TABLE IF EXISTS public.brands CASCADE; DROP TABLE IF EXISTS public.flyers CASCADE; DROP TABLE IF EXISTS public.master_grocery_items CASCADE; DROP TABLE IF EXISTS public.stores CASCADE; +DROP TABLE IF EXISTS public.appliances CASCADE; +DROP TABLE IF EXISTS public.user_dietary_restrictions CASCADE; +DROP TABLE IF EXISTS public.dietary_restrictions CASCADE; DROP TABLE IF EXISTS public.categories CASCADE; DROP TABLE IF EXISTS public.profiles CASCADE; DROP TABLE IF EXISTS public.password_reset_tokens CASCADE; diff --git a/sql/functions.sql b/sql/functions.sql index 5f34f3f3..8637771d 100644 --- a/sql/functions.sql +++ b/sql/functions.sql @@ -143,7 +143,6 @@ AS $$ COUNT(bcp.master_item_id) AS sale_ingredients -- COUNT(column) only counts non-NULL values. FROM public.recipe_ingredients ri LEFT JOIN BestCurrentPrices bcp ON ri.master_item_id = bcp.master_item_id -- Join to count how many ingredients are on sale - LEFT JOIN BestCurrentPrices bcp ON ri.master_item_id = bcp.master_item_id GROUP BY ri.recipe_id ), EligibleRecipes AS ( @@ -741,163 +740,6 @@ AS $$ OFFSET p_offset; $$; --- Function to archive a shopping list into a historical shopping trip. --- It creates a shopping_trip record, copies purchased items to shopping_trip_items, --- and then deletes the purchased items from the original shopping list. -CREATE OR REPLACE FUNCTION public.complete_shopping_list( - p_shopping_list_id BIGINT, - p_user_id UUID, - p_total_spent_cents INTEGER DEFAULT NULL -) -RETURNS BIGINT -- Returns the ID of the new shopping_trip record. -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - list_owner_id UUID; - new_trip_id BIGINT; -BEGIN - -- Security Check: Ensure the user calling this function owns the target shopping list. - SELECT user_id INTO list_owner_id - FROM public.shopping_lists - WHERE id = p_shopping_list_id; - - IF list_owner_id IS NULL OR list_owner_id <> p_user_id THEN - RAISE EXCEPTION 'Permission denied: You do not own shopping list %', p_shopping_list_id; - END IF; - - -- 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; - - -- 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) - SELECT new_trip_id, master_item_id, custom_item_name, quantity - FROM public.shopping_list_items - WHERE shopping_list_id = p_shopping_list_id AND is_purchased = true; - - -- 3. Delete the purchased items from the original shopping list. - DELETE FROM public.shopping_list_items - WHERE shopping_list_id = p_shopping_list_id AND is_purchased = true; - - RETURN new_trip_id; -END; -$$; --- 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, - user_id UUID, - activity_type TEXT, - entity_id TEXT, - details JSONB, - created_at TIMESTAMPTZ, - user_full_name TEXT, - user_avatar_url TEXT -) -LANGUAGE sql -STABLE -SECURITY INVOKER -AS $$ - SELECT - al.id, - al.user_id, - al.activity_type, - al.entity_id, - al.details, - al.created_at, - p.full_name AS user_full_name, - p.avatar_url AS user_avatar_url - FROM public.user_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 - ORDER BY - al.created_at DESC - LIMIT p_limit - OFFSET p_offset; -$$; - --- Function to get recipes that are compatible with a user's dietary restrictions (allergies). --- It filters out any recipe containing an ingredient that the user is allergic to. -CREATE OR REPLACE FUNCTION public.get_recipes_for_user_diets(p_user_id UUID) -RETURNS SETOF public.recipes -LANGUAGE sql -STABLE -SECURITY INVOKER -AS $$ - WITH UserAllergens AS ( - -- CTE 1: Find all master item IDs that are allergens for the given user. - SELECT mgi.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 - WHERE udr.user_id = p_user_id - AND dr.type = 'allergy' - AND mgi.is_allergen = true - ), - ForbiddenRecipes 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) - ) - -- 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) - ORDER BY r.avg_rating DESC, r.name ASC; -$$; - --- Function to get a personalized activity feed for a user based on who they follow. --- 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, - user_id UUID, - activity_type TEXT, - entity_id TEXT, - details JSONB, - created_at TIMESTAMPTZ, - user_full_name TEXT, - user_avatar_url TEXT -) -LANGUAGE sql -STABLE -SECURITY INVOKER -AS $$ - WITH FollowedUsers AS ( - -- CTE 1: Get the IDs of all users that the current user is following. - SELECT following_id FROM public.user_follows WHERE follower_id = p_user_id - ) - -- Final Selection: Get activities from the log where the user_id is in the followed list. - SELECT - al.id, - al.user_id, - al.activity_type, - al.entity_id, - al.details, - al.created_at, - p.full_name AS user_full_name, - p.avatar_url AS user_avatar_url - FROM public.user_activity_log al - JOIN public.profiles p ON al.user_id = p.id - WHERE - al.user_id IN (SELECT following_id FROM FollowedUsers) - -- We can filter for specific activity types to make the feed more relevant. - AND al.activity_type IN ( - 'new_recipe', - 'favorite_recipe', - 'share_shopping_list' - -- 'new_recipe_rating' could be added here later - ) - ORDER BY - al.created_at DESC - LIMIT p_limit - OFFSET p_offset; -$$; - -- Function to archive a shopping list into a historical shopping trip. -- It creates a shopping_trip record, copies purchased items to shopping_trip_items, -- and then deletes the purchased items from the original shopping list. diff --git a/sql/initial.sql b/sql/initial.sql index d257fa6b..44e86692 100644 --- a/sql/initial.sql +++ b/sql/initial.sql @@ -60,6 +60,7 @@ CREATE TABLE IF NOT EXISTS public.flyers ( store_address TEXT ); COMMENT ON TABLE public.flyers IS 'Stores metadata for each processed flyer, linking it to a store and its validity period.'; +CREATE INDEX IF NOT EXISTS idx_flyers_store_id ON public.flyers(store_id); -- DONE -- 4. Create the 'master_grocery_items' table. This is the master dictionary. @@ -72,6 +73,7 @@ CREATE TABLE IF NOT EXISTS public.master_grocery_items ( allergy_info JSONB ); 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); -- DONE -- 5. Create the 'user_watched_items' table. This links to the master list. @@ -83,6 +85,7 @@ CREATE TABLE IF NOT EXISTS public.user_watched_items ( UNIQUE(user_id, master_item_id) ); COMMENT ON TABLE public.user_watched_items IS 'A linking table that represents a user''s personal watchlist of grocery items.'; +CREATE INDEX IF NOT EXISTS idx_user_watched_items_master_item_id ON public.user_watched_items(master_item_id); -- DONE -- 6. Create the 'flyer_items' table with its full, final schema. @@ -104,6 +107,10 @@ CREATE TABLE IF NOT EXISTS public.flyer_items ( product_id BIGINT -- Future use for specific product linking ); COMMENT ON TABLE public.flyer_items IS 'Stores individual items extracted from a specific flyer.'; +CREATE INDEX IF NOT EXISTS idx_flyer_items_flyer_id ON public.flyer_items(flyer_id); +CREATE INDEX IF NOT EXISTS idx_flyer_items_master_item_id ON public.flyer_items(master_item_id); +CREATE INDEX IF NOT EXISTS idx_flyer_items_category_id ON public.flyer_items(category_id); +CREATE INDEX IF NOT EXISTS idx_flyer_items_product_id ON public.flyer_items(product_id); -- DONE -- 7. Create a table for user-defined alerts on watched items. @@ -117,6 +124,7 @@ CREATE TABLE IF NOT EXISTS public.user_alerts ( ); COMMENT ON TABLE public.user_alerts IS 'Stores user-configured alert rules for their watched items.'; COMMENT ON COLUMN public.user_alerts.alert_type IS 'The condition that triggers the alert, e.g., ''PRICE_BELOW''.'; +CREATE INDEX IF NOT EXISTS idx_user_alerts_user_watched_item_id ON public.user_alerts(user_watched_item_id); COMMENT ON COLUMN public.user_alerts.threshold_value IS 'The numeric threshold for the alert condition (e.g., price in cents, or percentage).'; -- DONE @@ -131,6 +139,7 @@ CREATE TABLE IF NOT EXISTS public.notifications ( ); COMMENT ON TABLE public.notifications IS 'A central log of notifications generated for users, such as price alerts.'; COMMENT ON COLUMN public.notifications.content IS 'The notification message displayed to the user.'; +CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON public.notifications(user_id); COMMENT ON COLUMN public.notifications.link_url IS 'A URL to navigate to when the notification is clicked.'; -- DONE @@ -148,6 +157,8 @@ CREATE TABLE IF NOT EXISTS public.item_price_history ( ); COMMENT ON TABLE public.item_price_history IS 'Serves as a summary table to speed up charting and analytics.'; COMMENT ON COLUMN public.item_price_history.summary_date IS 'The date for which the price data is summarized.'; +CREATE INDEX IF NOT EXISTS idx_item_price_history_master_item_id ON public.item_price_history(master_item_id); +CREATE INDEX IF NOT EXISTS idx_item_price_history_store_location_id ON public.item_price_history(store_location_id); COMMENT ON COLUMN public.item_price_history.min_price_in_cents IS 'The lowest price found for this item on this day, in cents,'; COMMENT ON COLUMN public.item_price_history.max_price_in_cents IS 'The highest price found for this item on this day, in cents.'; COMMENT ON COLUMN public.item_price_history.avg_price_in_cents IS 'The average price found for this item on this day, in cents.'; @@ -163,6 +174,7 @@ CREATE TABLE IF NOT EXISTS public.master_item_aliases ( ); COMMENT ON TABLE public.master_item_aliases IS 'Stores synonyms or alternative names for master items to improve matching.'; COMMENT ON COLUMN public.master_item_aliases.alias IS 'An alternative name, e.g., "Ground Chuck" for the master item "Ground Beef".'; +CREATE INDEX IF NOT EXISTS idx_master_item_aliases_master_item_id ON public.master_item_aliases(master_item_id); -- DONE -- 11. Create tables for user shopping lists. @@ -173,6 +185,7 @@ CREATE TABLE IF NOT EXISTS public.shopping_lists ( created_at TIMESTAMPTZ DEFAULT now() NOT NULL ); COMMENT ON TABLE public.shopping_lists IS 'Stores user-created shopping lists, e.g., "Weekly Groceries".'; +CREATE INDEX IF NOT EXISTS idx_shopping_lists_user_id ON public.shopping_lists(user_id); -- DONE CREATE TABLE IF NOT EXISTS public.shopping_list_items ( @@ -189,6 +202,8 @@ CREATE TABLE IF NOT EXISTS public.shopping_list_items ( ); COMMENT ON TABLE public.shopping_list_items IS 'Contains individual items for a specific shopping list.'; COMMENT ON COLUMN public.shopping_list_items.custom_item_name IS 'For items not in the master list, e.g., "Grandma''s special spice mix".'; +CREATE INDEX IF NOT EXISTS idx_shopping_list_items_shopping_list_id ON public.shopping_list_items(shopping_list_id); +CREATE INDEX IF NOT EXISTS idx_shopping_list_items_master_item_id ON public.shopping_list_items(master_item_id); COMMENT ON COLUMN public.shopping_list_items.is_purchased IS 'Lets users check items off their list as they shop.'; -- A table to manage shared access to shopping lists. @@ -202,6 +217,9 @@ CREATE TABLE IF NOT EXISTS public.shared_shopping_lists ( UNIQUE(shopping_list_id, shared_with_user_id) ); COMMENT ON TABLE public.shared_shopping_lists IS 'Allows users to share shopping lists with others and set permissions.'; +CREATE INDEX IF NOT EXISTS idx_shared_shopping_lists_shopping_list_id ON public.shared_shopping_lists(shopping_list_id); +CREATE INDEX IF NOT EXISTS idx_shared_shopping_lists_shared_by_user_id ON public.shared_shopping_lists(shared_by_user_id); +CREATE INDEX IF NOT EXISTS idx_shared_shopping_lists_shared_with_user_id ON public.shared_shopping_lists(shared_with_user_id); -- A table to manage shared access to menu plans. CREATE TABLE IF NOT EXISTS public.shared_menu_plans ( @@ -214,6 +232,9 @@ CREATE TABLE IF NOT EXISTS public.shared_menu_plans ( UNIQUE(menu_plan_id, shared_with_user_id) ); COMMENT ON TABLE public.shared_menu_plans IS 'Allows users to share and collaborate on meal plans.'; +CREATE INDEX IF NOT EXISTS idx_shared_menu_plans_menu_plan_id ON public.shared_menu_plans(menu_plan_id); +CREATE INDEX IF NOT EXISTS idx_shared_menu_plans_shared_by_user_id ON public.shared_menu_plans(shared_by_user_id); +CREATE INDEX IF NOT EXISTS idx_shared_menu_plans_shared_with_user_id ON public.shared_menu_plans(shared_with_user_id); -- DONE -- 12. Create a table to store user-submitted corrections for flyer items. CREATE TABLE IF NOT EXISTS public.suggested_corrections ( @@ -230,6 +251,8 @@ CREATE TABLE IF NOT EXISTS public.suggested_corrections ( COMMENT ON TABLE public.suggested_corrections IS 'A queue for user-submitted data corrections, enabling crowdsourced data quality improvements.'; COMMENT ON COLUMN public.suggested_corrections.correction_type IS 'The type of error the user is reporting.'; COMMENT ON COLUMN public.suggested_corrections.suggested_value IS 'The corrected value proposed by the user (e.g., a new price or master_item_id).'; +CREATE INDEX IF NOT EXISTS idx_suggested_corrections_flyer_item_id ON public.suggested_corrections(flyer_item_id); +CREATE INDEX IF NOT EXISTS idx_suggested_corrections_user_id ON public.suggested_corrections(user_id); COMMENT ON COLUMN public.suggested_corrections.status IS 'The moderation status of the correction: pending, approved, or rejected.'; -- DONE @@ -247,6 +270,8 @@ CREATE TABLE IF NOT EXISTS public.user_submitted_prices ( ); COMMENT ON TABLE public.user_submitted_prices IS 'Stores item prices submitted by users directly from physical stores.'; COMMENT ON COLUMN public.user_submitted_prices.photo_url IS 'URL to user-submitted photo evidence of the price.'; +CREATE INDEX IF NOT EXISTS idx_user_submitted_prices_user_id ON public.user_submitted_prices(user_id); +CREATE INDEX IF NOT EXISTS idx_user_submitted_prices_master_item_id ON public.user_submitted_prices(master_item_id); COMMENT ON COLUMN public.user_submitted_prices.upvotes IS 'Community validation score indicating accuracy.'; -- A table to log flyer items that could not be automatically matched to a master item. @@ -259,13 +284,17 @@ CREATE TABLE IF NOT EXISTS public.unmatched_flyer_items ( UNIQUE(flyer_item_id) ); COMMENT ON TABLE public.unmatched_flyer_items IS 'A queue for reviewing flyer items that the system failed to automatically match.'; +CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_flyer_item_id ON public.unmatched_flyer_items(flyer_item_id); -- A table to store brand information. CREATE TABLE IF NOT EXISTS public.brands ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - name TEXT NOT NULL UNIQUE + name TEXT NOT NULL UNIQUE, + logo_url TEXT, + store_id BIGINT REFERENCES public.stores(id) ON DELETE SET NULL ); COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".'; +COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand (e.g., President''s Choice), this links to the parent store.'; -- 14. Pre-populate categories table from a predefined list. INSERT INTO public.categories (name) VALUES @@ -292,6 +321,8 @@ COMMENT ON COLUMN public.products.upc_code IS 'Universal Product Code, if availa COMMENT ON COLUMN public.products.brand_id IS 'Can be null for generic/store-brand items.'; COMMENT ON COLUMN public.products.name IS 'Prime Raised without Antibiotics Chicken Breast.'; COMMENT ON COLUMN public.products.size IS 'e.g., "4L", "500g".'; +CREATE INDEX IF NOT EXISTS idx_products_master_item_id ON public.products(master_item_id); +CREATE INDEX IF NOT EXISTS idx_products_brand_id ON public.products(brand_id); ADD CONSTRAINT flyer_items_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id); @@ -318,7 +349,7 @@ CREATE TABLE IF NOT EXISTS public.store_locations ( -- Use the 'geography' type for lat/lon data. location GEOGRAPHY(Point, 4326) ); -CREATE INDEX IF NOT EXISTS store_locations_geo_idx ON public.store_locations USING GIST (location); +CREATE INDEX IF NOT EXISTS idx_store_locations_store_id ON public.store_locations(store_id); COMMENT ON TABLE public.store_locations IS 'Stores physical locations of stores with geographic data for proximity searches.'; COMMENT ON COLUMN public.store_locations.location IS 'Geographic coordinates (longitude, latitude) of the store.'; -- Add a GIST index for efficient geographic queries. @@ -333,6 +364,8 @@ CREATE TABLE IF NOT EXISTS public.flyer_locations ( PRIMARY KEY (flyer_id, store_location_id) ); COMMENT ON TABLE public.flyer_locations IS 'A linking table associating a single flyer with multiple store locations where its deals are valid.'; +CREATE INDEX IF NOT EXISTS idx_flyer_locations_flyer_id ON public.flyer_locations(flyer_id); +CREATE INDEX IF NOT EXISTS idx_flyer_locations_store_location_id ON public.flyer_locations(store_location_id); -- done -- A table to store recipes, which can be user-created or pre-populated. @@ -360,6 +393,8 @@ CREATE TABLE IF NOT EXISTS public.recipes ( ); COMMENT ON TABLE public.recipes IS 'Stores recipes that can be used to generate shopping lists.'; COMMENT ON COLUMN public.recipes.servings IS 'The number of servings this recipe yields.'; +CREATE INDEX IF NOT EXISTS idx_recipes_user_id ON public.recipes(user_id); +CREATE INDEX IF NOT EXISTS idx_recipes_original_recipe_id ON public.recipes(original_recipe_id); COMMENT ON COLUMN public.recipes.original_recipe_id IS 'If this recipe is a variation of another, this points to the original.'; -- done @@ -373,6 +408,8 @@ CREATE TABLE IF NOT EXISTS public.recipe_ingredients ( ); COMMENT ON TABLE public.recipe_ingredients IS 'Defines the ingredients and quantities needed for a recipe.'; COMMENT ON COLUMN public.recipe_ingredients.unit IS 'e.g., "cups", "tbsp", "g", "each".'; +CREATE INDEX IF NOT EXISTS idx_recipe_ingredients_recipe_id ON public.recipe_ingredients(recipe_id); +CREATE INDEX IF NOT EXISTS idx_recipe_ingredients_master_item_id ON public.recipe_ingredients(master_item_id); -- A table to suggest ingredient substitutions for a recipe. CREATE TABLE IF NOT EXISTS public.recipe_ingredient_substitutions ( @@ -383,6 +420,8 @@ CREATE TABLE IF NOT EXISTS public.recipe_ingredient_substitutions ( UNIQUE(recipe_ingredient_id, substitute_master_item_id) ); COMMENT ON TABLE public.recipe_ingredient_substitutions IS 'Stores suggested alternative ingredients for a recipe item (e.g., "butter" for "olive oil").'; +CREATE INDEX IF NOT EXISTS idx_recipe_ingredient_substitutions_recipe_ingredient_id ON public.recipe_ingredient_substitutions(recipe_ingredient_id); +CREATE INDEX IF NOT EXISTS idx_recipe_ingredient_substitutions_substitute_master_item_id ON public.recipe_ingredient_substitutions(substitute_master_item_id); -- done -- A table to store a predefined list of tags for recipes. @@ -400,6 +439,8 @@ CREATE TABLE IF NOT EXISTS public.recipe_tags ( PRIMARY KEY (recipe_id, tag_id) ); COMMENT ON TABLE public.recipe_tags IS 'A linking table to associate multiple tags with a single recipe.'; +CREATE INDEX IF NOT EXISTS idx_recipe_tags_recipe_id ON public.recipe_tags(recipe_id); +CREATE INDEX IF NOT EXISTS idx_recipe_tags_tag_id ON public.recipe_tags(tag_id); -- A linking table to associate recipes with required appliances. CREATE TABLE IF NOT EXISTS public.recipe_appliances ( @@ -408,6 +449,8 @@ CREATE TABLE IF NOT EXISTS public.recipe_appliances ( PRIMARY KEY (recipe_id, appliance_id) ); COMMENT ON TABLE public.recipe_appliances IS 'Links recipes to the specific kitchen appliances they require.'; +CREATE INDEX IF NOT EXISTS idx_recipe_appliances_recipe_id ON public.recipe_appliances(recipe_id); +CREATE INDEX IF NOT EXISTS idx_recipe_appliances_appliance_id ON public.recipe_appliances(appliance_id); -- done -- A table to store individual user ratings for recipes. @@ -421,6 +464,8 @@ CREATE TABLE IF NOT EXISTS public.recipe_ratings ( UNIQUE(recipe_id, user_id) -- A user can only rate a recipe once. ); COMMENT ON TABLE public.recipe_ratings IS 'Stores individual user ratings for recipes, ensuring a user can only rate a recipe once.'; +CREATE INDEX IF NOT EXISTS idx_recipe_ratings_recipe_id ON public.recipe_ratings(recipe_id); +CREATE INDEX IF NOT EXISTS idx_recipe_ratings_user_id ON public.recipe_ratings(user_id); -- A table for user comments on recipes to enable discussion. CREATE TABLE IF NOT EXISTS public.recipe_comments ( @@ -434,6 +479,9 @@ CREATE TABLE IF NOT EXISTS public.recipe_comments ( updated_at TIMESTAMPTZ ); COMMENT ON TABLE public.recipe_comments IS 'Allows for threaded discussions and comments on recipes.'; +CREATE INDEX IF NOT EXISTS idx_recipe_comments_recipe_id ON public.recipe_comments(recipe_id); +CREATE INDEX IF NOT EXISTS idx_recipe_comments_user_id ON public.recipe_comments(user_id); +CREATE INDEX IF NOT EXISTS idx_recipe_comments_parent_comment_id ON public.recipe_comments(parent_comment_id); -- DONE -- A table to store a user's collection of planned meals for a date range. @@ -447,6 +495,7 @@ CREATE TABLE IF NOT EXISTS public.menu_plans ( CONSTRAINT date_range_check CHECK (end_date >= start_date) ); COMMENT ON TABLE public.menu_plans IS 'Represents a user''s meal plan for a specific period, e.g., "Week of Oct 23".'; +CREATE INDEX IF NOT EXISTS idx_menu_plans_user_id ON public.menu_plans(user_id); -- DONE -- A table to associate a recipe with a specific date and meal type within a menu plan. @@ -460,6 +509,8 @@ CREATE TABLE IF NOT EXISTS public.planned_meals ( ); COMMENT ON TABLE public.planned_meals IS 'Assigns a recipe to a specific day and meal type within a user''s menu plan.'; COMMENT ON COLUMN public.planned_meals.meal_type IS 'The designated meal for the recipe, e.g., ''Breakfast'', ''Lunch'', ''Dinner''.'; +CREATE INDEX IF NOT EXISTS idx_planned_meals_menu_plan_id ON public.planned_meals(menu_plan_id); +CREATE INDEX IF NOT EXISTS idx_planned_meals_recipe_id ON public.planned_meals(recipe_id); -- DONE -- A table to track the grocery items a user currently has in their pantry. @@ -477,10 +528,12 @@ CREATE TABLE IF NOT EXISTS public.pantry_items ( ); COMMENT ON TABLE public.pantry_items IS 'Tracks a user''s personal inventory of grocery items to enable smart shopping lists.'; COMMENT ON COLUMN public.pantry_items.quantity IS 'The current amount of the item. Convention: use grams for weight, mL for volume where applicable.'; +CREATE INDEX IF NOT EXISTS idx_pantry_items_user_id ON public.pantry_items(user_id); +CREATE INDEX IF NOT EXISTS idx_pantry_items_master_item_id ON public.pantry_items(master_item_id); +CREATE INDEX IF NOT EXISTS idx_pantry_items_pantry_location_id ON public.pantry_items(pantry_location_id); COMMENT ON COLUMN public.pantry_items.pantry_location_id IS 'Links the item to a user-defined location like "Fridge" or "Freezer".'; COMMENT ON COLUMN public.pantry_items.unit IS 'e.g., ''g'', ''ml'', ''items''. Should align with recipe_ingredients.unit and quantity convention.'; -CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token_hash ON public.password_reset_tokens(token_hash); -- A table to store password reset tokens. CREATE TABLE IF NOT EXISTS public.password_reset_tokens ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, @@ -491,6 +544,8 @@ CREATE TABLE IF NOT EXISTS public.password_reset_tokens ( ); COMMENT ON TABLE public.password_reset_tokens IS 'Stores secure, single-use tokens for password reset requests.'; COMMENT ON COLUMN public.password_reset_tokens.token_hash IS 'A bcrypt hash of the reset token sent to the user.'; +CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON public.password_reset_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token_hash ON public.password_reset_tokens(token_hash); COMMENT ON COLUMN public.password_reset_tokens.expires_at IS 'The timestamp when this token is no longer valid.'; -- A table to store unit conversion factors for specific master grocery items. @@ -504,6 +559,7 @@ CREATE TABLE IF NOT EXISTS public.unit_conversions ( ); COMMENT ON TABLE public.unit_conversions IS 'Stores item-specific unit conversion factors (e.g., grams of flour to cups).'; COMMENT ON COLUMN public.unit_conversions.factor IS 'The multiplication factor to convert from_unit to to_unit.'; +CREATE INDEX IF NOT EXISTS idx_unit_conversions_master_item_id ON public.unit_conversions(master_item_id); -- A table for users to create their own private aliases for items. CREATE TABLE IF NOT EXISTS public.user_item_aliases ( @@ -514,6 +570,8 @@ CREATE TABLE IF NOT EXISTS public.user_item_aliases ( UNIQUE(user_id, alias) ); COMMENT ON TABLE public.user_item_aliases IS 'Allows users to create personal aliases for grocery items (e.g., "Dad''s Cereal").'; +CREATE INDEX IF NOT EXISTS idx_user_item_aliases_user_id ON public.user_item_aliases(user_id); +CREATE INDEX IF NOT EXISTS idx_user_item_aliases_master_item_id ON public.user_item_aliases(master_item_id); -- A table for users to mark their favorite recipes. CREATE TABLE IF NOT EXISTS public.favorite_recipes ( @@ -523,6 +581,8 @@ CREATE TABLE IF NOT EXISTS public.favorite_recipes ( PRIMARY KEY (user_id, recipe_id) ); COMMENT ON TABLE public.favorite_recipes IS 'A simple linking table for users to mark their favorite recipes.'; +CREATE INDEX IF NOT EXISTS idx_favorite_recipes_user_id ON public.favorite_recipes(user_id); +CREATE INDEX IF NOT EXISTS idx_favorite_recipes_recipe_id ON public.favorite_recipes(recipe_id); -- A table for users to mark their favorite stores. CREATE TABLE IF NOT EXISTS public.favorite_stores ( @@ -532,6 +592,8 @@ CREATE TABLE IF NOT EXISTS public.favorite_stores ( PRIMARY KEY (user_id, store_id) ); COMMENT ON TABLE public.favorite_stores IS 'A simple linking table for users to mark their favorite stores.'; +CREATE INDEX IF NOT EXISTS idx_favorite_stores_user_id ON public.favorite_stores(user_id); +CREATE INDEX IF NOT EXISTS idx_favorite_stores_store_id ON public.favorite_stores(store_id); -- A generic table to log key user activities for analytics. CREATE TABLE IF NOT EXISTS public.user_activity_log ( @@ -543,6 +605,7 @@ CREATE TABLE IF NOT EXISTS public.user_activity_log ( created_at TIMESTAMPTZ DEFAULT now() NOT NULL ); COMMENT ON TABLE public.user_activity_log IS 'Logs key user actions for analytics and behavior analysis.'; +CREATE INDEX IF NOT EXISTS idx_user_activity_log_user_id ON public.user_activity_log(user_id); -- A table for users to group recipes into collections. CREATE TABLE IF NOT EXISTS public.recipe_collections ( @@ -553,6 +616,7 @@ CREATE TABLE IF NOT EXISTS public.recipe_collections ( created_at TIMESTAMPTZ DEFAULT now() NOT NULL ); COMMENT ON TABLE public.recipe_collections IS 'Allows users to create personal collections of recipes (e.g., "Holiday Baking").'; +CREATE INDEX IF NOT EXISTS idx_recipe_collections_user_id ON public.recipe_collections(user_id); -- A linking table to associate recipes with a user's collection. CREATE TABLE IF NOT EXISTS public.recipe_collection_items ( @@ -562,6 +626,8 @@ CREATE TABLE IF NOT EXISTS public.recipe_collection_items ( PRIMARY KEY (collection_id, recipe_id) ); COMMENT ON TABLE public.recipe_collection_items IS 'Links recipes to a user-defined collection.'; +CREATE INDEX IF NOT EXISTS idx_recipe_collection_items_collection_id ON public.recipe_collection_items(collection_id); +CREATE INDEX IF NOT EXISTS idx_recipe_collection_items_recipe_id ON public.recipe_collection_items(recipe_id); -- A table for users to define locations within their pantry. CREATE TABLE IF NOT EXISTS public.pantry_locations ( @@ -571,6 +637,7 @@ CREATE TABLE IF NOT EXISTS public.pantry_locations ( UNIQUE(user_id, name) ); COMMENT ON TABLE public.pantry_locations IS 'User-defined locations for organizing pantry items (e.g., "Fridge", "Freezer", "Spice Rack").'; +CREATE INDEX IF NOT EXISTS idx_pantry_locations_user_id ON public.pantry_locations(user_id); -- A table to log user search queries for analysis. CREATE TABLE IF NOT EXISTS public.search_queries ( @@ -583,6 +650,7 @@ CREATE TABLE IF NOT EXISTS public.search_queries ( ); COMMENT ON TABLE public.search_queries IS 'Logs user search queries to analyze search effectiveness and identify gaps in data.'; COMMENT ON COLUMN public.search_queries.was_successful IS 'Indicates if the user interacted with a search result.'; +CREATE INDEX IF NOT EXISTS idx_search_queries_user_id ON public.search_queries(user_id); -- A table to store historical records of completed shopping trips. CREATE TABLE IF NOT EXISTS public.shopping_trips ( @@ -594,6 +662,8 @@ CREATE TABLE IF NOT EXISTS public.shopping_trips ( ); COMMENT ON TABLE public.shopping_trips IS 'A historical record of a completed shopping trip.'; COMMENT ON COLUMN public.shopping_trips.total_spent_cents IS 'The total amount spent on this shopping trip, if provided by the user.'; +CREATE INDEX IF NOT EXISTS idx_shopping_trips_user_id ON public.shopping_trips(user_id); +CREATE INDEX IF NOT EXISTS idx_shopping_trips_shopping_list_id ON public.shopping_trips(shopping_list_id); -- A table to store the items purchased during a specific shopping trip. CREATE TABLE IF NOT EXISTS public.shopping_trip_items ( @@ -607,6 +677,8 @@ CREATE TABLE IF NOT EXISTS public.shopping_trip_items ( ); COMMENT ON TABLE public.shopping_trip_items IS 'A historical log of items purchased during a shopping trip.'; COMMENT ON COLUMN public.shopping_trip_items.price_paid_cents IS 'The actual price paid for the item during the trip, if provided.'; +CREATE INDEX IF NOT EXISTS idx_shopping_trip_items_shopping_trip_id ON public.shopping_trip_items(shopping_trip_id); +CREATE INDEX IF NOT EXISTS idx_shopping_trip_items_master_item_id ON public.shopping_trip_items(master_item_id); @@ -625,6 +697,8 @@ CREATE TABLE IF NOT EXISTS public.user_dietary_restrictions ( PRIMARY KEY (user_id, restriction_id) ); COMMENT ON TABLE public.user_dietary_restrictions IS 'Connects users to their selected dietary needs and allergies.'; +CREATE INDEX IF NOT EXISTS idx_user_dietary_restrictions_user_id ON public.user_dietary_restrictions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_dietary_restrictions_restriction_id ON public.user_dietary_restrictions(restriction_id); -- A table to store a predefined list of kitchen appliances. CREATE TABLE IF NOT EXISTS public.appliances ( @@ -640,6 +714,8 @@ CREATE TABLE IF NOT EXISTS public.user_appliances ( PRIMARY KEY (user_id, appliance_id) ); COMMENT ON TABLE public.user_appliances IS 'Tracks the kitchen appliances a user owns to help with recipe recommendations.'; +CREATE INDEX IF NOT EXISTS idx_user_appliances_user_id ON public.user_appliances(user_id); +CREATE INDEX IF NOT EXISTS idx_user_appliances_appliance_id ON public.user_appliances(appliance_id); -- A table to manage the social graph (following relationships). CREATE TABLE IF NOT EXISTS public.user_follows ( @@ -650,3 +726,37 @@ CREATE TABLE IF NOT EXISTS public.user_follows ( CONSTRAINT cant_follow_self CHECK (follower_id <> following_id) ); COMMENT ON TABLE public.user_follows IS 'Stores user following relationships to build a social graph.'; +CREATE INDEX IF NOT EXISTS idx_user_follows_follower_id ON public.user_follows(follower_id); +CREATE INDEX IF NOT EXISTS idx_user_follows_following_id ON public.user_follows(following_id); + +-- A table to 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_image_url TEXT NOT NULL, + transaction_date TIMESTAMPTZ, + total_amount_cents INTEGER, + status TEXT DEFAULT 'pending' NOT NULL CHECK (status IN ('pending', 'processing', 'completed', 'failed')), + raw_text TEXT, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + processed_at TIMESTAMPTZ +); +COMMENT ON TABLE public.receipts IS 'Stores uploaded user receipts for purchase tracking and analysis.'; +CREATE INDEX IF NOT EXISTS idx_receipts_user_id ON public.receipts(user_id); +CREATE INDEX IF NOT EXISTS idx_receipts_store_id ON public.receipts(store_id); + +-- A table to 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, + 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), + status TEXT DEFAULT 'unmatched' NOT NULL CHECK (status IN ('unmatched', 'matched', 'needs_review', 'ignored')) +); +COMMENT ON TABLE public.receipt_items IS 'Stores individual line items extracted from a user receipt.'; +CREATE INDEX IF NOT EXISTS idx_receipt_items_receipt_id ON public.receipt_items(receipt_id); +CREATE INDEX IF NOT EXISTS idx_receipt_items_master_item_id ON public.receipt_items(master_item_id); diff --git a/sql/initial_data.sql b/sql/initial_data.sql index 90d92db1..5d2efef6 100644 --- a/sql/initial_data.sql +++ b/sql/initial_data.sql @@ -56,11 +56,15 @@ END $$; -- This block adds common brands and links them to specific products. DO $$ DECLARE - -- Brand IDs - coke_id BIGINT; kraft_id BIGINT; maple_leaf_id BIGINT; dempsters_id BIGINT; no_name_id BIGINT; pc_id BIGINT; + -- Store & Brand IDs + loblaws_id BIGINT; coke_id BIGINT; kraft_id BIGINT; maple_leaf_id BIGINT; dempsters_id BIGINT; no_name_id BIGINT; pc_id BIGINT; -- Master Item IDs soda_item_id BIGINT; pasta_item_id BIGINT; turkey_item_id BIGINT; bread_item_id BIGINT; cheese_item_id BIGINT; 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'; + -- 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; @@ -72,6 +76,10 @@ BEGIN 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'; + -- 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 id INTO soda_item_id FROM public.master_grocery_items WHERE name = 'soda'; SELECT id INTO pasta_item_id FROM public.master_grocery_items WHERE name = 'pasta'; @@ -232,11 +240,3 @@ INSERT INTO public.appliances (name) VALUES ('Stand Mixer'), ('Hand Mixer'), ('Air Fryer'), ('Instant Pot'), ('Slow Cooker'), ('Grill'), ('Toaster') ON CONFLICT (name) DO NOTHING; -END $$; - --- 8. Pre-populate the appliances table. -INSERT INTO public.appliances (name) VALUES -('Oven'), ('Microwave'), ('Stovetop'), ('Blender'), ('Food Processor'), -('Stand Mixer'), ('Hand Mixer'), ('Air Fryer'), ('Instant Pot'), ('Slow Cooker'), -('Grill'), ('Toaster') -ON CONFLICT (name) DO NOTHING; \ No newline at end of file diff --git a/sql/master_schema_rollup.sql b/sql/master_schema_rollup.sql index 5a29b40e..8bc1606e 100644 --- a/sql/master_schema_rollup.sql +++ b/sql/master_schema_rollup.sql @@ -86,6 +86,7 @@ CREATE TABLE IF NOT EXISTS public.flyers ( store_address TEXT ); COMMENT ON TABLE public.flyers IS 'Stores metadata for each processed flyer, linking it to a store and its validity period.'; +CREATE INDEX IF NOT EXISTS idx_flyers_store_id ON public.flyers(store_id); COMMENT ON COLUMN public.flyers.file_name IS 'The original name of the uploaded flyer file (e.g., "flyer_week_1.pdf").'; COMMENT ON COLUMN public.flyers.image_url IS 'The public URL of the primary flyer image stored in Supabase Storage.'; COMMENT ON COLUMN public.flyers.checksum IS 'A SHA-256 hash of the original file content to prevent duplicate processing.'; @@ -94,6 +95,17 @@ COMMENT ON COLUMN public.flyers.valid_from IS 'The start date of the sale period COMMENT ON COLUMN public.flyers.valid_to IS 'The end date of the sale period for this flyer, extracted by the AI.'; COMMENT ON COLUMN public.flyers.store_address IS 'The physical store address if it was successfully extracted from the flyer image.'; +-- 4. Create 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, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + name TEXT NOT NULL UNIQUE, + category_id BIGINT REFERENCES public.categories(id), + is_allergen BOOLEAN DEFAULT false, + allergy_info JSONB +); +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); -- 6. Create the 'flyer_items' table with its full, final schema. CREATE TABLE IF NOT EXISTS public.flyer_items ( @@ -114,6 +126,10 @@ CREATE TABLE IF NOT EXISTS public.flyer_items ( product_id BIGINT -- Future use for specific product linking ); COMMENT ON TABLE public.flyer_items IS 'Stores individual items extracted from a specific flyer.'; +CREATE INDEX IF NOT EXISTS idx_flyer_items_flyer_id ON public.flyer_items(flyer_id); +CREATE INDEX IF NOT EXISTS idx_flyer_items_master_item_id ON public.flyer_items(master_item_id); +CREATE INDEX IF NOT EXISTS idx_flyer_items_category_id ON public.flyer_items(category_id); +CREATE INDEX IF NOT EXISTS idx_flyer_items_product_id ON public.flyer_items(product_id); COMMENT ON COLUMN public.flyer_items.flyer_id IS 'Foreign key linking this item to its parent flyer in the `flyers` table.'; COMMENT ON COLUMN public.flyer_items.item IS 'The raw item name as it appears in the flyer (e.g., "Granny Smith Apples").'; COMMENT ON COLUMN public.flyer_items.price_display IS 'The raw price string from the flyer (e.g., "$3.99", "2 for $5.00").'; @@ -151,6 +167,7 @@ CREATE TABLE IF NOT EXISTS public.user_watched_items ( UNIQUE(user_id, master_item_id) ); COMMENT ON TABLE public.user_watched_items IS 'A linking table that represents a user''s personal watchlist of grocery items.'; +CREATE INDEX IF NOT EXISTS idx_user_watched_items_master_item_id ON public.user_watched_items(master_item_id); -- 7. Create a table for user-defined alerts on watched items. CREATE TABLE IF NOT EXISTS public.user_alerts ( @@ -163,6 +180,7 @@ CREATE TABLE IF NOT EXISTS public.user_alerts ( ); COMMENT ON TABLE public.user_alerts IS 'Stores user-configured alert rules for their watched items.'; COMMENT ON COLUMN public.user_alerts.alert_type IS 'The condition that triggers the alert, e.g., ''PRICE_BELOW''.'; +CREATE INDEX IF NOT EXISTS idx_user_alerts_user_watched_item_id ON public.user_alerts(user_watched_item_id); COMMENT ON COLUMN public.user_alerts.threshold_value IS 'The numeric threshold for the alert condition (e.g., price in cents, or percentage).'; @@ -177,6 +195,7 @@ CREATE TABLE IF NOT EXISTS public.notifications ( ); COMMENT ON TABLE public.notifications IS 'A central log of notifications generated for users, such as price alerts.'; COMMENT ON COLUMN public.notifications.content IS 'The notification message displayed to the user.'; +CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON public.notifications(user_id); COMMENT ON COLUMN public.notifications.link_url IS 'A URL to navigate to when the notification is clicked.'; @@ -194,6 +213,8 @@ CREATE TABLE IF NOT EXISTS public.item_price_history ( ); COMMENT ON TABLE public.item_price_history IS 'Serves as a summary table to speed up charting and analytics.'; COMMENT ON COLUMN public.item_price_history.summary_date IS 'The date for which the price data is summarized.'; +CREATE INDEX IF NOT EXISTS idx_item_price_history_master_item_id ON public.item_price_history(master_item_id); +CREATE INDEX IF NOT EXISTS idx_item_price_history_store_location_id ON public.item_price_history(store_location_id); -- 10. Create a table to map various names to a single master grocery item. @@ -204,6 +225,7 @@ CREATE TABLE IF NOT EXISTS public.master_item_aliases ( ); COMMENT ON TABLE public.master_item_aliases IS 'Stores synonyms or alternative names for master items to improve matching.'; COMMENT ON COLUMN public.master_item_aliases.alias IS 'An alternative name, e.g., "Ground Chuck" for the master item "Ground Beef".'; +CREATE INDEX IF NOT EXISTS idx_master_item_aliases_master_item_id ON public.master_item_aliases(master_item_id); -- 11. Create tables for user shopping lists. @@ -214,6 +236,7 @@ CREATE TABLE IF NOT EXISTS public.shopping_lists ( created_at TIMESTAMPTZ DEFAULT now() NOT NULL ); COMMENT ON TABLE public.shopping_lists IS 'Stores user-created shopping lists, e.g., "Weekly Groceries".'; +CREATE INDEX IF NOT EXISTS idx_shopping_lists_user_id ON public.shopping_lists(user_id); CREATE TABLE IF NOT EXISTS public.shopping_list_items ( @@ -229,6 +252,8 @@ CREATE TABLE IF NOT EXISTS public.shopping_list_items ( ); COMMENT ON TABLE public.shopping_list_items IS 'Contains individual items for a specific shopping list.'; COMMENT ON COLUMN public.shopping_list_items.custom_item_name IS 'For items not in the master list, e.g., "Grandma''s special spice mix".'; +CREATE INDEX IF NOT EXISTS idx_shopping_list_items_shopping_list_id ON public.shopping_list_items(shopping_list_id); +CREATE INDEX IF NOT EXISTS idx_shopping_list_items_master_item_id ON public.shopping_list_items(master_item_id); COMMENT ON COLUMN public.shopping_list_items.is_purchased IS 'Lets users check items off their list as they shop.'; @@ -243,6 +268,9 @@ CREATE TABLE IF NOT EXISTS public.shared_shopping_lists ( UNIQUE(shopping_list_id, shared_with_user_id) ); COMMENT ON TABLE public.shared_shopping_lists IS 'Allows users to share shopping lists with others and set permissions.'; +CREATE INDEX IF NOT EXISTS idx_shared_shopping_lists_shopping_list_id ON public.shared_shopping_lists(shopping_list_id); +CREATE INDEX IF NOT EXISTS idx_shared_shopping_lists_shared_by_user_id ON public.shared_shopping_lists(shared_by_user_id); +CREATE INDEX IF NOT EXISTS idx_shared_shopping_lists_shared_with_user_id ON public.shared_shopping_lists(shared_with_user_id); -- A table to manage shared access to menu plans. CREATE TABLE IF NOT EXISTS public.shared_menu_plans ( @@ -255,6 +283,9 @@ CREATE TABLE IF NOT EXISTS public.shared_menu_plans ( UNIQUE(menu_plan_id, shared_with_user_id) ); COMMENT ON TABLE public.shared_menu_plans IS 'Allows users to share and collaborate on meal plans.'; +CREATE INDEX IF NOT EXISTS idx_shared_menu_plans_menu_plan_id ON public.shared_menu_plans(menu_plan_id); +CREATE INDEX IF NOT EXISTS idx_shared_menu_plans_shared_by_user_id ON public.shared_menu_plans(shared_by_user_id); +CREATE INDEX IF NOT EXISTS idx_shared_menu_plans_shared_with_user_id ON public.shared_menu_plans(shared_with_user_id); -- 12. Create a table to store user-submitted corrections for flyer items. CREATE TABLE IF NOT EXISTS public.suggested_corrections ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, @@ -270,6 +301,8 @@ CREATE TABLE IF NOT EXISTS public.suggested_corrections ( COMMENT ON TABLE public.suggested_corrections IS 'A queue for user-submitted data corrections, enabling crowdsourced data quality improvements.'; COMMENT ON COLUMN public.suggested_corrections.correction_type IS 'The type of error the user is reporting.'; COMMENT ON COLUMN public.suggested_corrections.suggested_value IS 'The corrected value proposed by the user (e.g., a new price or master_item_id).'; +CREATE INDEX IF NOT EXISTS idx_suggested_corrections_flyer_item_id ON public.suggested_corrections(flyer_item_id); +CREATE INDEX IF NOT EXISTS idx_suggested_corrections_user_id ON public.suggested_corrections(user_id); COMMENT ON COLUMN public.suggested_corrections.status IS 'The moderation status of the correction: pending, approved, or rejected.'; @@ -287,6 +320,8 @@ CREATE TABLE IF NOT EXISTS public.user_submitted_prices ( ); COMMENT ON TABLE public.user_submitted_prices IS 'Stores item prices submitted by users directly from physical stores.'; COMMENT ON COLUMN public.user_submitted_prices.photo_url IS 'URL to user-submitted photo evidence of the price.'; +CREATE INDEX IF NOT EXISTS idx_user_submitted_prices_user_id ON public.user_submitted_prices(user_id); +CREATE INDEX IF NOT EXISTS idx_user_submitted_prices_master_item_id ON public.user_submitted_prices(master_item_id); COMMENT ON COLUMN public.user_submitted_prices.upvotes IS 'Community validation score indicating accuracy.'; -- A table to log flyer items that could not be automatically matched to a master item. @@ -299,21 +334,17 @@ CREATE TABLE IF NOT EXISTS public.unmatched_flyer_items ( UNIQUE(flyer_item_id) ); COMMENT ON TABLE public.unmatched_flyer_items IS 'A queue for reviewing flyer items that the system failed to automatically match.'; +CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_flyer_item_id ON public.unmatched_flyer_items(flyer_item_id); -- A table to store brand information. CREATE TABLE IF NOT EXISTS public.brands ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - name TEXT NOT NULL UNIQUE -); -COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".'; - - --- A table to store brand information. -CREATE TABLE IF NOT EXISTS public.brands ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - name TEXT NOT NULL UNIQUE + name TEXT NOT NULL UNIQUE, + logo_url TEXT, + store_id BIGINT REFERENCES public.stores(id) ON DELETE SET NULL ); COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".'; +COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand (e.g., President''s Choice), this links to the parent store.'; -- A table for specific products, linking a master item with a brand and size. @@ -328,6 +359,8 @@ CREATE TABLE IF NOT EXISTS public.products ( ); COMMENT ON TABLE public.products IS 'Represents a specific, sellable product, combining a generic item with a brand and size.'; COMMENT ON COLUMN public.products.upc_code IS 'Universal Product Code, if available, for exact product matching.'; +CREATE INDEX IF NOT EXISTS idx_products_master_item_id ON public.products(master_item_id); +CREATE INDEX IF NOT EXISTS idx_products_brand_id ON public.products(brand_id); -- Link flyer_items to the new products table. -- This is done via ALTER TABLE because 'products' is created after 'flyer_items'. @@ -347,6 +380,7 @@ CREATE TABLE IF NOT EXISTS public.store_locations ( ); COMMENT ON TABLE public.store_locations IS 'Stores physical locations of stores with geographic data for proximity searches.'; COMMENT ON COLUMN public.store_locations.location IS 'Geographic coordinates (longitude, latitude) of the store.'; +CREATE INDEX IF NOT EXISTS idx_store_locations_store_id ON public.store_locations(store_id); -- Add a GIST index for efficient geographic queries. -- This requires the postgis extension. @@ -360,6 +394,8 @@ CREATE TABLE IF NOT EXISTS public.flyer_locations ( PRIMARY KEY (flyer_id, store_location_id) ); COMMENT ON TABLE public.flyer_locations IS 'A linking table associating a single flyer with multiple store locations where its deals are valid.'; +CREATE INDEX IF NOT EXISTS idx_flyer_locations_flyer_id ON public.flyer_locations(flyer_id); +CREATE INDEX IF NOT EXISTS idx_flyer_locations_store_location_id ON public.flyer_locations(store_location_id); -- A table to store recipes, which can be user-created or pre-populated. CREATE TABLE IF NOT EXISTS public.recipes ( @@ -384,6 +420,8 @@ CREATE TABLE IF NOT EXISTS public.recipes ( ); COMMENT ON TABLE public.recipes IS 'Stores recipes that can be used to generate shopping lists.'; COMMENT ON COLUMN public.recipes.servings IS 'The number of servings this recipe yields.'; +CREATE INDEX IF NOT EXISTS idx_recipes_user_id ON public.recipes(user_id); +CREATE INDEX IF NOT EXISTS idx_recipes_original_recipe_id ON public.recipes(original_recipe_id); COMMENT ON COLUMN public.recipes.original_recipe_id IS 'If this recipe is a variation of another, this points to the original.'; @@ -396,6 +434,8 @@ CREATE TABLE IF NOT EXISTS public.recipe_ingredients ( unit TEXT NOT NULL ); COMMENT ON TABLE public.recipe_ingredients IS 'Defines the ingredients and quantities needed for a recipe.'; +CREATE INDEX IF NOT EXISTS idx_recipe_ingredients_recipe_id ON public.recipe_ingredients(recipe_id); +CREATE INDEX IF NOT EXISTS idx_recipe_ingredients_master_item_id ON public.recipe_ingredients(master_item_id); -- A table to suggest ingredient substitutions for a recipe. CREATE TABLE IF NOT EXISTS public.recipe_ingredient_substitutions ( @@ -406,6 +446,8 @@ CREATE TABLE IF NOT EXISTS public.recipe_ingredient_substitutions ( UNIQUE(recipe_ingredient_id, substitute_master_item_id) ); COMMENT ON TABLE public.recipe_ingredient_substitutions IS 'Stores suggested alternative ingredients for a recipe item (e.g., "butter" for "olive oil").'; +CREATE INDEX IF NOT EXISTS idx_recipe_ingredient_substitutions_recipe_ingredient_id ON public.recipe_ingredient_substitutions(recipe_ingredient_id); +CREATE INDEX IF NOT EXISTS idx_recipe_ingredient_substitutions_substitute_master_item_id ON public.recipe_ingredient_substitutions(substitute_master_item_id); -- A table to store a predefined list of tags for recipes. CREATE TABLE IF NOT EXISTS public.tags ( @@ -422,6 +464,8 @@ CREATE TABLE IF NOT EXISTS public.recipe_tags ( PRIMARY KEY (recipe_id, tag_id) ); COMMENT ON TABLE public.recipe_tags IS 'A linking table to associate multiple tags with a single recipe.'; +CREATE INDEX IF NOT EXISTS idx_recipe_tags_recipe_id ON public.recipe_tags(recipe_id); +CREATE INDEX IF NOT EXISTS idx_recipe_tags_tag_id ON public.recipe_tags(tag_id); -- A linking table to associate recipes with required appliances. CREATE TABLE IF NOT EXISTS public.recipe_appliances ( @@ -430,14 +474,8 @@ CREATE TABLE IF NOT EXISTS public.recipe_appliances ( PRIMARY KEY (recipe_id, appliance_id) ); COMMENT ON TABLE public.recipe_appliances IS 'Links recipes to the specific kitchen appliances they require.'; - --- A linking table to 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, - PRIMARY KEY (recipe_id, appliance_id) -); -COMMENT ON TABLE public.recipe_appliances IS 'Links recipes to the specific kitchen appliances they require.'; +CREATE INDEX IF NOT EXISTS idx_recipe_appliances_recipe_id ON public.recipe_appliances(recipe_id); +CREATE INDEX IF NOT EXISTS idx_recipe_appliances_appliance_id ON public.recipe_appliances(appliance_id); -- A table to store individual user ratings for recipes. CREATE TABLE IF NOT EXISTS public.recipe_ratings ( @@ -450,6 +488,8 @@ CREATE TABLE IF NOT EXISTS public.recipe_ratings ( UNIQUE(recipe_id, user_id) ); COMMENT ON TABLE public.recipe_ratings IS 'Stores individual user ratings for recipes, ensuring a user can only rate a recipe once.'; +CREATE INDEX IF NOT EXISTS idx_recipe_ratings_recipe_id ON public.recipe_ratings(recipe_id); +CREATE INDEX IF NOT EXISTS idx_recipe_ratings_user_id ON public.recipe_ratings(user_id); -- A table for user comments on recipes to enable discussion. CREATE TABLE IF NOT EXISTS public.recipe_comments ( @@ -463,6 +503,9 @@ CREATE TABLE IF NOT EXISTS public.recipe_comments ( updated_at TIMESTAMPTZ ); COMMENT ON TABLE public.recipe_comments IS 'Allows for threaded discussions and comments on recipes.'; +CREATE INDEX IF NOT EXISTS idx_recipe_comments_recipe_id ON public.recipe_comments(recipe_id); +CREATE INDEX IF NOT EXISTS idx_recipe_comments_user_id ON public.recipe_comments(user_id); +CREATE INDEX IF NOT EXISTS idx_recipe_comments_parent_comment_id ON public.recipe_comments(parent_comment_id); -- A table to 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, @@ -473,6 +516,7 @@ CREATE TABLE IF NOT EXISTS public.menu_plans ( created_at TIMESTAMPTZ DEFAULT now() NOT NULL ); COMMENT ON TABLE public.menu_plans IS 'Represents a user''s meal plan for a specific period, e.g., "Week of Oct 23".'; +CREATE INDEX IF NOT EXISTS idx_menu_plans_user_id ON public.menu_plans(user_id); -- A table to associate a recipe with a specific date and meal type within a menu plan. @@ -486,6 +530,8 @@ CREATE TABLE IF NOT EXISTS public.planned_meals ( ); COMMENT ON TABLE public.planned_meals IS 'Assigns a recipe to a specific day and meal type within a user''s menu plan.'; COMMENT ON COLUMN public.planned_meals.meal_type IS 'The designated meal for the recipe, e.g., ''Breakfast'', ''Lunch'', ''Dinner''.'; +CREATE INDEX IF NOT EXISTS idx_planned_meals_menu_plan_id ON public.planned_meals(menu_plan_id); +CREATE INDEX IF NOT EXISTS idx_planned_meals_recipe_id ON public.planned_meals(recipe_id); -- A table to track the grocery items a user currently has in their pantry. @@ -503,6 +549,9 @@ CREATE TABLE IF NOT EXISTS public.pantry_items ( ); COMMENT ON TABLE public.pantry_items IS 'Tracks a user''s personal inventory of grocery items to enable smart shopping lists.'; COMMENT ON COLUMN public.pantry_items.quantity IS 'The current amount of the item. Convention: use grams for weight, mL for volume where applicable.'; +CREATE INDEX IF NOT EXISTS idx_pantry_items_user_id ON public.pantry_items(user_id); +CREATE INDEX IF NOT EXISTS idx_pantry_items_master_item_id ON public.pantry_items(master_item_id); +CREATE INDEX IF NOT EXISTS idx_pantry_items_pantry_location_id ON public.pantry_items(pantry_location_id); COMMENT ON COLUMN public.pantry_items.unit IS 'e.g., ''g'', ''ml'', ''items''. Should align with recipe_ingredients.unit and quantity convention.'; COMMENT ON COLUMN public.pantry_items.pantry_location_id IS 'Links the item to a user-defined location like "Fridge" or "Freezer".'; @@ -516,6 +565,8 @@ CREATE TABLE IF NOT EXISTS public.password_reset_tokens ( ); COMMENT ON TABLE public.password_reset_tokens IS 'Stores secure, single-use tokens for password reset requests.'; COMMENT ON COLUMN public.password_reset_tokens.token_hash IS 'A bcrypt hash of the reset token sent to the user.'; +CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON public.password_reset_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token_hash ON public.password_reset_tokens(token_hash); COMMENT ON COLUMN public.password_reset_tokens.expires_at IS 'The timestamp when this token is no longer valid.'; CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token_hash ON public.password_reset_tokens(token_hash); @@ -531,6 +582,7 @@ CREATE TABLE IF NOT EXISTS public.unit_conversions ( ); COMMENT ON TABLE public.unit_conversions IS 'Stores item-specific unit conversion factors (e.g., grams of flour to cups).'; COMMENT ON COLUMN public.unit_conversions.factor IS 'The multiplication factor to convert from_unit to to_unit.'; +CREATE INDEX IF NOT EXISTS idx_unit_conversions_master_item_id ON public.unit_conversions(master_item_id); -- A table for users to create their own private aliases for items. CREATE TABLE IF NOT EXISTS public.user_item_aliases ( @@ -541,6 +593,8 @@ CREATE TABLE IF NOT EXISTS public.user_item_aliases ( UNIQUE(user_id, alias) ); COMMENT ON TABLE public.user_item_aliases IS 'Allows users to create personal aliases for grocery items (e.g., "Dad''s Cereal").'; +CREATE INDEX IF NOT EXISTS idx_user_item_aliases_user_id ON public.user_item_aliases(user_id); +CREATE INDEX IF NOT EXISTS idx_user_item_aliases_master_item_id ON public.user_item_aliases(master_item_id); -- A table for users to mark their favorite recipes. CREATE TABLE IF NOT EXISTS public.favorite_recipes ( @@ -550,6 +604,8 @@ CREATE TABLE IF NOT EXISTS public.favorite_recipes ( PRIMARY KEY (user_id, recipe_id) ); COMMENT ON TABLE public.favorite_recipes IS 'A simple linking table for users to mark their favorite recipes.'; +CREATE INDEX IF NOT EXISTS idx_favorite_recipes_user_id ON public.favorite_recipes(user_id); +CREATE INDEX IF NOT EXISTS idx_favorite_recipes_recipe_id ON public.favorite_recipes(recipe_id); -- A table for users to mark their favorite stores. CREATE TABLE IF NOT EXISTS public.favorite_stores ( @@ -559,6 +615,8 @@ CREATE TABLE IF NOT EXISTS public.favorite_stores ( PRIMARY KEY (user_id, store_id) ); COMMENT ON TABLE public.favorite_stores IS 'A simple linking table for users to mark their favorite stores.'; +CREATE INDEX IF NOT EXISTS idx_favorite_stores_user_id ON public.favorite_stores(user_id); +CREATE INDEX IF NOT EXISTS idx_favorite_stores_store_id ON public.favorite_stores(store_id); -- A generic table to log key user activities for analytics. CREATE TABLE IF NOT EXISTS public.user_activity_log ( @@ -570,17 +628,7 @@ CREATE TABLE IF NOT EXISTS public.user_activity_log ( created_at TIMESTAMPTZ DEFAULT now() NOT NULL ); COMMENT ON TABLE public.user_activity_log IS 'Logs key user actions for analytics and behavior analysis.'; - --- A generic table to log key user activities for analytics. -CREATE TABLE IF NOT EXISTS public.user_activity_log ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID REFERENCES public.users(id) ON DELETE SET NULL, - activity_type TEXT NOT NULL, - entity_id TEXT, - details JSONB, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -COMMENT ON TABLE public.user_activity_log IS 'Logs key user actions for analytics and behavior analysis.'; +CREATE INDEX IF NOT EXISTS idx_user_activity_log_user_id ON public.user_activity_log(user_id); -- A table for users to group recipes into collections. CREATE TABLE IF NOT EXISTS public.recipe_collections ( @@ -591,6 +639,7 @@ CREATE TABLE IF NOT EXISTS public.recipe_collections ( created_at TIMESTAMPTZ DEFAULT now() NOT NULL ); COMMENT ON TABLE public.recipe_collections IS 'Allows users to create personal collections of recipes (e.g., "Holiday Baking").'; +CREATE INDEX IF NOT EXISTS idx_recipe_collections_user_id ON public.recipe_collections(user_id); -- A linking table to associate recipes with a user's collection. CREATE TABLE IF NOT EXISTS public.recipe_collection_items ( @@ -600,6 +649,8 @@ CREATE TABLE IF NOT EXISTS public.recipe_collection_items ( PRIMARY KEY (collection_id, recipe_id) ); COMMENT ON TABLE public.recipe_collection_items IS 'Links recipes to a user-defined collection.'; +CREATE INDEX IF NOT EXISTS idx_recipe_collection_items_collection_id ON public.recipe_collection_items(collection_id); +CREATE INDEX IF NOT EXISTS idx_recipe_collection_items_recipe_id ON public.recipe_collection_items(recipe_id); -- A table to store predefined dietary restrictions (diets and allergies). CREATE TABLE IF NOT EXISTS public.dietary_restrictions ( @@ -616,6 +667,8 @@ CREATE TABLE IF NOT EXISTS public.user_dietary_restrictions ( PRIMARY KEY (user_id, restriction_id) ); COMMENT ON TABLE public.user_dietary_restrictions IS 'Connects users to their selected dietary needs and allergies.'; +CREATE INDEX IF NOT EXISTS idx_user_dietary_restrictions_user_id ON public.user_dietary_restrictions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_dietary_restrictions_restriction_id ON public.user_dietary_restrictions(restriction_id); -- A table to store uploaded user receipts for purchase tracking and analysis. CREATE TABLE IF NOT EXISTS public.receipts ( @@ -631,6 +684,8 @@ CREATE TABLE IF NOT EXISTS public.receipts ( processed_at TIMESTAMPTZ ); COMMENT ON TABLE public.receipts IS 'Stores uploaded user receipts for purchase tracking and analysis.'; +CREATE INDEX IF NOT EXISTS idx_receipts_user_id ON public.receipts(user_id); +CREATE INDEX IF NOT EXISTS idx_receipts_store_id ON public.receipts(store_id); -- A table to store individual line items extracted from a user receipt. CREATE TABLE IF NOT EXISTS public.receipt_items ( @@ -644,6 +699,8 @@ CREATE TABLE IF NOT EXISTS public.receipt_items ( status TEXT DEFAULT 'unmatched' NOT NULL CHECK (status IN ('unmatched', 'matched', 'needs_review', 'ignored')) ); COMMENT ON TABLE public.receipt_items IS 'Stores individual line items extracted from a user receipt.'; +CREATE INDEX IF NOT EXISTS idx_receipt_items_receipt_id ON public.receipt_items(receipt_id); +CREATE INDEX IF NOT EXISTS idx_receipt_items_master_item_id ON public.receipt_items(master_item_id); -- A table to store a predefined list of kitchen appliances. CREATE TABLE IF NOT EXISTS public.appliances ( @@ -659,6 +716,8 @@ CREATE TABLE IF NOT EXISTS public.user_appliances ( PRIMARY KEY (user_id, appliance_id) ); COMMENT ON TABLE public.user_appliances IS 'Tracks the kitchen appliances a user owns to help with recipe recommendations.'; +CREATE INDEX IF NOT EXISTS idx_user_appliances_user_id ON public.user_appliances(user_id); +CREATE INDEX IF NOT EXISTS idx_user_appliances_appliance_id ON public.user_appliances(appliance_id); -- A table to manage the social graph (following relationships). CREATE TABLE IF NOT EXISTS public.user_follows ( @@ -669,6 +728,8 @@ CREATE TABLE IF NOT EXISTS public.user_follows ( CONSTRAINT cant_follow_self CHECK (follower_id <> following_id) ); COMMENT ON TABLE public.user_follows IS 'Stores user following relationships to build a social graph.'; +CREATE INDEX IF NOT EXISTS idx_user_follows_follower_id ON public.user_follows(follower_id); +CREATE INDEX IF NOT EXISTS idx_user_follows_following_id ON public.user_follows(following_id); -- A table for users to define locations within their pantry. CREATE TABLE IF NOT EXISTS public.pantry_locations ( @@ -678,6 +739,7 @@ CREATE TABLE IF NOT EXISTS public.pantry_locations ( UNIQUE(user_id, name) ); COMMENT ON TABLE public.pantry_locations IS 'User-defined locations for organizing pantry items (e.g., "Fridge", "Freezer", "Spice Rack").'; +CREATE INDEX IF NOT EXISTS idx_pantry_locations_user_id ON public.pantry_locations(user_id); -- A table to log user search queries for analysis. CREATE TABLE IF NOT EXISTS public.search_queries ( @@ -690,6 +752,7 @@ CREATE TABLE IF NOT EXISTS public.search_queries ( ); COMMENT ON TABLE public.search_queries IS 'Logs user search queries to analyze search effectiveness and identify gaps in data.'; COMMENT ON COLUMN public.search_queries.was_successful IS 'Indicates if the user interacted with a search result.'; +CREATE INDEX IF NOT EXISTS idx_search_queries_user_id ON public.search_queries(user_id); -- A table to store historical records of completed shopping trips. CREATE TABLE IF NOT EXISTS public.shopping_trips ( @@ -728,6 +791,8 @@ CREATE TABLE IF NOT EXISTS public.shopping_trips ( ); COMMENT ON TABLE public.shopping_trips IS 'A historical record of a completed shopping trip.'; COMMENT ON COLUMN public.shopping_trips.total_spent_cents IS 'The total amount spent on this shopping trip, if provided by the user.'; +CREATE INDEX IF NOT EXISTS idx_shopping_trips_user_id ON public.shopping_trips(user_id); +CREATE INDEX IF NOT EXISTS idx_shopping_trips_shopping_list_id ON public.shopping_trips(shopping_list_id); -- A table to store the items purchased during a specific shopping trip. CREATE TABLE IF NOT EXISTS public.shopping_trip_items ( @@ -821,15 +886,27 @@ DECLARE bolognese_recipe_id BIGINT; stir_fry_recipe_id BIGINT; + -- Store & Brand IDs + loblaws_id BIGINT; coke_id BIGINT; kraft_id BIGINT; maple_leaf_id BIGINT; dempsters_id BIGINT; no_name_id BIGINT; pc_id BIGINT; + -- Ingredient (Master Item) IDs chicken_breast_id BIGINT; rice_id BIGINT; broccoli_id BIGINT; ground_beef_id BIGINT; pasta_id BIGINT; tomatoes_id BIGINT; onions_id BIGINT; garlic_id BIGINT; bell_peppers_id BIGINT; carrots_id BIGINT; soy_sauce_id BIGINT; + soda_item_id BIGINT; turkey_item_id BIGINT; bread_item_id BIGINT; cheese_item_id BIGINT; -- Tag IDs quick_easy_tag BIGINT; healthy_tag BIGINT; chicken_tag BIGINT; family_tag BIGINT; beef_tag BIGINT; weeknight_tag BIGINT; vegetarian_tag BIGINT; 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'; + + -- 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; + -- Insert sample recipes and get their IDs INSERT INTO public.recipes (name, description, instructions, prep_time_minutes, cook_time_minutes, servings) VALUES ('Simple Chicken and Rice', 'A quick and healthy weeknight meal with chicken, rice, and broccoli.', '1. Cook rice according to package directions. 2. Steam broccoli. 3. Pan-sear chicken breast until cooked through. 4. Combine and serve.', 10, 20, 4), @@ -841,6 +918,17 @@ BEGIN 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'; + -- 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'; + -- Get ingredient IDs from master_grocery_items SELECT id INTO chicken_breast_id FROM public.master_grocery_items WHERE name = 'chicken breast'; SELECT id INTO rice_id FROM public.master_grocery_items WHERE name = 'rice'; @@ -853,6 +941,10 @@ BEGIN SELECT id INTO bell_peppers_id FROM public.master_grocery_items WHERE name = 'bell peppers'; SELECT id INTO carrots_id FROM public.master_grocery_items WHERE name = 'carrots'; SELECT id INTO soy_sauce_id FROM public.master_grocery_items WHERE name = 'soy sauce'; + SELECT id INTO soda_item_id FROM public.master_grocery_items WHERE name = 'soda'; + SELECT id INTO turkey_item_id FROM public.master_grocery_items WHERE name = 'turkey'; + SELECT id INTO bread_item_id FROM public.master_grocery_items WHERE name = 'bread'; + SELECT id INTO cheese_item_id FROM public.master_grocery_items WHERE name = 'cheese'; -- Insert ingredients for each recipe INSERT INTO public.recipe_ingredients (recipe_id, master_item_id, quantity, unit) VALUES @@ -861,6 +953,16 @@ BEGIN (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; + -- Insert specific products, linking master items and brands + INSERT INTO public.products (master_item_id, brand_id, name, size, upc_code) VALUES + (soda_item_id, coke_id, 'Coca-Cola Classic', '2L Bottle', '067000004114'), + (pasta_item_id, kraft_id, 'Kraft Dinner Original Macaroni & Cheese', '225g Box', '068100058918'), + (turkey_item_id, maple_leaf_id, 'Maple Leaf Natural Selections Sliced Turkey Breast', '175g', '063100123456'), + (bread_item_id, dempsters_id, 'Dempster''s 100% Whole Wheat Bread', '675g Loaf', '068721001005'), + (cheese_item_id, no_name_id, 'No Name Cheddar Cheese Block', '400g', '060383037575'), + (cheese_item_id, pc_id, 'PC Old Cheddar Cheese', '400g', '060383000005') + ON CONFLICT (upc_code) 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; @@ -884,7 +986,7 @@ END $$; -- Pre-populate the unit_conversions table with common cooking conversions. DO $$ DECLARE - flour_id BIGINT; sugar_id BIGINT; butter_id BIGINT; milk_id BIGINT; water_id BIGINT; + flour_id BIGINT; sugar_id BIGINT; butter_id BIGINT; milk_id BIGINT; water_id BIGINT; rice_id BIGINT; BEGIN -- Get master item IDs SELECT id INTO flour_id FROM public.master_grocery_items WHERE name = 'flour'; @@ -892,6 +994,7 @@ BEGIN SELECT id INTO butter_id FROM public.master_grocery_items WHERE name = 'butter'; SELECT id INTO milk_id FROM public.master_grocery_items WHERE name = 'milk'; SELECT id INTO water_id FROM public.master_grocery_items WHERE name = 'water'; + SELECT id INTO rice_id FROM public.master_grocery_items WHERE name = 'rice'; -- Insert conversion factors INSERT INTO public.unit_conversions (master_item_id, from_unit, to_unit, factor) VALUES @@ -911,7 +1014,6 @@ END $$; INSERT INTO public.appliances (name) VALUES ('Oven'), ('Microwave'), ('Stovetop'), ('Blender'), ('Food Processor'), ('Stand Mixer'), ('Hand Mixer'), ('Air Fryer'), ('Instant Pot'), ('Slow Cooker'), ('Grill'), ('Toaster') ON CONFLICT (name) DO NOTHING; -END $$; -- Pre-populate the dietary_restrictions table. INSERT INTO public.dietary_restrictions (name, type) VALUES @@ -920,13 +1022,6 @@ INSERT INTO public.dietary_restrictions (name, type) VALUES ('Tree Nuts', 'allergy'), ('Peanuts', 'allergy'), ('Soy', 'allergy'), ('Wheat', 'allergy') ON CONFLICT (name) DO NOTHING; --- Pre-populate the appliances table. -INSERT INTO public.appliances (name) VALUES -('Oven'), ('Microwave'), ('Stovetop'), ('Blender'), ('Food Processor'), -('Stand Mixer'), ('Hand Mixer'), ('Air Fryer'), ('Instant Pot'), ('Slow Cooker'), -('Grill'), ('Toaster') -ON CONFLICT (name) DO NOTHING; - -- ============================================================================ @@ -1092,7 +1187,6 @@ AS $$ COUNT(bcp.master_item_id) AS sale_ingredients -- COUNT(column) only counts non-NULL values. FROM public.recipe_ingredients ri LEFT JOIN BestCurrentPrices bcp ON ri.master_item_id = bcp.master_item_id - GROUP BY ri.recipe_id ), EligibleRecipes AS ( -- CTE 3: Filter recipes based on the minimum sale percentage provided as an argument. @@ -1749,54 +1843,6 @@ AS $$ ORDER BY potential_savings_cents DESC; $$; --- Function to approve a suggested correction and apply it. --- This is a SECURITY DEFINER function to allow an admin to update tables --- they might not have direct RLS access to. -CREATE OR REPLACE FUNCTION public.approve_correction(p_correction_id BIGINT) -RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - correction_record RECORD; -BEGIN - -- 1. Fetch the correction details, ensuring it's still pending. - SELECT * INTO correction_record - FROM public.suggested_corrections - WHERE id = p_correction_id AND status = 'pending'; - - IF NOT FOUND THEN - RAISE EXCEPTION 'Correction with ID % not found or already processed.', p_correction_id; - END IF; - - -- 2. Apply the correction based on its type. - IF correction_record.correction_type = 'INCORRECT_ITEM_LINK' THEN - UPDATE public.flyer_items - SET master_item_id = correction_record.suggested_value::BIGINT - WHERE id = correction_record.flyer_item_id; - ELSIF correction_record.correction_type = 'WRONG_PRICE' THEN - UPDATE public.flyer_items - SET price_in_cents = correction_record.suggested_value::INTEGER - WHERE id = correction_record.flyer_item_id; - END IF; - - -- 3. Update the correction status to 'approved'. - UPDATE public.suggested_corrections - SET status = 'approved', reviewed_at = now() - WHERE id = p_correction_id; -END; -$$; - --- ============================================================================ --- PART 6: SYSTEM CHECK HELPER FUNCTIONS --- These functions are called by the 'system-check' Edge Function to inspect --- the database state. They are Supabase-specific and not needed for a --- self-hosted setup. --- ============================================================================ --- The functions `check_schema`, `check_rls`, and `check_trigger_security` --- have been removed. - - -- ============================================================================ -- PART 7: TRIGGERS -- ============================================================================ @@ -2067,170 +2113,3 @@ DROP TRIGGER IF EXISTS on_new_list_share ON public.shared_shopping_lists; CREATE TRIGGER on_new_list_share AFTER INSERT ON public.shared_shopping_lists FOR EACH ROW EXECUTE FUNCTION public.log_new_list_share(); - --- 8. Trigger to log when a user favorites a recipe. -CREATE OR REPLACE FUNCTION public.log_new_favorite_recipe() -RETURNS TRIGGER AS $$ -BEGIN - INSERT INTO public.user_activity_log (user_id, activity_type, entity_id, details) - VALUES ( - NEW.user_id, - 'favorite_recipe', - NEW.recipe_id::text, - jsonb_build_object( - 'recipe_name', (SELECT name FROM public.recipes WHERE id = NEW.recipe_id) - ) - ); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS on_new_favorite_recipe ON public.favorite_recipes; -CREATE TRIGGER on_new_favorite_recipe - AFTER INSERT ON public.favorite_recipes - FOR EACH ROW EXECUTE FUNCTION public.log_new_favorite_recipe(); - --- 9. Trigger to log when a user shares a shopping list. -CREATE OR REPLACE FUNCTION public.log_new_list_share() -RETURNS TRIGGER AS $$ -BEGIN - INSERT INTO public.user_activity_log (user_id, activity_type, entity_id, details) - VALUES ( - NEW.shared_by_user_id, - 'share_shopping_list', - NEW.shopping_list_id::text, - jsonb_build_object( - 'list_name', (SELECT name FROM public.shopping_lists WHERE id = NEW.shopping_list_id), - 'shared_with_name', (SELECT full_name FROM public.profiles WHERE id = NEW.shared_with_user_id) - ) - ); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS on_new_list_share ON public.shared_shopping_lists; -CREATE TRIGGER on_new_list_share - AFTER INSERT ON public.shared_shopping_lists - FOR EACH ROW EXECUTE FUNCTION public.log_new_list_share(); --- Function to get recipes that are compatible with a user's dietary restrictions (allergies). --- It filters out any recipe containing an ingredient that the user is allergic to. -CREATE OR REPLACE FUNCTION public.get_recipes_for_user_diets(p_user_id UUID) -RETURNS SETOF public.recipes -LANGUAGE sql -STABLE -SECURITY INVOKER -AS $$ - WITH UserAllergens AS ( - -- CTE 1: Find all master item IDs that are allergens for the given user. - SELECT mgi.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 - WHERE udr.user_id = p_user_id - AND dr.type = 'allergy' - AND mgi.is_allergen = true - ), - ForbiddenRecipes 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) - ) - -- 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) - ORDER BY r.avg_rating DESC, r.name ASC; -$$; - --- Function to get a personalized activity feed for a user based on who they follow. --- 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, - user_id UUID, - activity_type TEXT, - entity_id TEXT, - details JSONB, - created_at TIMESTAMPTZ, - user_full_name TEXT, - user_avatar_url TEXT -) -LANGUAGE sql -STABLE -SECURITY INVOKER -AS $$ - WITH FollowedUsers AS ( - -- CTE 1: Get the IDs of all users that the current user is following. - SELECT following_id FROM public.user_follows WHERE follower_id = p_user_id - ) - -- Final Selection: Get activities from the log where the user_id is in the followed list. - SELECT - al.id, - al.user_id, - al.activity_type, - al.entity_id, - al.details, - al.created_at, - p.full_name AS user_full_name, - p.avatar_url AS user_avatar_url - FROM public.user_activity_log al - JOIN public.profiles p ON al.user_id = p.id - WHERE - al.user_id IN (SELECT following_id FROM FollowedUsers) - -- We can filter for specific activity types to make the feed more relevant. - AND al.activity_type IN ( - 'new_recipe', - 'favorite_recipe', - 'share_shopping_list' - -- 'new_recipe_rating' could be added here later - ) - ORDER BY - al.created_at DESC - LIMIT p_limit - OFFSET p_offset; -$$; - --- Function to archive a shopping list into a historical shopping trip. --- It creates a shopping_trip record, copies purchased items to shopping_trip_items, --- and then deletes the purchased items from the original shopping list. -CREATE OR REPLACE FUNCTION public.complete_shopping_list( - p_shopping_list_id BIGINT, - p_user_id UUID, - p_total_spent_cents INTEGER DEFAULT NULL -) -RETURNS BIGINT -- Returns the ID of the new shopping_trip record. -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - list_owner_id UUID; - new_trip_id BIGINT; -BEGIN - -- Security Check: Ensure the user calling this function owns the target shopping list. - SELECT user_id INTO list_owner_id - FROM public.shopping_lists - WHERE id = p_shopping_list_id; - - IF list_owner_id IS NULL OR list_owner_id <> p_user_id THEN - RAISE EXCEPTION 'Permission denied: You do not own shopping list %', p_shopping_list_id; - END IF; - - -- 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; - - -- 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) - SELECT new_trip_id, master_item_id, custom_item_name, quantity - FROM public.shopping_list_items - WHERE shopping_list_id = p_shopping_list_id AND is_purchased = true; - - -- 3. Delete the purchased items from the original shopping list. - DELETE FROM public.shopping_list_items - WHERE shopping_list_id = p_shopping_list_id AND is_purchased = true; - - RETURN new_trip_id; -END; -$$; diff --git a/sql/triggers.sql b/sql/triggers.sql index 8fec5d6d..b7cf098c 100644 --- a/sql/triggers.sql +++ b/sql/triggers.sql @@ -25,10 +25,6 @@ BEGIN INSERT INTO public.user_activity_log (user_id, activity_type, entity_id, details) VALUES (new.id, 'new_user', new.id, jsonb_build_object('full_name', user_meta_data->>'full_name')); - -- Log the new user event - INSERT INTO public.user_activity_log (user_id, activity_type, entity_id, details) - VALUES (new.id, 'new_user', new.id, jsonb_build_object('full_name', user_meta_data->>'full_name')); - RETURN new; END; $$ LANGUAGE plpgsql; @@ -303,51 +299,6 @@ END; $$ LANGUAGE plpgsql; -- Trigger to call the function after a shopping list is shared. -DROP TRIGGER IF EXISTS on_new_list_share ON public.shared_shopping_lists; -CREATE TRIGGER on_new_list_share - AFTER INSERT ON public.shared_shopping_lists - FOR EACH ROW EXECUTE FUNCTION public.log_new_list_share(); - --- 8. Trigger to log when a user favorites a recipe. -CREATE OR REPLACE FUNCTION public.log_new_favorite_recipe() -RETURNS TRIGGER AS $$ -BEGIN - INSERT INTO public.user_activity_log (user_id, activity_type, entity_id, details) - VALUES ( - NEW.user_id, - 'favorite_recipe', - NEW.recipe_id::text, - jsonb_build_object( - 'recipe_name', (SELECT name FROM public.recipes WHERE id = NEW.recipe_id) - ) - ); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS on_new_favorite_recipe ON public.favorite_recipes; -CREATE TRIGGER on_new_favorite_recipe - AFTER INSERT ON public.favorite_recipes - FOR EACH ROW EXECUTE FUNCTION public.log_new_favorite_recipe(); - --- 9. Trigger to log when a user shares a shopping list. -CREATE OR REPLACE FUNCTION public.log_new_list_share() -RETURNS TRIGGER AS $$ -BEGIN - INSERT INTO public.user_activity_log (user_id, activity_type, entity_id, details) - VALUES ( - NEW.shared_by_user_id, - 'share_shopping_list', - NEW.shopping_list_id::text, - jsonb_build_object( - 'list_name', (SELECT name FROM public.shopping_lists WHERE id = NEW.shopping_list_id), - 'shared_with_name', (SELECT full_name FROM public.profiles WHERE id = NEW.shared_with_user_id) - ) - ); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - DROP TRIGGER IF EXISTS on_new_list_share ON public.shared_shopping_lists; CREATE TRIGGER on_new_list_share AFTER INSERT ON public.shared_shopping_lists diff --git a/src/App.tsx b/src/App.tsx index b2280a47..0c46337c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,7 @@ import { AdminPage } from './pages/AdminPage'; import { AdminRoute } from './components/AdminRoute'; import { CorrectionsPage } from './pages/CorrectionsPage'; import { ActivityLog, ActivityLogClickHandler } from './components/ActivityLog'; +import { SampleDataButton } from './components/SampleDataButton'; import { WatchedItemsList } from './components/WatchedItemsList'; import { AdminStatsPage } from './pages/AdminStatPages'; import { ResetPasswordPage } from './pages/ResetPasswordPage'; @@ -817,6 +818,9 @@ function App() {

Welcome to Flyer Crawler!

Upload a new grocery flyer to begin, or select a previously processed flyer from the list on the left.

+
+ { console.log("Sample data loading not yet implemented."); }} /> +
)} diff --git a/src/components/AdminBrandManager.tsx b/src/components/AdminBrandManager.tsx new file mode 100644 index 00000000..749a2e71 --- /dev/null +++ b/src/components/AdminBrandManager.tsx @@ -0,0 +1,111 @@ +import React, { useState, useEffect } from 'react'; +import toast from 'react-hot-toast'; +import { fetchAllBrands, uploadBrandLogo } from '../services/apiClient'; +import { Brand } from '../types'; +import { ErrorDisplay } from './ErrorDisplay'; // Corrected path assuming file is in src/components + +export const AdminBrandManager: React.FC = () => { + const [brands, setBrands] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadBrands = async () => { + try { + setLoading(true); + const fetchedBrands = await fetchAllBrands(); + setBrands(fetchedBrands); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + setError(`Failed to load brands: ${errorMessage}`); + } finally { + setLoading(false); + } + }; + loadBrands(); + }, []); + + const handleLogoUpload = async (brandId: number, file: File) => { + if (!file) { + toast.error('Please select a file to upload.'); + return; + } + + // Basic file type and size validation + if (!['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'].includes(file.type)) { + toast.error('Invalid file type. Please upload a PNG, JPG, WEBP, or SVG.'); + return; + } + if (file.size > 2 * 1024 * 1024) { // 2MB limit + toast.error('File is too large. Maximum size is 2MB.'); + return; + } + + const toastId = toast.loading('Uploading logo...'); + + try { + const { logoUrl } = await uploadBrandLogo(brandId, file); + toast.success('Logo updated successfully!', { id: toastId }); + + // Update the state to show the new logo immediately + setBrands(prevBrands => + prevBrands.map(brand => + brand.id === brandId ? { ...brand, logo_url: logoUrl } : brand + ) + ); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + toast.error(`Upload failed: ${errorMessage}`, { id: toastId }); + } + }; + + if (loading) { + return
Loading brands...
; + } + + if (error) { + return ; + } + + return ( +
+

Brand Management

+
+ + + + + + + + + + {brands.map((brand) => ( + + + + + + ))} + +
LogoBrand NameUpload New Logo
+ {brand.logo_url ? ( + {`${brand.name} + ) : ( +
No Logo
+ )} +
+ {brand.name} + {brand.store_name && ({brand.store_name})} + + e.target.files && handleLogoUpload(brand.id, e.target.files[0])} + className="text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-brand-light file:text-brand-dark hover:file:bg-brand-primary/20" + /> +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/AdminPage.tsx b/src/components/AdminPage.tsx index a722fcb2..ff5fb37d 100644 --- a/src/components/AdminPage.tsx +++ b/src/components/AdminPage.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { SystemCheck } from './SystemCheck'; import { Link } from 'react-router-dom'; +import { AdminBrandManager } from './AdminBrandManager'; export const AdminPage: React.FC = () => { return ( @@ -12,6 +13,7 @@ export const AdminPage: React.FC = () => {
+
); diff --git a/src/components/ProfileManager.tsx b/src/components/ProfileManager.tsx index 8a1d096a..04015f76 100644 --- a/src/components/ProfileManager.tsx +++ b/src/components/ProfileManager.tsx @@ -22,7 +22,7 @@ interface ProfileManagerProps { profile: Profile | null; // Can be null for login/register onProfileUpdate: (updatedProfile: Profile) => void; onSignOut: () => void; - onLoginSuccess: (user: User, token: string) => void; // Add login handler + onLoginSuccess: (user: User, token: string, rememberMe: boolean) => void; // Add login handler } export const ProfileManager: React.FC = ({ isOpen, onClose, user, authStatus, profile, onProfileUpdate, onSignOut, onLoginSuccess }) => { @@ -246,7 +246,7 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, } else { response = await loginUser(authEmail, authPassword, rememberMe); } - onLoginSuccess(response.user, response.token); + onLoginSuccess(response.user, response.token, rememberMe); onClose(); // Close modal on success } catch (error) { const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.'; @@ -373,7 +373,13 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, )} {isRegistering && } {!isRegistering && ( -
+
+
+ setRememberMe(e.target.checked)} className="h-4 w-4 text-brand-primary border-gray-300 rounded focus:ring-brand-secondary" /> + +
diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index fea22241..2cb6181f 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -1,4 +1,4 @@ -import { Profile, UserProfile, Flyer, MasterGroceryItem, ShoppingList, ShoppingListItem, FlyerItem, SuggestedCorrection, Category, UserDataExport, Recipe, FavoriteRecipe, RecipeComment, UnmatchedFlyerItem, ActivityLogItem, DietaryRestriction, Appliance, PantryLocation, ShoppingTrip, SearchQuery, Receipt, ReceiptDeal } from '../types'; +import { Profile, UserProfile, Flyer, MasterGroceryItem, ShoppingList, ShoppingListItem, FlyerItem, SuggestedCorrection, Category, UserDataExport, Recipe, FavoriteRecipe, RecipeComment, UnmatchedFlyerItem, ActivityLogItem, DietaryRestriction, Appliance, PantryLocation, ShoppingTrip, SearchQuery, Receipt, ReceiptDeal, Brand } from '../types'; interface AuthResponse { user: { id: string; email: string }; @@ -290,6 +290,27 @@ export const uploadLogoAndUpdateStore = async (storeId: number, logoImage: File) return response.json(); }; +/** + * Uploads a new logo for a brand. Requires admin privileges. + * @param brandId The ID of the brand to update. + * @param logoImage The logo image file. + * @returns A promise that resolves with the new logo URL. + */ +export const uploadBrandLogo = async (brandId: number, logoImage: File): Promise<{ logoUrl: string }> => { + const formData = new FormData(); + formData.append('logoImage', logoImage); + + // Use apiFetch to ensure the user is an authenticated admin. + const response = await apiFetch(`${API_BASE_URL}/admin/brands/${brandId}/logo`, { + method: 'POST', + body: formData, + // Do not set Content-Type for FormData, browser handles it. + }); + + return response.json(); +}; + + /** * Fetches historical price data for a given list of master item IDs. * @param masterItemIds An array of master grocery item IDs. @@ -869,6 +890,19 @@ export const updateRecipeCommentStatus = async (commentId: number, status: 'visi } return response.json(); }; + +/** + * Fetches all brands from the backend. Requires admin privileges. + * @returns A promise that resolves to an array of Brand objects. + */ +export const fetchAllBrands = async (): Promise => { + const response = await apiFetch(`${API_BASE_URL}/admin/brands`); + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: 'Failed to fetch brands.' })); + throw new Error(errorData.message); + } + return response.json(); +}; export interface AppStats { flyerCount: number; userCount: number; @@ -1050,13 +1084,9 @@ export async function resetPassword(token: string, newPassword: string): Promise * @returns A promise that resolves to the user's full, updated profile object. */ export async function updateUserPreferences(preferences: Partial): Promise { - const token = localStorage.getItem('authToken'); - if (!token) { - throw new Error('No authentication token found.'); - } - const response = await apiFetch(`${API_BASE_URL}/users/profile/preferences`, { method: 'PUT', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(preferences), }); @@ -1074,11 +1104,6 @@ export async function updateUserPreferences(preferences: Partial { - const token = localStorage.getItem('authToken'); - if (!token) { - throw new Error('No authentication token found.'); - } - const response = await apiFetch(`${API_BASE_URL}/users/profile`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -1098,11 +1123,6 @@ export async function updateUserProfile(profileData: { full_name?: string; avata * @returns A promise that resolves to a JSON object of the user's data. */ export async function exportUserData(): Promise { - const token = localStorage.getItem('authToken'); - if (!token) { - throw new Error('No authentication token found.'); - } - const response = await apiFetch(`${API_BASE_URL}/users/data-export`, { method: 'GET', }); @@ -1151,13 +1171,9 @@ export const setUserAppliances = async (applianceIds: number[]): Promise = * @returns A promise that resolves on success. */ export async function updateUserPassword(newPassword: string): Promise<{ message: string }> { - const token = localStorage.getItem('authToken'); - if (!token) { - throw new Error('No authentication token found.'); - } - const response = await apiFetch(`${API_BASE_URL}/users/profile/password`, { method: 'PUT', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ newPassword }), }); @@ -1174,13 +1190,9 @@ export async function updateUserPassword(newPassword: string): Promise<{ message * @returns A promise that resolves on success. */ export async function deleteUserAccount(password: string): Promise<{ message: string }> { - const token = localStorage.getItem('authToken'); - if (!token) { - throw new Error('No authentication token found.'); - } - const response = await apiFetch(`${API_BASE_URL}/users/account`, { method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), }); diff --git a/src/services/db.ts b/src/services/db.ts index 23f44ed6..a1035564 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -1,6 +1,6 @@ import { Pool } from 'pg'; import { logger } from './logger'; -import { Profile, Flyer, MasterGroceryItem, ShoppingList, ShoppingListItem, FlyerItem, SuggestedCorrection, Recipe, RecipeComment, FavoriteRecipe, ActivityLogItem, DietaryRestriction, Appliance, PantryLocation, ShoppingTrip, UserAppliance, Receipt, ReceiptItem, ReceiptDeal } from '../types'; // Assuming your Profile type is in `types.ts` +import { Profile, Flyer, MasterGroceryItem, ShoppingList, ShoppingListItem, FlyerItem, SuggestedCorrection, Recipe, RecipeComment, FavoriteRecipe, ActivityLogItem, DietaryRestriction, Appliance, PantryLocation, ShoppingTrip, UserAppliance, Receipt, ReceiptItem, ReceiptDeal, Brand } from '../types'; // Assuming your Profile type is in `types.ts` // Configure your PostgreSQL connection pool // IMPORTANT: For production, use environment variables for these credentials. @@ -361,6 +361,26 @@ export async function getFlyers(): Promise { } } +/** + * Retrieves all brands from the database, including the associated store name for store brands. + * @returns A promise that resolves to an array of Brand objects. + */ +export async function getAllBrands(): Promise { + try { + const query = ` + SELECT b.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 + ORDER BY b.name ASC; + `; + const res = await pool.query(query); + return res.rows; + } catch (error) { + logger.error('Database error in getAllBrands:', { error }); + throw new Error('Failed to retrieve brands from database.'); + } +} + /** * Retrieves all master grocery items from the database, joining with category information. * @returns A promise that resolves to an array of MasterGroceryItem objects. @@ -768,6 +788,23 @@ export async function updateStoreLogo(storeId: number, logoUrl: string): Promise } } +/** + * Updates the logo URL for a specific brand. + * @param brandId The ID of the brand to update. + * @param logoUrl The new URL for the brand's logo. + */ +export async function updateBrandLogo(brandId: number, logoUrl: string): Promise { + try { + await pool.query( + 'UPDATE public.brands SET logo_url = $1 WHERE id = $2', + [logoUrl, brandId] + ); + } catch (error) { + logger.error('Database error in updateBrandLogo:', { error, brandId }); + throw new Error('Failed to update brand logo in database.'); + } +} + // --- Correction Handler Logic --- /** @@ -852,16 +889,16 @@ export async function getSuggestedCorrections(): Promise * @param correctionId The ID of the correction to approve. */ export async function approveCorrection(correctionId: number): Promise { - try { - // The database function `approve_correction` now contains all the logic. - // It finds the correction, applies the change, and updates the status in a single transaction. - // This simplifies the application code and keeps the business logic in the database. - await pool.query('SELECT public.approve_correction($1)', [correctionId]); - logger.info(`Successfully approved and applied correction ID: ${correctionId}`); - } catch (error) { - logger.error('Database transaction error in approveCorrection:', { error, correctionId }); - throw new Error('Failed to approve correction.'); - } + try { + // The database function `approve_correction` now contains all the logic. + // It finds the correction, applies the change, and updates the status in a single transaction. + // This simplifies the application code and keeps the business logic in the database. + await pool.query('SELECT public.approve_correction($1)', [correctionId]); + logger.info(`Successfully approved and applied correction ID: ${correctionId}`); + } catch (error) { + logger.error('Database transaction error in approveCorrection:', { error, correctionId }); + throw new Error('Failed to approve correction.'); + } } /** @@ -1092,8 +1129,7 @@ export async function addMenuPlanToShoppingList(menuPlanId: number, shoppingList export async function getRecipesBySalePercentage(minPercentage: number): Promise { try { const res = await pool.query('SELECT * FROM public.get_recipes_by_sale_percentage($1)', [minPercentage]); - // The function returns JSONB, so we need to parse it. - return res.rows.map(row => row.recipe_details); + return res.rows; } catch (error) { logger.error('Database error in getRecipesBySalePercentage:', { error }); throw new Error('Failed to get recipes by sale percentage.'); @@ -1174,36 +1210,14 @@ export async function createReceipt(userId: string, receiptImageUrl: string): Pr * @returns A promise that resolves when the operation is complete. */ export async function processReceiptItems(receiptId: number, rawText: string, items: { raw_item_description: string; price_paid_cents: number }[]): Promise { - const client = await pool.connect(); try { - await client.query('BEGIN'); - - // Update the parent receipt with the raw text and set status to 'processing' - await client.query('UPDATE public.receipts SET raw_text = $1, status = $2 WHERE id = $3', [rawText, 'processing', receiptId]); - - // Insert all extracted items - if (items.length > 0) { - const itemInsertQuery = ` - INSERT INTO public.receipt_items (receipt_id, raw_item_description, price_paid_cents, master_item_id, status) - VALUES ($1, $2, $3, public.suggest_master_item_for_flyer_item($2), - CASE WHEN public.suggest_master_item_for_flyer_item($2) IS NOT NULL THEN 'matched' ELSE 'unmatched' END) - `; - for (const item of items) { - await client.query(itemInsertQuery, [receiptId, item.raw_item_description, item.price_paid_cents]); - } - } - - // Finally, update the status to 'completed' - await client.query("UPDATE public.receipts SET status = 'completed', processed_at = now() WHERE id = $1", [receiptId]); - - await client.query('COMMIT'); + // The complex transaction logic is now handled by a single SQL function. + // We pass the items array as a JSON string, which the SQL function will parse. + await pool.query('SELECT public.process_receipt_items($1, $2, $3)', [receiptId, rawText, JSON.stringify(items)]); + logger.info(`Successfully processed items for receipt ID: ${receiptId}`); } catch (error) { - await client.query('ROLLBACK'); - await pool.query("UPDATE public.receipts SET status = 'failed' WHERE id = $1", [receiptId]); logger.error('Database transaction error in processReceiptItems:', { error, receiptId }); throw new Error('Failed to process and save receipt items.'); - } finally { - client.release(); } } @@ -1511,6 +1525,42 @@ export async function completeShoppingList(shoppingListId: number, userId: strin } } +/** + * Finds the owner of a specific pantry item. + * @param pantryItemId The ID of the pantry item. + * @returns A promise that resolves to an object containing the user_id, or undefined if not found. + */ +export async function findPantryItemOwner(pantryItemId: number): Promise<{ user_id: string } | undefined> { + try { + const res = await pool.query<{ user_id: string }>( + 'SELECT user_id FROM public.pantry_items WHERE id = $1', + [pantryItemId] + ); + return res.rows[0]; + } catch (error) { + logger.error('Database error in findPantryItemOwner:', { error, pantryItemId }); + throw new Error('Failed to retrieve pantry item owner from database.'); + } +} + +/** + * Finds the owner of a specific receipt. + * @param receiptId The ID of the receipt. + * @returns A promise that resolves to an object containing the user_id, or undefined if not found. + */ +export async function findReceiptOwner(receiptId: number): Promise<{ user_id: string } | undefined> { + try { + const res = await pool.query<{ user_id: string }>( + 'SELECT user_id FROM public.receipts WHERE id = $1', + [receiptId] + ); + return res.rows[0]; + } catch (error) { + logger.error('Database error in findReceiptOwner:', { error, receiptId }); + throw new Error('Failed to retrieve receipt owner from database.'); + } +} + export async function getShoppingTripHistory(userId: string): Promise { try { const query = ` @@ -1601,38 +1651,12 @@ export async function getUserFeed(userId: string, limit: number, offset: number) } export async function forkRecipe(userId: string, originalRecipeId: number): Promise { - const client = await pool.connect(); try { - await client.query('BEGIN'); - const originalRecipeRes = await client.query('SELECT * FROM public.recipes WHERE id = $1', [originalRecipeId]); - const originalRecipe = originalRecipeRes.rows[0]; - if (!originalRecipe) { - throw new Error('Original recipe not found.'); - } - - const newRecipeName = `${originalRecipe.name} (Fork)`; - - const newRecipeRes = await client.query( - `INSERT INTO public.recipes (user_id, original_recipe_id, name, description, instructions, prep_time_minutes, cook_time_minutes, servings, photo_url, status) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'private') RETURNING *`, - [userId, originalRecipeId, newRecipeName, originalRecipe.description, originalRecipe.instructions, originalRecipe.prep_time_minutes, originalRecipe.cook_time_minutes, originalRecipe.servings, originalRecipe.photo_url] - ); - const newRecipe = newRecipeRes.rows[0]; - - // Copy ingredients from original recipe - await client.query( - `INSERT INTO public.recipe_ingredients (recipe_id, master_item_id, quantity, unit) - SELECT $1, master_item_id, quantity, unit FROM public.recipe_ingredients WHERE recipe_id = $2`, - [newRecipe.id, originalRecipeId] - ); - - await client.query('COMMIT'); - return newRecipe; + // The entire forking logic is now encapsulated in a single, atomic database function. + const res = await pool.query('SELECT * FROM public.fork_recipe($1, $2)', [userId, originalRecipeId]); + return res.rows[0]; } catch (error) { - await client.query('ROLLBACK'); logger.error('Database error in forkRecipe:', { error }); throw new Error('Failed to fork recipe.'); - } finally { - client.release(); } } \ No newline at end of file diff --git a/src/services/geminiService.ts b/src/services/geminiService.ts index c99120b9..add02840 100644 --- a/src/services/geminiService.ts +++ b/src/services/geminiService.ts @@ -139,58 +139,33 @@ interface ExtractedLogoData { export const extractCoreDataFromImage = async (imageFiles: File[], masterItems: MasterGroceryItem[]): Promise => { - const imageParts = await Promise.all(imageFiles.map(fileToGenerativePart)); - // TODO: This logic needs to be migrated to a new endpoint on your Express backend. - // The backend endpoint will securely call the Gemini API. - // For now, this is a placeholder to show what needs to be done. - const response = await fetch('/api/ai/process-flyer', { // This endpoint does not exist yet. - method: 'POST', - body: JSON.stringify({ imageParts, masterItems }) +const formData = new FormData(); + imageFiles.forEach(file => { + formData.append('flyerImages', file); }); - const { data: parsedJson, error } = await response.json(); - if (error) { - throw new Error(`Error invoking process-flyer function: ${error.message}`); + formData.append('masterItems', JSON.stringify(masterItems)); + + // This now calls the real backend endpoint. + // We use a direct fetch call and manually add the auth token to avoid circular dependency issues with apiClient. + const response = await fetch('/api/ai/process-flyer', { + method: 'POST', + headers: { 'Authorization': `Bearer ${localStorage.getItem('authToken')}` }, + body: formData, + }); + + const responseData = await response.json(); + + if (!response.ok) { + throw new Error(responseData.message || 'Failed to process flyer with AI.'); } - if (!parsedJson) { + if (!responseData.data) { return null; } - const UNMATCHED_ITEM_ID = 0; - const rawData = parsedJson as { - store_name: string; - valid_from: string | null; - valid_to: string | null; - items: RawFlyerItem[]; - }; - - const processedItems: Omit[] = rawData.items.map(rawItem => ({ - item: rawItem.item, - price_display: rawItem.price, - price_in_cents: parsePriceToCents(rawItem.price), - quantity: rawItem.quantity, - category_name: rawItem.category, - quantity_num: rawItem.quantity_num, - // Convert the special _UNMATCHED_ ID back to null for the database. - master_item_id: rawItem.master_item_id === UNMATCHED_ITEM_ID ? null : rawItem.master_item_id, - unit_price: rawItem.unit_price, - view_count: 0, // Add missing property - click_count: 0, // Add missing property - })); - - const today = new Date().toISOString().split('T')[0]; - - const finalData: ExtractedCoreData = { - store_name: rawData.store_name, - // Per user instruction, a date is mandatory. If the AI cannot find one, - // we must use today's date as a fallback. - valid_from: rawData.valid_from || today, - valid_to: rawData.valid_to || today, - items: processedItems, - }; - - return finalData; + // The backend now returns the fully processed data in the correct format. + return responseData.data as ExtractedCoreData; }; export const extractLogoFromImage = async (imageFiles: File[]): Promise => { @@ -419,16 +394,15 @@ export const extractItemsFromReceiptImage = async ( const imagePart = await serverFileToGenerativePart(imagePath, imageMimeType); - const result = await model.generateContent({ - model: 'gemini-pro-vision', + const response = await model.generateContent({ + model: 'gemini-1.5-flash', // Updated to a newer model as pro-vision is deprecated contents: [{ parts: [{text: prompt}, imagePart] }] }); - const response = result.response; - const text = response.text(); + const text = response.text; // Clean up the response to ensure it's valid JSON - const jsonMatch = text.match(/\[[\s\S]*\]/); + const jsonMatch = text?.match(/\[[\s\S]*\]/); if (!jsonMatch) { throw new Error('AI response did not contain a valid JSON array.'); } @@ -440,3 +414,78 @@ export const extractItemsFromReceiptImage = async ( throw new Error('Failed to parse structured data from the AI response.'); } }; + +/** + * SERVER-SIDE FUNCTION + * Uses Gemini Pro Vision to extract structured core data (store, dates, items) from flyer images. + * @param imagePaths An array of paths to the uploaded flyer image files. + * @param masterItems A list of master grocery items for the AI to reference. + * @returns A promise that resolves to the extracted core data object. + */ +export const extractCoreDataFromFlyerImage = async ( + imagePaths: { path: string; mimetype: string }[], + masterItems: MasterGroceryItem[] +): Promise => { + const genAI = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! }); + const model = genAI.models; + + const UNMATCHED_ITEM_ID = 0; // Special ID for items the AI cannot match. + + const prompt = ` + You are a highly accurate data extraction tool for grocery store flyers. + Analyze the provided image(s) and extract the following information: + 1. **store_name**: The name of the grocery store (e.g., "Safeway", "No Frills"). + 2. **valid_from**: The start date of the sale period in YYYY-MM-DD format. If not found, return null. + 3. **valid_to**: The end date of the sale period in YYYY-MM-DD format. If not found, return null. + 4. **items**: An array of all sale items. For each item, extract: + - **item**: The name of the product. + - **price**: The sale price as a string (e.g., "$3.99", "2 for $5.00"). + - **quantity**: The quantity description (e.g., "per lb", "500g bag", "each"). + - **category**: The most appropriate category from the provided list. + - **master_item_id**: Find the best match for the item from the provided master list. Use the ID of the matched item. If no good match is found, use the special ID ${UNMATCHED_ITEM_ID}. + + Return ONLY a single, valid JSON object matching the specified schema. Do not include any other text, explanations, or markdown formatting. + + Here is the list of master items to match against (use their 'id' for 'master_item_id'): + ${JSON.stringify(masterItems.map(item => ({ id: item.id, name: item.name, category: item.category_name })))} + `; + + const imageParts = await Promise.all( + imagePaths.map(file => serverFileToGenerativePart(file.path, file.mimetype)) + ); + + const response = await model.generateContent({ + model: 'gemini-1.5-flash', // Updated to a newer model + contents: [{ parts: [{ text: prompt }, ...imageParts] }] + }); + + const text = response.text; + + const jsonMatch = text?.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error('AI response did not contain a valid JSON object.'); + } + + try { + const rawData = JSON.parse(jsonMatch[0]) as { + store_name: string; + valid_from: string | null; + valid_to: string | null; + items: RawFlyerItem[]; + }; + + const processedItems = rawData.items.map(rawItem => ({ + ...rawItem, + price_display: rawItem.price, // Add the missing price_display field + price_in_cents: parsePriceToCents(rawItem.price), + master_item_id: rawItem.master_item_id === UNMATCHED_ITEM_ID ? null : rawItem.master_item_id, + view_count: 0, + click_count: 0, + })); + + return { ...rawData, items: processedItems }; + } catch (e) { + console.error("Failed to parse JSON from AI response:", text); + throw new Error('Failed to parse structured data from the AI response.'); + } +}; \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 4b62a6c5..b5dd49ae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,6 +58,9 @@ export interface Category { export interface Brand { id: number; name: string; + logo_url?: string | null; + store_id?: number | null; + store_name?: string | null; } export interface Product {