import { createClient, SupabaseClient } from '@supabase/supabase-js'; import type { Flyer, FlyerItem, MasterGroceryItem, Profile, ShoppingList, ShoppingListItem } from '../types'; export let supabase: SupabaseClient | null = null; // Attempt to initialize from environment variables const supabaseUrl = process.env.REACT_APP_SUPABASE_URL; const supabaseAnonKey = process.env.REACT_APP_SUPABASE_ANON_KEY; if (supabaseUrl && supabaseAnonKey) { try { supabase = createClient(supabaseUrl, supabaseAnonKey); } catch (e) { console.error("Failed to initialize Supabase from env vars:", e); supabase = null; } } /** * Initializes the Supabase client. Can be called with user-provided credentials. * @param url - The Supabase project URL. * @param key - The Supabase anon key. * @returns The Supabase client instance. */ export const initializeSupabase = (url: string, key: string): SupabaseClient => { if (!supabase) { supabase = createClient(url, key); } return supabase; }; /** * Disconnects the Supabase client by setting the instance to null. */ export const disconnectSupabase = () => { 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 }> => { if (!supabase) return { success: false, error: 'Supabase client not initialized.' }; try { const { error } = await supabase.from('stores').select('id').limit(1); if (error) throw error; return { success: true, error: null }; } catch (error: any) { return { success: false, error: `Database connection test failed: ${error.message}. Check RLS policies.` }; } }; /** * 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 }> => { if (!supabase) return { success: false, error: 'Supabase client not initialized.' }; const testItem = { item: `DB_SELF_TEST_ITEM_${Date.now()}`, price_display: '$0.00', quantity: 'test', }; try { // 1. Insert const { data: insertData, error: insertError } = await supabase .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 supabase .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 supabase .from('flyer_items') .delete() .eq('id', testItemId); if (deleteError) throw new Error(`Delete failed: ${deleteError.message}`); return { success: true, error: null }; } catch (error: any) { return { success: false, error: `Database self-test failed: ${error.message}. 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 }> => { if (!supabase) return { success: false, error: 'Supabase client not initialized.' }; const bucketName = 'flyers'; const testFileName = `storage-self-test-${Date.now()}.txt`; const testFileContent = 'test'; try { // 1. Upload const { error: uploadError } = await supabase.storage .from(bucketName) .upload(testFileName, testFileContent); if (uploadError) throw new Error(`Upload to storage failed: ${uploadError.message}`); // 2. Delete const { error: deleteError } = await supabase.storage .from(bucketName) .remove([testFileName]); if (deleteError) throw new Error(`Deleting from storage failed: ${deleteError.message}`); return { success: true, error: null }; } catch (error: any) { return { success: false, error: `Storage connection test failed: ${error.message}. 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 => { if (!supabase) throw new Error("Supabase client not initialized"); const fileName = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}`; const { data, error } = await supabase.storage .from('flyers') .upload(fileName, file); if (error) throw new Error(`Failed to upload flyer image: ${error.message}`); const { data: { publicUrl } } = supabase.storage.from('flyers').getPublicUrl(data.path); 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 => { if (!supabase) throw new Error("Supabase client not initialized"); let { data: store } = await supabase .from('stores') .select('*') .ilike('name', storeName) .single(); if (!store) { const { data: newStore, error: newStoreError } = await supabase .from('stores') .insert({ name: storeName }) .select() .single(); if (newStoreError) throw new Error(`Error creating store: ${newStoreError.message}`); store = newStore; } 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 => { if (!supabase) throw new Error("Supabase client not initialized"); if (items.length === 0) return []; const itemsToInsert = items.map(item => ({ ...item, flyer_id: flyerId })); const { data: savedItems, error } = await supabase .from('flyer_items') .insert(itemsToInsert) .select(); if (error) throw new Error(`Failed to save flyer items: ${error.message}`); return savedItems; }; /** * Retrieves all flyers from the database, ordered by most recent. * @returns An array of flyer objects. */ export const getFlyers = async (): Promise => { if (!supabase) return []; const { data, error } = await supabase .from('flyers') .select('*, store:stores(*)') .order('created_at', { ascending: false }); if (error) throw new Error(`Failed to get flyers: ${error.message}`); 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 => { if (!supabase) return []; const { data, error } = await supabase .from('flyer_items') .select('*') .eq('flyer_id', flyerId) .order('item', { ascending: true }); if (error) throw new Error(`Failed to get flyer items: ${error.message}`); return data || []; }; /** * 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 => { if (!supabase) return null; const { data, error } = await supabase .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 => { if (!supabase) { console.warn("Cannot upload logo: Supabase client not initialized."); return null; } 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 supabase.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: { publicUrl } } = supabase.storage.from('flyers').getPublicUrl(data.path); 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 supabase .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: any) { console.warn(`An error occurred during logo processing: ${e.message}`); 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 => { if (!supabase) return []; const { data, error } = await supabase .from('user_watched_items') .select('master_grocery_items(*, category_name:categories(name))') .eq('user_id', userId) .order('name', { ascending: true, referencedTable: 'master_grocery_items' }); if (error) throw new Error(`Error fetching watched items: ${error.message}`); return (data || []).map((item: any) => ({ ...item.master_grocery_items, category_name: item.master_grocery_items.category_name?.name, })); }; /** * 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 => { if (!supabase) return []; const { data, error } = await supabase .from('master_grocery_items') .select('*, category_name:categories(name)') .order('name', { ascending: true }); if (error) throw new Error(`Error fetching master items: ${error.message}`); return (data || []).map(item => ({ ...item, category_name: (item.category_name as any)?.name, })); }; /** * 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 => { if (!supabase) throw new Error("Supabase client not initialized"); // 1. Find or create the category let { data: categoryData } = await supabase .from('categories') .select('id') .eq('name', category) .single(); if (!categoryData) { const { data: newCategoryData, error: newCategoryError } = await supabase .from('categories') .insert({ name: category }) .select('id') .single(); if (newCategoryError) throw new Error(`Error creating category: ${newCategoryError.message}`); categoryData = newCategoryData; } // 2. Upsert the master item to ensure it exists and get its ID const { data: masterItem, error: masterItemError } = await supabase .from('master_grocery_items') .upsert({ name: itemName.trim(), category_id: categoryData.id }, { onConflict: 'name', ignoreDuplicates: false }) .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 supabase .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') { throw new Error(`Failed to add item to watchlist: ${watchLinkError.message}`); } // 4. Return the full master item object for UI update return { ...masterItem, category_name: (masterItem.category_name as any)?.name, }; }; /** * 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 => { if (!supabase) throw new Error("Supabase client not initialized"); const { error } = await supabase .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 => { if (!supabase || flyerIds.length === 0) return []; const { data, error } = await supabase .from('flyer_items') .select('*') .in('flyer_id', flyerIds); if (error) throw new Error(`Error fetching items for flyers: ${error.message}`); return data || []; }; /** * 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 => { if (!supabase || flyerIds.length === 0) return 0; const { count, error } = await supabase .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; }; /** * Loads historical price data for watched items. * @param watchedItems An array of master grocery items. * @returns An array of historical price data points. */ export const loadAllHistoricalItems = async (watchedItems: MasterGroceryItem[]): Promise[]> => { if (!supabase || watchedItems.length === 0) return []; const watchedItemIds = watchedItems.map(item => item.id); const { data, error } = await supabase .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 }); if (error) throw new Error(`Error loading historical items: ${error.message}`); 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 => { if (!supabase) throw new Error("Supabase client not initialized"); const { data, error } = await supabase .from('profiles') .select('*') .eq('id', userId) .single(); if (error) { console.error("Error fetching user profile:", error.message); return null; } return data; }; /** * 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 => { if (!supabase) throw new Error("Supabase client not initialized"); const { data, error } = await supabase .from('profiles') .update(updates) .eq('id', userId) .select() .single(); if (error) throw new Error(`Error updating profile: ${error.message}`); return data; }; /** * 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 => { if (!supabase) throw new Error("Supabase client not initialized"); const { data, error } = await supabase .from('profiles') .update({ preferences }) .eq('id', userId) .select() .single(); if (error) throw new Error(`Error updating preferences: ${error.message}`); return data; }; /** * Updates the authenticated user's password. * @param newPassword The new password. */ export const updateUserPassword = async (newPassword: string): Promise => { if (!supabase) throw new Error("Supabase client not initialized"); const { error } = await supabase.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. */ export const exportUserData = async (userId: string): Promise => { if (!supabase) throw new Error("Supabase client not initialized"); const { data: profile, error: profileError } = await supabase .from('profiles') .select('*') .eq('id', userId) .single(); if (profileError) throw new Error(`Could not fetch profile: ${profileError.message}`); const { data: watchedItems, error: watchedItemsError } = await supabase .from('user_watched_items') .select('created_at, item:master_grocery_items(name, category:categories(name))') .eq('user_id', userId); if (watchedItemsError) throw new Error(`Could not fetch watched items: ${watchedItemsError.message}`); const { data: shoppingLists, error: shoppingListsError } = await supabase .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); if (shoppingListsError) throw new Error(`Could not fetch shopping lists: ${shoppingListsError.message}`); 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 => { if (!supabase) throw new Error("Supabase client not initialized"); const { data, error } = await supabase.functions.invoke('delete-user', { body: { password }, }); if (error) { let errorDetails = `Edge Function returned an error: ${error.message}.`; 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 (parseError) { 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 => { if (!supabase) throw new Error("Supabase client not initialized"); const { data, error } = await supabase.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 { try { const errorBody = await error.context.json(); errorDetails += `\nDetails: ${errorBody.error || 'Unknown error'}`; } catch (e) { /* ignore */ } } 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 }> => { if (!supabase) throw new Error("Supabase client not initialized"); const { data, error } = await supabase.functions.invoke('seed-database'); if (error) { let errorDetails = `Edge Function returned a non-2xx status code: ${error.message}.`; 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 (parseError) { 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; }; // ============================================= // 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 => { if (!supabase) return []; const { data, error } = await supabase .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' }); if (error) throw new Error(`Error fetching shopping lists: ${error.message}`); 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 => { if (!supabase) throw new Error("Supabase client not initialized"); const { data, error } = await supabase .from('shopping_lists') .insert({ user_id: userId, name }) .select() .single(); if (error) throw new Error(`Error creating shopping list: ${error.message}`); 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 => { if (!supabase) throw new Error("Supabase client not initialized"); const { error } = await supabase .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 => { if (!supabase) throw new Error("Supabase client not initialized"); if (!masterItemId && !customItemName) throw new Error("Either masterItemId or customItemName must be provided."); const itemToInsert = { shopping_list_id: listId, master_item_id: masterItemId, custom_item_name: customItemName, quantity: 1 }; // Use upsert to handle potential duplicates of master items gracefully const query = supabase.from('shopping_list_items').upsert(itemToInsert, { onConflict: 'shopping_list_id, master_item_id', ignoreDuplicates: masterItemId ? false : true // Only upsert quantity for master items }).select('*, master_item:master_grocery_items(name)').single(); const { data, error } = await query; if (error) throw new Error(`Error adding shopping list item: ${error.message}`); return data; }; /** * 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 => { if (!supabase) throw new Error("Supabase client not initialized"); const { data, error } = await supabase .from('shopping_list_items') .update(updates) .eq('id', itemId) .select('*, master_item:master_grocery_items(name)') .single(); if (error) throw new Error(`Error updating shopping list item: ${error.message}`); return data; }; /** * Removes an item from a shopping list. * @param itemId The ID of the item to remove. */ export const removeShoppingListItem = async (itemId: number): Promise => { if (!supabase) throw new Error("Supabase client not initialized"); const { error } = await supabase .from('shopping_list_items') .delete() .eq('id', itemId); if (error) throw new Error(`Error removing shopping list item: ${error.message}`); };