database expansion prior to creating on server

This commit is contained in:
2025-11-19 22:56:54 -08:00
parent 502e0754a1
commit 367d12bd00
14 changed files with 1048 additions and 26 deletions

View File

@@ -300,6 +300,13 @@ CREATE TABLE IF NOT EXISTS public.unmatched_flyer_items (
);
COMMENT ON TABLE public.unmatched_flyer_items IS 'A queue for reviewing flyer items that the system failed to automatically match.';
-- 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 (
@@ -324,7 +331,6 @@ COMMENT ON COLUMN public.products.upc_code IS 'Universal Product Code, if availa
-- Link flyer_items to the new products table.
-- This is done via ALTER TABLE because 'products' is created after 'flyer_items'.
ALTER TABLE public.flyer_items
ADD CONSTRAINT flyer_items_product_id_fkey
FOREIGN KEY (product_id) REFERENCES public.products(id);
@@ -371,7 +377,7 @@ CREATE TABLE IF NOT EXISTS public.recipes (
protein_grams NUMERIC,
fat_grams NUMERIC,
carb_grams NUMERIC,
avg_rating NUMERIC(2,1) DEFAULT 0.0 NOT NULL,
avg_rating NUMERIC(2,1) DEFAULT 0.0,
status TEXT DEFAULT 'private' NOT NULL CHECK (status IN ('private', 'pending_review', 'public')),
rating_count INTEGER DEFAULT 0 NOT NULL,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL
@@ -425,6 +431,14 @@ CREATE TABLE IF NOT EXISTS public.recipe_appliances (
);
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.';
-- 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,
@@ -444,6 +458,7 @@ CREATE TABLE IF NOT EXISTS public.recipe_comments (
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
parent_comment_id BIGINT REFERENCES public.recipe_comments(id) ON DELETE CASCADE, -- For threaded comments
content TEXT NOT NULL,
status TEXT DEFAULT 'visible' NOT NULL CHECK (status IN ('visible', 'hidden', 'reported')),
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
updated_at TIMESTAMPTZ
);
@@ -556,6 +571,17 @@ CREATE TABLE IF NOT EXISTS public.user_activity_log (
);
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.';
-- A table for users to group recipes into collections.
CREATE TABLE IF NOT EXISTS public.recipe_collections (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
@@ -591,6 +617,34 @@ CREATE TABLE IF NOT EXISTS public.user_dietary_restrictions (
);
COMMENT ON TABLE public.user_dietary_restrictions IS 'Connects users to their selected dietary needs and allergies.';
-- 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.';
-- 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.';
-- A table to store a predefined list of kitchen appliances.
CREATE TABLE IF NOT EXISTS public.appliances (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
@@ -664,6 +718,33 @@ COMMENT ON COLUMN public.shopping_trip_items.price_paid_cents IS 'The actual pri
-- A table to store historical records of completed shopping trips.
CREATE TABLE IF NOT EXISTS public.shopping_trips (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
shopping_list_id BIGINT REFERENCES public.shopping_lists(id) ON DELETE SET NULL,
completed_at TIMESTAMPTZ DEFAULT now() NOT NULL,
total_spent_cents INTEGER
);
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.';
-- A table to store the items purchased during a specific shopping trip.
CREATE TABLE IF NOT EXISTS public.shopping_trip_items (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(id) ON DELETE CASCADE,
master_item_id BIGINT REFERENCES public.master_grocery_items(id),
custom_item_name TEXT,
quantity NUMERIC NOT NULL,
price_paid_cents INTEGER,
CONSTRAINT trip_must_have_item_identifier CHECK (master_item_id IS NOT NULL OR custom_item_name IS NOT NULL)
);
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.';
@@ -826,6 +907,12 @@ BEGIN
ON CONFLICT (master_item_id, from_unit, to_unit) DO NOTHING;
END $$;
-- 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;
END $$;
-- Pre-populate the dietary_restrictions table.
INSERT INTO public.dietary_restrictions (name, type) VALUES
('Vegetarian', 'diet'), ('Vegan', 'diet'), ('Gluten-Free', 'diet'), ('Keto', 'diet'),
@@ -1443,6 +1530,225 @@ AS $$
ORDER BY r.name ASC;
$$;
-- 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.
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 find better deals for items on a recently processed receipt.
-- It compares the price paid on the receipt with current flyer prices.
CREATE OR REPLACE FUNCTION public.find_deals_for_receipt_items(p_receipt_id BIGINT)
RETURNS TABLE (
receipt_item_id BIGINT,
master_item_id BIGINT,
item_name TEXT,
price_paid_cents INTEGER,
current_best_price_in_cents INTEGER,
potential_savings_cents INTEGER,
deal_store_name TEXT,
flyer_id BIGINT
)
LANGUAGE sql
STABLE
SECURITY INVOKER
AS $$
WITH ReceiptItems AS (
-- CTE 1: Get all matched items from the specified receipt.
SELECT
ri.id AS receipt_item_id,
ri.master_item_id,
mgi.name AS item_name,
ri.price_paid_cents
FROM public.receipt_items ri
JOIN public.master_grocery_items mgi ON ri.master_item_id = mgi.id
WHERE ri.receipt_id = p_receipt_id
AND ri.master_item_id IS NOT NULL
),
BestCurrentPrices AS (
-- CTE 2: Find the single best price for every item currently on sale.
SELECT DISTINCT ON (fi.master_item_id)
fi.master_item_id,
fi.price_in_cents,
s.name AS store_name,
f.id AS flyer_id
FROM public.flyer_items fi
JOIN public.flyers f ON fi.flyer_id = f.id
JOIN public.stores s ON f.store_id = s.id
WHERE fi.master_item_id IS NOT NULL
AND fi.price_in_cents IS NOT NULL
AND CURRENT_DATE BETWEEN f.valid_from AND f.valid_to
ORDER BY fi.master_item_id, fi.price_in_cents ASC
)
-- Final Selection: Join receipt items with current deals and find savings.
SELECT
ri.receipt_item_id,
ri.master_item_id,
ri.item_name,
ri.price_paid_cents,
bcp.price_in_cents AS current_best_price_in_cents,
(ri.price_paid_cents - bcp.price_in_cents) AS potential_savings_cents,
bcp.store_name AS deal_store_name,
bcp.flyer_id
FROM ReceiptItems ri
JOIN BestCurrentPrices bcp ON ri.master_item_id = bcp.master_item_id
-- Only return rows where the current sale price is better than the price paid.
WHERE bcp.price_in_cents < ri.price_paid_cents
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.
@@ -1514,6 +1820,10 @@ BEGIN
-- Also create a default shopping list for the new user.
INSERT INTO public.shopping_lists (user_id, name)
VALUES (new_profile_id, 'Main Shopping List');
-- 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;
@@ -1758,6 +2068,50 @@ 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)