diff --git a/sql/master_schema_rollup.sql b/sql/master_schema_rollup.sql index d638bd76..2efdcc51 100644 --- a/sql/master_schema_rollup.sql +++ b/sql/master_schema_rollup.sql @@ -2115,6 +2115,61 @@ AS $$ ORDER BY potential_savings_cents DESC; $$; +-- Function to get a user's spending breakdown by category for a given date range. +DROP FUNCTION IF EXISTS public.get_spending_by_category(UUID, DATE, DATE); + +CREATE OR REPLACE FUNCTION public.get_spending_by_category(p_user_id UUID, p_start_date DATE, p_end_date DATE) +RETURNS TABLE ( + category_id BIGINT, + category_name TEXT, + total_spent_cents BIGINT +) +LANGUAGE sql +STABLE +SECURITY INVOKER +AS $$ + WITH all_purchases AS ( + -- CTE 1: Combine purchases from completed shopping trips. + -- We only consider items that have a price paid. + SELECT + sti.master_item_id, + sti.price_paid_cents + FROM public.shopping_trip_items sti + JOIN public.shopping_trips st ON sti.shopping_trip_id = st.shopping_trip_id + WHERE st.user_id = p_user_id + AND st.completed_at::date BETWEEN p_start_date AND p_end_date + AND sti.price_paid_cents IS NOT NULL + + UNION ALL + + -- CTE 2: Combine purchases from processed receipts. + SELECT + ri.master_item_id, + ri.price_paid_cents + FROM public.receipt_items ri + JOIN public.receipts r ON ri.receipt_id = r.receipt_id + WHERE r.user_id = p_user_id + AND r.transaction_date::date BETWEEN p_start_date AND p_end_date + AND ri.master_item_id IS NOT NULL -- Only include items matched to a master item + ) + -- Final Aggregation: Group all combined purchases by category and sum the spending. + SELECT + c.category_id, + c.name AS category_name, + SUM(ap.price_paid_cents)::BIGINT AS total_spent_cents + FROM all_purchases ap + -- Join with master_grocery_items to get the category_id for each purchase. + JOIN public.master_grocery_items mgi ON ap.master_item_id = mgi.master_grocery_item_id + -- Join with categories to get the category name for display. + JOIN public.categories c ON mgi.category_id = c.category_id + GROUP BY + c.category_id, c.name + HAVING + SUM(ap.price_paid_cents) > 0 + ORDER BY + total_spent_cents DESC; +$$; + -- Function to approve a suggested correction and apply it. DROP FUNCTION IF EXISTS public.approve_correction(BIGINT); @@ -2669,6 +2724,58 @@ CREATE TRIGGER on_new_recipe_collection_share AFTER INSERT ON public.shared_recipe_collections FOR EACH ROW EXECUTE FUNCTION public.log_new_recipe_collection_share(); +-- 10. Trigger function to geocode a store location's address. +-- This function is designed to be extensible for external geocoding services. +DROP FUNCTION IF EXISTS public.geocode_store_location(); + +CREATE OR REPLACE FUNCTION public.geocode_store_location() +RETURNS TRIGGER AS $$ +DECLARE + full_address TEXT; +BEGIN + -- Only proceed if the address has actually changed. + -- Note: We check against the linked address fields via the NEW.address_id in a real scenario, + -- but for this trigger to work effectively, it usually requires a direct update on the address table + -- or this trigger should be moved to the 'addresses' table. + -- However, based on the provided logic, we are keeping the placeholder structure. + + -- Placeholder logic: + IF TG_OP = 'INSERT' THEN + -- Logic to fetch address string based on NEW.address_id and geocode + NULL; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to call the geocoding function. +DROP TRIGGER IF EXISTS on_store_location_address_change ON public.store_locations; +CREATE TRIGGER on_store_location_address_change + BEFORE INSERT OR UPDATE ON public.store_locations + FOR EACH ROW EXECUTE FUNCTION public.geocode_store_location(); + +-- 11. Trigger function to increment the fork_count on the original recipe. +DROP FUNCTION IF EXISTS public.increment_recipe_fork_count(); + +CREATE OR REPLACE FUNCTION public.increment_recipe_fork_count() +RETURNS TRIGGER AS $$ +BEGIN + -- Only run if the recipe is a fork (original_recipe_id is not null). + IF NEW.original_recipe_id IS NOT NULL THEN + UPDATE public.recipes SET fork_count = fork_count + 1 WHERE recipe_id = NEW.original_recipe_id; + -- Award 'First Fork' achievement. + PERFORM public.award_achievement(NEW.user_id, 'First Fork'); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS on_recipe_fork ON public.recipes; +CREATE TRIGGER on_recipe_fork + AFTER INSERT ON public.recipes + FOR EACH ROW EXECUTE FUNCTION public.increment_recipe_fork_count(); + -- ================================================================= -- Function: get_best_sale_prices_for_all_users() -- Description: Retrieves the best sale price for every item on every user's watchlist. diff --git a/src/services/flyerDataTransformer.test.ts b/src/services/flyerDataTransformer.test.ts index 2ad1d95b..ca14310b 100644 --- a/src/services/flyerDataTransformer.test.ts +++ b/src/services/flyerDataTransformer.test.ts @@ -70,6 +70,8 @@ describe('FlyerDataTransformer', () => { mockLogger, ); + const baseUrl = `http://localhost:${process.env.PORT || 3000}`; + // Assert // 0. Check logging expect(mockLogger.info).toHaveBeenCalledWith( @@ -83,8 +85,8 @@ describe('FlyerDataTransformer', () => { // 1. Check flyer data expect(flyerData).toEqual({ file_name: originalFileName, - image_url: '/flyer-images/flyer-page-1.jpg', - icon_url: '/flyer-images/icons/icon-flyer-page-1.webp', + image_url: `${baseUrl}/flyer-images/flyer-page-1.jpg`, + icon_url: `${baseUrl}/flyer-images/icons/icon-flyer-page-1.webp`, checksum, store_name: 'Test Store', valid_from: '2024-01-01', @@ -151,6 +153,8 @@ describe('FlyerDataTransformer', () => { mockLogger, ); + const baseUrl = `http://localhost:${process.env.PORT || 3000}`; + // Assert // 0. Check logging expect(mockLogger.info).toHaveBeenCalledWith( @@ -167,8 +171,8 @@ describe('FlyerDataTransformer', () => { expect(itemsForDb).toHaveLength(0); expect(flyerData).toEqual({ file_name: originalFileName, - image_url: '/flyer-images/another.png', - icon_url: '/flyer-images/icons/icon-another.webp', + image_url: `${baseUrl}/flyer-images/another.png`, + icon_url: `${baseUrl}/flyer-images/icons/icon-another.webp`, checksum, store_name: 'Unknown Store (auto)', // Should use fallback valid_from: null, diff --git a/src/services/flyerDataTransformer.ts b/src/services/flyerDataTransformer.ts index 78142dfa..9011eb9e 100644 --- a/src/services/flyerDataTransformer.ts +++ b/src/services/flyerDataTransformer.ts @@ -80,8 +80,8 @@ export class FlyerDataTransformer { const flyerData: FlyerInsert = { file_name: originalFileName, - image_url: `${baseUrl}/flyer-images/${path.basename(firstImage)}`, - icon_url: `${baseUrl}/flyer-images/icons/${iconFileName}`, + image_url: new URL(`/flyer-images/${path.basename(firstImage)}`, baseUrl).href, + icon_url: new URL(`/flyer-images/icons/${iconFileName}`, baseUrl).href, checksum, store_name: storeName, valid_from: extractedData.valid_from,