import { createClient, SupabaseClient, PostgrestError } from '@supabase/supabase-js'; import type { Flyer, FlyerItem, MasterGroceryItem, Profile, ShoppingList, ShoppingListItem, Store, SuggestedCorrection, UnitPrice } from '../types'; import { logger } from './logger'; import { Database, Json } from '../types/supabase'; // In a Vite project, environment variables are exposed on the `import.meta.env` object. // For security, only variables prefixed with `VITE_` are exposed to the client-side code. const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; if (!supabaseUrl || !supabaseAnonKey) { console.warn("Supabase environment variables not set. Running in no-database mode."); } // Create and export the Supabase client. // If the keys are missing, this will be null, and features requiring it will be disabled. export let supabase = (supabaseUrl && supabaseAnonKey) ? createClient(supabaseUrl, supabaseAnonKey) : null; // ============================================= // INTERNAL HELPERS // ============================================= /** * Initializes or re-initializes the Supabase client with new credentials. * This is used for manual connection from the UI. * @param url The Supabase project URL. * @param anonKey The Supabase anon key. * @returns The newly created Supabase client instance. */ export const initializeSupabase = (url: string, anonKey: string): SupabaseClient => { if (!url || !anonKey) { throw new Error("Supabase URL and Anon Key are required."); } supabase = createClient(url, anonKey); return supabase; }; /** * A centralized check to ensure the Supabase client is initialized before use. * @throws {Error} If the client is not initialized. */ const ensureSupabase = (): SupabaseClient => { if (!supabase) { throw new Error("Supabase client not initialized. Please check your environment variables and configuration."); } return supabase; }; /** * Generic helper to handle Supabase query responses, centralizing error handling and data casting. * @param query A Supabase query promise. * @returns The data from the query, or throws an error. */ const handleResponse = async (query: PromiseLike<{ data: T | null; error: PostgrestError | null; }>): Promise => { const { data, error } = await query; if (error) throw new Error(error.message); return data; }; /** * A type guard to check if a given JSON object conforms to the UnitPrice interface. * This provides runtime validation to safely cast the generic `Json` type from * Supabase to our specific application `UnitPrice` type. * @param obj The object to check. * @returns True if the object is a valid UnitPrice, false otherwise. */ const isUnitPrice = (obj: unknown): obj is UnitPrice => { return ( typeof obj === 'object' && obj !== null && 'value' in obj && 'unit' in obj && typeof (obj as UnitPrice).value === 'number' && typeof (obj as UnitPrice).unit === 'string' ); }; /** * Disconnects the Supabase client by setting the instance to null. */ export const disconnectSupabase = (): void => { supabase = null; // Clear stored credentials on explicit disconnect localStorage.removeItem('supabaseUrl'); localStorage.removeItem('supabaseAnonKey'); }; /** * Tests basic read access to the database. * @returns An object indicating success and any error message. */ export const testDatabaseConnection = async (): Promise<{ success: boolean; error: string | null }> => { try { const { error } = await ensureSupabase().from('stores').select('id').limit(1); if (error) throw error; return { success: true, error: null }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, error: `Database connection test failed: ${errorMessage}. Check RLS policies and client initialization.` }; } }; /** * Performs a full CRUD (Create, Read, Update, Delete) test on a table. * @returns An object indicating success and any error message. */ export const runDatabaseSelfTest = async (): Promise<{ success: boolean; error: string | null }> => { const supabaseClient = ensureSupabase(); const testItem = { item: `DB_SELF_TEST_ITEM_${Date.now()}`, price_display: '$0.00', quantity: 'test', }; try { // 1. Insert const { data: insertData, error: insertError } = await supabaseClient .from('flyer_items') .insert(testItem) .select() .single(); if (insertError) throw new Error(`Insert failed: ${insertError.message}`); if (!insertData) throw new Error('Insert did not return data.'); // 2. Select (implicit in insert's .select()) const testItemId = insertData.id; // 3. Update const { error: updateError } = await supabaseClient .from('flyer_items') .update({ item: 'DB_SELF_TEST_ITEM_UPDATED' }) .eq('id', testItemId); if (updateError) throw new Error(`Update failed: ${updateError.message}`); // 4. Delete const { error: deleteError } = await supabaseClient .from('flyer_items') .delete() .eq('id', testItemId); if (deleteError) throw new Error(`Delete failed: ${deleteError.message}`); return { success: true, error: null }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, error: `Database self-test failed: ${errorMessage}. Check table permissions (select, insert, update, delete) and RLS policies for 'flyer_items'.` }; } }; /** * Tests storage by uploading and deleting a file. * @returns An object indicating success and any error message. */ export const testStorageConnection = async (): Promise<{ success: boolean; error: string | null }> => { const supabaseClient = ensureSupabase(); const bucketName = 'flyers'; const testFileName = `storage-self-test-${Date.now()}.txt`; const testFileContent = 'test'; try { // 1. Upload const { error: uploadError } = await supabaseClient.storage .from(bucketName) .upload(testFileName, testFileContent); if (uploadError) throw new Error(`Upload to storage failed: ${uploadError.message}`); // 2. Delete const { error: deleteError } = await supabaseClient.storage .from(bucketName) .remove([testFileName]); if (deleteError) throw new Error(`Deleting from storage failed: ${deleteError.message}`); return { success: true, error: null }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, error: `Storage connection test failed: ${errorMessage}. Check bucket permissions (select, insert, delete) and RLS policies for bucket '${bucketName}'.` }; } }; /** * Uploads a flyer image to Supabase storage. * @param file The image file to upload. * @returns The public URL of the uploaded image. */ export const uploadFlyerImage = async (file: File): Promise => { const supabaseClient = ensureSupabase(); const fileName = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}`; const { data: uploadData, error } = await supabaseClient.storage .from('flyers') .upload(fileName, file); if (error) throw new Error(`Failed to upload flyer image: ${error.message}`); if (!uploadData) throw new Error("Upload did not return a path."); const { data: urlData } = supabaseClient.storage.from('flyers').getPublicUrl(uploadData.path); const publicUrl = urlData?.publicUrl; if (!publicUrl) throw new Error("Could not get public URL for uploaded flyer image."); return publicUrl; }; /** * Creates a record for a new flyer in the database, handling store creation if needed. * @returns The newly created flyer object, joined with its store. */ export const createFlyerRecord = async ( fileName: string, imageUrl: string, checksum: string, storeName: string, validFrom: string | null, validTo: string | null, storeAddress: string | null ): Promise => { const supabaseClient = ensureSupabase(); let store = await handleResponse( supabaseClient .from('stores') .select('*') .ilike('name', storeName) .maybeSingle() ); if (!store) { store = await handleResponse(supabaseClient .from('stores') .insert({ name: storeName }) .select() .single()); } const { data: newFlyer, error: flyerError } = await supabase .from('flyers') .insert({ file_name: fileName, image_url: imageUrl, checksum: checksum, store_id: store.id, valid_from: validFrom, valid_to: validTo, store_address: storeAddress, }) .select('*, store:stores(*)') .single(); if (flyerError) throw new Error(`Failed to create flyer record: ${flyerError.message}`); if (!newFlyer) throw new Error("Flyer record creation did not return data."); return newFlyer as Flyer; }; /** * Saves a list of extracted items to the database. * @param items The items to save. * @param flyerId The ID of the flyer these items belong to. * @returns The array of saved items with their new IDs. */ export const saveFlyerItems = async (items: Omit[], flyerId: number): Promise => { const supabaseClient = ensureSupabase(); if (items.length === 0) return []; // Define the type for the items we are inserting, which matches the DB schema. type FlyerItemInsert = Database['public']['Tables']['flyer_items']['Insert']; const itemsToInsert: FlyerItemInsert[] = items.map(item => { // Create a new object without the properties that don't exist in the DB table return { ...item, flyer_id: flyerId, unit_price: item.unit_price as unknown as Json | null }; }); const { data: savedItems, error } = await supabaseClient .from('flyer_items') // The `itemsToInsert` array is now correctly typed for the insert operation. .insert(itemsToInsert) .select(); if (error) throw new Error(`Failed to save flyer items: ${error.message}`); if (!savedItems) return []; // Safely map the returned data to the application type, handling unit_price return savedItems.map(item => ({ ...item, unit_price: isUnitPrice(item.unit_price) ? item.unit_price : null, })); }; /** * Retrieves all flyers from the database, ordered by most recent. * @returns An array of flyer objects. */ export const getFlyers = async (): Promise => { const supabaseClient = ensureSupabase(); const data = await handleResponse(supabaseClient .from('flyers') .select('*, store:stores(*)') .order('created_at', { ascending: false })); return data ?? []; }; /** * Retrieves all items for a specific flyer. * @param flyerId The ID of the flyer. * @returns An array of flyer item objects. */ export const getFlyerItems = async (flyerId: number): Promise => { const supabaseClient = ensureSupabase(); // Fetch the data using the raw, auto-generated type from Supabase. // This avoids the initial type conflict with `unit_price`. const rawData = await handleResponse(supabaseClient .from('flyer_items') .select('*') .eq('flyer_id', flyerId) .order('item', { ascending: true })); if (!rawData) { return []; } // Explicitly map the raw data to our application's `FlyerItem` type. // We use the `isUnitPrice` type guard to safely handle the conversion // from the generic `Json` type to our specific `UnitPrice` type. return rawData.map(item => ({ ...item, // The type guard validates the shape at runtime. If it's not a valid // UnitPrice, we treat it as null to maintain type safety. unit_price: isUnitPrice(item.unit_price) ? item.unit_price : null, })); }; /** * Looks for an existing flyer with a matching checksum to prevent duplicates. * @param checksum The SHA-256 checksum of the file. * @returns The found flyer object or null. */ export const findFlyerByChecksum = async (checksum: string): Promise => { const supabaseClient = ensureSupabase(); const { data, error } = await supabaseClient .from('flyers') .select('*') .eq('checksum', checksum) .single(); if (error && error.code !== 'PGRST116') throw new Error(`Error finding flyer by checksum: ${error.message}`); return data; }; /** * Uploads a store logo and updates the store record. * This is designed to be a non-critical step. It won't throw an error but will log warnings. * It will only update the store if no logo_url is currently set. * @param storeId The ID of the store to update. * @param logoBase64 The base64 encoded logo string. * @returns The public URL of the logo if successful, otherwise null. */ export const uploadLogoAndUpdateStore = async (storeId: number, logoBase64: string): Promise => { const supabaseClient = ensureSupabase(); try { // Helper function to convert base64 to a Blob for uploading. const base64ToBlob = (base64: string, mimeType: string): Blob => { const byteCharacters = atob(base64); const byteNumbers = new Array(byteCharacters.length); for (let i = 0; i < byteCharacters.length; i++) { byteNumbers[i] = byteCharacters.charCodeAt(i); } const byteArray = new Uint8Array(byteNumbers); return new Blob([byteArray], { type: mimeType }); }; const logoBlob = base64ToBlob(logoBase64, 'image/png'); const filePath = `logos/store_logo_${storeId}.png`; const { data, error: uploadError } = await supabaseClient.storage .from('flyers') .upload(filePath, logoBlob, { cacheControl: '3600', upsert: true, // Overwrite if it exists, simplifies logic }); if (uploadError) { console.warn(`Failed to upload logo image: ${uploadError.message}`); return null; } const { data: urlData } = supabaseClient.storage.from('flyers').getPublicUrl(data.path); const publicUrl = urlData?.publicUrl; if (!publicUrl) { console.warn("Could not get public URL for uploaded logo."); return null; } // Update the store record with the new URL, but only if it's currently null. const { error: updateError } = await supabaseClient .from('stores') .update({ logo_url: publicUrl }) .eq('id', storeId) .is('logo_url', null); if (updateError) { console.warn(`Failed to update store with new logo URL: ${updateError.message}`); } return publicUrl; } catch (e) { // This catch block is now valid because of the preceding `try`. const errorMessage = e instanceof Error ? e.message : String(e); console.warn(`An error occurred during logo processing: ${errorMessage}`); return null; } }; /** * Retrieves all items a specific user is watching. * @param userId The UUID of the user. * @returns An array of master grocery item objects. */ export const getWatchedItems = async (userId: string): Promise => { const supabaseClient = ensureSupabase(); const data = await handleResponse<{ master_item: MasterGroceryItem & { category: { name: string } | null } }[] | null>(supabaseClient .from('user_watched_items') .select(` master_item:master_grocery_items ( *, category:categories (name) ) `) .eq('user_id', userId) .order('name', { referencedTable: 'master_grocery_items', ascending: true })); return (data?.map(item => ({ ...item.master_item, category_name: item.master_item.category?.name })) ?? []) as MasterGroceryItem[]; }; /** * Retrieves all master grocery items. This is used for matching during extraction. * @returns An array of master grocery item objects. */ export const getAllMasterItems = async (): Promise => { const supabaseClient = ensureSupabase(); // Define the expected raw type from the query, including the nested category object. type MasterItemWithCategory = Database['public']['Tables']['master_grocery_items']['Row'] & { category_name: { name: string } | null; }; // Fetch the data using the raw, auto-generated type from Supabase. const rawData = await handleResponse(supabaseClient .from('master_grocery_items') .select('*, category_name:categories(name)') .order('name', { ascending: true })); // Explicitly map the raw data to our application's `MasterGroceryItem` type, // flattening the nested category name in the process. return (rawData?.map(item => ({ ...item, category_name: item.category_name?.name, })) ?? []) as MasterGroceryItem[]; // The final cast is safe as we've constructed the correct shape. }; /** * Adds a new item to a user's watchlist. * It first ensures the master item exists, then creates the user-item link. * @param userId The UUID of the user. * @param itemName The name of the item to add. * @param category The category of the item. * @returns The master item object that was added to the watchlist. */ export const addWatchedItem = async (userId: string, itemName: string, category: string): Promise => { const supabaseClient = ensureSupabase(); try { // 1. Find or create the category let categoryData = await handleResponse<{ id: number } | null>( supabaseClient .from('categories') .select('id') .eq('name', category) .maybeSingle() ); if (!categoryData) { categoryData = await handleResponse<{ id: number } | null>( supabaseClient .from('categories') .insert({ name: category }) .select('id') .single() ); } // 2. Upsert the master item to ensure it exists and get its ID const { data: masterItem, error: masterItemError } = await supabaseClient .from('master_grocery_items') .upsert({ name: itemName.trim(), category_id: categoryData.id }, { onConflict: 'name' }) .select('*, category_name:categories(name)') .single(); if (masterItemError) throw new Error(`Failed to upsert master item: ${masterItemError.message}`); if (!masterItem) throw new Error("Master item operation did not return data."); // 3. Create the link in user_watched_items const { error: watchLinkError } = await supabaseClient .from('user_watched_items') .insert({ user_id: userId, master_item_id: masterItem.id }); // Ignore duplicate errors (user already watching), throw others if (watchLinkError && watchLinkError.code !== '23505') { // 23505 is unique_violation throw new Error(`Failed to add item to watchlist: ${watchLinkError.message}`); } else if (watchLinkError) { // This block is now valid because of the preceding `if`. // Log the ignored duplicate error for debugging purposes if needed. logger.debug(`Ignored duplicate watch item error for user ${userId} and item ${masterItem.id}`); } // 4. Return the full master item object for UI update. // We explicitly construct the object to match the `MasterGroceryItem` type // by flattening the nested `category_name` object into a string. return { id: masterItem.id, created_at: masterItem.created_at, name: masterItem.name, category_id: masterItem.category_id, category_name: masterItem.category_name?.name ?? null, }; } catch (error) { // This catch block is now valid because of the preceding `try`. const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred in addWatchedItem.'; throw new Error(`Failed to add watched item: ${errorMessage}`); } }; /** * 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. */ export const removeWatchedItem = async (userId: string, masterItemId: number): Promise => { const supabaseClient = ensureSupabase(); const { error } = await supabaseClient .from('user_watched_items') .delete() .eq('user_id', userId) .eq('master_item_id', masterItemId); if (error) { throw new Error(`Failed to remove watched item: ${error.message}`); } }; /** * Fetches all flyer items for a given list of flyer IDs. * @param flyerIds An array of flyer IDs. * @returns An array of flyer item objects. */ export const getFlyerItemsForFlyers = async (flyerIds: number[]): Promise => { const supabaseClient = ensureSupabase(); if (flyerIds.length === 0) return []; // Fetch the data using the raw, auto-generated type from Supabase. const rawData = await handleResponse(supabaseClient .from('flyer_items') .select('*') .in('flyer_id', flyerIds)); if (!rawData) { return []; } // Explicitly map the raw data to our application's `FlyerItem` type, // using the type guard to safely handle the `unit_price` conversion. return rawData.map(item => ({ ...item, unit_price: isUnitPrice(item.unit_price) ? item.unit_price : null, })); }; /** * Counts the total number of items across a list of flyers. * @param flyerIds An array of flyer IDs. * @returns The total count of items. */ export const countFlyerItemsForFlyers = async (flyerIds: number[]): Promise => { const supabaseClient = ensureSupabase(); if (flyerIds.length === 0) return 0; const { count, error } = await supabaseClient .from('flyer_items') .select('*', { count: 'exact', head: true }) .in('flyer_id', flyerIds); if (error) throw new Error(`Error counting items for flyers: ${error.message}`); return count || 0; }; /** * Fetches all flyer items corresponding to a user's watched items to build a price history. * @param watchedItems An array of master grocery items. * @returns An array of historical price data points. */ export const getHistoricalWatchedItems = async (watchedItems: MasterGroceryItem[]): Promise[]> => { const supabaseClient = ensureSupabase(); if (watchedItems.length === 0) return []; const watchedItemIds = watchedItems.map(item => item.id); const data = await handleResponse[] | null>(supabaseClient .from('flyer_items') .select('master_item_id, price_in_cents, created_at') .in('master_item_id', watchedItemIds) .not('price_in_cents', 'is', null) .order('created_at', { ascending: true })); return data ?? []; }; /** * Fetches a user's profile from the database. * @param userId The UUID of the user. * @returns The user's profile object. */ export const getUserProfile = async (userId: string): Promise => { const supabaseClient = ensureSupabase(); const data = await handleResponse(supabaseClient .from('profiles') .select('*, role') .eq('id', userId) .single()); if (!data) { // handleResponse returns null if no data console.error("Error fetching user profile: No data returned."); return null; } // Cast the result back to the expected application type return data as Profile | null; }; /** * Updates a user's profile information. * @param userId The UUID of the user. * @param updates The profile fields to update. * @returns The updated profile object. */ export const updateUserProfile = async (userId: string, updates: { full_name?: string; avatar_url?: string }): Promise => { const supabaseClient = ensureSupabase(); const data = await handleResponse(supabaseClient .from('profiles') // Cast to `any` to handle potential JSONB type mismatches if more fields are added .update(updates) .eq('id', userId) .select() .single()); return data as Profile; // Cast to application's Profile type }; /** * Updates a user's preferences. * @param userId The UUID of the user. * @param preferences The preferences object to save. * @returns The updated profile object. */ export const updateUserPreferences = async (userId: string, preferences: Profile['preferences']): Promise => { const supabaseClient = ensureSupabase(); const data = await handleResponse(supabaseClient .from('profiles') // Cast to `any` to handle the JSONB type mismatch for `preferences` .update({ preferences: preferences as Json }) .eq('id', userId) .select().single() ); return data as Profile; // Cast is safe here because we expect a single result }; /** * Updates the authenticated user's password. * @param newPassword The new password. */ export const updateUserPassword = async (newPassword: string): Promise => { const supabaseClient = ensureSupabase(); const { error } = await supabaseClient.auth.updateUser({ password: newPassword }); if (error) throw new Error(`Error updating password: ${error.message}`); }; /** * Gathers all data for a specific user for export. * @param userId The UUID of the user. * @returns An object containing all of the user's data. */ type WatchedItemExport = { created_at: string; item: { name: string; category: { name: string } | null } | null }; type ShoppingListExport = { name: string; created_at: string; items: { custom_item_name: string | null; quantity: number; is_purchased: boolean; master_item: { name: string } | null }[] }; interface UserDataExport { profile: Profile | null; watchedItems: WatchedItemExport[] | null; shoppingLists: ShoppingListExport[] | null; } export const exportUserData = async (userId: string): Promise => { const supabaseClient = ensureSupabase(); const profile = await handleResponse(supabaseClient .from('profiles') .select('*') .eq('id', userId) .single()); const watchedItems = await handleResponse(supabaseClient .from('user_watched_items') .select('created_at, item:master_grocery_items(name, category:categories(name))') .eq('user_id', userId)); const shoppingLists = await handleResponse(supabaseClient .from('shopping_lists') .select('name, created_at, items:shopping_list_items(custom_item_name, quantity, is_purchased, master_item:master_grocery_items(name))') .eq('user_id', userId)); return { profile, watchedItems, shoppingLists, }; }; /** * Deletes the current user's account by invoking a secure Edge Function. * @param password The user's current password for verification. */ export const deleteUserAccount = async (password: string): Promise => { // Adding detailed logging to trace who is calling this function. console.trace("deleteUserAccount called. This trace will show the call stack."); const supabaseClient = ensureSupabase(); // Invoking the 'delete-user' edge function. const { data, error } = await supabaseClient.functions.invoke('delete-user', { body: { password }, }); if (error) { let errorDetails = `Edge Function returned an error: ${error.message}.`; if (error.context) { try { const errorBody = await error.context.json(); const message = errorBody.error || 'No error message in body.'; const stack = errorBody.stack || 'No stack trace in body.'; errorDetails = `Error: ${message}\n\nStack Trace:\n${stack}`; } catch { errorDetails += `\nCould not parse error response body.`; } } throw new Error(errorDetails); } if (data?.error) throw new Error(data.error); }; /** * Calls the `system-check` Edge Function to verify the backend setup. * @returns The results of the system checks. */ export const invokeSystemCheckFunction = async (): Promise => { const supabaseClient = ensureSupabase(); const { data, error } = await supabaseClient.functions.invoke('system-check'); if (error) { let errorDetails = `System check function failed: ${error.message}.`; if (error.message.includes("Not found")) { errorDetails = "The 'system-check' Edge Function is not deployed. Please follow the instructions in the README to deploy it."; } else if (error.context) { try { const errorBody = await error.context.json(); errorDetails += `\nDetails: ${errorBody.error || 'Unknown error'}`; } catch { /* ignore parsing error */ } } throw new Error(errorDetails); } if (data?.error) throw new Error(data.error); return data.results; }; /** * Creates the initial development users by invoking a secure Edge Function. */ export const invokeSeedDatabaseFunction = async (): Promise<{ message: string; }> => { const supabaseClient = ensureSupabase(); const { data, error } = await supabaseClient.functions.invoke('seed-database'); if (error) { let errorDetails = `Edge Function returned a non-2xx status code: ${error.message}.`; if (error.context) { try { const errorBody = await error.context.json(); const message = errorBody.error || 'No error message in body.'; const stack = errorBody.stack || 'No stack trace in body.'; errorDetails = `Error: ${message}\n\nStack Trace:\n${stack}`; } catch { errorDetails += `\nCould not parse error response body. Raw response might be in browser network tab.`; } } throw new Error(errorDetails); } if (data?.error) throw new Error(data.error); return data; }; /** * Fetches all pending suggested corrections from the database. * This is an admin-only function. * @returns An array of suggested correction objects with related user and item data. */ export const getSuggestedCorrections = async (): Promise => { const supabaseClient = ensureSupabase(); // Define a more specific type for the query result to avoid `any`. type CorrectionWithRelations = SuggestedCorrection & { user: { email: string | null } | null; item: { item: string; price_display: string } | null; }; // Re-writing this query explicitly to avoid a complex type-inference issue // that was causing the TypeScript language server to report a phantom syntax error. const query = supabaseClient .from('suggested_corrections') .select(` *, user:profiles(email), item:flyer_items(item, price_display) `) .eq('status', 'pending') .order('created_at', { ascending: true }); const { data, error } = await query; if (error) { throw new Error(`Failed to get suggested corrections: ${error.message}`); } // The data can be null if the query returns no rows, so we handle that case. if (!data) { return []; } return (data as unknown as CorrectionWithRelations[]).map((c) => ({ ...c, user_email: c.user?.email ?? 'Unknown', flyer_item_name: c.item?.item ?? 'Unknown Item', flyer_item_price_display: c.item?.price_display ?? 'N/A' })) as SuggestedCorrection[]; }; /** * Approves a suggested correction by calling the `approve_correction` RPC. * This is an admin-only function. * @param correctionId The ID of the correction to approve. */ export const approveCorrection = async (correctionId: number): Promise => { const supabaseClient = ensureSupabase(); const { error } = await supabaseClient.rpc('approve_correction', { p_correction_id: correctionId }); if (error) { throw new Error(`Failed to approve correction: ${error.message}`); } }; /** * Rejects a suggested correction. * This is an admin-only function. * @param correctionId The ID of the correction to reject. */ export const rejectCorrection = async (correctionId: number): Promise => { const supabaseClient = ensureSupabase(); const { error } = await supabaseClient.from('suggested_corrections').update({ status: 'rejected', reviewed_at: new Date().toISOString() }).eq('id', correctionId); if (error) throw new Error(`Failed to reject correction: ${error.message}`); }; // ============================================= // SHOPPING LIST FUNCTIONS // ============================================= /** * Fetches all shopping lists for a user, including their items. * @param userId The UUID of the user. * @returns An array of shopping list objects. */ export const getShoppingLists = async (userId: string): Promise => { const supabaseClient = ensureSupabase(); const data = await handleResponse( supabaseClient .from('shopping_lists') .select(` *, items:shopping_list_items ( *, master_item:master_grocery_items (name) ) `) .eq('user_id', userId) .order('created_at', { ascending: true }) .order('added_at', { ascending: true, referencedTable: 'shopping_list_items' }) ); return data ?? []; }; /** * Creates a new shopping list for a user. * @param userId The UUID of the user. * @param name The name of the new list. * @returns The newly created shopping list object. */ export const createShoppingList = async (userId: string, name: string): Promise => { const supabaseClient = ensureSupabase(); const data = await handleResponse(supabaseClient .from('shopping_lists') .insert({ user_id: userId, name }) .select() .single()); return { ...data, items: [] }; // Return with empty items array }; /** * Deletes a shopping list. * @param listId The ID of the list to delete. */ export const deleteShoppingList = async (listId: number): Promise => { const supabaseClient = ensureSupabase(); const { error } = await supabaseClient .from('shopping_lists') .delete() .eq('id', listId); if (error) throw new Error(`Error deleting shopping list: ${error.message}`); }; /** * Adds an item to a shopping list. * @param listId The ID of the list. * @param masterItemId Optional ID of the master grocery item. * @param customItemName Optional name for a custom item. * @returns The newly created shopping list item. */ export const addShoppingListItem = async (listId: number, { masterItemId, customItemName }: { masterItemId?: number; customItemName?: string }): Promise => { const supabaseClient = ensureSupabase(); if (!masterItemId && !customItemName) { throw new Error("Either masterItemId or customItemName must be provided."); } let query; if (masterItemId) { const itemToUpsert = { shopping_list_id: listId, master_item_id: masterItemId, custom_item_name: null, // Ensure the object shape is complete for the type checker quantity: 1, }; query = supabaseClient.from('shopping_list_items').upsert(itemToUpsert, { onConflict: 'shopping_list_id, master_item_id' }); } else { const itemToInsert = { shopping_list_id: listId, master_item_id: null, // Ensure the object shape is complete custom_item_name: customItemName, quantity: 1, }; query = supabaseClient.from('shopping_list_items').insert(itemToInsert); } // Bypassing the generic `handleResponse` helper to resolve the complex type inference issue. const { data, error } = await query.select('*, master_item:master_grocery_items(name)').single(); if (error) throw new Error(`Failed to add shopping list item: ${error.message}`); if (!data) throw new Error("Adding shopping list item did not return data."); return data as ShoppingListItem; }; /** * Updates a shopping list item. * @param itemId The ID of the item to update. * @param updates The fields to update (e.g., is_purchased, quantity). * @returns The updated shopping list item. */ export const updateShoppingListItem = async (itemId: number, updates: Partial>): Promise => { const supabaseClient = ensureSupabase(); // The 'id' and 'master_item' cannot be part of the update payload. const { ...updateData } = updates; const data = await handleResponse(supabaseClient .from('shopping_list_items') .update(updateData) .eq('id', itemId) .select('*, master_item:master_grocery_items(name)') .single()); return data as ShoppingListItem; }; /** * Removes an item from a shopping list. * @param itemId The ID of the item to remove. */ export const removeShoppingListItem = async (itemId: number): Promise => { const supabaseClient = ensureSupabase(); const { error } = await supabaseClient .from('shopping_list_items') .delete() .eq('id', itemId); if (error) throw new Error(`Error removing shopping list item: ${error.message}`); };