diff --git a/src/services/userPointsService.ts b/src/services/userPointsService.ts index 369af6b..8725b4c 100644 --- a/src/services/userPointsService.ts +++ b/src/services/userPointsService.ts @@ -3,7 +3,7 @@ import supabase, { supabaseAdmin } from './supabaseClient'; import { generateImageHash } from './imageHashService'; import { v4 as uuidv4, v5 as uuidv5, validate as validateUuid } from 'uuid'; -// Namespace for deterministic UUID generation (using v5) +// Namespace for deterministic UUID generation (using v5) - as fallback only const UUID_NAMESPACE = '1b671a64-40d5-491e-99b0-da01ff1f3341'; // Point values for different actions @@ -12,20 +12,63 @@ export const POINTS = { }; /** - * Ensure user ID is a valid UUID + * Ensure a user exists in the database */ -function ensureUuid(userId: string): string { +export async function ensureUserExists(userId: string, email: string, name?: string) { try { - // Validate that the userId is a valid UUID format - if (validateUuid(userId)) { - return userId; - } else { - // Generate a deterministic UUID v5 from the string userId - return uuidv5(userId, UUID_NAMESPACE); + if (!userId) { + console.warn('Cannot ensure user exists with undefined userId'); + return null; } + + console.log('Ensuring user exists:', { userId, email }); + + // IMPORTANT: Do NOT generate a new UUID, only validate format + if (!validateUuid(userId)) { + console.warn('Invalid UUID format for user:', userId); + return null; + } + + // Call the Supabase function to ensure user exists + const { data, error } = await supabaseAdmin.rpc('ensure_user_exists', { + user_id_param: userId, + email_param: email, + name_param: name || null + }); + + if (error) { + console.error('Error ensuring user exists:', error); + + // Fallback to direct insert + try { + const { data: insertData, error: insertError } = await supabaseAdmin + .from('users') + .insert({ + id: userId, + email: email, + name: name || null, + first_login: new Date().toISOString(), + last_login: new Date().toISOString() + }) + .onConflict('id') + .merge({ last_login: new Date().toISOString() }); + + if (insertError) { + console.error('Error inserting user:', insertError); + return null; + } + + return userId; + } catch (err) { + console.error('Error in fallback user creation:', err); + return null; + } + } + + return data; } catch (error) { - console.log('Error processing UUID, generating a new one:', error); - return uuidv5(userId, UUID_NAMESPACE); + console.error('Unexpected error in ensureUserExists:', error); + return null; } } @@ -49,19 +92,34 @@ export async function awardPointsForImage( }; } + // Validate user ID format + if (!validateUuid(userId)) { + console.warn('Invalid UUID format for user:', userId); + return { + success: false, + message: 'Invalid user ID format' + }; + } + console.log('Award points starting values:', { userId, email }); - // Convert userId to UUID format - const userIdUUID = ensureUuid(userId); - console.log(`Using UUID ${userIdUUID} for user ${userId}`); + // Ensure user exists in database + const userUuid = await ensureUserExists(userId, email); + if (!userUuid) { + console.error('Failed to ensure user exists'); + return { + success: false, + message: 'Failed to register user' + }; + } // Generate hash for the image const imageHash = generateImageHash(imageBuffer); - console.log(`Processing points for image ${imageHash.substring(0, 8)}... for user ${userIdUUID}`); + console.log(`Processing points for image ${imageHash.substring(0, 8)}... for user ${userId}`); // Check if this image has already been awarded points const { data: existingImage, error: checkError } = await supabaseAdmin.rpc('check_duplicate_upload', { - user_id_param: userIdUUID, + user_id_param: userId, image_hash_param: imageHash }); @@ -73,7 +131,7 @@ export async function awardPointsForImage( .from('point_transactions') .select('*') .eq('image_hash', imageHash) - .eq('user_id', userIdUUID) + .eq('user_id', userId) .maybeSingle(); if (!directError && existingTransaction) { @@ -94,7 +152,7 @@ export async function awardPointsForImage( // Create transaction using RPC const { data: transactionData, error: rpcError } = await supabaseAdmin .rpc('create_point_transaction', { - user_id_param: userIdUUID, + user_id_param: userId, points_param: POINTS.IMAGE_UPLOAD, transaction_type_param: 'image_upload', image_hash_param: imageHash, @@ -110,7 +168,7 @@ export async function awardPointsForImage( const { data: insertData, error: insertError } = await supabaseAdmin .from('point_transactions') .insert({ - user_id: userIdUUID, + user_id: userId, points: POINTS.IMAGE_UPLOAD, transaction_type: 'image_upload', image_hash: imageHash, @@ -148,6 +206,42 @@ export async function awardPointsForImage( } } +/** + * Find a user by email + */ +export async function findUserByEmail(email: string) { + if (!email) return null; + + try { + const { data, error } = await supabaseAdmin.rpc('find_user_by_email', { + email_param: email + }); + + if (error || !data) { + console.log('Error or no data finding user by email:', error); + + // Fallback to direct query + const { data: directData, error: directError } = await supabaseAdmin + .from('users') + .select('*') + .eq('email', email) + .maybeSingle(); + + if (directError || !directData) { + console.log('Error or no data in direct user lookup:', directError); + return null; + } + + return directData; + } + + return data; + } catch (error) { + console.error('Error in findUserByEmail:', error); + return null; + } +} + /** * Get a user's total points */ @@ -160,58 +254,52 @@ export async function getUserPoints(userId: string) { console.log('Fetching points for user:', userId); - // Generate potential IDs to try - const identifiersToTry = [userId]; - - // If userId is an email, add a UUID version - if (userId.includes('@')) { - const emailUuid = uuidv5(userId, UUID_NAMESPACE); - identifiersToTry.push(emailUuid); - } - // If it's not a UUID, generate one - else if (!validateUuid(userId)) { - const generatedUuid = uuidv5(userId, UUID_NAMESPACE); - identifiersToTry.push(generatedUuid); + // If the userId is not a UUID and looks like an email, try to find by email + if (!validateUuid(userId) && userId.includes('@')) { + const user = await findUserByEmail(userId); + if (user) { + userId = user.id; + console.log('Found user by email, using ID:', userId); + } else { + console.log('No user found by email, returning 0 points'); + return 0; + } } - console.log('Will try these identifiers for points calculation:', identifiersToTry); + // Validate final user ID format + if (!validateUuid(userId)) { + console.warn('Invalid UUID format for user:', userId); + return 0; + } // Try getting total points using the RPC function - for (const idToTry of identifiersToTry) { - const userUuid = validateUuid(idToTry) ? idToTry : uuidv5(idToTry, UUID_NAMESPACE); + try { + const { data: totalPoints, error } = await supabaseAdmin.rpc('get_user_total_points', { + user_id_param: userId + }); - try { - const { data: totalPoints, error } = await supabaseAdmin.rpc('get_user_total_points', { - user_id_param: userUuid - }); - - if (!error) { - console.log(`Total points for user ${userUuid}: ${totalPoints}`); - return totalPoints || 0; - } - } catch (err) { - console.log(`Error getting points for ID ${userUuid}:`, err); + if (!error) { + console.log(`Total points for user ${userId}: ${totalPoints}`); + return totalPoints || 0; } + } catch (err) { + console.log(`Error getting points for ID ${userId}:`, err); } // Fallback to direct calculation - for (const idToTry of identifiersToTry) { - const userUuid = validateUuid(idToTry) ? idToTry : uuidv5(idToTry, UUID_NAMESPACE); - - try { - const { data: transactions, error } = await supabaseAdmin - .from('point_transactions') - .select('points') - .eq('user_id', userUuid); - - if (!error && transactions && transactions.length > 0) { - const totalPoints = transactions.reduce((sum, tx) => sum + (tx.points || 0), 0); - console.log(`Calculated ${totalPoints} points from ${transactions.length} transactions`); - return totalPoints; - } - } catch (err) { - console.log(`Error calculating points for ID ${userUuid}:`, err); + try { + const { data: transactions, error } = await supabaseAdmin + .from('point_transactions') + .select('points') + .eq('user_id', userId); + + if (!error && transactions && transactions.length > 0) { + const totalPoints = transactions.reduce((sum, tx) => sum + (tx.points || 0), 0); + console.log(`Calculated ${totalPoints} points from ${transactions.length} transactions`); + return totalPoints; } + } catch (err) { + console.log(`Error calculating points for ID ${userId}:`, err); } // If all methods fail, return 0 @@ -234,51 +322,52 @@ export async function getUserTransactions(userId: string) { console.log('Fetching transactions for user:', userId); - // Generate potential IDs to try - const identifiersToTry = [userId]; - - // If userId is an email, add a UUID version - if (userId.includes('@')) { - const emailUuid = uuidv5(userId, UUID_NAMESPACE); - identifiersToTry.push(emailUuid); - } - // If it's not a UUID, generate one - else if (!validateUuid(userId)) { - const generatedUuid = uuidv5(userId, UUID_NAMESPACE); - identifiersToTry.push(generatedUuid); + // If the userId is not a UUID and looks like an email, try to find by email + if (!validateUuid(userId) && userId.includes('@')) { + const user = await findUserByEmail(userId); + if (user) { + userId = user.id; + console.log('Found user by email, using ID:', userId); + } else { + console.log('No user found by email, returning empty transactions'); + return []; + } } - console.log('Will try these identifiers for transaction lookup:', identifiersToTry); + // Validate final user ID format + if (!validateUuid(userId)) { + console.warn('Invalid UUID format for user:', userId); + return []; + } - // Try each identifier - for (const idToTry of identifiersToTry) { - const userUuid = validateUuid(idToTry) ? idToTry : uuidv5(idToTry, UUID_NAMESPACE); + // Try the RPC function first + try { + const { data: transactions, error: rpcError } = await supabaseAdmin.rpc('get_user_transactions', { + user_id_param: userId + }); - try { - // Try the RPC function first - const { data: transactions, error: rpcError } = await supabaseAdmin.rpc('get_user_transactions', { - user_id_param: userUuid - }); - - if (!rpcError && transactions && transactions.length > 0) { - console.log(`Found ${transactions.length} transactions for ${userUuid} via RPC`); - return transactions; - } - - // Fallback to direct query - const { data: directTransactions, error } = await supabaseAdmin - .from('point_transactions') - .select('*') - .eq('user_id', userUuid) - .order('created_at', { ascending: false }); - - if (!error && directTransactions && directTransactions.length > 0) { - console.log(`Found ${directTransactions.length} transactions for ${userUuid} via direct query`); - return directTransactions; - } - } catch (err) { - console.log(`Error getting transactions for ID ${userUuid}:`, err); + if (!rpcError && transactions && transactions.length > 0) { + console.log(`Found ${transactions.length} transactions for ${userId} via RPC`); + return transactions; } + } catch (err) { + console.log(`Error getting transactions for ID ${userId} via RPC:`, err); + } + + // Fallback to direct query + try { + const { data: directTransactions, error } = await supabaseAdmin + .from('point_transactions') + .select('*') + .eq('user_id', userId) + .order('created_at', { ascending: false }); + + if (!error && directTransactions && directTransactions.length > 0) { + console.log(`Found ${directTransactions.length} transactions for ${userId} via direct query`); + return directTransactions; + } + } catch (err) { + console.log(`Error getting transactions for ID ${userId} via direct query:`, err); } // If no transactions found, return empty array @@ -287,4 +376,49 @@ export async function getUserTransactions(userId: string) { console.log('Unexpected error in getUserTransactions:', error); return []; } +} + +/** + * Get full user stats + */ +export async function getUserStats(userId: string) { + try { + if (!userId) { + console.warn('Cannot get stats for undefined userId'); + return null; + } + + // If the userId is not a UUID and looks like an email, try to find by email + if (!validateUuid(userId) && userId.includes('@')) { + const user = await findUserByEmail(userId); + if (user) { + userId = user.id; + console.log('Found user by email, using ID:', userId); + } else { + console.log('No user found by email, returning null stats'); + return null; + } + } + + // Validate final user ID format + if (!validateUuid(userId)) { + console.warn('Invalid UUID format for user:', userId); + return null; + } + + // Get user stats using the RPC function + const { data, error } = await supabaseAdmin.rpc('get_user_stats', { + user_id_param: userId + }); + + if (error) { + console.error('Error getting user stats:', error); + return null; + } + + return data; + } catch (error) { + console.error('Unexpected error in getUserStats:', error); + return null; + } } \ No newline at end of file diff --git a/supabase-drop-functions.sql b/supabase-drop-functions.sql index 37f05d9..4b533ee 100644 --- a/supabase-drop-functions.sql +++ b/supabase-drop-functions.sql @@ -16,7 +16,9 @@ DROP FUNCTION IF EXISTS ensure_user_exists; DROP FUNCTION IF EXISTS get_user_points_by_id; DROP FUNCTION IF EXISTS force_set_user_points; DROP FUNCTION IF EXISTS create_user_with_raw_sql; +DROP FUNCTION IF EXISTS check_duplicate_upload; +DROP FUNCTION IF EXISTS get_user_total_points; --- The following command can be used to drop the user_points table after migrating all data +-- The following commands can be used to drop the user_points table after migrating all data -- This is commented out as it should be run manually after ensuring successful data migration -- DROP TABLE IF EXISTS user_points; \ No newline at end of file diff --git a/supabase-schema-updates.sql b/supabase-schema-updates.sql index 353c9d0..146f15b 100644 --- a/supabase-schema-updates.sql +++ b/supabase-schema-updates.sql @@ -1,25 +1,6 @@ --- Additional SQL functions for the simplified points system +-- Additional SQL functions for the points system -- This file is meant to be run after supabase-schema.sql --- Function to check duplicate image uploads for a specific user -CREATE OR REPLACE FUNCTION check_duplicate_upload( - user_id_param TEXT, - image_hash_param TEXT -) -RETURNS BOOLEAN AS $$ -DECLARE - exists_flag BOOLEAN; -BEGIN - SELECT EXISTS ( - SELECT 1 FROM point_transactions - WHERE user_id = user_id_param::UUID - AND image_hash = image_hash_param - ) INTO exists_flag; - - RETURN exists_flag; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - -- Function to get transaction count for a user CREATE OR REPLACE FUNCTION get_user_transaction_count( user_id_param TEXT @@ -27,10 +8,14 @@ CREATE OR REPLACE FUNCTION get_user_transaction_count( RETURNS INTEGER AS $$ DECLARE count_result INTEGER; + user_uuid UUID; BEGIN + -- Convert to UUID + user_uuid := user_id_param::UUID; + SELECT COUNT(*) INTO count_result FROM point_transactions - WHERE user_id = user_id_param::UUID; + WHERE user_id = user_uuid; RETURN count_result; END; @@ -43,10 +28,14 @@ CREATE OR REPLACE FUNCTION get_user_first_transaction_date( RETURNS TIMESTAMP WITH TIME ZONE AS $$ DECLARE first_date TIMESTAMP WITH TIME ZONE; + user_uuid UUID; BEGIN + -- Convert to UUID + user_uuid := user_id_param::UUID; + SELECT MIN(created_at) INTO first_date FROM point_transactions - WHERE user_id = user_id_param::UUID; + WHERE user_id = user_uuid; RETURN first_date; END; @@ -59,11 +48,121 @@ CREATE OR REPLACE FUNCTION get_user_latest_transaction_date( RETURNS TIMESTAMP WITH TIME ZONE AS $$ DECLARE latest_date TIMESTAMP WITH TIME ZONE; + user_uuid UUID; BEGIN + -- Convert to UUID + user_uuid := user_id_param::UUID; + SELECT MAX(created_at) INTO latest_date FROM point_transactions - WHERE user_id = user_id_param::UUID; + WHERE user_id = user_uuid; RETURN latest_date; END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to get user info by ID +CREATE OR REPLACE FUNCTION get_user_info( + user_id_param TEXT +) +RETURNS json AS $$ +DECLARE + user_record users; + user_uuid UUID; +BEGIN + -- Convert to UUID + user_uuid := user_id_param::UUID; + + SELECT * INTO user_record + FROM users + WHERE id = user_uuid; + + IF FOUND THEN + RETURN row_to_json(user_record); + ELSE + RETURN NULL; + END IF; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to get user stats (combines user info and point total) +CREATE OR REPLACE FUNCTION get_user_stats( + user_id_param TEXT +) +RETURNS json AS $$ +DECLARE + user_info json; + points_total INTEGER; + transaction_count INTEGER; + first_date TIMESTAMP WITH TIME ZONE; + latest_date TIMESTAMP WITH TIME ZONE; + user_uuid UUID; + result json; +BEGIN + -- Convert to UUID + user_uuid := user_id_param::UUID; + + -- Get user info + SELECT row_to_json(u) INTO user_info + FROM users u + WHERE id = user_uuid; + + -- Get points total + SELECT COALESCE(SUM(points), 0) INTO points_total + FROM point_transactions + WHERE user_id = user_uuid; + + -- Get transaction count + SELECT COUNT(*) INTO transaction_count + FROM point_transactions + WHERE user_id = user_uuid; + + -- Get first and latest transaction dates + SELECT MIN(created_at), MAX(created_at) + INTO first_date, latest_date + FROM point_transactions + WHERE user_id = user_uuid; + + -- Build result object + SELECT json_build_object( + 'user', user_info, + 'points', points_total, + 'transaction_count', transaction_count, + 'first_transaction', first_date, + 'latest_transaction', latest_date + ) INTO result; + + RETURN result; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Migration helper function to transfer data from old schema to new schema +-- This is only needed if you're migrating from the old schema +CREATE OR REPLACE FUNCTION migrate_user_data( + old_user_id_param TEXT, + new_user_id_param TEXT, + email_param TEXT, + name_param TEXT DEFAULT NULL +) +RETURNS BOOLEAN AS $$ +DECLARE + old_user_uuid UUID; + new_user_uuid UUID; +BEGIN + -- Convert to UUIDs + old_user_uuid := old_user_id_param::UUID; + new_user_uuid := new_user_id_param::UUID; + + -- Ensure new user exists + INSERT INTO users (id, email, name, first_login, last_login) + VALUES (new_user_uuid, email_param, name_param, now(), now()) + ON CONFLICT (id) DO NOTHING; + + -- Update transactions to use new user ID + UPDATE point_transactions + SET user_id = new_user_uuid + WHERE user_id = old_user_uuid; + + RETURN TRUE; +END; $$ LANGUAGE plpgsql SECURITY DEFINER; \ No newline at end of file diff --git a/supabase-schema.sql b/supabase-schema.sql index 3c8f281..23fd31d 100644 --- a/supabase-schema.sql +++ b/supabase-schema.sql @@ -1,10 +1,19 @@ -- Schema for Supabase to set up user points tracking -- This file is the main schema definition for the points system +-- Create users table to track user information +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, + email TEXT NOT NULL, + name TEXT, + first_login TIMESTAMP WITH TIME ZONE DEFAULT now(), + last_login TIMESTAMP WITH TIME ZONE DEFAULT now() +); + -- Create point_transactions table to track individual point awards CREATE TABLE IF NOT EXISTS point_transactions ( id BIGSERIAL PRIMARY KEY, - user_id UUID NOT NULL, + user_id UUID NOT NULL REFERENCES users(id), points INTEGER NOT NULL, transaction_type TEXT NOT NULL, image_hash TEXT, @@ -17,8 +26,43 @@ CREATE TABLE IF NOT EXISTS point_transactions ( -- Create indexes for faster queries CREATE INDEX IF NOT EXISTS idx_point_transactions_user_id ON point_transactions(user_id); CREATE INDEX IF NOT EXISTS idx_point_transactions_image_hash ON point_transactions(image_hash); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); --- Create a function to create a point transaction +-- Function to ensure a user exists in the database +CREATE OR REPLACE FUNCTION ensure_user_exists( + user_id_param TEXT, + email_param TEXT, + name_param TEXT DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + user_uuid UUID; + exists_flag BOOLEAN; +BEGIN + -- Convert to UUID + user_uuid := user_id_param::UUID; + + -- Check if user exists + SELECT EXISTS ( + SELECT 1 FROM users WHERE id = user_uuid + ) INTO exists_flag; + + -- If user doesn't exist, create them + IF NOT exists_flag THEN + INSERT INTO users (id, email, name, first_login, last_login) + VALUES (user_uuid, email_param, name_param, now(), now()); + ELSE + -- Update last_login + UPDATE users + SET last_login = now() + WHERE id = user_uuid; + END IF; + + RETURN user_uuid; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to create a point transaction CREATE OR REPLACE FUNCTION create_point_transaction( user_id_param TEXT, points_param INTEGER, @@ -31,7 +75,11 @@ CREATE OR REPLACE FUNCTION create_point_transaction( RETURNS json AS $$ DECLARE new_transaction point_transactions; + user_uuid UUID; BEGIN + -- Convert to UUID + user_uuid := user_id_param::UUID; + INSERT INTO point_transactions ( user_id, points, @@ -43,7 +91,7 @@ BEGIN created_at ) VALUES ( - user_id_param::UUID, + user_uuid, points_param, transaction_type_param, image_hash_param, @@ -65,41 +113,54 @@ CREATE OR REPLACE FUNCTION get_user_total_points( RETURNS INTEGER AS $$ DECLARE total INTEGER; + user_uuid UUID; BEGIN + -- Convert to UUID + user_uuid := user_id_param::UUID; + SELECT COALESCE(SUM(points), 0) INTO total FROM point_transactions - WHERE user_id = user_id_param::UUID; + WHERE user_id = user_uuid; RETURN total; END; $$ LANGUAGE plpgsql SECURITY DEFINER; --- Create a function to get user transactions +-- Function to get user transactions CREATE OR REPLACE FUNCTION get_user_transactions( user_id_param TEXT ) RETURNS SETOF point_transactions AS $$ +DECLARE + user_uuid UUID; BEGIN + -- Convert to UUID + user_uuid := user_id_param::UUID; + RETURN QUERY SELECT * FROM point_transactions - WHERE user_id = user_id_param::UUID + WHERE user_id = user_uuid ORDER BY created_at DESC; END; $$ LANGUAGE plpgsql SECURITY DEFINER; --- Check if an image has already been processed for a user -CREATE OR REPLACE FUNCTION check_image_exists( +-- Function to check if an image has already been processed for a user +CREATE OR REPLACE FUNCTION check_duplicate_upload( user_id_param TEXT, image_hash_param TEXT ) RETURNS BOOLEAN AS $$ DECLARE exists_flag BOOLEAN; + user_uuid UUID; BEGIN + -- Convert to UUID + user_uuid := user_id_param::UUID; + SELECT EXISTS ( SELECT 1 FROM point_transactions - WHERE user_id = user_id_param::UUID + WHERE user_id = user_uuid AND image_hash = image_hash_param ) INTO exists_flag; @@ -107,11 +168,41 @@ BEGIN END; $$ LANGUAGE plpgsql SECURITY DEFINER; +-- Function to find a user by email +CREATE OR REPLACE FUNCTION find_user_by_email( + email_param TEXT +) +RETURNS json AS $$ +DECLARE + user_record users; +BEGIN + SELECT * INTO user_record + FROM users + WHERE email = email_param; + + IF FOUND THEN + RETURN row_to_json(user_record); + ELSE + RETURN NULL; + END IF; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + -- Enable Row Level Security +ALTER TABLE users ENABLE ROW LEVEL SECURITY; ALTER TABLE point_transactions ENABLE ROW LEVEL SECURITY; -- Create policies section - uncomment if you need to recreate policies /* +-- Users policies +CREATE POLICY "Users can view their own profile" + ON users FOR SELECT + USING (auth.uid() = id); + +CREATE POLICY "Service role can manage all users" + ON users FOR ALL + USING (auth.role() = 'service_role'); + -- Transaction policies CREATE POLICY "Users can view their own transactions" ON point_transactions FOR SELECT