Files
flyer-crawler.projectium.com/services/supabaseClient.ts

807 lines
29 KiB
TypeScript

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<string> => {
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<Flyer> => {
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<FlyerItem, 'id' | 'created_at' | 'flyer_id'>[], flyerId: number): Promise<FlyerItem[]> => {
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<Flyer[]> => {
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<FlyerItem[]> => {
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<Flyer | null> => {
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<string | null> => {
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<MasterGroceryItem[]> => {
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<MasterGroceryItem[]> => {
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<MasterGroceryItem> => {
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<void> => {
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<FlyerItem[]> => {
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<number> => {
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<Pick<FlyerItem, 'master_item_id' | 'price_in_cents' | 'created_at'>[]> => {
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<Profile | null> => {
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<Profile> => {
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<Profile> => {
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<void> => {
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<object> => {
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<void> => {
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<any> => {
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<ShoppingList[]> => {
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<ShoppingList> => {
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<void> => {
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<ShoppingListItem> => {
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<ShoppingListItem>): Promise<ShoppingListItem> => {
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<void> => {
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}`);
};