diff --git a/README.md b/README.md index 62a8574..66416fb 100644 --- a/README.md +++ b/README.md @@ -55,3 +55,7 @@ Please see the Issues tab for `enhancement` tagged issues. ## [Database setup](/docs/database-setup.md) ## [Stripe webhook event listening](/docs/stripe-setup.md) + +## Testing the example credits management system + +There is an example system in this project for being able to manage credits that are sold to users. To test this system you can visit the `http://localhost:5173/test/test-credits` route. This will allow you to add credits to a user and then spend them by clicking the buttons on the page, and will show the user's credit balance updating in real time. diff --git a/src/lib/types/supabase.d.ts b/src/lib/types/supabase.d.ts index a79afeb..d774c18 100644 --- a/src/lib/types/supabase.d.ts +++ b/src/lib/types/supabase.d.ts @@ -34,6 +34,38 @@ export type Database = { } public: { Tables: { + credit_transactions: { + Row: { + created_at: string + credits_change: number + description: string | null + id: string + user_id: string + } + Insert: { + created_at?: string + credits_change: number + description?: string | null + id?: string + user_id: string + } + Update: { + created_at?: string + credits_change?: number + description?: string | null + id?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "credit_transactions_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } customers: { Row: { id: string @@ -268,6 +300,32 @@ export type Database = { }, ] } + user_credits: { + Row: { + credits_remaining: number + last_updated: string + user_id: string + } + Insert: { + credits_remaining?: number + last_updated?: string + user_id: string + } + Update: { + credits_remaining?: number + last_updated?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "user_credits_user_id_fkey" + columns: ["user_id"] + isOneToOne: true + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } users: { Row: { id: string diff --git a/src/lib/utils/supabase/admin.ts b/src/lib/utils/supabase/admin.ts index 5dafd4f..563b9d2 100644 --- a/src/lib/utils/supabase/admin.ts +++ b/src/lib/utils/supabase/admin.ts @@ -298,6 +298,117 @@ const hasProductAccess = async (userId: string, productId: string) => { return false; }; +const validateInput = (userId: string, credits: number, description: string) => { + if (!userId) throw new Error('User ID is required'); + if (credits <= 0) throw new Error('Credits must be greater than zero'); + if (!description) throw new Error('Description is required'); +}; + +const logCreditTransaction = async (userId: string, creditsChange: number, description: string) => { + const { error } = await supabaseAdmin.from('credit_transactions').insert({ + user_id: userId, + credits_change: creditsChange, + description, + created_at: new Date().toISOString() + }); + + if (error) { + throw new Error(`Error logging credit transaction: ${error.message}`); + } +}; + +const updateUserCredits = async (userId: string, creditsChange: number, description: string) => { + validateInput(userId, Math.abs(creditsChange), description); + + const { data: creditData, error: fetchError } = await supabaseAdmin + .from('user_credits') + .select('credits_remaining, last_updated') + .eq('user_id', userId) + .single(); + + if (fetchError) { + throw new Error(`Error fetching credits: ${fetchError.message}`); + } + + const newCreditTotal = (creditData?.credits_remaining || 0) + creditsChange; + + if (newCreditTotal < 0) { + throw new Error('Insufficient credits'); + } + + const { error: upsertError } = await supabaseAdmin + .from('user_credits') + .upsert( + { + user_id: userId, + credits_remaining: newCreditTotal, + last_updated: new Date().toISOString() + }, + { + onConflict: 'user_id' + } + ) + .eq('last_updated', creditData?.last_updated); // Optimistic concurrency control + + if (upsertError) { + throw new Error(`Error updating credits: ${upsertError.message}`); + } + + await logCreditTransaction(userId, creditsChange, description); + + console.log( + `${creditsChange > 0 ? 'Added' : 'Deducted'} ${Math.abs(creditsChange)} credits to user [${userId}]` + ); +}; + +const addCredits = async (userId: string, creditsToAdd: number, description: string) => { + try { + await updateUserCredits(userId, creditsToAdd, description); + } catch (error) { + console.error(error); + } +}; + +const deductCredits = async (userId: string, creditsToDeduct: number, description: string) => { + try { + await updateUserCredits(userId, -creditsToDeduct, description); + } catch (error) { + console.error(error); + } +}; + +const getUserCredits = async (userId: string) => { + if (!userId) throw new Error('User ID is required'); + + // Fetch the user's credits from the user_credits table + const { data: creditData, error } = await supabaseAdmin + .from('user_credits') + .select('credits_remaining') + .eq('user_id', userId) + .single(); + + if (error && error.code !== 'PGRST116') { + // Code 'PGRST116' represents no rows found + throw new Error(`Error fetching credits: ${error.message}`); + } + + if (!creditData) { + // If no row exists in the user_credits table, insert a new row for the user + const { error: insertError } = await supabaseAdmin.from('user_credits').insert({ + user_id: userId, + credits_remaining: 0 + }); + + if (insertError) { + throw new Error(`Error creating user credits: ${insertError.message}`); + } + + return 0; + } + + return creditData.credits_remaining; +}; + export { upsertProductRecord, upsertPriceRecord, @@ -306,5 +417,8 @@ export { createOrRetrieveCustomer, manageSubscriptionStatusChange, recordProductPurchase, - hasProductAccess + hasProductAccess, + addCredits, + deductCredits, + getUserCredits }; diff --git a/src/routes/test/test-credits/+page.server.ts b/src/routes/test/test-credits/+page.server.ts new file mode 100644 index 0000000..3f79606 --- /dev/null +++ b/src/routes/test/test-credits/+page.server.ts @@ -0,0 +1,18 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => { + const { session } = await safeGetSession(); + + if (!session) { + redirect(303, '/'); + } + + const { data: user } = await supabase + .from('users') + .select(`name, id`) + .eq('id', session.user.id) + .single(); + + return { session, user }; +}; diff --git a/src/routes/test/test-credits/+page.svelte b/src/routes/test/test-credits/+page.svelte new file mode 100644 index 0000000..c4363a1 --- /dev/null +++ b/src/routes/test/test-credits/+page.svelte @@ -0,0 +1,93 @@ + + +
+

