From 838d31420ec613d3ae841b874a752c0fd1c4d06d Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Thu, 20 Nov 2025 09:07:11 -0800 Subject: [PATCH] database expansion prior to creating on server --- server.ts | 10 - sql/drop_tables.sql | 4 +- sql/initial.sql | 90 +++++++++ sql/master_schema_rollup.sql | 70 ++++--- src/App.tsx | 2 - src/components/WatchedItemsList.tsx | 6 +- src/pages/AdminPage.tsx | 2 +- src/services/db.ts | 293 ++++++++++++++++++++++++++-- 8 files changed, 420 insertions(+), 57 deletions(-) diff --git a/server.ts b/server.ts index fba79071..75d08682 100644 --- a/server.ts +++ b/server.ts @@ -832,16 +832,6 @@ app.get('/api/shopping-history', passport.authenticate('jwt', { session: false } // --- Pantry Location Routes --- -app.get('/api/pantry/locations', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { - const user = req.user as { id: string }; - try { - const locations = await db.getPantryLocations(user.id); - res.json(locations); - } catch (error) { - next(error); - } -}); - app.post('/api/pantry/locations', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; const { name } = req.body; diff --git a/sql/drop_tables.sql b/sql/drop_tables.sql index e1d60c91..cec46518 100644 --- a/sql/drop_tables.sql +++ b/sql/drop_tables.sql @@ -47,7 +47,6 @@ DROP TABLE IF EXISTS public.unmatched_flyer_items CASCADE; DROP TABLE IF EXISTS public.item_price_history CASCADE; DROP TABLE IF EXISTS public.flyer_items CASCADE; DROP TABLE IF EXISTS public.products CASCADE; -DROP TABLE IF EXISTS public.products CASCADE; DROP TABLE IF EXISTS public.brands CASCADE; DROP TABLE IF EXISTS public.flyers CASCADE; DROP TABLE IF EXISTS public.master_grocery_items CASCADE; @@ -58,4 +57,5 @@ DROP TABLE IF EXISTS public.dietary_restrictions CASCADE; DROP TABLE IF EXISTS public.categories CASCADE; DROP TABLE IF EXISTS public.profiles CASCADE; DROP TABLE IF EXISTS public.password_reset_tokens CASCADE; -DROP TABLE IF EXISTS public.users CASCADE; \ No newline at end of file +DROP TABLE IF EXISTS public.users CASCADE; +DROP TABLE IF EXISTS public.unmatched_flyer_items CASCADE; \ No newline at end of file diff --git a/sql/initial.sql b/sql/initial.sql index 44e86692..a254303c 100644 --- a/sql/initial.sql +++ b/sql/initial.sql @@ -1,6 +1,7 @@ -- DONE -- ============================================================================ +-- PART 0: EXTENSIONS -- PART 0.5: USER AUTHENTICATION TABLE -- ============================================================================ -- This replaces the Supabase `auth.users` table. @@ -548,6 +549,20 @@ CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON public.password_ CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token_hash ON public.password_reset_tokens(token_hash); COMMENT ON COLUMN public.password_reset_tokens.expires_at IS 'The timestamp when this token is no longer valid.'; +-- A table to store password reset tokens. +CREATE TABLE IF NOT EXISTS public.password_reset_tokens ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL +); +COMMENT ON TABLE public.password_reset_tokens IS 'Stores secure, single-use tokens for password reset requests.'; +COMMENT ON COLUMN public.password_reset_tokens.token_hash IS 'A bcrypt hash of the reset token sent to the user.'; +CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON public.password_reset_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token_hash ON public.password_reset_tokens(token_hash); +COMMENT ON COLUMN public.password_reset_tokens.expires_at IS 'The timestamp when this token is no longer valid.'; + -- A table to store unit conversion factors for specific master grocery items. CREATE TABLE IF NOT EXISTS public.unit_conversions ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, @@ -679,6 +694,20 @@ COMMENT ON TABLE public.shopping_trip_items IS 'A historical log of items purcha 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); +-- A table to store the items purchased during a specific shopping trip. +CREATE TABLE IF NOT EXISTS public.shopping_trip_items ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(id) ON DELETE CASCADE, + master_item_id BIGINT REFERENCES public.master_grocery_items(id), + custom_item_name TEXT, + quantity NUMERIC NOT NULL, + price_paid_cents INTEGER, + CONSTRAINT trip_must_have_item_identifier CHECK (master_item_id IS NOT NULL OR custom_item_name IS NOT NULL) +); +COMMENT ON TABLE public.shopping_trip_items IS 'A historical log of items purchased during a shopping trip.'; +COMMENT ON COLUMN public.shopping_trip_items.price_paid_cents IS 'The actual price paid for the item during the trip, if provided.'; +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); @@ -700,6 +729,16 @@ COMMENT ON TABLE public.user_dietary_restrictions IS 'Connects users to their se CREATE INDEX IF NOT EXISTS idx_user_dietary_restrictions_user_id ON public.user_dietary_restrictions(user_id); CREATE INDEX IF NOT EXISTS idx_user_dietary_restrictions_restriction_id ON public.user_dietary_restrictions(restriction_id); +-- A linking table 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) +); +COMMENT ON TABLE public.user_dietary_restrictions IS 'Connects users to their selected dietary needs and allergies.'; +CREATE INDEX IF NOT EXISTS idx_user_dietary_restrictions_user_id ON public.user_dietary_restrictions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_dietary_restrictions_restriction_id ON public.user_dietary_restrictions(restriction_id); + -- A table to store a predefined list of kitchen appliances. CREATE TABLE IF NOT EXISTS public.appliances ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, @@ -717,6 +756,27 @@ COMMENT ON TABLE public.user_appliances IS 'Tracks the kitchen appliances a user CREATE INDEX IF NOT EXISTS idx_user_appliances_user_id ON public.user_appliances(user_id); CREATE INDEX IF NOT EXISTS idx_user_appliances_appliance_id ON public.user_appliances(appliance_id); +-- A linking table 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) +); +COMMENT ON TABLE public.user_appliances IS 'Tracks the kitchen appliances a user owns to help with recipe recommendations.'; +CREATE INDEX IF NOT EXISTS idx_user_appliances_user_id ON public.user_appliances(user_id); +CREATE INDEX IF NOT EXISTS idx_user_appliances_appliance_id ON public.user_appliances(appliance_id); + +-- A table to manage the social graph (following relationships). +CREATE TABLE IF NOT EXISTS public.user_follows ( + 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), + CONSTRAINT cant_follow_self CHECK (follower_id <> following_id) +); +COMMENT ON TABLE public.user_follows IS 'Stores user following relationships to build a social graph.'; +CREATE INDEX IF NOT EXISTS idx_user_follows_follower_id ON public.user_follows(follower_id); +CREATE INDEX IF NOT EXISTS idx_user_follows_following_id ON public.user_follows(following_id); -- A table to 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, @@ -729,6 +789,22 @@ COMMENT ON TABLE public.user_follows IS 'Stores user following relationships to CREATE INDEX IF NOT EXISTS idx_user_follows_follower_id ON public.user_follows(follower_id); CREATE INDEX IF NOT EXISTS idx_user_follows_following_id ON public.user_follows(following_id); +-- A table to store uploaded user receipts for purchase tracking and analysis. +CREATE TABLE IF NOT EXISTS public.receipts ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + store_id BIGINT REFERENCES public.stores(id), + receipt_image_url TEXT NOT NULL, + transaction_date TIMESTAMPTZ, + total_amount_cents INTEGER, + status TEXT DEFAULT 'pending' NOT NULL CHECK (status IN ('pending', 'processing', 'completed', 'failed')), + raw_text TEXT, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + processed_at TIMESTAMPTZ +); +COMMENT ON TABLE public.receipts IS 'Stores uploaded user receipts for purchase tracking and analysis.'; +CREATE INDEX IF NOT EXISTS idx_receipts_user_id ON public.receipts(user_id); +CREATE INDEX IF NOT EXISTS idx_receipts_store_id ON public.receipts(store_id); -- A table to store uploaded user receipts for purchase tracking and analysis. CREATE TABLE IF NOT EXISTS public.receipts ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, @@ -760,3 +836,17 @@ CREATE TABLE IF NOT EXISTS public.receipt_items ( COMMENT ON TABLE public.receipt_items IS 'Stores individual line items extracted from a user receipt.'; CREATE INDEX IF NOT EXISTS idx_receipt_items_receipt_id ON public.receipt_items(receipt_id); CREATE INDEX IF NOT EXISTS idx_receipt_items_master_item_id ON public.receipt_items(master_item_id); +-- A table to store individual line items extracted from a user receipt. +CREATE TABLE IF NOT EXISTS public.receipt_items ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + receipt_id BIGINT NOT NULL REFERENCES public.receipts(id) ON DELETE CASCADE, + raw_item_description TEXT NOT NULL, + quantity NUMERIC DEFAULT 1 NOT NULL, + price_paid_cents INTEGER NOT NULL, + master_item_id BIGINT REFERENCES public.master_grocery_items(id), + product_id BIGINT REFERENCES public.products(id), + status TEXT DEFAULT 'unmatched' NOT NULL CHECK (status IN ('unmatched', 'matched', 'needs_review', 'ignored')) +); +COMMENT ON TABLE public.receipt_items IS 'Stores individual line items extracted from a user receipt.'; +CREATE INDEX IF NOT EXISTS idx_receipt_items_receipt_id ON public.receipt_items(receipt_id); +CREATE INDEX IF NOT EXISTS idx_receipt_items_master_item_id ON public.receipt_items(master_item_id); diff --git a/sql/master_schema_rollup.sql b/sql/master_schema_rollup.sql index 8bc1606e..0f8028a1 100644 --- a/sql/master_schema_rollup.sql +++ b/sql/master_schema_rollup.sql @@ -72,6 +72,7 @@ CREATE TABLE IF NOT EXISTS public.master_grocery_items ( allergy_info JSONB ); COMMENT ON TABLE public.master_grocery_items IS 'The master dictionary of canonical grocery items. Each item has a unique name and is linked to a category.'; +CREATE INDEX IF NOT EXISTS idx_master_grocery_items_category_id ON public.master_grocery_items(category_id); -- 3. Create the 'flyers' table with its full, final schema. CREATE TABLE IF NOT EXISTS public.flyers ( @@ -94,19 +95,6 @@ COMMENT ON COLUMN public.flyers.store_id IS 'Foreign key linking this flyer to a 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.'; - --- 4. Create the 'master_grocery_items' table. This is the master dictionary. -CREATE TABLE IF NOT EXISTS public.master_grocery_items ( - id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - name TEXT NOT NULL UNIQUE, - category_id BIGINT REFERENCES public.categories(id), - is_allergen BOOLEAN DEFAULT false, - allergy_info JSONB -); -COMMENT ON TABLE public.master_grocery_items IS 'The master dictionary of canonical grocery items. Each item has a unique name and is linked to a category.'; -CREATE INDEX IF NOT EXISTS idx_master_grocery_items_category_id ON public.master_grocery_items(category_id); - -- 6. Create the 'flyer_items' table with its full, final schema. CREATE TABLE IF NOT EXISTS public.flyer_items ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, @@ -807,20 +795,6 @@ CREATE TABLE IF NOT EXISTS public.shopping_trip_items ( COMMENT ON TABLE public.shopping_trip_items IS 'A historical log of items purchased during a shopping trip.'; COMMENT ON COLUMN public.shopping_trip_items.price_paid_cents IS 'The actual price paid for the item during the trip, if provided.'; - - - - - - - - - - - - - - -- ============================================================================ -- PART 2: DATA SEEDING -- ============================================================================ @@ -1187,6 +1161,7 @@ AS $$ COUNT(bcp.master_item_id) AS sale_ingredients -- COUNT(column) only counts non-NULL values. FROM public.recipe_ingredients ri LEFT JOIN BestCurrentPrices bcp ON ri.master_item_id = bcp.master_item_id + GROUP BY ri.recipe_id ), EligibleRecipes AS ( -- CTE 3: Filter recipes based on the minimum sale percentage provided as an argument. @@ -1601,7 +1576,11 @@ FROM RankedRecommendations rr JOIN public.recipes r ON rr.recipe_id = r.id ORDER BY rr.total_score DESC, - r.avg_rating + r.avg_rating DESC, -- As a tie-breaker, prefer higher-rated recipes. + r.rating_count DESC, + r.name ASC +LIMIT p_limit; +$$; -- Function to get a user's favorite recipes. CREATE OR REPLACE FUNCTION public.get_user_favorite_recipes(p_user_id UUID) @@ -2102,6 +2081,41 @@ CREATE TRIGGER on_new_flyer_created AFTER INSERT ON public.flyers FOR EACH ROW EXECUTE FUNCTION public.log_new_flyer(); +-- 8. Trigger function to log when a user favorites a recipe. +CREATE OR REPLACE FUNCTION public.log_new_favorite_recipe() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO public.user_activity_log (user_id, activity_type, entity_id, details) + VALUES ( + NEW.user_id, + 'favorite_recipe', + NEW.recipe_id::text, + jsonb_build_object( + 'recipe_name', (SELECT name FROM public.recipes WHERE id = NEW.recipe_id) + ) + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- 9. Trigger function to log when a user shares a shopping list. +CREATE OR REPLACE FUNCTION public.log_new_list_share() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO public.user_activity_log (user_id, activity_type, entity_id, details) + VALUES ( + NEW.shared_by_user_id, + 'share_shopping_list', + NEW.shopping_list_id::text, + jsonb_build_object( + 'list_name', (SELECT name FROM public.shopping_lists WHERE id = NEW.shopping_list_id), + 'shared_with_name', (SELECT full_name FROM public.profiles WHERE id = NEW.shared_with_user_id) + ) + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + -- 8. Trigger to log when a user favorites a recipe. DROP TRIGGER IF EXISTS on_new_favorite_recipe ON public.favorite_recipes; CREATE TRIGGER on_new_favorite_recipe diff --git a/src/App.tsx b/src/App.tsx index 0c46337c..658258db 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -769,8 +769,6 @@ function App() { isProcessing={isProcessing} /> )} - {/* The SystemCheck component was here. It has been moved to the Admin page. */} - {/* We now set isReady to true immediately if the DB is not connected, or after a short delay if it is, to allow other components to load. */}
diff --git a/src/components/WatchedItemsList.tsx b/src/components/WatchedItemsList.tsx index 9e0ef3e8..d11f87da 100644 --- a/src/components/WatchedItemsList.tsx +++ b/src/components/WatchedItemsList.tsx @@ -120,7 +120,7 @@ export const WatchedItemsList: React.FC = ({ items, onAdd value={newItemName} onChange={(e) => setNewItemName(e.target.value)} placeholder="Add item (e.g., Avocados)" - className="flex-grow block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm" + className="grow block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm" disabled={isAdding} />
@@ -148,11 +148,11 @@ export const WatchedItemsList: React.FC = ({ items, onAdd
    {sortedAndFilteredItems.map(item => (
  • -
    +
    {item.name} {item.category_name}
    -
    +
    - { /* The system is ready */ }} /> +
); diff --git a/src/services/db.ts b/src/services/db.ts index a1035564..637e0ae6 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -12,6 +12,9 @@ const pool = new Pool({ 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'}`); /** @@ -90,6 +93,7 @@ export async function createUser( * @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 }>( @@ -108,6 +112,7 @@ export async function findUserById(id: string): Promise<{ id: string; email: str * @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' @@ -128,6 +133,7 @@ export async function findUserProfileById(id: string): Promise { try { const res = await pool.query( @@ -152,6 +158,7 @@ export async function updateUserProfile(id: string, profileData: { full_name?: s * @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( @@ -173,6 +180,7 @@ export async function updateUserPreferences(id: string, preferences: Profile['pr * @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( @@ -189,6 +197,7 @@ export async function updateUserPassword(id: string, passwordHash: string): Prom * 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]); @@ -203,6 +212,7 @@ export async function deleteUserById(id: string): Promise { * @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. @@ -228,6 +238,7 @@ export async function checkTablesExist(tableNames: string[]): Promise * 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. @@ -244,6 +255,7 @@ export function getPoolStatus() { * @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. @@ -262,6 +274,7 @@ export async function saveRefreshToken(userId: string, refreshToken: string): Pr * @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 }>( @@ -281,6 +294,7 @@ export async function findUserByRefreshToken(refreshToken: string): Promise<{ id * @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. @@ -301,6 +315,7 @@ export async function createPasswordResetToken(userId: string, tokenHash: string * 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 }>( @@ -318,6 +333,7 @@ export async function getValidResetTokens(): Promise<{ user_id: string; token_ha * 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]); @@ -331,6 +347,7 @@ export async function deleteResetToken(tokenHash: string): Promise { * 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 = ` @@ -365,6 +382,7 @@ export async function getFlyers(): Promise { * Retrieves all brands from the database, including the associated store name for store brands. * @returns A promise that resolves to an array of Brand objects. */ +// prettier-ignore export async function getAllBrands(): Promise { try { const query = ` @@ -385,6 +403,7 @@ export async function getAllBrands(): Promise { * 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 = ` @@ -410,6 +429,7 @@ export async function getAllMasterItems(): Promise { * 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 = ` @@ -430,6 +450,7 @@ export async function getAllCategories(): Promise<{id: number, name: string}[]> * @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 = ` @@ -454,6 +475,7 @@ export async function getWatchedItems(userId: string): Promise { const client = await pool.connect(); try { @@ -501,6 +523,7 @@ export async function addWatchedItem(userId: string, itemName: string, categoryN * @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]); @@ -517,30 +540,37 @@ export async function removeWatchedItem(userId: string, masterItemId: number): P * @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) + '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 - ) FROM public.shopping_list_items sli - LEFT JOIN public.master_grocery_items mgi ON sli.master_item_id = mgi.id - WHERE sli.shopping_list_id = sl.id), + ) 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]); @@ -551,6 +581,12 @@ export async function getShoppingLists(userId: string): Promise } } +/** + * 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( @@ -565,6 +601,11 @@ export async function createShoppingList(userId: string, name: string): Promise< } } +/** + * 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. @@ -575,6 +616,12 @@ export async function deleteShoppingList(listId: number, userId: string): Promis } } +/** + * 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( @@ -588,6 +635,10 @@ export async function addShoppingListItem(listId: number, item: { masterItemId?: } } +/** + * 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]); @@ -602,6 +653,7 @@ export async function removeShoppingListItem(itemId: number): Promise { * @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 { @@ -632,6 +684,7 @@ export async function exportUserData(userId: string): Promise<{ profile: Profile * @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]); @@ -648,6 +701,7 @@ export async function findFlyerByChecksum(checksum: string): Promise & { store_name: string }, items: Omit[] @@ -721,6 +775,7 @@ export async function createFlyerAndItems( * @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 = ` @@ -741,6 +796,7 @@ export async function getFlyerItems(flyerId: number): Promise { * @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 = ` @@ -760,6 +816,7 @@ export async function getFlyerItemsForFlyers(flyerIds: number[]): Promise { try { const query = `SELECT COUNT(*) FROM public.flyer_items WHERE flyer_id = ANY($1::bigint[])`; @@ -776,6 +833,7 @@ export async function countFlyerItemsForFlyers(flyerIds: number[]): Promise { try { await pool.query( @@ -793,6 +851,7 @@ export async function updateStoreLogo(storeId: number, logoUrl: string): Promise * @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( @@ -855,6 +914,7 @@ const correctionHandlers: { [key: string]: (client: any, flyerItemId: number, su * 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 = ` @@ -888,6 +948,7 @@ export async function getSuggestedCorrections(): Promise * 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. @@ -905,6 +966,7 @@ export async function approveCorrection(correctionId: number): Promise { * 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( @@ -930,6 +992,7 @@ export async function rejectCorrection(correctionId: number): Promise { * @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( @@ -950,6 +1013,7 @@ export async function updateSuggestedCorrection(correctionId: number, newSuggest * 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; @@ -994,6 +1058,7 @@ export async function getApplicationStats(): Promise<{ * @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 []; @@ -1017,6 +1082,7 @@ export async function getHistoricalPriceDataForItems(masterItemIds: number[]): P * 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 = ` @@ -1056,6 +1122,12 @@ export async function getDailyStatsForLast30Days(): Promise<{ date: string; new_ } } +/** + * 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]); @@ -1066,6 +1138,11 @@ export async function getMostFrequentSaleItems(days: number, limit: number): Pro } } +/** + * 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]); @@ -1076,6 +1153,12 @@ export async function findRecipesFromPantry(userId: string): Promise { } } +/** + * 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]); @@ -1086,6 +1169,11 @@ export async function recommendRecipesForUser(userId: string, limit: number): Pr } } +/** + * 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]); @@ -1096,6 +1184,11 @@ export async function getBestSalePricesForUser(userId: string): Promise { } } +/** + * 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]); @@ -1106,6 +1199,12 @@ export async function suggestPantryItemConversions(pantryItemId: number): Promis } } +/** + * 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]); @@ -1116,6 +1215,13 @@ export async function generateShoppingListForMenuPlan(menuPlanId: number, userId } } +/** + * 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]); @@ -1126,6 +1232,11 @@ export async function addMenuPlanToShoppingList(menuPlanId: number, shoppingList } } +/** + * 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]); @@ -1136,6 +1247,11 @@ export async function getRecipesBySalePercentage(minPercentage: number): Promise } } +/** + * 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]); @@ -1146,6 +1262,12 @@ export async function getRecipesByMinSaleIngredients(minIngredients: number): Pr } } +/** + * 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]); @@ -1156,6 +1278,11 @@ export async function findRecipesByIngredientAndTag(ingredient: string, tag: str } } +/** + * 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]); @@ -1166,6 +1293,12 @@ export async function getUserFavoriteRecipes(userId: string): Promise } } +/** + * 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( @@ -1221,11 +1354,21 @@ export async function processReceiptItems(receiptId: number, rawText: string, it } } +/** + * 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]); @@ -1235,6 +1378,11 @@ export async function removeFavoriteRecipe(userId: string, recipeId: number): Pr } } +/** + * 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 = ` @@ -1255,6 +1403,14 @@ export async function getRecipeComments(recipeId: number): Promise { try { const res = await pool.query( @@ -1268,6 +1424,12 @@ export async function addRecipeComment(recipeId: number, userId: string, content } } +/** + * 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( @@ -1286,6 +1448,10 @@ export async function updateRecipeCommentStatus(commentId: number, status: 'visi +/** + * 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 = ` @@ -1313,6 +1479,12 @@ export async function getUnmatchedFlyerItems(): Promise { } } +/** + * 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( @@ -1335,6 +1507,7 @@ export async function updateRecipeStatus(recipeId: number, status: 'private' | ' * @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]); @@ -1347,6 +1520,10 @@ export async function getActivityLog(limit: number, offset: number): Promise { try { const res = await pool.query('SELECT * FROM public.dietary_restrictions ORDER BY type, name'); @@ -1357,6 +1534,11 @@ export async function getDietaryRestrictions(): Promise { } } +/** + * 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 = ` @@ -1372,6 +1554,12 @@ export async function getUserDietaryRestrictions(userId: string): Promise { const client = await pool.connect(); try { @@ -1393,6 +1581,11 @@ export async function setUserDietaryRestrictions(userId: string, restrictionIds: } } +/** + * 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 = ` @@ -1408,6 +1601,12 @@ export async function getUserAppliances(userId: string): Promise { } } +/** + * 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 { @@ -1430,6 +1629,11 @@ export async function setUserAppliances(userId: string, applianceIds: number[]): } // --- 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'; @@ -1442,6 +1646,10 @@ export async function trackFlyerItemInteraction(itemId: number, type: 'view' | ' } } +/** + * 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( @@ -1454,6 +1662,11 @@ export async function logSearchQuery(query: { userId?: string, queryText: string } } +/** + * 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]); @@ -1464,6 +1677,12 @@ export async function getPantryLocations(userId: string): Promise { try { const res = await pool.query( @@ -1477,6 +1696,12 @@ export async function createPantryLocation(userId: string, name: string): Promis } } +/** + * 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 @@ -1512,6 +1737,13 @@ export async function updateShoppingListItem(itemId: number, updates: Partial { try { const res = await pool.query<{ complete_shopping_list: number }>( @@ -1530,6 +1762,7 @@ export async function completeShoppingList(shoppingListId: number, userId: strin * @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 }>( @@ -1548,6 +1781,7 @@ export async function findPantryItemOwner(pantryItemId: number): Promise<{ user_ * @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 }>( @@ -1561,6 +1795,11 @@ export async function findReceiptOwner(receiptId: number): Promise<{ user_id: st } } +/** + * 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 = ` @@ -1594,6 +1833,10 @@ export async function getShoppingTripHistory(userId: string): Promise { try { const res = await pool.query('SELECT * FROM public.appliances ORDER BY name'); @@ -1604,6 +1847,11 @@ export async function getAppliances(): Promise { } } +/** + * 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]); @@ -1616,6 +1864,11 @@ export async function getRecipesForUserDiets(userId: string): Promise // --- 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.'); @@ -1631,6 +1884,11 @@ export async function followUser(followerId: string, followingId: string): Promi } } +/** + * 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]); @@ -1640,6 +1898,13 @@ export async function unfollowUser(followerId: string, followingId: string): Pro } } +/** + * 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]); @@ -1650,6 +1915,12 @@ export async function getUserFeed(userId: string, limit: number, offset: number) } } +/** + * 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.