mirror of
https://github.com/jcreek/SvelteKitSaasBoilerplate.git
synced 2026-07-13 19:13:48 +00:00
feat(#37): Enable tracking credits usage with proof of concept test page
This commit is contained in:
Vendored
+58
@@ -34,6 +34,38 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
public: {
|
public: {
|
||||||
Tables: {
|
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: {
|
customers: {
|
||||||
Row: {
|
Row: {
|
||||||
id: string
|
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: {
|
users: {
|
||||||
Row: {
|
Row: {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -298,6 +298,117 @@ const hasProductAccess = async (userId: string, productId: string) => {
|
|||||||
return false;
|
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 {
|
export {
|
||||||
upsertProductRecord,
|
upsertProductRecord,
|
||||||
upsertPriceRecord,
|
upsertPriceRecord,
|
||||||
@@ -306,5 +417,8 @@ export {
|
|||||||
createOrRetrieveCustomer,
|
createOrRetrieveCustomer,
|
||||||
manageSubscriptionStatusChange,
|
manageSubscriptionStatusChange,
|
||||||
recordProductPurchase,
|
recordProductPurchase,
|
||||||
hasProductAccess
|
hasProductAccess,
|
||||||
|
addCredits,
|
||||||
|
deductCredits,
|
||||||
|
getUserCredits
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
|
||||||
|
let { session, supabase, user } = data;
|
||||||
|
$: ({ session, supabase, user } = data);
|
||||||
|
|
||||||
|
const credits = writable<number>(0);
|
||||||
|
let errorMessage: string = '';
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const pageUserId = user?.id;
|
||||||
|
if (pageUserId) {
|
||||||
|
const response = await fetch('/test/test-credits', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'getUserCredits', userId: pageUserId })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch credits');
|
||||||
|
const { credits: fetchedCredits } = await response.json();
|
||||||
|
credits.set(fetchedCredits);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const addCredits = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/test/test-credits', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'addCredits',
|
||||||
|
userId: user?.id,
|
||||||
|
credits: 10,
|
||||||
|
description: 'Test add credits'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to add credits');
|
||||||
|
const { credits: updatedCredits } = await response.json();
|
||||||
|
credits.set(updatedCredits);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deductCredits = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/test/test-credits', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'deductCredits',
|
||||||
|
userId: user?.id,
|
||||||
|
credits: 5,
|
||||||
|
description: 'Test deduct credits'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to deduct credits');
|
||||||
|
const { credits: updatedCredits } = await response.json();
|
||||||
|
credits.set(updatedCredits);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h1>Credits Management for User: {user?.id}</h1>
|
||||||
|
<p>Credits Remaining: {$credits}</p>
|
||||||
|
{#if errorMessage}
|
||||||
|
<p style="color: red;">{errorMessage}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button on:click={addCredits}>Add 10 Credits</button>
|
||||||
|
<button on:click={deductCredits}>Deduct 5 Credits</button>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
margin: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user