Credits Management for User: {user?.id}

+

Credits Remaining: {$credits}

+ {#if errorMessage} +

{errorMessage}

+ {/if} + + + +
+ + diff --git a/src/routes/test/test-credits/+server.ts b/src/routes/test/test-credits/+server.ts new file mode 100644 index 0000000..5949f67 --- /dev/null +++ b/src/routes/test/test-credits/+server.ts @@ -0,0 +1,29 @@ +import { json } from '@sveltejs/kit'; +import { addCredits, deductCredits, getUserCredits } from '$lib/utils/supabase/admin'; + +export const POST = async ({ request }) => { + const { action, userId, credits, description } = await request.json(); + + if (!userId) { + return json({ success: false, error: 'User ID is required' }, { status: 400 }); + } + + try { + if (action === 'addCredits') { + await addCredits(userId, credits, description); + const updatedCredits = await getUserCredits(userId); + return json({ success: true, credits: updatedCredits }); + } else if (action === 'deductCredits') { + await deductCredits(userId, credits, description); + const updatedCredits = await getUserCredits(userId); + return json({ success: true, credits: updatedCredits }); + } else if (action === 'getUserCredits') { + const fetchedCredits = await getUserCredits(userId); + return json({ success: true, credits: fetchedCredits }); + } else { + return json({ success: false, error: 'Invalid action' }, { status: 400 }); + } + } catch (error) { + return json({ success: false, error: error.message }, { status: 500 }); + } +}; diff --git a/supabase/migrations/20240929120431_add_credits.sql b/supabase/migrations/20240929120431_add_credits.sql new file mode 100644 index 0000000..44680d4 --- /dev/null +++ b/supabase/migrations/20240929120431_add_credits.sql @@ -0,0 +1,35 @@ +create table user_credits ( + user_id uuid references auth.users not null primary key, + credits_remaining integer not null default 0, + last_updated timestamp with time zone not null default now() +); + +alter table user_credits enable row level security; + +create policy "Can view own credits." on user_credits + for select using (auth.uid() = user_id); + +-- Uncomment and modify the update policy if allowing users to modify their own credits +-- create policy "Can update own credits." on user_credits +-- for update using (auth.uid() = user_id); + +create table credit_transactions ( + id uuid default uuid_generate_v4() primary key, + user_id uuid references auth.users not null, + credits_change integer not null, + description text, + created_at timestamp with time zone not null default now() +); + +alter table credit_transactions enable row level security; + +create policy "Can view own transactions." on credit_transactions + for select using (auth.uid() = user_id); + +-- Adding indexing to improve query performance +create index on user_credits (user_id); +create index on credit_transactions (user_id); + +-- Adding a check constraint to enforce data integrity +alter table credit_transactions +add constraint check_credits_change_nonzero check (credits_change <> 0);