-- DONE -- ============================================================================ -- PART 0.5: USER AUTHENTICATION TABLE -- ============================================================================ -- This replaces the Supabase `auth.users` table. CREATE TABLE IF NOT EXISTS public.users ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, refresh_token TEXT, -- Stores the long-lived refresh token for re-authentication. created_at TIMESTAMPTZ DEFAULT now() NOT NULL ); COMMENT ON TABLE public.users IS 'Stores user authentication information, replacing Supabase auth.'; -- Add an index on the refresh_token for faster lookups when refreshing tokens. CREATE INDEX IF NOT EXISTS idx_users_refresh_token ON public.users(refresh_token); -- 0. Create a table for public user profiles. -- This table is linked to the auth.users table and stores non-sensitive user data. CREATE TABLE IF NOT EXISTS public.profiles ( id UUID PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE, updated_at TIMESTAMPTZ, full_name TEXT, avatar_url TEXT, preferences JSONB, role TEXT CHECK (role IN ('admin', 'user')) ); COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to the private auth.users table.'; -- DONE -- 1. Create the 'stores' table for normalized store data. CREATE TABLE IF NOT EXISTS public.stores ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, name TEXT NOT NULL UNIQUE, logo_url TEXT ); COMMENT ON TABLE public.stores IS 'Stores metadata for grocery store chains (e.g., Safeway, Kroger).'; -- DONE -- 2. Create the 'categories' table for normalized category data. CREATE TABLE IF NOT EXISTS public.categories ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL UNIQUE ); COMMENT ON TABLE public.categories IS 'Stores a predefined list of grocery item categories (e.g., ''Fruits & Vegetables'', ''Dairy & Eggs'').'; -- DONE -- 3. Create the 'flyers' table with its full, final schema. CREATE TABLE IF NOT EXISTS public.flyers ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, file_name TEXT NOT NULL, image_url TEXT NOT NULL, checksum TEXT UNIQUE, store_id BIGINT REFERENCES public.stores(id), valid_from DATE, valid_to DATE, store_address TEXT ); COMMENT ON TABLE public.flyers IS 'Stores metadata for each processed flyer, linking it to a store and its validity period.'; -- DONE -- 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) ); 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.'; -- DONE -- 5. Create the 'user_watched_items' table. This links to the master list. CREATE TABLE IF NOT EXISTS public.user_watched_items ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, 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.'; -- DONE -- 6. Create the 'flyer_items' table with its full, final schema. CREATE TABLE IF NOT EXISTS public.flyer_items ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, flyer_id BIGINT REFERENCES public.flyers(id) ON DELETE CASCADE, item TEXT NOT NULL, price_display TEXT NOT NULL, price_in_cents INTEGER, quantity_num NUMERIC, quantity TEXT NOT NULL, category_id BIGINT REFERENCES public.categories(id), category_name TEXT, -- Denormalized for easier display unit_price JSONB, master_item_id BIGINT REFERENCES public.master_grocery_items(id), product_id BIGINT -- Future use for specific product linking ); COMMENT ON TABLE public.flyer_items IS 'Stores individual items extracted from a specific flyer.'; -- DONE -- 7. Create a table for user-defined alerts on watched items. CREATE TABLE IF NOT EXISTS public.user_alerts ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, user_watched_item_id BIGINT NOT NULL REFERENCES public.user_watched_items(id) ON DELETE CASCADE, alert_type TEXT NOT NULL CHECK (alert_type IN ('PRICE_BELOW', 'PERCENT_OFF_AVERAGE')), threshold_value NUMERIC NOT NULL, is_active BOOLEAN DEFAULT true NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL ); 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''.'; COMMENT ON COLUMN public.user_alerts.threshold_value IS 'The numeric threshold for the alert condition (e.g., price in cents, or percentage).'; -- DONE -- 8. Create a table to store notifications for users. CREATE TABLE IF NOT EXISTS public.notifications ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, content TEXT NOT NULL, link_url TEXT, is_read BOOLEAN DEFAULT false NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL ); 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.'; COMMENT ON COLUMN public.notifications.link_url IS 'A URL to navigate to when the notification is clicked.'; -- DONE -- 9. Create a table for aggregated, historical price data for master items. CREATE TABLE IF NOT EXISTS public.item_price_history ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, summary_date DATE NOT NULL, min_price_in_cents INTEGER, max_price_in_cents INTEGER, avg_price_in_cents INTEGER, data_points_count INTEGER DEFAULT 0 NOT NULL, UNIQUE(master_item_id, summary_date) ); 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.'; 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.'; COMMENT ON COLUMN public.item_price_history.data_points_count IS 'How many data points were used for this summary.'; -- DONE -- 10. Create a table to map various names to a single master grocery item. CREATE TABLE IF NOT EXISTS public.master_item_aliases ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, alias TEXT NOT NULL UNIQUE ); 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".'; -- DONE -- 11. Create tables for user shopping lists. CREATE TABLE IF NOT EXISTS public.shopping_lists ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, name TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL ); COMMENT ON TABLE public.shopping_lists IS 'Stores user-created shopping lists, e.g., "Weekly Groceries".'; -- DONE CREATE TABLE IF NOT EXISTS public.shopping_list_items ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(id) ON DELETE CASCADE, master_item_id BIGINT REFERENCES public.master_grocery_items(id), custom_item_name TEXT, quantity NUMERIC DEFAULT 1 NOT NULL, is_purchased BOOLEAN DEFAULT false NOT NULL, added_at TIMESTAMPTZ DEFAULT now() NOT NULL, -- Ensure one of the item identifiers is present CONSTRAINT must_have_item_identifier CHECK (master_item_id IS NOT NULL OR custom_item_name IS NOT NULL) ); 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".'; COMMENT ON COLUMN public.shopping_list_items.is_purchased IS 'Lets users check items off their list as they shop.'; -- DONE -- 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, flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES public.users(id), correction_type TEXT NOT NULL, suggested_value TEXT NOT NULL, status TEXT DEFAULT 'pending' NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, reviewed_notes TEXT, reviewed_at TIMESTAMPTZ ); 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).'; COMMENT ON COLUMN public.suggested_corrections.status IS 'The moderation status of the correction: pending, approved, or rejected.'; -- DONE -- 13. Create a table for prices submitted directly by users from in-store. CREATE TABLE IF NOT EXISTS public.user_submitted_prices ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, user_id UUID NOT NULL REFERENCES public.users(id), master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id), store_id BIGINT NOT NULL REFERENCES public.stores(id), price_in_cents INTEGER NOT NULL, photo_url TEXT, upvotes INTEGER DEFAULT 0 NOT NULL, downvotes INTEGER DEFAULT 0 NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL ); 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.'; COMMENT ON COLUMN public.user_submitted_prices.upvotes IS 'Community validation score indicating accuracy.'; -- 14. Pre-populate categories table from a predefined list. INSERT INTO public.categories (name) VALUES ('Fruits & Vegetables'), ('Meat & Seafood'), ('Dairy & Eggs'), ('Bakery & Bread'), ('Pantry & Dry Goods'), ('Beverages'), ('Frozen Foods'), ('Snacks'), ('Household & Cleaning'), ('Personal Care & Health'), ('Baby & Child'), ('Pet Supplies'), ('Deli & Prepared Foods'), ('Canned Goods'), ('Condiments & Spices'), ('Breakfast & Cereal'), ('Organic'), ('International Foods'), ('Other/Miscellaneous') ON CONFLICT (name) DO NOTHING; -- DONE -- 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".'; -- DONE -- A table for specific products, linking a master item with a brand and size. CREATE TABLE IF NOT EXISTS public.products ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id), brand_id BIGINT REFERENCES public.brands(id), name TEXT NOT NULL, description TEXT, size TEXT, upc_code TEXT UNIQUE ); 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.'; 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".'; -- Then, you would update 'flyer_items' to link to this new table. ALTER TABLE public.flyer_items ADD COLUMN IF NOT EXISTS product_id BIGINT REFERENCES public.products(id); ADD CONSTRAINT flyer_items_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id); -- Enable trigram support for fuzzy string matching CREATE EXTENSION IF NOT EXISTS pg_trgm; -- Add a GIN index to the 'item' column for fast fuzzy text searching. -- This requires the pg_trgm extension. CREATE INDEX IF NOT EXISTS flyer_items_item_trgm_idx ON public.flyer_items USING GIN (item gin_trgm_ops); -- First, enable the PostGIS extension if you haven't already. -- In Supabase, you can do this under Database -> Extensions. -- CREATE EXTENSION IF NOT EXISTS postgis; -- DONE -- A table to store individual store locations with geographic data. CREATE TABLE IF NOT EXISTS public.store_locations ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, store_id BIGINT NOT NULL REFERENCES public.stores(id) ON DELETE CASCADE, address TEXT NOT NULL, city TEXT, province_state TEXT, postal_code TEXT, -- 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); 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. -- This requires the postgis extension. CREATE INDEX IF NOT EXISTS store_locations_geo_idx ON public.store_locations USING GIST (location); -- DONE -- You might also need a linking table if one flyer is valid for multiple locations. CREATE TABLE IF NOT EXISTS public.flyer_locations ( flyer_id BIGINT NOT NULL REFERENCES public.flyers(id) ON DELETE CASCADE, store_location_id BIGINT NOT NULL REFERENCES public.store_locations(id) ON DELETE CASCADE, 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.'; -- done -- A table to store recipes, which can be user-created or pre-populated. CREATE TABLE IF NOT EXISTS public.recipes ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, user_id UUID REFERENCES public.users(id) ON DELETE SET NULL, -- Can be a system recipe (user_id is NULL) or user-submitted name TEXT NOT NULL, description TEXT, instructions TEXT, prep_time_minutes INTEGER, cook_time_minutes INTEGER, servings INTEGER, photo_url TEXT, -- Optional nutritional information calories_per_serving INTEGER, protein_grams NUMERIC, fat_grams NUMERIC, carb_grams NUMERIC, -- Aggregated rating data for fast sorting/display avg_rating NUMERIC(2,1) DEFAULT 0.0 NOT NULL, rating_count INTEGER DEFAULT 0 NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL ); 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.'; -- done -- A linking table for ingredients required for each recipe. CREATE TABLE IF NOT EXISTS public.recipe_ingredients ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id), quantity NUMERIC NOT NULL, unit TEXT NOT NULL ); 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".'; -- done -- A table to store a predefined list of tags for recipes. CREATE TABLE IF NOT EXISTS public.tags ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL UNIQUE ); COMMENT ON TABLE public.tags IS 'Stores tags for categorizing recipes, e.g., "Vegetarian", "Quick & Easy".'; -- done -- A linking table to associate multiple tags with a recipe. CREATE TABLE IF NOT EXISTS public.recipe_tags ( recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, tag_id BIGINT NOT NULL REFERENCES public.tags(id) ON DELETE CASCADE, PRIMARY KEY (recipe_id, tag_id) ); COMMENT ON TABLE public.recipe_tags IS 'A linking table to associate multiple tags with a single recipe.'; -- done -- A table to store individual user ratings for recipes. CREATE TABLE IF NOT EXISTS public.recipe_ratings ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), comment TEXT, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, 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.'; -- DONE -- 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, user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, name TEXT NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, 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".'; -- DONE -- A table to associate a recipe with a specific date and meal type within a menu plan. CREATE TABLE IF NOT EXISTS public.planned_meals ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, menu_plan_id BIGINT NOT NULL REFERENCES public.menu_plans(id) ON DELETE CASCADE, recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, plan_date DATE NOT NULL, meal_type TEXT NOT NULL, servings_to_cook INTEGER ); 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''.'; -- DONE -- A table to track the grocery items a user currently has in their pantry. CREATE TABLE IF NOT EXISTS public.pantry_items ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, quantity NUMERIC NOT NULL, unit TEXT, best_before_date DATE, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, UNIQUE(user_id, master_item_id) ); 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.'; 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, user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL ); 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.'; 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. CREATE TABLE IF NOT EXISTS public.unit_conversions ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, from_unit TEXT NOT NULL, to_unit TEXT NOT NULL, factor NUMERIC NOT NULL, UNIQUE(master_item_id, from_unit, to_unit) ); 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.';