Merge branch 'master' into 60-if-you-sign-out-the-basket-doesnt-get-cleared

This commit is contained in:
Josh Creek
2024-11-05 22:41:56 +00:00
committed by GitHub
18 changed files with 804 additions and 270 deletions
+3 -1
View File
@@ -21,7 +21,9 @@
<div class="fixed bottom-0 left-0 w-full bg-gray-800 text-white p-4">
<p class="text-sm">
We use cookies to ensure you get the best experience on our website. By continuing, you agree
to our use of cookies.
to our use of cookies. For details, please see our <a href="/privacy" class="underline"
>Privacy Policy</a
>.
</p>
<button class="bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded" on:click={acceptCookies}
>Accept</button
-126
View File
@@ -1,126 +0,0 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { general } from '$lib/stores/generalStore.js';
const dispatch = createEventDispatcher();
function emitSignedInEvent() {
dispatch('signedIn', {});
}
let email = '';
let password = '';
// Access the supabase client from the layout data
export let supabase: any;
async function signInWithEmail() {
// TODO use the data and error from the response
const { data, error } = await supabase.auth
.signInWithPassword({
email: email,
password: password
})
.then(() => {
emitSignedInEvent();
});
}
async function forgotPassword() {
if (email === '') {
general.update((value) => {
return {
...value,
hideToast: false,
toastMessage: 'Please enter your email address',
toastType: 'error'
};
});
setTimeout(() => {
general.update((value) => {
return { ...value, hideToast: true, toastMessage: '', toastType: 'success' };
});
}, 5000);
return;
}
const { data, error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: 'https://example.com/auth/update-password'
});
if (error) {
console.error('Error sending password reset email:', error.message);
general.update((value) => {
return {
...value,
hideToast: false,
toastMessage: 'Error sending password reset email. Please try again.',
toastType: 'error'
};
});
} else {
console.log('Password reset email sent:', data);
general.update((value) => {
return {
...value,
hideToast: false,
toastMessage: 'Password reset email sent. Please check your inbox.',
toastType: 'success'
};
});
}
setTimeout(() => {
general.update((value) => {
return { ...value, hideToast: true, toastMessage: '', toastType: 'success' };
});
}, 5000);
}
</script>
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-base-100">
<form class="card-body">
<div class="form-control">
<label class="input input-bordered flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4 opacity-70"
><path
d="M2.5 3A1.5 1.5 0 0 0 1 4.5v.793c.026.009.051.02.076.032L7.674 8.51c.206.1.446.1.652 0l6.598-3.185A.755.755 0 0 1 15 5.293V4.5A1.5 1.5 0 0 0 13.5 3h-11Z"
/><path
d="M15 6.954 8.978 9.86a2.25 2.25 0 0 1-1.956 0L1 6.954V11.5A1.5 1.5 0 0 0 2.5 13h11a1.5 1.5 0 0 0 1.5-1.5V6.954Z"
/>
</svg>
<input type="text" class="grow" placeholder="Email" required bind:value={email} />
</label>
</div>
<div class="form-control">
<label class="input input-bordered flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4 opacity-70"
><path
fill-rule="evenodd"
d="M14 6a4 4 0 0 1-4.899 3.899l-1.955 1.955a.5.5 0 0 1-.353.146H5v1.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-2.293a.5.5 0 0 1 .146-.353l3.955-3.955A4 4 0 1 1 14 6Zm-4-2a.75.75 0 0 0 0 1.5.5.5 0 0 1 .5.5.75.75 0 0 0 1.5 0 2 2 0 0 0-2-2Z"
clip-rule="evenodd"
/>
</svg>
<input type="password" class="grow" placeholder="Password" required bind:value={password} />
</label>
</div>
<div class="form-control">
<button id="forgot-password" class="label-text-alt link link-hover" on:click={forgotPassword}>
Forgot your password?
</button>
</div>
<div class="form-control mt-2">
<button class="btn btn-primary" on:click={signInWithEmail}>Sign In</button>
</div>
</form>
</div>
-68
View File
@@ -1,68 +0,0 @@
<script lang="ts">
let email = '';
let password = '';
// Access the supabase client from the layout data
export let supabase: any;
async function signUpNewUser() {
try {
const { data, error } = await supabase.auth.signUp({
email: email,
password: password,
options: {
// Redirect URL after successful sign-up
redirectTo: '/account'
}
});
if (error) {
throw error;
}
// Handle success (optional)
} catch (error: any) {
console.error('Sign up error:', error.message);
// Handle error
}
}
</script>
<form class="card-body">
<div class="form-control">
<label class="input input-bordered flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4 opacity-70"
><path
d="M2.5 3A1.5 1.5 0 0 0 1 4.5v.793c.026.009.051.02.076.032L7.674 8.51c.206.1.446.1.652 0l6.598-3.185A.755.755 0 0 1 15 5.293V4.5A1.5 1.5 0 0 0 13.5 3h-11Z"
/><path
d="M15 6.954 8.978 9.86a2.25 2.25 0 0 1-1.956 0L1 6.954V11.5A1.5 1.5 0 0 0 2.5 13h11a1.5 1.5 0 0 0 1.5-1.5V6.954Z"
/>
</svg>
<input type="text" class="grow" placeholder="Email" required bind:value={email} />
</label>
</div>
<div class="form-control">
<label class="input input-bordered flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4 opacity-70"
><path
fill-rule="evenodd"
d="M14 6a4 4 0 0 1-4.899 3.899l-1.955 1.955a.5.5 0 0 1-.353.146H5v1.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-2.293a.5.5 0 0 1 .146-.353l3.955-3.955A4 4 0 1 1 14 6Zm-4-2a.75.75 0 0 0 0 1.5.5.5 0 0 1 .5.5.75.75 0 0 0 1.5 0 2 2 0 0 0-2-2Z"
clip-rule="evenodd"
/>
</svg>
<input type="password" class="grow" placeholder="Password" required bind:value={password} />
</label>
</div>
<div class="form-control mt-6">
<button class="btn btn-primary" on:click={signUpNewUser}>Sign Up</button>
</div>
</form>
+40
View File
@@ -0,0 +1,40 @@
import { createLogger, transports, format } from 'winston';
import { WinstonTransport as AxiomTransport } from '@axiomhq/winston';
import { VITE_AXIOM_DATASET, VITE_AXIOM_TOKEN } from '$env/static/private';
const axiomTransport = new AxiomTransport({
dataset: VITE_AXIOM_DATASET,
token: VITE_AXIOM_TOKEN
});
const logger = createLogger({
level: 'info',
defaultMeta: { service: 'example-site' }, // Change this so you can identify your website if you have multiple logging into Axiom
format: format.combine(
format.errors({ stack: true }), // Captures error stack traces
format.timestamp(),
format.json() // Formats logs as JSON for structured logging
),
transports: [
new transports.Console({
format: format.combine(
format.colorize(),
format.printf(({ timestamp, level, message, stack }) => {
// Stack traces will be included in error messages automatically
return `${timestamp} [${level.toUpperCase()}]: ${message}${stack ? `\n${stack}` : ''}`;
})
)
}),
axiomTransport
],
exceptionHandlers: [axiomTransport], // Logs uncaught exceptions
rejectionHandlers: [axiomTransport] // Logs unhandled promise rejections
});
// Uncomment this for testing your initial logging setup
// logger.log({
// level: 'info',
// message: 'Logger successfully setup'
// });
export default logger;
+142 -36
View File
@@ -4,6 +4,7 @@ import type { Database, Tables, TablesInsert } from '$lib/types/supabase';
import { PUBLIC_SUPABASE_URL } from '$env/static/public';
import { SUPABASE_SERVICE_ROLE_KEY } from '$env/static/private';
import { stripe as stripeClient } from '$lib/utils/stripe';
import logger from '$lib/utils/logger/logger';
const toDateTime = (secs: number) => {
const t = new Date(+0); // Unix epoch start.
@@ -39,7 +40,7 @@ const upsertProductRecord = async (product: stripe.Product) => {
const { error: upsertError } = await supabaseAdmin.from('products').upsert([productData]);
if (upsertError) throw new Error(`Product insert/update failed: ${upsertError.message}`);
console.log(`Product inserted/updated: ${product.id}`);
logger.info(`Product inserted/updated: ${product.id}`);
};
const upsertPriceRecord = async (price: stripe.Price, retryCount = 0, maxRetries = 3) => {
@@ -61,7 +62,7 @@ const upsertPriceRecord = async (price: stripe.Price, retryCount = 0, maxRetries
if (upsertError?.message.includes('foreign key constraint')) {
if (retryCount < maxRetries) {
console.log(`Retry attempt ${retryCount + 1} for price ID: ${price.id}`);
logger.info(`Retry attempt ${retryCount + 1} for price ID: ${price.id}`);
await new Promise((resolve) => setTimeout(resolve, 2000));
await upsertPriceRecord(price, retryCount + 1, maxRetries);
} else {
@@ -72,7 +73,7 @@ const upsertPriceRecord = async (price: stripe.Price, retryCount = 0, maxRetries
} else if (upsertError) {
throw new Error(`Price insert/update failed: ${upsertError.message}`);
} else {
console.log(`Price inserted/updated: ${price.id}`);
logger.info(`Price inserted/updated: ${price.id}`);
}
};
@@ -82,13 +83,13 @@ const deleteProductRecord = async (product: stripe.Product) => {
.delete()
.eq('id', product.id);
if (deletionError) throw new Error(`Product deletion failed: ${deletionError.message}`);
console.log(`Product deleted: ${product.id}`);
logger.info(`Product deleted: ${product.id}`);
};
const deletePriceRecord = async (price: stripe.Price) => {
const { error: deletionError } = await supabaseAdmin.from('prices').delete().eq('id', price.id);
if (deletionError) throw new Error(`Price deletion failed: ${deletionError.message}`);
console.log(`Price deleted: ${price.id}`);
logger.info(`Price deleted: ${price.id}`);
};
const upsertCustomerToSupabase = async (uuid: string, customerId: string) => {
@@ -254,11 +255,18 @@ const manageSubscriptionStatusChange = async (
trial_end: subscription.trial_end ? toDateTime(subscription.trial_end).toISOString() : null
};
const { error: upsertError } = await supabaseAdmin
.from('subscriptions')
.upsert([subscriptionData]);
if (upsertError) throw new Error(`Subscription insert/update failed: ${upsertError.message}`);
console.log(`Inserted/updated subscription [${subscription.id}] for user [${uuid}]`);
try {
const { error: upsertError } = await supabaseAdmin
.from('subscriptions')
.upsert([subscriptionData]);
if (upsertError) {
throw new Error(`Subscription insert/update failed: ${upsertError.message}`);
}
} catch (error) {
logger.error(error);
}
logger.info(`Inserted/updated subscription [${subscription.id}] for user [${uuid}]`);
// // For a new subscription copy the billing details to the customer object.
// // NOTE: This is a costly operation and should happen at the very end.
@@ -271,7 +279,7 @@ const manageSubscriptionStatusChange = async (
};
const recordProductPurchase = async (userId: string, productId: string, priceId: string) => {
console.log(
logger.info(
`Recording product purchase for user [${userId}] and product [${productId}] with price [${priceId}]`
);
@@ -284,40 +292,48 @@ const recordProductPurchase = async (userId: string, productId: string, priceId:
throw new Error(`Failed to record product purchase: ${error.message}`);
}
} catch (error) {
console.error(error);
logger.error(error);
}
console.log(`Product purchased recorded for user [${userId}] and product [${productId}]`);
logger.info(`Product purchased recorded for user [${userId}] and product [${productId}]`);
};
const hasProductAccess = async (userId: string, productId: string) => {
// Check if user has purchased the product
const { data: purchases, error: purchaseError } = await supabaseAdmin
.from('purchases')
.select('*')
.eq('user_id', userId)
.eq('product_id', productId);
try {
const { data: purchases, error: purchaseError } = await supabaseAdmin
.from('purchases')
.select('*')
.eq('user_id', userId)
.eq('product_id', productId);
if (purchaseError) throw new Error(purchaseError.message);
if (purchaseError) throw new Error(purchaseError.message);
if (purchases && purchases.length > 0) {
// User has purchased the product
return true;
if (purchases && purchases.length > 0) {
// User has purchased the product
return true;
}
} catch (error) {
logger.error(error);
}
// Check if user has an active subscription for the product
const { data: subscriptions, error: subError } = await supabaseAdmin
.from('subscriptions')
.select('*')
.eq('user_id', userId)
.eq('product_id', productId)
.in('status', ['active', 'trialing']);
try {
const { data: subscriptions, error: subError } = await supabaseAdmin
.from('subscriptions')
.select('*')
.eq('user_id', userId)
.eq('product_id', productId)
.in('status', ['active', 'trialing']);
if (subError) throw new Error(subError.message);
if (subError) throw new Error(subError.message);
if (subscriptions && subscriptions.length > 0) {
// User has an active subscription
return true;
if (subscriptions && subscriptions.length > 0) {
// User has an active subscription
return true;
}
} catch (error) {
logger.error(error);
}
return false;
@@ -381,7 +397,7 @@ const updateUserCredits = async (userId: string, creditsChange: number, descript
await logCreditTransaction(userId, creditsChange, description);
console.log(
logger.info(
`${creditsChange > 0 ? 'Added' : 'Deducted'} ${Math.abs(creditsChange)} credits to user [${userId}]`
);
};
@@ -390,7 +406,7 @@ const addCredits = async (userId: string, creditsToAdd: number, description: str
try {
await updateUserCredits(userId, creditsToAdd, description);
} catch (error) {
console.error(error);
logger.error(error);
}
};
@@ -398,7 +414,7 @@ const deductCredits = async (userId: string, creditsToDeduct: number, descriptio
try {
await updateUserCredits(userId, -creditsToDeduct, description);
} catch (error) {
console.error(error);
logger.error(error);
}
};
@@ -480,6 +496,94 @@ const getProductById = async (productId: string) => {
return product as Product;
};
const getUserSubscriptions = async (userId: string) => {
try {
// Step 1: Retrieve subscription data
const { data: subscriptions, error } = await supabaseAdmin
.from('subscriptions')
.select('*')
.eq('user_id', userId);
if (error) {
logger.error('Error retrieving subscriptions:', error.message);
throw new Error('Failed to fetch subscriptions');
}
// Step 2: Map through each subscription and fetch product and price details
const subscriptionDetails = await Promise.all(
subscriptions.map(async (sub) => {
// Fetch product and price details from Stripe
const product = await stripeClient.products.retrieve(sub.product_id);
const price = await stripeClient.prices.retrieve(sub.price_id);
const expiryDate = new Date(sub.current_period_end);
// Format output data
return {
productName: product.name,
amount: (price.unit_amount / 100).toFixed(2),
currency: price.currency.toUpperCase(),
interval: price.recurring?.interval || 'one-time',
expiryDate: expiryDate.toLocaleDateString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
}),
status: sub.status
};
})
);
return subscriptionDetails;
} catch (error) {
logger.error('An error occurred while retrieving subscription details:', error);
throw new Error('Could not retrieve subscription details');
}
};
const getUserTransactions = async (userId: string) => {
try {
// Step 1: Retrieve the customer ID associated with the user
const { data, error } = await supabaseAdmin
.from('customers')
.select('stripe_customer_id')
.eq('id', userId)
.single();
if (error) {
console.error('Error retrieving user data:', error.message);
throw new Error('Failed to fetch user data');
}
const customerId = data?.stripe_customer_id;
if (!customerId) {
throw new Error('No Stripe customer ID found for this user');
}
// Step 2: Fetch charge transactions from Stripe
const charges = await stripeClient.charges.list({ customer: customerId });
// Step 3: Format transaction data
const transactions = charges.data.map((charge) => ({
amount: (charge.amount / 100).toFixed(2),
currency: charge.currency.toUpperCase(),
description: charge.description ?? 'No description provided',
status: charge.status,
created: new Date(charge.created * 1000).toLocaleDateString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
}),
receipt_url: charge.receipt_url
}));
return transactions;
} catch (error) {
console.error('An error occurred while retrieving transactions:', error);
throw new Error('Could not retrieve transactions');
}
};
export {
upsertProductRecord,
upsertPriceRecord,
@@ -495,5 +599,7 @@ export {
getActiveProductsWithPrices,
getProductById,
upsertCustomerToSupabase,
getStripeCustomerId
getStripeCustomerId,
getUserSubscriptions,
getUserTransactions
};