This commit is contained in:
zramsay 2025-03-12 09:25:54 -04:00
parent 92333ec559
commit bea4b6a1b4
4 changed files with 460 additions and 134 deletions

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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