diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index c315657e..4ef69f4c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -60,35 +60,19 @@ jobs: run: npm run lint # Run the linter to check for code quality issues. continue-on-error: true # Allows the workflow to proceed even if linting fails. - - name: Setup Test Database - id: setup_test_db # Add an ID to reference this step's outcome later. - run: | - echo "--- Creating test database and user ---" - # Execute the setup script as the 'postgres' superuser. - # This creates the 'flyer-crawler-test' database and 'test_runner' role. - sudo -u postgres psql -f sql/test_setup.sql - - name: Run Unit Tests # We override the production DB variables for this step only. # The 'global-setup.ts' will use these to connect to the test database, # create the schema, and seed data before tests run. env: - DB_HOST: localhost - DB_PORT: 5432 - DB_USER: test_runner - DB_PASSWORD: a_secure_test_password - DB_DATABASE: flyer-crawler-test + DB_HOST: ${{ secrets.DB_HOST }} + DB_PORT: ${{ secrets.DB_PORT }} + DB_USER: ${{ secrets.DB_USER }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + DB_DATABASE: "flyer-crawler-test" # Hardcode the test database name run: npm test # Run the test suite against the temporary test database. # also Run the test suite to ensure code correctness. - - name: Teardown Test Database - # This step runs regardless of whether the tests passed or failed. - # It will only execute if the 'setup_test_db' step was successful, preventing errors if setup failed. - if: always() && steps.setup_test_db.outcome == 'success' - run: | - echo "--- Dropping test database and user ---" - sudo -u postgres psql -f sql/test_teardown.sql - - name: Archive Code Coverage Report # This action saves the generated HTML coverage report as a downloadable artifact. uses: actions/upload-artifact@v3 diff --git a/README.md b/README.md index 93d2f585..8038121f 100644 --- a/README.md +++ b/README.md @@ -228,4 +228,43 @@ Your tests run against this clean, isolated "flyer-crawler-test" database. Teardown (sql/test_teardown.sql): After tests complete (whether they pass or fail), the if: always() step in your workflow ensures that sql/test_teardown.sql is executed. -This script terminates any lingering connections to the test database, drops the "flyer-crawler-test" database completely, and drops the test_runner role. \ No newline at end of file +This script terminates any lingering connections to the test database, drops the "flyer-crawler-test" database completely, and drops the test_runner role. + +Part 3: Test Database Setup (for CI/CD and Local Testing) +Your Gitea workflow and local test runner rely on a permanent test database. This database needs to be created once on your server. The test runner will automatically reset the schema inside it before every test run. + +Step 1: Create the Test Database +On your server, switch to the postgres system user to get superuser access. + +bash +sudo -u postgres psql +Inside the psql shell, create a new database. We will assign ownership to the same flyer_crawler_user that your application uses. This user needs to be the owner to have permission to drop and recreate the schema during testing. + +sql +-- Create the test database and assign ownership to your existing application user +CREATE DATABASE "flyer-crawler-test" WITH OWNER = flyer_crawler_user; + +-- Exit the psql shell +\q + +Step 2: Configure Gitea Secrets for Testing +Your CI pipeline needs to know how to connect to this test database. Ensure the following secrets are set in your Gitea repository settings: + +DB_HOST: The hostname of your database server (e.g., localhost). +DB_PORT: The port for your database (e.g., 5432). +DB_USER: The user for the database (e.g., flyer_crawler_user). +DB_PASSWORD: The password for the database user. +The workflow file (.gitea/workflows/deploy.yml) is configured to use these secrets and will automatically connect to the "flyer-crawler-test" database when it runs the npm test command. + +How the Test Workflow Works +The CI pipeline no longer uses sudo or creates/destroys the database on each run. Instead, the process is now: + +Setup: The vitest global setup script (src/tests/setup/global-setup.ts) connects to the permanent "flyer-crawler-test" database. + +Schema Reset: It executes sql/drop_tables.sql (which runs DROP SCHEMA public CASCADE) to completely wipe all tables, functions, and triggers. + +Schema Application: It then immediately executes sql/master_schema_rollup.sql to build a fresh, clean schema and seed initial data. + +Test Execution: Your tests run against this clean, isolated schema. + +This approach is faster, more reliable, and removes the need for sudo access within the CI pipeline. \ No newline at end of file diff --git a/sql/drop_tables.sql b/sql/drop_tables.sql index 9a15a213..6119071b 100644 --- a/sql/drop_tables.sql +++ b/sql/drop_tables.sql @@ -58,3 +58,8 @@ DROP TABLE IF EXISTS public.profiles CASCADE; DROP TABLE IF EXISTS public.password_reset_tokens CASCADE; DROP TABLE IF EXISTS public.users CASCADE; DROP TABLE IF EXISTS public.unmatched_flyer_items CASCADE; +-- Drop the entire 'public' schema, cascading to all objects within it. +DROP SCHEMA IF EXISTS public CASCADE; + +-- Recreate the 'public' schema with default permissions. +CREATE SCHEMA public; diff --git a/sql/temp-mas-for-compare.sql b/sql/temp-mas-for-compare.sql deleted file mode 100644 index 97385d14..00000000 --- a/sql/temp-mas-for-compare.sql +++ /dev/null @@ -1,845 +0,0 @@ --- ============================================================================ --- MASTER SCHEMA SCRIPT --- ============================================================================ --- Purpose: --- This file contains the master SQL schema for the entire Supabase database. --- It is designed to be a "one-click" script that can be run in a PostgreSQL --- database to set up the entire backend from scratch, including: --- 1. Enabling required Postgres extensions. --- 2. Creating all tables with relationships and constraints. --- 3. Seeding essential initial data (categories, master items). --- 6. Defining database functions for business logic. --- 7. Setting up triggers for automation (e.g., creating user profiles). --- --- Usage: --- For a fresh setup, run this entire script. To reset the environment, --- first run 'delete_all_tables.sql.txt' and then run this script. - --- ============================================================================ --- PART 1: EXTENSIONS --- ============================================================================ --- Enable necessary PostgreSQL extensions. --- postgis: For storing and querying geographic data (store locations). --- pg_trgm: For trigram-based fuzzy string matching (improving item searches). -CREATE EXTENSION IF NOT EXISTS postgis; -CREATE EXTENSION IF NOT EXISTS pg_trgm; -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- For generating UUIDs - --- ============================================================================ --- PART 2: TABLES --- ============================================================================ --- 1. Users - 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, - refresh_token TEXT, - failed_login_attempts INTEGER DEFAULT 0, - last_failed_login TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -COMMENT ON TABLE public.users IS 'Stores user authentication information, replacing Supabase auth.'; -COMMENT ON COLUMN public.users.refresh_token IS 'Stores the long-lived refresh token for re-authentication.'; -COMMENT ON COLUMN public.users.failed_login_attempts IS 'Tracks the number of consecutive failed login attempts.'; -COMMENT ON COLUMN public.users.last_failed_login IS 'Timestamp of the last failed login attempt.'; --- 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); - --- 2. Log key user activities for analytics. --- This needs to be created early as many triggers will insert into it. -CREATE TABLE IF NOT EXISTS public.activity_log ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID REFERENCES public.users(id) ON DELETE SET NULL, - action TEXT NOT NULL, - display_text TEXT NOT NULL, - icon TEXT, - details JSONB, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -COMMENT ON TABLE public.activity_log IS 'Logs key user and system actions for auditing and display in an activity feed.'; -CREATE INDEX IF NOT EXISTS idx_activity_log_user_id ON public.activity_log(user_id); - --- 3. for public user profiles. --- This table is linked to the users table and stores non-sensitive user data. -CREATE TABLE IF NOT EXISTS public.profiles ( - id UUID PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE, - full_name TEXT, - avatar_url TEXT, - preferences JSONB, - role TEXT CHECK (role IN ('admin', 'user')), - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, - created_by UUID REFERENCES public.users(id) ON DELETE SET NULL, - updated_by UUID REFERENCES public.users(id) ON DELETE SET NULL -); -COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to the public.users table.'; - --- 4. The 'stores' table for normalized store data. -CREATE TABLE IF NOT EXISTS public.stores ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - name TEXT NOT NULL UNIQUE, - logo_url TEXT, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, - created_by UUID REFERENCES public.users(id) ON DELETE SET NULL -); -COMMENT ON TABLE public.stores IS 'Stores metadata for grocery store chains (e.g., Safeway, Kroger).'; - --- 5. The 'categories' table for normalized category data. -CREATE TABLE IF NOT EXISTS public.categories ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - name TEXT NOT NULL UNIQUE, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -COMMENT ON TABLE public.categories IS 'Stores a predefined list of grocery item categories (e.g., ''Fruits & Vegetables'', ''Dairy & Eggs'').'; - --- 6. flyers' table -CREATE TABLE IF NOT EXISTS public.flyers ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - file_name TEXT NOT NULL, - image_url TEXT NOT NULL, - checksum TEXT UNIQUE, - store_id BIGINT REFERENCES public.stores(id), - valid_from DATE, - valid_to DATE, - store_address TEXT, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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.'; -COMMENT ON COLUMN public.flyers.store_id IS 'Foreign key linking this flyer to a specific store in the `stores` table.'; -COMMENT ON COLUMN public.flyers.valid_from IS 'The start date of the sale period for this flyer, extracted by the AI.'; -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.'; - --- 7. The 'master_grocery_items' table. This is the master dictionary. -CREATE TABLE IF NOT EXISTS public.master_grocery_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - name TEXT NOT NULL UNIQUE, - category_id BIGINT REFERENCES public.categories(id), - is_allergen BOOLEAN DEFAULT false, - allergy_info JSONB, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, - created_by UUID REFERENCES public.users(id) ON DELETE SET NULL -); -COMMENT ON TABLE public.master_grocery_items IS 'The master dictionary of canonical grocery items. Each item has a unique name and is linked to a category.'; -CREATE INDEX IF NOT EXISTS idx_master_grocery_items_category_id ON public.master_grocery_items(category_id); - --- 8. The 'user_watched_items' table. This links to the master list. -CREATE TABLE IF NOT EXISTS public.user_watched_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_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.'; -CREATE INDEX IF NOT EXISTS idx_user_watched_items_master_item_id ON public.user_watched_items(master_item_id); - --- 9. The 'flyer_items' table. This stores individual items from flyers. -CREATE TABLE IF NOT EXISTS public.flyer_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - flyer_id BIGINT REFERENCES public.flyers(id) ON DELETE CASCADE, - 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, - unit_price JSONB, - view_count INTEGER DEFAULT 0 NOT NULL, - click_count INTEGER DEFAULT 0 NOT NULL, - master_item_id BIGINT REFERENCES public.master_grocery_items(id), - product_id BIGINT, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -COMMENT ON TABLE public.flyer_items IS 'Stores individual items extracted from a specific flyer.'; -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").'; -COMMENT ON COLUMN public.flyer_items.price_in_cents IS 'The normalized price for a single item, in cents, for easier sorting and comparison.'; -COMMENT ON COLUMN public.flyer_items.quantity IS 'The raw quantity or deal description string (e.g., "per lb", "500g bag").'; -COMMENT ON COLUMN public.flyer_items.quantity_num IS 'The parsed primary numeric value from the quantity string (e.g., 500 from "500g").'; -COMMENT ON COLUMN public.flyer_items.master_item_id IS 'Foreign key linking this flyer item to its canonical entry in `master_grocery_items`. Null if no match was found.'; -COMMENT ON COLUMN public.flyer_items.category_id IS 'Foreign key to the item''s category. Can be redundant if master_item_id is set, but useful.'; -COMMENT ON COLUMN public.flyer_items.category_name IS 'The denormalized name of the category for faster UI display without extra joins.'; -COMMENT ON COLUMN public.flyer_items.unit_price IS 'A JSONB object storing the calculated unit price (e.g., {"value": 1.99, "unit": "lb"}) for standardized price comparisons.'; -COMMENT ON COLUMN public.flyer_items.product_id IS 'A foreign key for future use, to link to a specific product with a UPC code in the `products` table.'; -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); --- 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); - --- 10. For user-defined alerts on watched items. -CREATE TABLE IF NOT EXISTS public.user_alerts ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_watched_item_id BIGINT NOT NULL REFERENCES public.user_watched_items(id) ON DELETE CASCADE, - 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, - updated_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).'; -CREATE INDEX IF NOT EXISTS idx_user_alerts_user_watched_item_id ON public.user_alerts(user_watched_item_id); - --- 11. Store notifications for users. -CREATE TABLE IF NOT EXISTS public.notifications ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - content TEXT NOT NULL, - link_url TEXT, - is_read BOOLEAN DEFAULT false NOT NULL, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_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.'; -CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON public.notifications(user_id); - --- 12. Store individual store locations with geographic data. -CREATE TABLE IF NOT EXISTS public.store_locations ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - store_id BIGINT NOT NULL REFERENCES public.stores(id) ON DELETE CASCADE, - address TEXT NOT NULL, - city TEXT, - province_state TEXT, - postal_code TEXT, - location GEOGRAPHY(Point, 4326), - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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. -CREATE INDEX IF NOT EXISTS store_locations_geo_idx ON public.store_locations USING GIST (location); - --- 13. For aggregated, historical price data for master items. -CREATE TABLE IF NOT EXISTS public.item_price_history ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, - summary_date DATE NOT NULL, - store_location_id BIGINT REFERENCES public.store_locations(id) ON DELETE CASCADE, - 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, store_location_id), - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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.'; -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); - --- 14. Map various names to a single master grocery item. -CREATE TABLE IF NOT EXISTS public.master_item_aliases ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, - alias TEXT NOT NULL UNIQUE, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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); - --- 15. For user shopping lists. -CREATE TABLE IF NOT EXISTS public.shopping_lists ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_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); - --- 16. For items in a user's shopping list. -CREATE TABLE IF NOT EXISTS public.shopping_list_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(id) ON DELETE CASCADE, - master_item_id BIGINT REFERENCES public.master_grocery_items(id), - custom_item_name TEXT, - quantity NUMERIC DEFAULT 1 NOT NULL, - is_purchased BOOLEAN DEFAULT false NOT NULL, - notes TEXT, - added_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, - 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.'; -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); - --- 17. Manage shared access to shopping lists. -CREATE TABLE IF NOT EXISTS public.shared_shopping_lists ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(id) ON DELETE CASCADE, - shared_by_user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - shared_with_user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - permission_level TEXT NOT NULL CHECK (permission_level IN ('view', 'edit')), - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, - 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); - --- 18. Store a user's collection of planned meals for a date range. -CREATE TABLE IF NOT EXISTS public.menu_plans ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - start_date DATE NOT NULL, - end_date DATE NOT NULL, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_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".'; -CREATE INDEX IF NOT EXISTS idx_menu_plans_user_id ON public.menu_plans(user_id); - --- 19. Manage shared access to menu plans. -CREATE TABLE IF NOT EXISTS public.shared_menu_plans ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - menu_plan_id BIGINT NOT NULL REFERENCES public.menu_plans(id) ON DELETE CASCADE, - shared_by_user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - shared_with_user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - permission_level TEXT NOT NULL CHECK (permission_level IN ('view', 'edit')), - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, - 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); - --- 20. Store user-submitted corrections for flyer items. -CREATE TABLE IF NOT EXISTS public.suggested_corrections ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES public.users(id), - 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, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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.'; -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); - --- 21. For prices submitted directly by users from in-store. -CREATE TABLE IF NOT EXISTS public.user_submitted_prices ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id), - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id), - store_id BIGINT NOT NULL REFERENCES public.stores(id), - 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, - updated_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.'; -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); - --- 22. Log flyer items that could not be automatically matched to a master item. -CREATE TABLE IF NOT EXISTS public.unmatched_flyer_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(id) ON DELETE CASCADE, - status TEXT DEFAULT 'pending' NOT NULL CHECK (status IN ('pending', 'reviewed', 'ignored')), - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - reviewed_at TIMESTAMPTZ, - UNIQUE(flyer_item_id), - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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); - --- 23. Store brand information. -CREATE TABLE IF NOT EXISTS public.brands ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - name TEXT NOT NULL UNIQUE, - logo_url TEXT, - store_id BIGINT REFERENCES public.stores(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT 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.'; - --- 24. For specific products, linking a master item with a brand and size. -CREATE TABLE IF NOT EXISTS public.products ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id), - brand_id BIGINT REFERENCES public.brands(id), - name TEXT NOT NULL, - description TEXT, - size TEXT, - upc_code TEXT UNIQUE, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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".'; -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); - --- 25. Linking table for when one flyer is valid for multiple locations. -CREATE TABLE IF NOT EXISTS public.flyer_locations ( - flyer_id BIGINT NOT NULL REFERENCES public.flyers(id) ON DELETE CASCADE, - store_location_id BIGINT NOT NULL REFERENCES public.store_locations(id) ON DELETE CASCADE, - PRIMARY KEY (flyer_id, store_location_id), - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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); - --- 26. Store recipes, which can be user-created or pre-populated. -CREATE TABLE IF NOT EXISTS public.recipes ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID REFERENCES public.users(id) ON DELETE CASCADE, - original_recipe_id BIGINT REFERENCES public.recipes(id) ON DELETE SET NULL, - name TEXT NOT NULL, - description TEXT, - instructions TEXT, - prep_time_minutes INTEGER, - cook_time_minutes INTEGER, - servings INTEGER, - photo_url TEXT, - calories_per_serving INTEGER, - protein_grams NUMERIC, - fat_grams NUMERIC, - carb_grams NUMERIC, - 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, - updated_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.'; -COMMENT ON COLUMN public.recipes.original_recipe_id IS 'If this recipe is a variation of another, this points to the original.'; -COMMENT ON COLUMN public.recipes.user_id IS 'Can be a system recipe (user_id is NULL) or user-submitted.'; -COMMENT ON COLUMN public.recipes.avg_rating IS 'Aggregated rating data for fast sorting/display.'; -COMMENT ON COLUMN public.recipes.calories_per_serving IS 'Optional nutritional information.'; -COMMENT ON COLUMN public.recipes.protein_grams IS 'Optional nutritional information.'; -COMMENT ON COLUMN public.recipes.fat_grams IS 'Optional nutritional information.'; -COMMENT ON COLUMN public.recipes.carb_grams IS 'Optional nutritional information.'; -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); - --- 27. For ingredients required for each recipe. -CREATE TABLE IF NOT EXISTS public.recipe_ingredients ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id), - quantity NUMERIC NOT NULL, - unit TEXT NOT NULL, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() 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".'; -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); - --- 28. Suggest ingredient substitutions for a recipe. -CREATE TABLE IF NOT EXISTS public.recipe_ingredient_substitutions ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - recipe_ingredient_id BIGINT NOT NULL REFERENCES public.recipe_ingredients(id) ON DELETE CASCADE, - substitute_master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, - notes TEXT, - UNIQUE(recipe_ingredient_id, substitute_master_item_id), - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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); - --- 29. 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, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -COMMENT ON TABLE public.tags IS 'Stores tags for categorizing recipes, e.g., "Vegetarian", "Quick & Easy".'; - --- 30. Associate multiple tags with a recipe. -CREATE TABLE IF NOT EXISTS public.recipe_tags ( - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, - tag_id BIGINT NOT NULL REFERENCES public.tags(id) ON DELETE CASCADE, - PRIMARY KEY (recipe_id, tag_id), - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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); - --- 31. Store a predefined list of kitchen appliances. -CREATE TABLE IF NOT EXISTS public.appliances ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - name TEXT NOT NULL UNIQUE, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -COMMENT ON TABLE public.appliances IS 'A predefined list of kitchen appliances (e.g., Air Fryer, Instant Pot).'; - --- 32. Associate recipes with required appliances. -CREATE TABLE IF NOT EXISTS public.recipe_appliances ( - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, - appliance_id BIGINT NOT NULL REFERENCES public.appliances(id) ON DELETE CASCADE, - PRIMARY KEY (recipe_id, appliance_id), - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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); - --- 33. Store individual user ratings for recipes. -CREATE TABLE IF NOT EXISTS public.recipe_ratings ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), - comment TEXT, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - UNIQUE(recipe_id, user_id), - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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); - --- 34. For user comments on recipes to enable discussion. -CREATE TABLE IF NOT EXISTS public.recipe_comments ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - parent_comment_id BIGINT REFERENCES public.recipe_comments(id) ON DELETE CASCADE, - 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 DEFAULT now() NOT NULL -); -COMMENT ON TABLE public.recipe_comments IS 'Allows for threaded discussions and comments on recipes.'; -COMMENT ON COLUMN public.recipe_comments.parent_comment_id IS 'For threaded comments.'; -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); - --- 35. For users to define locations within their pantry. -CREATE TABLE IF NOT EXISTS public.pantry_locations ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, - 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); - --- 36. Associate a recipe with a specific date and meal type within a menu plan. -CREATE TABLE IF NOT EXISTS public.planned_meals ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - menu_plan_id BIGINT NOT NULL REFERENCES public.menu_plans(id) ON DELETE CASCADE, - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, - plan_date DATE NOT NULL, - meal_type TEXT NOT NULL, - servings_to_cook INTEGER, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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); - --- 37. Track the grocery items a user currently has in their pantry. -CREATE TABLE IF NOT EXISTS public.pantry_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, - quantity NUMERIC NOT NULL, - unit TEXT, - best_before_date DATE, - pantry_location_id BIGINT REFERENCES public.pantry_locations(id) ON DELETE SET NULL, - notification_sent_at TIMESTAMPTZ, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, - UNIQUE(user_id, master_item_id, unit) -); -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.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_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); - --- 38. Store password reset tokens. -CREATE TABLE IF NOT EXISTS public.password_reset_tokens ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - token_hash TEXT NOT NULL UNIQUE, - expires_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_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.'; -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); - --- 39. Store unit conversion factors for specific master grocery items. -CREATE TABLE IF NOT EXISTS public.unit_conversions ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, - from_unit TEXT NOT NULL, - to_unit TEXT NOT NULL, - factor NUMERIC NOT NULL, - UNIQUE(master_item_id, from_unit, to_unit), - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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); - --- 40. For users to create their own private aliases for items. -CREATE TABLE IF NOT EXISTS public.user_item_aliases ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, - alias TEXT NOT NULL, - UNIQUE(user_id, alias), - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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); - --- 41. For users to mark their favorite recipes. -CREATE TABLE IF NOT EXISTS public.favorite_recipes ( - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - PRIMARY KEY (user_id, recipe_id), - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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); - --- 42. For users to mark their favorite stores. -CREATE TABLE IF NOT EXISTS public.favorite_stores ( - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - store_id BIGINT NOT NULL REFERENCES public.stores(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - PRIMARY KEY (user_id, store_id), - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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); - --- 43. For users to group recipes into collections. -CREATE TABLE IF NOT EXISTS public.recipe_collections ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - description TEXT, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_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); - --- 44. Associate recipes with a user's collection. -CREATE TABLE IF NOT EXISTS public.recipe_collection_items ( - collection_id BIGINT NOT NULL REFERENCES public.recipe_collections(id) ON DELETE CASCADE, - recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, - added_at TIMESTAMPTZ DEFAULT now() NOT NULL, - PRIMARY KEY (collection_id, recipe_id), - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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); - --- 45. Log user search queries for analysis. -CREATE TABLE IF NOT EXISTS public.search_queries ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID REFERENCES public.users(id) ON DELETE SET NULL, - query_text TEXT NOT NULL, - result_count INTEGER, - was_successful BOOLEAN, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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); - --- 46. Store historical records of completed shopping trips. -CREATE TABLE IF NOT EXISTS public.shopping_trips ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - shopping_list_id BIGINT REFERENCES public.shopping_lists(id) ON DELETE SET NULL, - completed_at TIMESTAMPTZ DEFAULT now() NOT NULL, - total_spent_cents INTEGER, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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); - --- 47. Store the items purchased during a specific shopping trip. -CREATE TABLE IF NOT EXISTS public.shopping_trip_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(id) ON DELETE CASCADE, - master_item_id BIGINT REFERENCES public.master_grocery_items(id), - custom_item_name TEXT, - quantity NUMERIC NOT NULL, - price_paid_cents INTEGER, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, - 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.'; -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); - --- 48. Store predefined dietary restrictions (diets and allergies). -CREATE TABLE IF NOT EXISTS public.dietary_restrictions ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - name TEXT NOT NULL UNIQUE, - type TEXT NOT NULL CHECK (type IN ('diet', 'allergy')), - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -COMMENT ON TABLE public.dietary_restrictions IS 'A predefined list of common diets (e.g., Vegan) and allergies (e.g., Nut Allergy).'; - --- 49. For a user's specific dietary restrictions. -CREATE TABLE IF NOT EXISTS public.user_dietary_restrictions ( - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - restriction_id BIGINT NOT NULL REFERENCES public.dietary_restrictions(id) ON DELETE CASCADE, - PRIMARY KEY (user_id, restriction_id), - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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); - --- 50. For a user's owned kitchen appliances. -CREATE TABLE IF NOT EXISTS public.user_appliances ( - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - appliance_id BIGINT NOT NULL REFERENCES public.appliances(id) ON DELETE CASCADE, - PRIMARY KEY (user_id, appliance_id), - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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); - --- 51. Manage the social graph (following relationships). -CREATE TABLE IF NOT EXISTS public.user_follows ( - follower_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - following_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - PRIMARY KEY (follower_id, following_id), - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, - 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); - --- 52. Store uploaded user receipts for purchase tracking and analysis. -CREATE TABLE IF NOT EXISTS public.receipts ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - store_id BIGINT REFERENCES public.stores(id), - receipt_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, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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); - --- 53. Store individual line items extracted from a user receipt. -CREATE TABLE IF NOT EXISTS public.receipt_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - receipt_id BIGINT NOT NULL REFERENCES public.receipts(id) ON DELETE CASCADE, - 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')), - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL -); -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/src/services/db.ts.bak b/src/services/db.ts.bak deleted file mode 100644 index f7132598..00000000 --- a/src/services/db.ts.bak +++ /dev/null @@ -1,1950 +0,0 @@ -import { Pool } from 'pg'; -import { logger } from './logger'; -import { - Profile, - Flyer, - MasterGroceryItem, - ShoppingList, - ShoppingListItem, - FlyerItem, - SuggestedCorrection, - Recipe, - RecipeComment, - FavoriteRecipe, - ActivityLogItem, - DietaryRestriction, - Appliance, - PantryLocation, - UserAppliance, - ShoppingTrip, - Receipt, - ReceiptDeal, - Brand, - MostFrequentSaleItem, - PantryRecipe, - RecommendedRecipe, - WatchedItemDeal, - ReceiptItem, - PantryItemConversion, - MenuPlanShoppingListItem, - UnmatchedFlyerItem, -} from '../types'; // Assuming your Profile type is in `types.ts` - -// Configure your PostgreSQL connection pool -// IMPORTANT: For production, use environment variables for these credentials. -const pool = new Pool({ - user: process.env.DB_USER || 'postgres', - host: process.env.DB_HOST || 'localhost', - database: process.env.DB_NAME || 'flyer-crawler', // Replace with your actual database name - password: process.env.DB_PASSWORD || 'your_db_password', // Replace with your actual database password - port: parseInt(process.env.DB_PORT || '5432', 10), -}); - -/** - * Logs a message indicating the database connection pool has been created. - */ -logger.info(`Database connection pool created for host: ${process.env.DB_HOST || 'localhost'}`); - -/** - * Defines the structure of a user object as returned from the database. - */ -interface DbUser { - id: string; // UUID - email: string; - password_hash: string; - refresh_token?: string | null; -} - -/** - * Finds a user by their email in the public.users table. - * @param email The email of the user to find. - * @returns A promise that resolves to the user object or undefined if not found. - */ -export async function findUserByEmail(email: string): Promise { - try { - const res = await pool.query( - 'SELECT id, email, password_hash, refresh_token FROM public.users WHERE email = $1', - [email] - ); - return res.rows[0]; - } catch (error) { - logger.error('Database error in findUserByEmail:', { error }); - throw new Error('Failed to retrieve user from database.'); - } -} - -/** - * Creates a new user in the public.users table. - * @param email The user's email. - * @param passwordHash The bcrypt hashed password. - * @param profileData An object containing optional full_name and avatar_url for the profile. - * @returns A promise that resolves to the newly created user object (id, email). - */ -export async function createUser( - email: string, - passwordHash: string | null, - profileData: { full_name?: string; avatar_url?: string } -): Promise<{ id: string; email: string }> { - // Use a client from the pool to run multiple queries in a transaction - const client = await pool.connect(); - try { - // Start the transaction - await client.query('BEGIN'); - - // Set a temporary session variable with the user metadata. - // The 'handle_new_user' trigger will read this variable. - // We stringify the object to pass it as a single JSONB value. - await client.query('SET LOCAL my_app.user_metadata = $1', [JSON.stringify(profileData)]); - - // Insert the new user into the 'users' table. This will fire the trigger. - const res = await client.query<{ id: string; email: string }>( - 'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING id, email', - [email, passwordHash] - ); - - // Commit the transaction - await client.query('COMMIT'); - return res.rows[0]; - } catch (error) { - // If any query fails, roll back the entire transaction - await client.query('ROLLBACK'); - logger.error('Database transaction error in createUser:', { error }); - throw new Error('Failed to create user in database.'); - } finally { - // Release the client back to the pool - client.release(); - } -} - -/** - * Finds a user by their ID. Used by the JWT strategy to validate tokens. - * @param id The UUID of the user to find. - * @returns A promise that resolves to the user object (id, email) or undefined if not found. - */ -// prettier-ignore -export async function findUserById(id: string): Promise<{ id: string; email: string } | undefined> { - try { - const res = await pool.query<{ id: string; email: string }>( - 'SELECT id, email FROM public.users WHERE id = $1', - [id] - ); - return res.rows[0]; - } catch (error) { - logger.error('Database error in findUserById:', { error }); - throw new Error('Failed to retrieve user by ID from database.'); - } -} - -/** - * Finds a user's profile by their user ID. - * @param id The UUID of the user. - * @returns A promise that resolves to the user's profile object or undefined if not found. - */ -// prettier-ignore -export async function findUserProfileById(id: string): Promise { - try { - // This query assumes your 'profiles' table has a foreign key 'id' referencing 'users.id' - const res = await pool.query( - 'SELECT id, full_name, avatar_url, preferences, role FROM public.profiles WHERE id = $1', - [id] - ); - return res.rows[0]; - } catch (error) { - logger.error('Database error in findUserProfileById:', { error }); - throw new Error('Failed to retrieve user profile from database.'); - } -} - -/** - * Updates the profile for a given user. - * @param id The UUID of the user. - * @param profileData The profile data to update (e.g., full_name, avatar_url). - * @returns A promise that resolves to the updated profile object. - */ -// prettier-ignore -export async function updateUserProfile(id: string, profileData: { full_name?: string; avatar_url?: string }): Promise { - try { - const res = await pool.query( - `UPDATE public.profiles - SET full_name = COALESCE($1, full_name), - avatar_url = COALESCE($2, avatar_url), - updated_by = $3 - WHERE id = $3 -- Use the same parameter for updated_by and the WHERE clause - RETURNING id, full_name, avatar_url, preferences, role`, - [profileData.full_name, profileData.avatar_url, id] - ); - return res.rows[0]; - } catch (error) { - logger.error('Database error in updateUserProfile:', { error }); - throw new Error('Failed to update user profile in database.'); - } -} - - -/** - * Updates the preferences for a given user. - * The `pg` driver automatically handles serializing the JS object to JSONB. - * @param id The UUID of the user. - * @param preferences The preferences object to save. - * @returns A promise that resolves to the updated profile object. - */ -// prettier-ignore -export async function updateUserPreferences(id: string, preferences: Profile['preferences']): Promise { - try { - const res = await pool.query( - `UPDATE public.profiles - SET preferences = preferences || $1, updated_at = now() - WHERE id = $2 - RETURNING id, full_name, avatar_url, preferences, role`, - [preferences, id] - ); - return res.rows[0]; - } catch (error) { - logger.error('Database error in updateUserPreferences:', { error }); - throw new Error('Failed to update user preferences in database.'); - } -} - -/** - * Updates the password hash for a given user. - * @param id The UUID of the user. - * @param passwordHash The new bcrypt hashed password. - */ -// prettier-ignore -export async function updateUserPassword(id: string, passwordHash: string): Promise { - try { - await pool.query( - 'UPDATE public.users SET password_hash = $1 WHERE id = $2', - [passwordHash, id] - ); - } catch (error) { - logger.error('Database error in updateUserPassword:', { error }); - throw new Error('Failed to update user password in database.'); - } -} - -/** - * Deletes a user from the database by their ID. - * @param id The UUID of the user to delete. - */ -// prettier-ignore -export async function deleteUserById(id: string): Promise { - try { - await pool.query('DELETE FROM public.users WHERE id = $1', [id]); - } catch (error) { - logger.error('Database error in deleteUserById:', { error }); - throw new Error('Failed to delete user from database.'); - } -} - -/** - * Checks for the existence of a list of tables in the public schema. - * @param tableNames An array of table names to check. - * @returns A promise that resolves to an array of table names that are missing from the database. - */ -// prettier-ignore -export async function checkTablesExist(tableNames: string[]): Promise { - try { - // This query checks the information_schema to find which of the provided table names exist. - const query = ` - SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = ANY($1::text[]) - `; - const res = await pool.query<{ table_name: string }>(query, [tableNames]); - - const existingTables = new Set(res.rows.map(row => row.table_name)); - const missingTables = tableNames.filter(name => !existingTables.has(name)); - - return missingTables; - } catch (error) { - logger.error('Database error in checkTablesExist:', { error }); - throw new Error('Failed to check for tables in database.'); - } -} - -/** - * Gets the current status of the connection pool. - * @returns An object with the total, idle, and waiting client counts. - */ -// prettier-ignore -export function getPoolStatus() { - // pool.totalCount: The total number of clients in the pool. - // pool.idleCount: The number of clients that are idle and waiting for a query. - // pool.waitingCount: The number of queued requests waiting for a client to become available. - return { - totalCount: pool.totalCount, - idleCount: pool.idleCount, - waitingCount: pool.waitingCount, - }; -} - -/** - * Saves or updates a refresh token for a user. - * @param userId The UUID of the user. - * @param refreshToken The new refresh token to save. - */ -// prettier-ignore -export async function saveRefreshToken(userId: string, refreshToken: string): Promise { - try { - // For simplicity, we store one token per user. For multi-device support, a separate table is better. - await pool.query( - 'UPDATE public.users SET refresh_token = $1 WHERE id = $2', - [refreshToken, userId] - ); - } catch (error) { - logger.error('Database error in saveRefreshToken:', { error }); - throw new Error('Failed to save refresh token.'); - } -} - -/** - * Finds a user by their refresh token. - * @param refreshToken The refresh token to look up. - * @returns A promise that resolves to the user object (id, email) or undefined if not found. - */ -// prettier-ignore -export async function findUserByRefreshToken(refreshToken: string): Promise<{ id: string; email: string } | undefined> { - try { - const res = await pool.query<{ id: string; email: string }>( - 'SELECT id, email FROM public.users WHERE refresh_token = $1', - [refreshToken] - ); - return res.rows[0]; - } catch (error) { - logger.error('Database error in findUserByRefreshToken:', { error }); - return undefined; // Return undefined on error to prevent token leakage - } -} - -/** - * Creates a password reset token for a user. - * @param userId The UUID of the user. - * @param tokenHash The hashed version of the reset token. - * @param expiresAt The timestamp when the token expires. - */ -// prettier-ignore -export async function createPasswordResetToken(userId: string, tokenHash: string, expiresAt: Date): Promise { - try { - // First, delete any existing tokens for this user to ensure only one is active. - await pool.query('DELETE FROM public.password_reset_tokens WHERE user_id = $1', [userId]); - // Then, insert the new token. - await pool.query( - 'INSERT INTO public.password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)', - [userId, tokenHash, expiresAt] - ); - } catch (error) { - logger.error('Database error in createPasswordResetToken:', { error }); - throw new Error('Failed to create password reset token.'); - } -} - -/** - * Finds a user and token details by the token hash. - * It only returns a result if the token has not expired. - * @returns A promise that resolves to an array of valid token records. - */ -// prettier-ignore -export async function getValidResetTokens(): Promise<{ user_id: string; token_hash: string; expires_at: Date }[]> { - try { - const res = await pool.query<{ user_id: string; token_hash: string; expires_at: Date }>( - 'SELECT user_id, token_hash, expires_at FROM public.password_reset_tokens WHERE expires_at > NOW()' - ); - return res.rows; - } catch (error) { - logger.error('Database error in getValidResetTokens:', { error }); - throw new Error('Failed to retrieve valid reset tokens.'); - } -} - -/** - * Deletes a password reset token by its hash. - * This is used after a token has been successfully used to reset a password. - * @param tokenHash The hashed token to delete. - */ -// prettier-ignore -export async function deleteResetToken(tokenHash: string): Promise { - try { - await pool.query('DELETE FROM public.password_reset_tokens WHERE token_hash = $1', [tokenHash]); - } catch (error) { - logger.error('Database error in deleteResetToken:', { error }); - // We don't throw here, as failing to delete an expired token is not a critical failure for the user flow. - } -} - -/** - * Retrieves all flyers from the database, joining with store information. - * @returns A promise that resolves to an array of Flyer objects. - */ -// prettier-ignore -export async function getFlyers(): Promise { - try { - const query = ` - SELECT - f.id, - f.created_at, - f.file_name, - f.image_url, - f.checksum, - f.store_id, - f.valid_from, - f.valid_to, - f.store_address, - json_build_object( - 'id', s.id, - 'name', s.name, - 'logo_url', s.logo_url - ) as store - FROM public.flyers f - LEFT JOIN public.stores s ON f.store_id = s.id - ORDER BY f.valid_to DESC, s.name ASC; - `; - const res = await pool.query(query); - return res.rows; - } catch (error) { - logger.error('Database error in getFlyers:', { error }); - throw new Error('Failed to retrieve flyers from database.'); - } -} - -/** - * 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. - */ -// prettier-ignore -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. - */ -// prettier-ignore -export async function getAllMasterItems(): Promise { - try { - const query = ` - SELECT - m.id, - m.created_at, - m.name, - m.category_id, - c.name as category_name - FROM public.master_grocery_items m - LEFT JOIN public.categories c ON m.category_id = c.id - ORDER BY m.name ASC; - `; - const res = await pool.query(query); - return res.rows; - } catch (error) { - logger.error('Database error in getAllMasterItems:', { error }); - throw new Error('Failed to retrieve master items from database.'); - } -} - -/** - * Retrieves all categories from the database. - * @returns A promise that resolves to an array of Category objects. - */ -// prettier-ignore -export async function getAllCategories(): Promise<{id: number, name: string}[]> { - try { - const query = ` - SELECT id, name FROM public.categories ORDER BY name ASC; - `; - const res = await pool.query<{id: number, name: string}>(query); - return res.rows; - } catch (error) { - logger.error('Database error in getAllCategories:', { error }); - throw new Error('Failed to retrieve categories from database.'); - } -} - -// --- Watched Items Functions --- - -/** - * Retrieves all watched master items for a specific user. - * @param userId The UUID of the user. - * @returns A promise that resolves to an array of MasterGroceryItem objects. - */ -// prettier-ignore -export async function getWatchedItems(userId: string): Promise { - try { - const query = ` - SELECT mgi.* - FROM public.master_grocery_items mgi - JOIN public.user_watched_items uwi ON mgi.id = uwi.master_item_id - WHERE uwi.user_id = $1 - ORDER BY mgi.name ASC; - `; - const res = await pool.query(query, [userId]); - return res.rows; - } catch (error) { - logger.error('Database error in getWatchedItems:', { error, userId }); - throw new Error('Failed to retrieve watched items.'); - } -} - -/** - * Adds an item to a user's watchlist. If the master item doesn't exist, it creates it. - * @param userId The UUID of the user. - * @param itemName The name of the item to watch. - * @param categoryName The category of the item. - * @returns A promise that resolves to the MasterGroceryItem that was added to the watchlist. - */ -// prettier-ignore -export async function addWatchedItem(userId: string, itemName: string, categoryName: string): Promise { - const client = await pool.connect(); - try { - await client.query('BEGIN'); - - // Find category ID - const categoryRes = await client.query<{ id: number }>('SELECT id FROM public.categories WHERE name = $1', [categoryName]); - const categoryId = categoryRes.rows[0]?.id; - if (!categoryId) { - throw new Error(`Category '${categoryName}' not found.`); - } - - // Find or create master item - let masterItem: MasterGroceryItem; - const masterItemRes = await client.query('SELECT * FROM public.master_grocery_items WHERE name = $1', [itemName]); - if (masterItemRes.rows.length > 0) { - masterItem = masterItemRes.rows[0]; - } else { - const newMasterItemRes = await client.query( - 'INSERT INTO public.master_grocery_items (name, category_id) VALUES ($1, $2) RETURNING *', - [itemName, categoryId] - ); - masterItem = newMasterItemRes.rows[0]; - } - - // Add to user's watchlist, ignoring if it's already there. - await client.query( - 'INSERT INTO public.user_watched_items (user_id, master_item_id) VALUES ($1, $2) ON CONFLICT (user_id, master_item_id) DO NOTHING', - [userId, masterItem.id] - ); - - await client.query('COMMIT'); - return masterItem; - } catch (error) { - await client.query('ROLLBACK'); - logger.error('Database transaction error in addWatchedItem:', { error }); - throw new Error('Failed to add item to watchlist.'); - } finally { - client.release(); - } -} - -/** - * Removes an item from a user's watchlist. - * @param userId The UUID of the user. - * @param masterItemId The ID of the master item to remove. - */ -// prettier-ignore -export async function removeWatchedItem(userId: string, masterItemId: number): Promise { - try { - await pool.query('DELETE FROM public.user_watched_items WHERE user_id = $1 AND master_item_id = $2', [userId, masterItemId]); - } catch (error) { - logger.error('Database error in removeWatchedItem:', { error }); - throw new Error('Failed to remove item from watchlist.'); - } -} - -// --- Shopping List Functions --- - -/** - * Retrieves all shopping lists and their items for a user. - * @param userId The UUID of the user. - * @returns A promise that resolves to an array of ShoppingList objects. - */ -// prettier-ignore -export async function getShoppingLists(userId: string): Promise { - try { - // This refactored query uses a LEFT JOIN and a single GROUP BY aggregation, - // which is generally more performant than using a correlated subquery for each row. - const query = ` - SELECT - sl.id, sl.name, sl.created_at, - -- Aggregate all joined shopping list items into a single JSON array for each list. - -- The FILTER clause ensures that if a list has no items, we get an empty array '[]' - -- instead of an array with a single null value '[null]'. - COALESCE( - (SELECT json_agg( - json_build_object( - 'id', sli.id, - 'shopping_list_id', sli.shopping_list_id, - 'master_item_id', sli.master_item_id, - 'custom_item_name', sli.custom_item_name, - 'quantity', sli.quantity, - 'is_purchased', sli.is_purchased, - 'added_at', sli.added_at, - 'master_item', json_build_object('name', mgi.name) - ) ORDER BY sli.added_at ASC - ) FILTER (WHERE sli.id IS NOT NULL)), - '[]'::json - ) as items - FROM public.shopping_lists sl - LEFT JOIN public.shopping_list_items sli ON sl.id = sli.shopping_list_id - LEFT JOIN public.master_grocery_items mgi ON sli.master_item_id = mgi.id - WHERE sl.user_id = $1 - GROUP BY sl.id, sl.name, sl.created_at - ORDER BY sl.created_at ASC; - `; - const res = await pool.query(query, [userId]); - return res.rows; - } catch (error) { - logger.error('Database error in getShoppingLists:', { error, userId }); - throw new Error('Failed to retrieve shopping lists.'); - } -} - -/** - * Creates a new shopping list for a user. - * @param userId The ID of the user creating the list. - * @param name The name of the new shopping list. - * @returns A promise that resolves to the newly created ShoppingList object. - */ -export async function createShoppingList(userId: string, name: string): Promise { - try { - const res = await pool.query( - 'INSERT INTO public.shopping_lists (user_id, name) VALUES ($1, $2) RETURNING id, user_id, name, created_at', - [userId, name] - ); - // Return a complete ShoppingList object with an empty items array - return { ...res.rows[0], items: [] }; - } catch (error) { - logger.error('Database error in createShoppingList:', { error }); - throw new Error('Failed to create shopping list.'); - } -} - -/** - * Deletes a shopping list owned by a specific user. - * @param listId The ID of the shopping list to delete. - * @param userId The ID of the user who owns the list, for an ownership check. - */ -export async function deleteShoppingList(listId: number, userId: string): Promise { - try { - // The user_id check ensures a user can only delete their own list. - await pool.query('DELETE FROM public.shopping_lists WHERE id = $1 AND user_id = $2', [listId, userId]); - } catch (error) { - logger.error('Database error in deleteShoppingList:', { error }); - throw new Error('Failed to delete shopping list.'); - } -} - -/** - * Adds a new item to a shopping list. - * @param listId The ID of the shopping list to add the item to. - * @param item An object containing either a `masterItemId` or a `customItemName`. - * @returns A promise that resolves to the newly created ShoppingListItem object. - */ -export async function addShoppingListItem(listId: number, item: { masterItemId?: number, customItemName?: string }): Promise { - try { - const res = await pool.query( - 'INSERT INTO public.shopping_list_items (shopping_list_id, master_item_id, custom_item_name) VALUES ($1, $2, $3) RETURNING *', - [listId, item.masterItemId, item.customItemName] - ); - return res.rows[0]; - } catch (error) { - logger.error('Database error in addShoppingListItem:', { error }); - throw new Error('Failed to add item to shopping list.'); - } -} - -/** - * Removes an item from a shopping list. - * @param itemId The ID of the shopping list item to remove. - */ -export async function removeShoppingListItem(itemId: number): Promise { - try { - await pool.query('DELETE FROM public.shopping_list_items WHERE id = $1', [itemId]); - } catch (error) { - logger.error('Database error in removeShoppingListItem:', { error }); - throw new Error('Failed to remove item from shopping list.'); - } -} - -/** - * Gathers all data associated with a specific user for export. - * @param userId The UUID of the user. - * @returns A promise that resolves to an object containing all user data. - */ -// prettier-ignore -export async function exportUserData(userId: string): Promise<{ profile: Profile; watchedItems: MasterGroceryItem[]; shoppingLists: ShoppingList[] }> { - const client = await pool.connect(); - try { - // Run queries in parallel for efficiency - const profileQuery = findUserProfileById(userId); - const watchedItemsQuery = getWatchedItems(userId); - const shoppingListsQuery = getShoppingLists(userId); - - const [profile, watchedItems, shoppingLists] = await Promise.all([profileQuery, watchedItemsQuery, shoppingListsQuery]); - - if (!profile) { - throw new Error('User profile not found for data export.'); - } - - return { profile, watchedItems, shoppingLists }; - } catch (error) { - logger.error('Database error in exportUserData:', { error, userId }); - throw new Error('Failed to export user data.'); - } finally { - client.release(); - } -} - -// --- Flyer Processing Functions --- - -/** - * Finds a flyer by its checksum to prevent duplicate processing. - * @param checksum The SHA-256 checksum of the flyer file. - * @returns A promise that resolves to the Flyer object if found, otherwise undefined. - */ -// prettier-ignore -export async function findFlyerByChecksum(checksum: string): Promise { - try { - const res = await pool.query('SELECT * FROM public.flyers WHERE checksum = $1', [checksum]); - return res.rows[0]; - } catch (error) { - logger.error('Database error in findFlyerByChecksum:', { error }); - throw new Error('Failed to check for existing flyer.'); - } -} - -/** - * Creates a new flyer and all its associated items in a single database transaction. - * @param flyerData The metadata for the flyer. - * @param items The array of flyer items extracted from the flyer. - * @returns A promise that resolves to the newly created Flyer object. - */ -// prettier-ignore -export async function createFlyerAndItems( - flyerData: Omit & { store_name: string }, - items: Omit[] -): Promise { - const client = await pool.connect(); - try { - await client.query('BEGIN'); - - // Find or create the store - let storeId: number; - const storeRes = await client.query<{ id: number }>('SELECT id FROM public.stores WHERE name = $1', [flyerData.store_name]); - if (storeRes.rows.length > 0) { - storeId = storeRes.rows[0].id; - } else { - const newStoreRes = await client.query<{ id: number }>('INSERT INTO public.stores (name) VALUES ($1) RETURNING id', [flyerData.store_name]); - storeId = newStoreRes.rows[0].id; - } - - // Create the flyer record - const flyerQuery = ` - INSERT INTO public.flyers (file_name, image_url, checksum, store_id, valid_from, valid_to, store_address) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING *; - `; - const flyerValues = [flyerData.file_name, flyerData.image_url, flyerData.checksum, storeId, flyerData.valid_from, flyerData.valid_to, flyerData.store_address]; - const newFlyerRes = await client.query(flyerQuery, flyerValues); - const newFlyer = newFlyerRes.rows[0]; - - // Prepare and insert all flyer items - if (items.length > 0) { - const itemInsertQuery = ` - INSERT INTO public.flyer_items ( - flyer_id, item, price_display, price_in_cents, quantity, - master_item_id, -- This will be populated by our suggestion function - category_name, unit_price - ) - VALUES ($1, $2, $3, $4, $5, public.suggest_master_item_for_flyer_item($2), $6, $7) - `; - - // Loop through each item and execute the insert query. - // The query now directly calls the `suggest_master_item_for_flyer_item` function - // on the database side, passing the item name (`item.item`) as the argument. - // This is more efficient than making a separate DB call for each item to get the suggestion. - for (const item of items) { - const itemValues = [ - newFlyer.id, - item.item, - item.price_display, - item.price_in_cents, - item.quantity, - item.category_name, - item.unit_price ? JSON.stringify(item.unit_price) : null // Ensure JSONB is correctly stringified - ]; - await client.query(itemInsertQuery, itemValues); - } - } - - await client.query('COMMIT'); - return newFlyer; - } catch (error) { - await client.query('ROLLBACK'); - logger.error('Database transaction error in createFlyerAndItems:', { error }); - throw new Error('Failed to save flyer and its items.'); - } finally { - client.release(); - } -} - -/** - * Retrieves all items for a specific flyer. - * @param flyerId The ID of the flyer. - * @returns A promise that resolves to an array of FlyerItem objects. - */ -// prettier-ignore -export async function getFlyerItems(flyerId: number): Promise { - try { - const query = ` - SELECT * FROM public.flyer_items - WHERE flyer_id = $1 - ORDER BY id ASC; - `; - const res = await pool.query(query, [flyerId]); - return res.rows; - } catch (error) { - logger.error('Database error in getFlyerItems:', { error, flyerId }); - throw new Error('Failed to retrieve flyer items.'); - } -} - -/** - * Retrieves all flyer items for a given list of flyer IDs. - * @param flyerIds An array of flyer IDs. - * @returns A promise that resolves to an array of FlyerItem objects. - */ -// prettier-ignore -export async function getFlyerItemsForFlyers(flyerIds: number[]): Promise { - try { - const query = ` - SELECT * FROM public.flyer_items - WHERE flyer_id = ANY($1::bigint[]); - `; - const res = await pool.query(query, [flyerIds]); - return res.rows; - } catch (error) { - logger.error('Database error in getFlyerItemsForFlyers:', { error }); - throw new Error('Failed to retrieve items for multiple flyers.'); - } -} - -/** - * Counts the total number of flyer items for a given list of flyer IDs. - * @param flyerIds An array of flyer IDs. - * @returns A promise that resolves to the total count of items. - */ -// prettier-ignore -export async function countFlyerItemsForFlyers(flyerIds: number[]): Promise { - try { - const query = `SELECT COUNT(*) FROM public.flyer_items WHERE flyer_id = ANY($1::bigint[])`; - const res = await pool.query<{ count: string }>(query, [flyerIds]); - return parseInt(res.rows[0].count, 10); - } catch (error) { - logger.error('Database error in countFlyerItemsForFlyers:', { error }); - throw new Error('Failed to count items for multiple flyers.'); - } -} - -/** - * Updates the logo URL for a specific store. - * @param storeId The ID of the store to update. - * @param logoUrl The new URL for the store's logo. - */ -// prettier-ignore -export async function updateStoreLogo(storeId: number, logoUrl: string): Promise { - try { - await pool.query( - 'UPDATE public.stores SET logo_url = $1 WHERE id = $2', - [logoUrl, storeId] - ); - } catch (error) { - logger.error('Database error in updateStoreLogo:', { error, storeId }); - throw new Error('Failed to update store logo in database.'); - } -} - -/** - * 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. - */ -// prettier-ignore -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.'); - } -} - -// --- Admin Correction Functions --- - -/** - * Retrieves all pending suggested corrections from the database. - * Joins with users and flyer_items to provide context for the admin. - * @returns A promise that resolves to an array of SuggestedCorrection objects. - */ -// prettier-ignore -export async function getSuggestedCorrections(): Promise { - try { - const query = ` - SELECT - sc.id, - sc.flyer_item_id, - sc.user_id, - sc.correction_type, - sc.suggested_value, - sc.status, - sc.created_at, - fi.item as flyer_item_name, - fi.price_display as flyer_item_price_display, - u.email as user_email - FROM public.suggested_corrections sc - JOIN public.flyer_items fi ON sc.flyer_item_id = fi.id - LEFT JOIN public.users u ON sc.user_id = u.id - WHERE sc.status = 'pending' - ORDER BY sc.created_at ASC; - `; - const res = await pool.query(query); - return res.rows; - } catch (error) { - logger.error('Database error in getSuggestedCorrections:', { error }); - throw new Error('Failed to retrieve suggested corrections.'); - } -} - -/** - * Approves a correction and applies the change to the corresponding flyer item. - * This function runs as a transaction to ensure data integrity. - * @param correctionId The ID of the correction to approve. - */ -// prettier-ignore -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.'); - } -} - -/** - * Rejects a correction by updating its status. - * @param correctionId The ID of the correction to reject. - */ -// prettier-ignore -export async function rejectCorrection(correctionId: number): Promise { - try { - const res = await pool.query( - "UPDATE public.suggested_corrections SET status = 'rejected' WHERE id = $1 AND status = 'pending' RETURNING id", - [correctionId] - ); - if (res.rowCount === 0) { - // This could happen if the correction was already processed or doesn't exist. - logger.warn(`Attempted to reject correction ID ${correctionId}, but it was not found or not in 'pending' state.`); - // We don't throw an error here, as the end state (not pending) is achieved. - } else { - logger.info(`Successfully rejected correction ID: ${correctionId}`); - } - } catch (error) { - logger.error('Database error in rejectCorrection:', { error, correctionId }); - throw new Error('Failed to reject correction.'); - } -} - -/** - * Updates the suggested value of a pending correction. - * @param correctionId The ID of the correction to update. - * @param newSuggestedValue The new value to set for the suggestion. - * @returns A promise that resolves to the updated SuggestedCorrection object. - */ -// prettier-ignore -export async function updateSuggestedCorrection(correctionId: number, newSuggestedValue: string): Promise { - try { - const res = await pool.query( - "UPDATE public.suggested_corrections SET suggested_value = $1 WHERE id = $2 AND status = 'pending' RETURNING *", - [newSuggestedValue, correctionId] - ); - if (res.rowCount === 0) { - throw new Error(`Correction with ID ${correctionId} not found or is not in 'pending' state.`); - } - return res.rows[0]; - } catch (error) { - logger.error('Database error in updateSuggestedCorrection:', { error, correctionId }); - throw new Error('Failed to update suggested correction.'); - } -} - -/** - * Retrieves application-wide statistics for the admin dashboard. - * @returns A promise that resolves to an object containing various application stats. - */ -// prettier-ignore -export async function getApplicationStats(): Promise<{ - flyerCount: number; - userCount: number; - flyerItemCount: number; - storeCount: number; - pendingCorrectionCount: number; -}> { - try { - // Run count queries in parallel for better performance - const flyerCountQuery = pool.query<{ count: string }>('SELECT COUNT(*) FROM public.flyers'); - const userCountQuery = pool.query<{ count: string }>('SELECT COUNT(*) FROM public.users'); - const flyerItemCountQuery = pool.query<{ count: string }>('SELECT COUNT(*) FROM public.flyer_items'); - const storeCountQuery = pool.query<{ count: string }>('SELECT COUNT(*) FROM public.stores'); - const pendingCorrectionCountQuery = pool.query<{ count: string }>("SELECT COUNT(*) FROM public.suggested_corrections WHERE status = 'pending'"); - - const [ - flyerCountRes, - userCountRes, - flyerItemCountRes, - storeCountRes, - pendingCorrectionCountRes - ] = await Promise.all([ - flyerCountQuery, userCountQuery, flyerItemCountQuery, storeCountQuery, pendingCorrectionCountQuery - ]); - - return { - flyerCount: parseInt(flyerCountRes.rows[0].count, 10), - userCount: parseInt(userCountRes.rows[0].count, 10), - flyerItemCount: parseInt(flyerItemCountRes.rows[0].count, 10), - storeCount: parseInt(storeCountRes.rows[0].count, 10), - pendingCorrectionCount: parseInt(pendingCorrectionCountRes.rows[0].count, 10), - }; - } catch (error) { - logger.error('Database error in getApplicationStats:', { error }); - throw new Error('Failed to retrieve application statistics.'); - } -} - -/** - * Retrieves historical price data for a given list of master item IDs. - * This function queries the pre-aggregated `item_price_history` table for efficiency. - * @param masterItemIds An array of master grocery item IDs. - * @returns A promise that resolves to an array of historical price records. - */ -// prettier-ignore -export async function getHistoricalPriceDataForItems(masterItemIds: number[]): Promise<{ master_item_id: number; summary_date: string; avg_price_in_cents: number | null; }[]> { - if (masterItemIds.length === 0) { - return []; - } - try { - const query = ` - SELECT master_item_id, summary_date, avg_price_in_cents - FROM public.item_price_history - WHERE master_item_id = ANY($1::bigint[]) - ORDER BY summary_date ASC; - `; - const res = await pool.query(query, [masterItemIds]); - return res.rows; - } catch (error) { - logger.error('Database error in getHistoricalPriceDataForItems:', { error }); - throw new Error('Failed to retrieve historical price data.'); - } -} - -/** - * Retrieves daily statistics for user registrations and flyer uploads for the last 30 days. - * @returns A promise that resolves to an array of daily stats. - */ -// prettier-ignore -export async function getDailyStatsForLast30Days(): Promise<{ date: string; new_users: number; new_flyers: number; }[]> { - try { - const query = ` - WITH date_series AS ( - SELECT generate_series( - (CURRENT_DATE - interval '29 days'), - CURRENT_DATE, - '1 day'::interval - )::date AS day - ), - daily_users AS ( - SELECT created_at::date AS day, COUNT(*) AS user_count - FROM public.users - WHERE created_at >= (CURRENT_DATE - interval '29 days') - GROUP BY 1 - ), - daily_flyers AS ( - SELECT created_at::date AS day, COUNT(*) AS flyer_count - FROM public.flyers - WHERE created_at >= (CURRENT_DATE - interval '29 days') - GROUP BY 1 - ) - SELECT - to_char(ds.day, 'YYYY-MM-DD') as date, - COALESCE(du.user_count, 0)::int AS new_users, - COALESCE(df.flyer_count, 0)::int AS new_flyers - FROM date_series ds - LEFT JOIN daily_users du ON ds.day = du.day - LEFT JOIN daily_flyers df ON ds.day = df.day - ORDER BY ds.day ASC; - `; - const res = await pool.query(query); - return res.rows; - } catch (error) { - logger.error('Database error in getDailyStatsForLast30Days:', { error }); - throw new Error('Failed to retrieve daily statistics.'); - } -} - -/** - * Calls a database function to get the most frequently advertised items. - * @param days The number of past days to look back. - * @param limit The maximum number of items to return. - * @returns A promise that resolves to an array of the most frequent sale items. - */ -export async function getMostFrequentSaleItems(days: number, limit: number): Promise { - try { - const res = await pool.query('SELECT * FROM public.get_most_frequent_sale_items($1, $2)', [days, limit]); - return res.rows; - } catch (error) { - logger.error('Database error in getMostFrequentSaleItems:', { error }); - throw new Error('Failed to get most frequent sale items.'); - } -} - -/** - * Calls a database function to find recipes that can be made from a user's pantry. - * @param userId The ID of the user. - * @returns A promise that resolves to an array of recipes. - */ -export async function findRecipesFromPantry(userId: string): Promise { - try { - const res = await pool.query('SELECT * FROM public.find_recipes_from_pantry($1)', [userId]); - return res.rows; - } catch (error) { - logger.error('Database error in findRecipesFromPantry:', { error, userId }); - throw new Error('Failed to find recipes from pantry.'); - } -} - -/** - * Calls a database function to recommend recipes for a user. - * @param userId The ID of the user. - * @param limit The maximum number of recipes to recommend. - * @returns A promise that resolves to an array of recommended recipes. - */ -export async function recommendRecipesForUser(userId: string, limit: number): Promise { - try { - const res = await pool.query('SELECT * FROM public.recommend_recipes_for_user($1, $2)', [userId, limit]); - return res.rows; - } catch (error) { - logger.error('Database error in recommendRecipesForUser:', { error, userId }); - throw new Error('Failed to recommend recipes.'); - } -} - -/** - * Calls a database function to get the best current sale prices for a user's watched items. - * @param userId The ID of the user. - * @returns A promise that resolves to an array of the best deals. - */ -export async function getBestSalePricesForUser(userId: string): Promise { - try { - const res = await pool.query('SELECT * FROM public.get_best_sale_prices_for_user($1)', [userId]); - return res.rows; - } catch (error) { - logger.error('Database error in getBestSalePricesForUser:', { error, userId }); - throw new Error('Failed to get best sale prices.'); - } -} - -/** - * Calls a database function to suggest unit conversions for a pantry item. - * @param pantryItemId The ID of the pantry item. - * @returns A promise that resolves to an array of suggested conversions. - */ -export async function suggestPantryItemConversions(pantryItemId: number): Promise { - try { - const res = await pool.query('SELECT * FROM public.suggest_pantry_item_conversions($1)', [pantryItemId]); - return res.rows; - } catch (error) { - logger.error('Database error in suggestPantryItemConversions:', { error, pantryItemId }); - throw new Error('Failed to suggest pantry item conversions.'); - } -} - -/** - * Calls a database function to generate a shopping list from a menu plan. - * @param menuPlanId The ID of the menu plan. - * @param userId The ID of the user. - * @returns A promise that resolves to an array of items for the shopping list. - */ -export async function generateShoppingListForMenuPlan(menuPlanId: number, userId: string): Promise { - try { - const res = await pool.query('SELECT * FROM public.generate_shopping_list_for_menu_plan($1, $2)', [menuPlanId, userId]); - return res.rows; - } catch (error) { - logger.error('Database error in generateShoppingListForMenuPlan:', { error, menuPlanId }); - throw new Error('Failed to generate shopping list for menu plan.'); - } -} - -/** - * Calls a database function to add items from a menu plan to a shopping list. - * @param menuPlanId The ID of the menu plan. - * @param shoppingListId The ID of the shopping list to add items to. - * @param userId The ID of the user. - * @returns A promise that resolves to an array of the items that were added. - */ -export async function addMenuPlanToShoppingList(menuPlanId: number, shoppingListId: number, userId: string): Promise { - try { - const res = await pool.query('SELECT * FROM public.add_menu_plan_to_shopping_list($1, $2, $3)', [menuPlanId, shoppingListId, userId]); - return res.rows; - } catch (error) { - logger.error('Database error in addMenuPlanToShoppingList:', { error, menuPlanId }); - throw new Error('Failed to add menu plan to shopping list.'); - } -} - -/** - * Calls a database function to get recipes based on the percentage of their ingredients on sale. - * @param minPercentage The minimum percentage of ingredients that must be on sale. - * @returns A promise that resolves to an array of recipes. - */ -export async function getRecipesBySalePercentage(minPercentage: number): Promise { - try { - const res = await pool.query('SELECT * FROM public.get_recipes_by_sale_percentage($1)', [minPercentage]); - return res.rows; - } catch (error) { - logger.error('Database error in getRecipesBySalePercentage:', { error }); - throw new Error('Failed to get recipes by sale percentage.'); - } -} - -/** - * Calls a database function to get recipes by the minimum number of sale ingredients. - * @param minIngredients The minimum number of ingredients that must be on sale. - * @returns A promise that resolves to an array of recipes. - */ -export async function getRecipesByMinSaleIngredients(minIngredients: number): Promise { - try { - const res = await pool.query('SELECT * FROM public.get_recipes_by_min_sale_ingredients($1)', [minIngredients]); - return res.rows; - } catch (error) { - logger.error('Database error in getRecipesByMinSaleIngredients:', { error }); - throw new Error('Failed to get recipes by minimum sale ingredients.'); - } -} - -/** - * Calls a database function to find recipes by a specific ingredient and tag. - * @param ingredient The name of the ingredient to search for. - * @param tag The name of the tag to search for. - * @returns A promise that resolves to an array of matching recipes. - */ -export async function findRecipesByIngredientAndTag(ingredient: string, tag: string): Promise { - try { - const res = await pool.query('SELECT * FROM public.find_recipes_by_ingredient_and_tag($1, $2)', [ingredient, tag]); - return res.rows; - } catch (error) { - logger.error('Database error in findRecipesByIngredientAndTag:', { error }); - throw new Error('Failed to find recipes by ingredient and tag.'); - } -} - -/** - * Calls a database function to get a user's favorite recipes. - * @param userId The ID of the user. - * @returns A promise that resolves to an array of the user's favorite recipes. - */ -export async function getUserFavoriteRecipes(userId: string): Promise { - try { - const res = await pool.query('SELECT * FROM public.get_user_favorite_recipes($1)', [userId]); - return res.rows; - } catch (error) { - logger.error('Database error in getUserFavoriteRecipes:', { error, userId }); - throw new Error('Failed to get favorite recipes.'); - } -} - -/** - * Adds a recipe to a user's favorites. - * @param userId The ID of the user. - * @param recipeId The ID of the recipe to favorite. - * @returns A promise that resolves to the created favorite record. - */ -export async function addFavoriteRecipe(userId: string, recipeId: number): Promise { - try { - const res = await pool.query( - 'INSERT INTO public.favorite_recipes (user_id, recipe_id) VALUES ($1, $2) ON CONFLICT (user_id, recipe_id) DO NOTHING RETURNING *', - [userId, recipeId] - ); - return res.rows[0]; - } catch (error) { - logger.error('Database error in addFavoriteRecipe:', { error, userId, recipeId }); - throw new Error('Failed to add favorite recipe.'); - } -} - -// --- Receipt Processing & Deal Finding Functions --- - -/** - * Creates a new receipt record in the database. - * @param userId The ID of the user uploading the receipt. - * @param receiptImageUrl The URL where the receipt image is stored. - * @returns A promise that resolves to the newly created Receipt object. - */ -export async function createReceipt(userId: string, receiptImageUrl: string): Promise { - try { - const res = await pool.query( - `INSERT INTO public.receipts (user_id, receipt_image_url, status) - VALUES ($1, $2, 'pending') - RETURNING *`, - [userId, receiptImageUrl] - ); - return res.rows[0]; - } catch (error) { - logger.error('Database error in createReceipt:', { error, userId }); - throw new Error('Failed to create receipt record.'); - } -} - -/** - * Processes extracted receipt items, updates the receipt status, and saves the items. - * @param receiptId The ID of the receipt being processed. - * @param rawText The raw text extracted by OCR. - * @param items An array of items extracted from the receipt. - * @returns A promise that resolves when the operation is complete. - */ -export async function processReceiptItems( - receiptId: number, - items: Omit[] -): Promise { - try { - const itemsWithQuantity = items.map(item => ({ ...item, quantity: 1 })); - await pool.query('SELECT public.process_receipt_items($1, $2, $3)', [receiptId, JSON.stringify(itemsWithQuantity), JSON.stringify(itemsWithQuantity)]); - logger.info(`Successfully processed items for receipt ID: ${receiptId}`); - } catch (error) { - // On error, we should also update the receipt status to 'failed' - 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.'); - } -} - -/** - * Updates the status of a specific receipt. - * @param receiptId The ID of the receipt to update. - * @param status The new status for the receipt. - * @returns A promise that resolves to the updated Receipt object. - */ -export async function updateReceiptStatus(receiptId: number, status: 'pending' | 'processing' | 'completed' | 'failed'): Promise { - try { - const res = await pool.query( - `UPDATE public.receipts SET status = $1, processed_at = CASE WHEN $1 IN ('completed', 'failed') THEN now() ELSE processed_at END WHERE id = $2 RETURNING *`, - [status, receiptId] - ); - if (res.rowCount === 0) { - throw new Error(`Receipt with ID ${receiptId} not found.`); - } - return res.rows[0]; - } catch (error) { - logger.error('Database error in updateReceiptStatus:', { error, receiptId, status }); - throw new Error('Failed to update receipt status.'); - } -} -/** - * Finds better deals for items on a recently processed receipt. - * @param receiptId The ID of the receipt to check. - * @returns A promise that resolves to an array of potential deals. - */ -export async function findDealsForReceipt(receiptId: number): Promise { - const res = await pool.query('SELECT * FROM public.find_deals_for_receipt_items($1)', [receiptId]); - return res.rows; -} - -/** - * Removes a recipe from a user's favorites. - * @param userId The ID of the user. - * @param recipeId The ID of the recipe to unfavorite. - */ -export async function removeFavoriteRecipe(userId: string, recipeId: number): Promise { - try { - await pool.query('DELETE FROM public.favorite_recipes WHERE user_id = $1 AND recipe_id = $2', [userId, recipeId]); - } catch (error) { - logger.error('Database error in removeFavoriteRecipe:', { error, userId, recipeId }); - throw new Error('Failed to remove favorite recipe.'); - } -} - -/** - * Retrieves all comments for a specific recipe. - * @param recipeId The ID of the recipe. - * @returns A promise that resolves to an array of RecipeComment objects. - */ -export async function getRecipeComments(recipeId: number): Promise { - try { - const query = ` - SELECT - rc.*, - p.full_name as user_full_name, - p.avatar_url as user_avatar_url - FROM public.recipe_comments rc - LEFT JOIN public.profiles p ON rc.user_id = p.id - WHERE rc.recipe_id = $1 - ORDER BY rc.created_at ASC; - `; - const res = await pool.query(query, [recipeId]); - return res.rows; - } catch (error) { - logger.error('Database error in getRecipeComments:', { error, recipeId }); - throw new Error('Failed to get recipe comments.'); - } -} - -/** - * Adds a new comment to a recipe. - * @param recipeId The ID of the recipe to comment on. - * @param userId The ID of the user posting the comment. - * @param content The text content of the comment. - * @param parentCommentId Optional ID of the parent comment for threaded replies. - * @returns A promise that resolves to the newly created RecipeComment object. - */ -export async function addRecipeComment(recipeId: number, userId: string, content: string, parentCommentId?: number): Promise { - try { - const res = await pool.query( - 'INSERT INTO public.recipe_comments (recipe_id, user_id, content, parent_comment_id) VALUES ($1, $2, $3, $4) RETURNING *', - [recipeId, userId, content, parentCommentId] - ); - return res.rows[0]; - } catch (error) { - logger.error('Database error in addRecipeComment:', { error }); - throw new Error('Failed to add recipe comment.'); - } -} - -/** - * Updates the status of a recipe comment (e.g., for moderation). - * @param commentId The ID of the comment to update. - * @param status The new status ('visible', 'hidden', 'reported'). - * @returns A promise that resolves to the updated RecipeComment object. - */ -export async function updateRecipeCommentStatus(commentId: number, status: 'visible' | 'hidden' | 'reported'): Promise { - try { - const res = await pool.query( - 'UPDATE public.recipe_comments SET status = $1 WHERE id = $2 RETURNING *', - [status, commentId] - ); - if (res.rowCount === 0) { - throw new Error(`Recipe comment with ID ${commentId} not found.`); - } - return res.rows[0]; - } catch (error) { - logger.error('Database error in updateRecipeCommentStatus:', { error, commentId, status }); - throw new Error('Failed to update recipe comment status.'); - } -} - - - -/** - * Retrieves all flyer items that could not be automatically matched to a master item. - * @returns A promise that resolves to an array of unmatched flyer items with context. - */ -export async function getUnmatchedFlyerItems(): Promise { - try { - const query = ` - SELECT - ufi.id, - ufi.status, - ufi.created_at, - fi.id as flyer_item_id, - fi.item as flyer_item_name, - fi.price_display, - f.id as flyer_id, - s.name as store_name - FROM public.unmatched_flyer_items ufi - JOIN public.flyer_items fi ON ufi.flyer_item_id = fi.id - JOIN public.flyers f ON fi.flyer_id = f.id - JOIN public.stores s ON f.store_id = s.id - WHERE ufi.status = 'pending' - ORDER BY ufi.created_at ASC; - `; - const res = await pool.query(query); - return res.rows; - } catch (error) { - logger.error('Database error in getUnmatchedFlyerItems:', { error }); - throw new Error('Failed to retrieve unmatched flyer items.'); - } -} - -/** - * Updates the status of a recipe (e.g., for moderation). - * @param recipeId The ID of the recipe to update. - * @param status The new status ('private', 'pending_review', 'public', 'rejected'). - * @returns A promise that resolves to the updated Recipe object. - */ -export async function updateRecipeStatus(recipeId: number, status: 'private' | 'pending_review' | 'public' | 'rejected'): Promise { - try { - const res = await pool.query( - 'UPDATE public.recipes SET status = $1 WHERE id = $2 RETURNING *', - [status, recipeId] - ); - if (res.rowCount === 0) { - throw new Error(`Recipe with ID ${recipeId} not found.`); - } - return res.rows[0]; - } catch (error) { - logger.error('Database error in updateRecipeStatus:', { error, recipeId, status }); - throw new Error('Failed to update recipe status.'); - } -} - -/** - * Retrieves a paginated list of recent activities from the activity log. - * @param limit The number of log entries to retrieve. - * @param offset The number of log entries to skip (for pagination). - * @returns A promise that resolves to an array of ActivityLogItem objects. - */ -// prettier-ignore -export async function getActivityLog(limit: number, offset: number): Promise { - try { - const res = await pool.query('SELECT * FROM public.get_activity_log($1, $2)', [limit, offset]); - return res.rows; - } catch (error) { - logger.error('Database error in getActivityLog:', { error, limit, offset }); - throw new Error('Failed to retrieve activity log.'); - } -} - -// --- Personalization & Dietary Management Functions --- - -/** - * Retrieves the master list of all available dietary restrictions. - * @returns A promise that resolves to an array of DietaryRestriction objects. - */ -export async function getDietaryRestrictions(): Promise { - try { - const res = await pool.query('SELECT * FROM public.dietary_restrictions ORDER BY type, name'); - return res.rows; - } catch (error) { - logger.error('Database error in getDietaryRestrictions:', { error }); - throw new Error('Failed to get dietary restrictions.'); - } -} - -/** - * Retrieves the dietary restrictions for a specific user. - * @param userId The ID of the user. - * @returns A promise that resolves to an array of the user's selected DietaryRestriction objects. - */ -export async function getUserDietaryRestrictions(userId: string): Promise { - try { - const query = ` - SELECT dr.* FROM public.dietary_restrictions dr - JOIN public.user_dietary_restrictions udr ON dr.id = udr.restriction_id - WHERE udr.user_id = $1 ORDER BY dr.type, dr.name; - `; - const res = await pool.query(query, [userId]); - return res.rows; - } catch (error) { - logger.error('Database error in getUserDietaryRestrictions:', { error, userId }); - throw new Error('Failed to get user dietary restrictions.'); - } -} - -/** - * Sets the dietary restrictions for a user, replacing any existing ones. - * @param userId The ID of the user. - * @param restrictionIds An array of IDs for the selected dietary restrictions. - * @returns A promise that resolves when the operation is complete. - */ -export async function setUserDietaryRestrictions(userId: string, restrictionIds: number[]): Promise { - const client = await pool.connect(); - try { - await client.query('BEGIN'); - // Clear existing restrictions for the user - await client.query('DELETE FROM public.user_dietary_restrictions WHERE user_id = $1', [userId]); - // Insert new ones if any are provided - if (restrictionIds.length > 0) { - const values = restrictionIds.map(id => `('${userId}', ${id})`).join(','); - await client.query(`INSERT INTO public.user_dietary_restrictions (user_id, restriction_id) VALUES ${values}`); - } - await client.query('COMMIT'); - } catch (error) { - await client.query('ROLLBACK'); - logger.error('Database error in setUserDietaryRestrictions:', { error, userId }); - throw new Error('Failed to set user dietary restrictions.'); - } finally { - client.release(); - } -} - -/** - * Retrieves the kitchen appliances for a specific user. - * @param userId The ID of the user. - * @returns A promise that resolves to an array of the user's selected Appliance objects. - */ -export async function getUserAppliances(userId: string): Promise { - try { - const query = ` - SELECT a.* FROM public.appliances a - JOIN public.user_appliances ua ON a.id = ua.appliance_id - WHERE ua.user_id = $1 ORDER BY a.name; - `; - const res = await pool.query(query, [userId]); - return res.rows; - } catch (error) { - logger.error('Database error in getUserAppliances:', { error, userId }); - throw new Error('Failed to get user appliances.'); - } -} - -/** - * Sets the kitchen appliances for a user, replacing any existing ones. - * @param userId The ID of the user. - * @param applianceIds An array of IDs for the selected appliances. - * @returns A promise that resolves when the operation is complete. - */ -export async function setUserAppliances(userId: string, applianceIds: number[]): Promise { - const client = await pool.connect(); - try { - await client.query('BEGIN'); - // Clear existing appliances for the user - await client.query('DELETE FROM public.user_appliances WHERE user_id = $1', [userId]); - - let newAppliances: UserAppliance[] = []; - // Insert new ones if any are provided - if (applianceIds.length > 0) { - const insertQuery = `INSERT INTO public.user_appliances (user_id, appliance_id) SELECT $1, unnest($2::int[]) RETURNING *`; - const res = await client.query(insertQuery, [userId, applianceIds]); - newAppliances = res.rows; - } - - await client.query('COMMIT'); - return newAppliances; - } catch (error) { - await client.query('ROLLBACK'); - logger.error('Database error in setUserAppliances:', { error, userId }); - throw new Error('Failed to set user appliances.'); - } finally { - client.release(); - } -} -// --- Analytics & Shopping Enhancement Functions --- - -/** - * Tracks a user interaction with a flyer item (view or click). - * @param itemId The ID of the flyer item. - * @param type The type of interaction ('view' or 'click'). - */ -export async function trackFlyerItemInteraction(itemId: number, type: 'view' | 'click'): Promise { - try { - const column = type === 'view' ? 'view_count' : 'click_count'; - // Use the || operator to concatenate the column name safely into the query. - const query = `UPDATE public.flyer_items SET ${column} = ${column} + 1 WHERE id = $1`; - await pool.query(query, [itemId]); - } catch (error) { - logger.error('Database error in trackFlyerItemInteraction:', { error, itemId, type }); - // This is a non-critical operation, so we don't throw an error that would crash the user's request. - } -} - -/** - * Logs a user's search query for analytics purposes. - * @param query An object containing the search query details. - */ -export async function logSearchQuery(query: { userId?: string, queryText: string, resultCount: number, wasSuccessful: boolean }): Promise { - try { - await pool.query( - 'INSERT INTO public.search_queries (user_id, query_text, result_count, was_successful) VALUES ($1, $2, $3, $4)', - [query.userId, query.queryText, query.resultCount, query.wasSuccessful] - ); - } catch (error) { - logger.error('Database error in logSearchQuery:', { error }); - // Also a non-critical operation. - } -} - -/** - * Retrieves all pantry locations defined by a user. - * @param userId The ID of the user. - * @returns A promise that resolves to an array of PantryLocation objects. - */ -export async function getPantryLocations(userId: string): Promise { - try { - const res = await pool.query('SELECT * FROM public.pantry_locations WHERE user_id = $1 ORDER BY name', [userId]); - return res.rows; - } catch (error) { - logger.error('Database error in getPantryLocations:', { error, userId }); - throw new Error('Failed to get pantry locations.'); - } -} - -/** - * Creates a new pantry location for a user. - * @param userId The ID of the user. - * @param name The name of the new location (e.g., "Fridge"). - * @returns A promise that resolves to the newly created PantryLocation object. - */ -export async function createPantryLocation(userId: string, name: string): Promise { - try { - const res = await pool.query( - 'INSERT INTO public.pantry_locations (user_id, name) VALUES ($1, $2) RETURNING *', - [userId, name] - ); - return res.rows[0]; - } catch (error) { - logger.error('Database error in createPantryLocation:', { error }); - throw new Error('Failed to create pantry location.'); - } -} - -/** - * Updates an existing item in a shopping list. - * @param itemId The ID of the shopping list item to update. - * @param updates A partial object of the fields to update (e.g., quantity, is_purchased). - * @returns A promise that resolves to the updated ShoppingListItem object. - */ -export async function updateShoppingListItem(itemId: number, updates: Partial): Promise { - try { - // Build the update query dynamically to handle various fields - const setClauses = []; - const values = []; - let valueIndex = 1; - - if (updates.quantity !== undefined) { - setClauses.push(`quantity = $${valueIndex++}`); - values.push(updates.quantity); - } - if (updates.is_purchased !== undefined) { - setClauses.push(`is_purchased = $${valueIndex++}`); - values.push(updates.is_purchased); - } - if (updates.notes !== undefined) { - setClauses.push(`notes = $${valueIndex++}`); - values.push(updates.notes); - } - - if (setClauses.length === 0) { - throw new Error("No valid fields to update."); - } - - values.push(itemId); - const query = `UPDATE public.shopping_list_items SET ${setClauses.join(', ')} WHERE id = $${valueIndex} RETURNING *`; - - const res = await pool.query(query, values); - return res.rows[0]; - } catch (error) { - logger.error('Database error in updateShoppingListItem:', { error }); - throw new Error('Failed to update shopping list item.'); - } -} - -/** - * Archives a shopping list into a historical shopping trip. - * @param shoppingListId The ID of the shopping list to complete. - * @param userId The ID of the user owning the list. - * @param totalSpentCents Optional total amount spent on the trip. - * @returns A promise that resolves to the ID of the newly created shopping trip. - */ -export async function completeShoppingList(shoppingListId: number, userId: string, totalSpentCents?: number): Promise { - try { - const res = await pool.query<{ complete_shopping_list: number }>( - 'SELECT public.complete_shopping_list($1, $2, $3)', - [shoppingListId, userId, totalSpentCents] - ); - return res.rows[0].complete_shopping_list; - } catch (error) { - logger.error('Database error in completeShoppingList:', { error }); - throw new Error('Failed to complete shopping list.'); - } -} - -/** - * 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. - */ -// prettier-ignore -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. - */ -// prettier-ignore -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.'); - } -} - -/** - * Retrieves the historical shopping trips for a user, including all purchased items. - * @param userId The ID of the user. - * @returns A promise that resolves to an array of ShoppingTrip objects. - */ -export async function getShoppingTripHistory(userId: string): Promise { - try { - const query = ` - SELECT - st.id, st.user_id, st.shopping_list_id, st.completed_at, st.total_spent_cents, - COALESCE( - (SELECT json_agg( - json_build_object( - 'id', sti.id, - 'shopping_trip_id', sti.shopping_trip_id, - 'master_item_id', sti.master_item_id, - 'custom_item_name', sti.custom_item_name, - 'quantity', sti.quantity, - 'price_paid_cents', sti.price_paid_cents, - 'master_item_name', mgi.name - ) ORDER BY mgi.name ASC, sti.custom_item_name ASC - ) FROM public.shopping_trip_items sti - LEFT JOIN public.master_grocery_items mgi ON sti.master_item_id = mgi.id - WHERE sti.shopping_trip_id = st.id), - '[]'::json - ) as items - FROM public.shopping_trips st - WHERE st.user_id = $1 - ORDER BY st.completed_at DESC; - `; - const res = await pool.query(query, [userId]); - return res.rows; - } catch (error) { - logger.error('Database error in getShoppingTripHistory:', { error, userId }); - throw new Error('Failed to retrieve shopping trip history.'); - } -} - -/** - * Retrieves the master list of all available kitchen appliances. - * @returns A promise that resolves to an array of Appliance objects. - */ -export async function getAppliances(): Promise { - try { - const res = await pool.query('SELECT * FROM public.appliances ORDER BY name'); - return res.rows; - } catch (error) { - logger.error('Database error in getAppliances:', { error }); - throw new Error('Failed to get appliances.'); - } -} - -/** - * Calls a database function to get recipes that are compatible with a user's dietary restrictions. - * @param userId The ID of the user. - * @returns A promise that resolves to an array of compatible Recipe objects. - */ -export async function getRecipesForUserDiets(userId: string): Promise { - try { - const res = await pool.query('SELECT * FROM public.get_recipes_for_user_diets($1)', [userId]); - return res.rows; - } catch (error) { - logger.error('Database error in getRecipesForUserDiets:', { error, userId }); - throw new Error('Failed to get recipes compatible with user diet.'); - } -} - -// --- Social & Community Functions --- - -/** - * Creates a following relationship between two users. - * @param followerId The ID of the user who is following. - * @param followingId The ID of the user being followed. - */ -export async function followUser(followerId: string, followingId: string): Promise { - if (followerId === followingId) { - throw new Error('User cannot follow themselves.'); - } - try { - await pool.query( - 'INSERT INTO public.user_follows (follower_id, following_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', - [followerId, followingId] - ); - } catch (error) { - logger.error('Database error in followUser:', { error }); - throw new Error('Failed to follow user.'); - } -} - -/** - * Removes a following relationship between two users. - * @param followerId The ID of the user who is unfollowing. - * @param followingId The ID of the user being unfollowed. - */ -export async function unfollowUser(followerId: string, followingId: string): Promise { - try { - await pool.query('DELETE FROM public.user_follows WHERE follower_id = $1 AND following_id = $2', [followerId, followingId]); - } catch (error) { - logger.error('Database error in unfollowUser:', { error }); - throw new Error('Failed to unfollow user.'); - } -} - -/** - * Retrieves a personalized activity feed for a user based on who they follow. - * @param userId The ID of the user. - * @param limit The number of feed items to retrieve. - * @param offset The number of feed items to skip for pagination. - * @returns A promise that resolves to an array of ActivityLogItem objects. - */ -export async function getUserFeed(userId: string, limit: number, offset: number): Promise { - try { - const res = await pool.query('SELECT * FROM public.get_user_feed($1, $2, $3)', [userId, limit, offset]); - return res.rows; - } catch (error) { - logger.error('Database error in getUserFeed:', { error, userId }); - throw new Error('Failed to retrieve user feed.'); - } -} - -/** - * Creates a personal, editable copy (a "fork") of a public recipe for a user. - * @param userId The ID of the user forking the recipe. - * @param originalRecipeId The ID of the recipe to fork. - * @returns A promise that resolves to the newly created forked Recipe object. - */ -export async function forkRecipe(userId: string, originalRecipeId: number): Promise { - try { - // 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) { - logger.error('Database error in forkRecipe:', { error }); - throw new Error('Failed to fork recipe.'); - } -} \ No newline at end of file diff --git a/src/tests/setup/global-setup.ts b/src/tests/setup/global-setup.ts index 9e54ebe2..df2c8c4d 100644 --- a/src/tests/setup/global-setup.ts +++ b/src/tests/setup/global-setup.ts @@ -2,30 +2,39 @@ import { Pool } from 'pg'; import fs from 'fs/promises'; import path from 'path'; -/** - * This function is executed once before all tests. - * It connects to the test database (created by an external script) - * and applies the entire schema and initial seed data. - */ -export async function setup() { - console.log('\nSetting up test database schema and seed data...'); - +const getPool = () => { // This pool connects using the test-specific environment variables - // passed from the Gitea workflow (e.g., DB_NAME="flyer-crawler-test"). - const pool = new Pool({ + // passed from the Gitea workflow (e.g., DB_DATABASE="flyer-crawler-test"). + return new Pool({ user: process.env.DB_USER, host: process.env.DB_HOST, database: process.env.DB_DATABASE, password: process.env.DB_PASSWORD, port: parseInt(process.env.DB_PORT || '5432', 10), }); +}; + +/** + * This function is executed once before all tests. + * It connects to the permanent test database, wipes the public schema, + * and then rebuilds it from the master rollup script. + */ +export async function setup() { + console.log('\nResetting test database schema...'); + const pool = getPool(); try { - // Apply the master database schema to create all tables, functions, and triggers. + // 1. Drop the entire public schema to ensure a clean slate. + const dropScriptPath = path.resolve(process.cwd(), 'sql/drop_tables.sql'); + const dropSql = await fs.readFile(dropScriptPath, 'utf-8'); + await pool.query(dropSql); + console.log('āœ… Public schema dropped successfully.'); + + // 2. Apply the master database schema to create all tables, functions, and triggers. const schemaPath = path.resolve(process.cwd(), 'sql/master_schema_rollup.sql'); const schemaSql = await fs.readFile(schemaPath, 'utf-8'); await pool.query(schemaSql); - console.log('āœ… Test database schema applied successfully.'); + console.log('āœ… Test database schema rebuilt successfully.'); } catch (error) { console.error('šŸ”“ Error applying database schema:', error); // Exit immediately if schema setup fails, as tests cannot run. @@ -39,5 +48,5 @@ export async function setup() { * This function is executed once after all tests have completed. */ export async function teardown() { - console.log('āœ… Test database integration tests finished.'); + console.log('\nāœ… Test database integration tests finished.'); } \ No newline at end of file