58 Commits

Author SHA1 Message Date
Josh Creek 62f7525238 Merge pull request #94 from jcreek/79-create-contact-page
feat(#79): Create contact page
2024-11-10 20:53:47 +00:00
Josh Creek c3125a0db1 feat(#79): Handle loading state 2024-11-10 19:58:50 +00:00
Josh Creek 3b1ffb21a6 feat(#79): Enhance form security and accessibility 2024-11-10 19:55:57 +00:00
Josh Creek 829d8cc031 feat(#79): Add rate limiting to contact page form 2024-11-10 19:45:40 +00:00
Josh Creek 6cdc0f0bde build(*): Fix broken closing tag preventing build 2024-11-10 19:40:59 +00:00
Josh Creek 4137f85190 fix(#79): Add check for result.data 2024-11-10 19:33:32 +00:00
Josh Creek 58842c6f1e feat(#79): Create contact page 2024-11-10 19:31:23 +00:00
Josh Creek b2bfd12a35 Merge pull request #71 from jcreek/60-if-you-sign-out-the-basket-doesnt-get-cleared
60 if you sign out the basket doesnt get cleared
2024-11-10 18:53:27 +00:00
Josh Creek 6a10ab5983 refactor(#60): Strongly type supabase client 2024-11-10 18:10:56 +00:00
Josh Creek 7ec5c5bb38 chore(#60): Remove logger from client side code 2024-11-06 21:09:29 +00:00
Josh Creek 4f4f122702 fix(#60): Fix logger import 2024-11-06 21:02:39 +00:00
Olly 19f9b077d6 Merge pull request #68 from jcreek/55-enable-account-closure
55 enable account closure
2024-11-06 20:51:30 +00:00
Josh Creek a8c702123e chore(#55): Fix whitespace 2024-11-06 18:03:49 +00:00
Josh Creek af4b7c4e4c chore(#55): Improve account deletion messaging 2024-11-06 17:55:50 +00:00
Josh Creek dc7f6ba18e chore(#60): Remove unused type 2024-11-06 17:50:31 +00:00
Josh Creek 0be329ff06 refactor(#60): Utilise errors when signing out 2024-11-06 17:49:52 +00:00
Josh Creek 39701b2155 fix(#60): Unsubscribe to basket store after logging out 2024-11-06 17:43:29 +00:00
Josh Creek ed6dc6db74 Merge branch 'master' into 60-if-you-sign-out-the-basket-doesnt-get-cleared 2024-11-05 22:41:56 +00:00
Josh Creek eec6e2041c chore(#55): Re-add pnpm lock 2024-11-05 22:40:25 +00:00
Josh Creek df8af5146f Merge branch 'master' into 55-enable-account-closure 2024-11-05 22:39:43 +00:00
Olly 7a91eb5173 Merge pull request #70 from jcreek/65-add-a-proper-error-logging-solution
65 add a proper error logging solution
2024-11-05 22:29:30 +00:00
OllyNicholass 57a800ef2c refactor(#65): add types to products page params 2024-11-05 22:13:20 +00:00
OllyNicholass 3cd3200d1e refactor(#65): add additional error handling & logs 2024-11-05 22:06:54 +00:00
OllyNicholass d329989230 refactor(#65): move scope of logged params
Type defs added where relevent
2024-11-05 21:44:48 +00:00
Josh Creek 026524a1b3 Merge branch 'master' into 65-add-a-proper-error-logging-solution 2024-11-05 21:07:55 +00:00
Olly a5a14a93ef Merge pull request #69 from jcreek/59-style-account-page
59 style account page
2024-11-05 20:57:49 +00:00
Olly f032005d8f Merge pull request #72 from jcreek/53-make-a-privacy-page
53 make a privacy page
2024-11-05 20:05:54 +00:00
Josh Creek 86ced6e7f9 refactor(#65): Enhance error logging with context 2024-11-04 19:12:27 +00:00
Josh Creek 8a251f775a refactor(#65): Enhance error logging with request context 2024-11-04 19:10:18 +00:00
Josh Creek ad30f04078 refactor(#59): Verify that 'transactions' is always defined before iteration 2024-11-04 19:00:23 +00:00
Josh Creek 506ceefe26 refactor(#59): Validate receipt links before showing the buttons 2024-11-04 18:56:56 +00:00
Josh Creek de565bb965 chore(#59): Remove debugging log 2024-11-04 18:51:57 +00:00
Josh Creek 3bbbd340c5 chore(#59): Improve clarity of error logs 2024-11-04 18:50:19 +00:00
Josh Creek d62b17ad65 docs(#55): Improve clarity around db migrations
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-11-02 15:38:13 +00:00
Josh Creek 259d83a71e refactor(#55): Improve error handling for requestAccountDeletion
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-11-02 15:32:52 +00:00
Josh Creek 26426f9804 feat(#55): Add error check for Brevo API key
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-11-02 15:28:37 +00:00
Josh Creek 1250261afe docs(#55): Add comments to .env.example
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-11-02 15:18:42 +00:00
Josh Creek f1f2fe99d7 feat(#59): Enable showing subscriptions and transactions 2024-11-01 20:12:08 +00:00
Josh Creek 3189d8932b feat(#59): Add styling to account page 2024-11-01 19:12:34 +00:00
Josh Creek fe1f49090c chore(#65): Comment out test code 2024-10-30 21:08:20 +00:00
Josh Creek 9253926c7d chore(*): Remove unused components 2024-10-30 21:06:28 +00:00
Josh Creek ddac66e3a9 feat(#65): Update example env 2024-10-30 21:06:20 +00:00
Josh Creek 3d89222eec feat(#65): Replace existing console logs 2024-10-30 21:06:12 +00:00
Josh Creek 2ef478e4b1 feat(#65): Add winston and axiom logging 2024-10-30 21:04:40 +00:00
Josh Creek 67b748b88e chore(*): Remove redundant sign out button 2024-10-30 18:48:27 +00:00
Josh Creek c23de036c7 feat(#60): Clear basket on sign out 2024-10-30 18:48:11 +00:00
Josh Creek 478ff600d3 feat(#53): Add policy link to cookie consent banner 2024-10-30 18:33:03 +00:00
Josh Creek cb1321825d feat(#53): Add policy links to footer 2024-10-30 18:32:45 +00:00
Josh Creek 464f365085 feat(#55): Enable sending account deletion emails through brevo npm package 2024-10-29 22:10:21 +00:00
Olly b83b02fd12 Merge pull request #57 from jcreek/51-editing-the-input-for-basket-item-qty-doesnt-recalculate-subtotal
51 editing the input for basket item qty doesn't recalculate subtotal
2024-10-29 21:35:15 +00:00
Josh Creek c4865337ee chore(*): Remove redundant example file 2024-10-29 21:20:34 +00:00
Josh Creek 33409bc375 feat(#55): Enable deleting account and anonymising purchases and subscriptions (no email sending) 2024-10-29 21:20:23 +00:00
Josh Creek 90875517da docs(#55): Add clearer migration commands 2024-10-29 21:17:10 +00:00
Josh Creek e4ef376064 feat(#53): Add cookie and privacy policies 2024-10-29 19:30:06 +00:00
Josh Creek 8bc8399b5f chore(*): Remove redundant page 2024-10-29 19:17:25 +00:00
Josh Creek f28f250fab fix(#51): Ensure the input for quantity is reactive 2024-10-29 19:17:05 +00:00
Olly 243dc883ac Merge pull request #49 from jcreek/48-stripe-customer-creation-could-do-with-some-improved-security
refactor(#48): Improve security by moving create customer functionality to not be exposed in an endpoint
2024-10-24 00:29:44 +01:00
Josh Creek 24a70f123a refactor(#48): Improve security by moving create customer functionality to not be exposed in an endpoint 2024-10-22 20:01:49 +01:00
31 changed files with 1568 additions and 409 deletions
+10
View File
@@ -4,3 +4,13 @@ SUPABASE_SERVICE_ROLE_KEY=""
STRIPE_SECRET_KEY=""
STRIPE_ENDPOINT_SECRET=""
VITE_PRODUCT_ID_EXAMPLEPRODUCT=""
VITE_AXIOM_DATASET=""
VITE_AXIOM_TOKEN=""
# Brevo API configuration for transactional emails (account closure notifications)
VITE_BREVO_API_KEY=""
# Default sender email for system notifications
VITE_BREVO_SENDER_EMAIL="noreply@example.com"
# Display name for the sender email
VITE_BREVO_SENDER_NAME="No Reply"
# Contact email for the contact page form to send to
VITE_CONTACT_EMAIL=""
+9 -1
View File
@@ -79,7 +79,15 @@ There are two options to create a migration file:
## Applying migrations
Reset database in cloud
To do so without resetting the database, you can use the following command:
`npx supabase migration up`
> Use this command when you want to preserve existing data whilst applying new schema changes. For a fresh start with test data, use `npx supabase db reset` instead.
And for the database in the cloud:
`npx supabase db push`
> Prerequisite login using `npx supabase login`
+4 -1
View File
@@ -47,10 +47,13 @@
},
"type": "module",
"dependencies": {
"@axiomhq/winston": "^1.2.0",
"@getbrevo/brevo": "^2.2.0",
"@supabase/ssr": "^0.4.0",
"@supabase/supabase-js": "^2.44.2",
"stripe": "^15.4.0",
"ts-debounce": "^4.0.0"
"ts-debounce": "^4.0.0",
"winston": "^3.15.0"
},
"engines": {
"node": ">=18.13.0"
+516
View File
File diff suppressed because it is too large Load Diff
+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>
+33 -5
View File
@@ -1,5 +1,8 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher, onDestroy } from 'svelte';
import type { SupabaseClient } from '@supabase/supabase-js';
import { basket, type Basket } from '$lib/stores/basket.js';
const dispatch = createEventDispatcher();
function emitSignedOutEvent() {
@@ -7,13 +10,38 @@
}
// Access the supabase client from the layout data
export let supabase: any;
export let supabase: SupabaseClient;
let localBasket: Basket;
const unsubscribe = basket.subscribe((value) => {
localBasket = value;
});
onDestroy(unsubscribe);
async function signOut() {
// TODO use the error from the response
const { error } = await supabase.auth.signOut().then(() => {
const { error } = await supabase.auth.signOut();
if (error) {
console.error('Error signing out:', error.message);
alert('Error signing out');
return;
}
try {
emitSignedOutEvent();
});
// Clear the basket
localBasket = { items: [], subtotal: 0 };
basket.set(localBasket);
unsubscribe();
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('basket');
}
} catch (err) {
console.error('Error clearing basket:', err);
alert('Error clearing basket');
}
}
</script>
-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>
@@ -11,10 +11,11 @@
export let price = 0;
export let quantity = 0;
$: dispatch('changeQuantity', { itemId, quantity });
function reduceQuantityByOne() {
if (quantity > 1) {
quantity--;
dispatch('changeQuantity', { itemId, quantity });
} else {
dispatch('removeItem', { itemId });
}
@@ -22,7 +23,6 @@
function increaseQuantityByOne() {
quantity++;
dispatch('changeQuantity', { itemId, quantity });
}
</script>
+26
View File
@@ -34,6 +34,32 @@ export type Database = {
}
public: {
Tables: {
account_deletion_requests: {
Row: {
requested_at: string
token: string
user_id: string
}
Insert: {
requested_at?: string
token: string
user_id: string
}
Update: {
requested_at?: string
token?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "account_deletion_requests_user_id_fkey"
columns: ["user_id"]
isOneToOne: true
referencedRelation: "users"
referencedColumns: ["id"]
},
]
}
credit_transactions: {
Row: {
created_at: string
+34
View File
@@ -0,0 +1,34 @@
import brevo from '@getbrevo/brevo';
import {
VITE_BREVO_API_KEY,
VITE_BREVO_SENDER_EMAIL,
VITE_BREVO_SENDER_NAME
} from '$env/static/private';
const apiInstance = new brevo.TransactionalEmailsApi();
const apiKey = apiInstance.authentications['apiKey'];
if (!VITE_BREVO_API_KEY) {
throw new Error('Brevo API key is not configured');
}
apiKey.apiKey = VITE_BREVO_API_KEY;
const sendEmail = async (subject: string, htmlContentString: string, toAddress: string) => {
const sendSmtpEmail = new brevo.SendSmtpEmail();
sendSmtpEmail.subject = subject;
sendSmtpEmail.htmlContent = htmlContentString;
sendSmtpEmail.sender = { name: VITE_BREVO_SENDER_NAME, email: VITE_BREVO_SENDER_EMAIL };
sendSmtpEmail.to = [{ email: toAddress, name: toAddress }];
sendSmtpEmail.replyTo = { email: VITE_BREVO_SENDER_EMAIL, name: VITE_BREVO_SENDER_NAME };
apiInstance.sendTransacEmail(sendSmtpEmail).then(
function (data) {
console.log('API called successfully. Returned data: ' + JSON.stringify(data));
},
function (error) {
console.error(error);
}
);
};
export { sendEmail };
+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;
+30
View File
@@ -0,0 +1,30 @@
type RateLimitStore = {
[key: string]: { count: number; lastRequest: number };
};
const rateLimitStore: RateLimitStore = {};
const RATE_LIMIT_WINDOW = 60 * 5000; // 5 minutes
const MAX_REQUESTS = 5;
export function isRateLimited(ip: string): boolean {
const currentTime = Date.now();
const requestInfo = rateLimitStore[ip];
if (!requestInfo) {
rateLimitStore[ip] = { count: 1, lastRequest: currentTime };
return false;
}
if (currentTime - requestInfo.lastRequest > RATE_LIMIT_WINDOW) {
rateLimitStore[ip] = { count: 1, lastRequest: currentTime };
return false;
}
if (requestInfo.count >= MAX_REQUESTS) {
return true;
}
rateLimitStore[ip].count += 1;
rateLimitStore[ip].lastRequest = currentTime;
return false;
}
+171 -14
View File
@@ -4,6 +4,8 @@ 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';
import { sendEmail } from '../brevo/email';
const toDateTime = (secs: number) => {
const t = new Date(+0); // Unix epoch start.
@@ -39,7 +41,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 +63,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 +74,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 +84,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 +256,18 @@ const manageSubscriptionStatusChange = async (
trial_end: subscription.trial_end ? toDateTime(subscription.trial_end).toISOString() : null
};
try {
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}]`);
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 +280,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,14 +293,15 @@ 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
try {
const { data: purchases, error: purchaseError } = await supabaseAdmin
.from('purchases')
.select('*')
@@ -304,8 +314,12 @@ const hasProductAccess = async (userId: string, productId: string) => {
// User has purchased the product
return true;
}
} catch (error) {
logger.error(error);
}
// Check if user has an active subscription for the product
try {
const { data: subscriptions, error: subError } = await supabaseAdmin
.from('subscriptions')
.select('*')
@@ -319,6 +333,9 @@ const hasProductAccess = async (userId: string, productId: string) => {
// User has an active subscription
return true;
}
} catch (error) {
logger.error(error);
}
return false;
};
@@ -381,7 +398,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 +407,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 +415,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 +497,142 @@ const getProductById = async (productId: string) => {
return product as Product;
};
const requestAccountDeletion = async (userId: string, userEmail: string, baseUrl: string) => {
const token = crypto.randomUUID();
const { error: insertError } = await supabaseAdmin.from('account_deletion_requests').upsert({
user_id: userId,
token,
requested_at: new Date().toISOString()
});
if (insertError) throw new Error(`Account deletion request failed: ${insertError.message}`);
const deletionLink = `${baseUrl}/api/delete-account?token=${token}`;
// Send deletion email using Brevo
try {
await sendEmail(
'Confirm Account Deletion',
`<p>Click <a href="${deletionLink}">here</a> to confirm your account deletion.</p>`,
userEmail
);
console.log('Deletion email sent successfully.');
} catch (emailError) {
console.error('Failed to send deletion email:', emailError);
throw new Error('Failed to send confirmation email.');
}
};
const deleteAccount = async (token: string) => {
// Verify the token
const { data: deletionRequest, error } = await supabaseAdmin
.from('account_deletion_requests')
.select('user_id')
.eq('token', token)
.single();
if (error || !deletionRequest) {
return new Response('Invalid or expired token', { status: 400 });
}
// N.B. The SQL here will either cascade delete or nullify the foreign key constraints depending on the table, to enable anonymisation
const { error: deletionError } = await supabaseAdmin.auth.admin.deleteUser(
deletionRequest.user_id
);
if (deletionError) {
throw new Error(`Failed to delete user: ${deletionError.message}`);
}
};
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 +648,9 @@ export {
getActiveProductsWithPrices,
getProductById,
upsertCustomerToSupabase,
getStripeCustomerId
getStripeCustomerId,
getUserSubscriptions,
getUserTransactions,
requestAccountDeletion,
deleteAccount
};
@@ -0,0 +1,85 @@
<div class="container mx-auto p-6">
<h1 class="text-3xl font-bold text-primary mb-4">Cookie Policy</h1>
<p class="text-gray-700 mb-6">Effective Date: <span class="font-semibold">[Insert Date]</span></p>
<section class="mb-6">
<h2 class="text-2xl font-semibold mb-2">1. Introduction</h2>
<p class="text-gray-600">
This Cookie Policy explains how [Your Company/Website Name] ("we", "us", or "our") uses
cookies and similar technologies to collect and store information when you visit our website.
</p>
</section>
<section class="mb-6">
<h2 class="text-2xl font-semibold mb-2">2. What Are Cookies?</h2>
<p class="text-gray-600">
Cookies are small text files stored on your device (computer, tablet, smartphone) by your web
browser when you visit a website. They help us remember your preferences and improve your
experience on our website.
</p>
</section>
<section class="mb-6">
<h2 class="text-2xl font-semibold mb-2">3. Types of Cookies We Use</h2>
<ul class="list-disc pl-6 text-gray-600">
<li>
<span class="font-semibold">Essential Cookies</span>: These cookies are necessary for the
website to function properly and cannot be disabled in our systems. They include login
authentication and session management cookies.
</li>
<li>
<span class="font-semibold">Performance Cookies</span>: These cookies help us understand how
visitors interact with our website by collecting information on usage patterns, such as
pages visited and links clicked.
</li>
<li>
<span class="font-semibold">Functional Cookies</span>: These cookies allow us to remember
choices you make (such as your language preference) to provide a more personalised
experience.
</li>
<li>
<span class="font-semibold">Targeting/Advertising Cookies</span>: These cookies are used to
deliver relevant advertisements and track the effectiveness of our marketing campaigns.
</li>
</ul>
</section>
<section class="mb-6">
<h2 class="text-2xl font-semibold mb-2">4. How We Use Cookies</h2>
<p class="text-gray-600">
We use cookies to improve the functionality and performance of our website, analyse visitor
usage, and provide personalised content and advertisements. This helps us improve our website
and better understand our audience.
</p>
</section>
<section class="mb-6">
<h2 class="text-2xl font-semibold mb-2">5. Managing Your Cookie Preferences</h2>
<p class="text-gray-600">
You have the right to accept or reject cookies. You can manage your cookie preferences by
adjusting your browser settings. However, disabling certain cookies may affect your experience
on our website. For specific instructions on managing cookies, please consult your browser's
help documentation.
</p>
</section>
<section class="mb-6">
<h2 class="text-2xl font-semibold mb-2">6. Changes to Our Cookie Policy</h2>
<p class="text-gray-600">
We may update this Cookie Policy from time to time. Significant changes will be posted on this
page. We encourage you to review this policy periodically to stay informed about our use of
cookies.
</p>
</section>
<section class="mb-6">
<h2 class="text-2xl font-semibold mb-2">Contact Us</h2>
<p class="text-gray-600">
For questions about our Cookie Policy, please contact us at <a
href="mailto:your-email@example.com"
class="text-primary font-semibold">[Your Contact Email]</a
>
or visit our <a href="/contact" class="text-primary font-semibold">Contact Page</a>.
</p>
</section>
</div>
+125
View File
@@ -0,0 +1,125 @@
<div class="container mx-auto p-6">
<h1 class="text-3xl font-bold text-primary mb-4">Privacy Policy</h1>
<p class="text-gray-700 mb-6">Effective Date: <span class="font-semibold">[Insert Date]</span></p>
<section class="mb-6">
<h2 class="text-2xl font-semibold mb-2">1. Introduction</h2>
<p class="text-gray-600">
This Privacy Policy explains how we at [Your Company/Website Name] collect, use, and protect
your personal information when you visit our website. We are committed to safeguarding your
data and complying with relevant data protection laws, including the General Data Protection
Regulation (GDPR).
</p>
</section>
<section class="mb-6">
<h2 class="text-2xl font-semibold mb-2">2. Information We Collect</h2>
<p class="text-gray-600">
We collect personal and non-personal information to provide our services and enhance your
experience.
</p>
<ul class="list-disc pl-6 text-gray-600">
<li>
<span class="font-semibold">Personal Information</span>: Details like your name, email
address, and payment information collected when you make a purchase or create an account.
</li>
<li>
<span class="font-semibold">Usage Data</span>: Information such as IP address, browser type,
and browsing history for analytics and functionality.
</li>
</ul>
</section>
<section class="mb-6">
<h2 class="text-2xl font-semibold mb-2">3. How We Use Your Information</h2>
<ul class="list-disc pl-6 text-gray-600">
<li>To process orders and manage customer support.</li>
<li>To improve website functionality and provide a better user experience.</li>
<li>To send marketing emails, if you opt in (you may unsubscribe at any time).</li>
</ul>
</section>
<section class="mb-6">
<h2 class="text-2xl font-semibold mb-2">4. Sharing Information with Third Parties</h2>
<p class="text-gray-600">
We work with trusted third-party providers to offer secure services, such as payment
processing and authentication.
</p>
<div class="mt-4">
<h3 class="text-xl font-semibold">Stripe</h3>
<p class="text-gray-600">
For payment processing, we use <a
href="https://stripe.com"
target="_blank"
class="text-primary font-semibold">Stripe</a
>. Please refer to their
<a href="https://stripe.com/privacy" target="_blank" class="text-primary font-semibold"
>Privacy Policy</a
> for details.
</p>
</div>
<div class="mt-4">
<h3 class="text-xl font-semibold">Supabase</h3>
<p class="text-gray-600">
For database and authentication, we use <a
href="https://supabase.com"
target="_blank"
class="text-primary font-semibold">Supabase</a
>. See their
<a href="https://supabase.com/privacy" target="_blank" class="text-primary font-semibold"
>Privacy Policy</a
> for more information.
</p>
</div>
</section>
<section class="mb-6">
<h2 class="text-2xl font-semibold mb-2">5. Cookies and Tracking</h2>
<p class="text-gray-600">
We use cookies to provide essential site functionality, analytics, and improved user
experience. By continuing to use our site, you consent to our use of cookies. You can manage
your cookie preferences in your browser settings. For more details, please refer to our <a
href="/cookies"
class="text-primary font-semibold">Cookie Policy</a
>.
</p>
</section>
<section class="mb-6">
<h2 class="text-2xl font-semibold mb-2">6. Your Data Rights</h2>
<p class="text-gray-600">
Under GDPR, you have the right to access, correct, delete, and restrict the processing of your
personal data. To make a request, please contact us at <a
href="mailto:your-email@example.com"
class="text-primary font-semibold">[Your Contact Email]</a
>.
</p>
</section>
<section class="mb-6">
<h2 class="text-2xl font-semibold mb-2">7. Data Security</h2>
<p class="text-gray-600">
We take reasonable steps to protect your information through encryption and secure access
protocols. However, no online service can be 100% secure.
</p>
</section>
<section class="mb-6">
<h2 class="text-2xl font-semibold mb-2">8. Policy Updates</h2>
<p class="text-gray-600">
We may update this policy periodically. Significant changes will be posted on this page, so
please review it regularly.
</p>
</section>
<section class="mb-6">
<h2 class="text-2xl font-semibold mb-2">Contact Us</h2>
<p class="text-gray-600">
If you have questions about this Privacy Policy, please contact us at <a
href="mailto:your-email@example.com"
class="text-primary font-semibold">[Your Contact Email]</a
>
or visit our <a href="/contact" class="text-primary font-semibold">Contact Page</a>.
</p>
</section>
</div>
+5 -1
View File
@@ -195,8 +195,12 @@
d="M22.672 15.226l-2.432.811.841 2.515c.33 1.019-.209 2.127-1.23 2.456-1.15.325-2.148-.321-2.463-1.226l-.84-2.518-5.013 1.677.84 2.517c.391 1.203-.434 2.542-1.831 2.542-.88 0-1.601-.564-1.86-1.314l-.842-2.516-2.431.809c-1.135.328-2.145-.317-2.463-1.229-.329-1.018.211-2.127 1.231-2.456l2.432-.809-1.621-4.823-2.432.808c-1.355.384-2.558-.59-2.558-1.839 0-.817.509-1.582 1.327-1.846l2.433-.809-.842-2.515c-.33-1.02.211-2.129 1.232-2.458 1.02-.329 2.13.209 2.461 1.229l.842 2.515 5.011-1.677-.839-2.517c-.403-1.238.484-2.553 1.843-2.553.819 0 1.585.509 1.85 1.326l.841 2.517 2.431-.81c1.02-.33 2.131.211 2.461 1.229.332 1.018-.21 2.126-1.23 2.456l-2.433.809 1.622 4.823 2.433-.809c1.242-.401 2.557.484 2.557 1.838 0 .819-.51 1.583-1.328 1.847m-8.992-6.428l-5.01 1.675 1.619 4.828 5.011-1.674-1.62-4.829z"
></path>
</svg>
<p>Copyright © 2024 - All right reserved</p>
<p>Copyright © 2024 - All rights reserved</p>
</aside>
<div class="grid-flow-col gap-4">
<a href="/privacy">Privacy Policy</a>
<a href="/cookies">Cookie Policy</a>
</div>
<nav class="grid-flow-col gap-4 md:place-self-center md:justify-self-end">
<a href="/">
<svg
+43 -14
View File
@@ -1,5 +1,8 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import logger from '$lib/utils/logger/logger';
import { getUserSubscriptions, getUserTransactions } from '$lib/utils/supabase/admin';
import { requestAccountDeletion } from '$lib/utils/supabase/admin';
export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => {
const { session } = await safeGetSession();
@@ -14,10 +17,35 @@ export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession
.eq('id', session.user.id)
.single();
return { session, user };
const subscriptions = await getUserSubscriptions(session.user.id);
const transactions = await getUserTransactions(session.user.id);
return { session, user, subscriptions, transactions };
};
export const actions: Actions = {
delete: async ({ url, locals: { safeGetSession } }) => {
const { session } = await safeGetSession();
const baseUrl = url.origin;
if (session) {
if (!session.user.email) {
return fail(400, { message: 'Email is required for account deletion' });
}
try {
await requestAccountDeletion(session.user.id, session.user.email, baseUrl);
return {
success: true,
message: 'Account deletion request submitted successfully'
};
} catch (error) {
console.error('Failed to request account deletion:', error);
return fail(500, { message: 'Failed to process deletion request' });
}
} else {
redirect(303, '/');
}
},
update: async ({ request, locals: { supabase, safeGetSession } }) => {
const formData = await request.formData();
const name = formData.get('name') as string;
@@ -27,27 +55,28 @@ export const actions: Actions = {
if (!session) {
return fail(401, { name });
}
const { error } = await supabase.from('users').update({
name: name,
}).eq('id', session?.user.id);
const { error } = await supabase
.from('users')
.update({
name: name
})
.eq('id', session?.user.id);
console.error(error);
if (error) {
logger.error('Failed to update user name', {
userId: session?.user.id,
error
});
}
if (error) {
return fail(500, {
name,
name
});
}
return {
name,
name
};
},
signout: async ({ locals: { supabase, safeGetSession } }) => {
const { session } = await safeGetSession();
if (session) {
await supabase.auth.signOut();
redirect(303, '/');
}
}
};
+145 -10
View File
@@ -5,7 +5,7 @@
export let data;
export let form;
let { session, supabase, user } = data;
let { session, user, subscriptions, transactions } = data;
$: ({ session, supabase, user } = data);
let profileForm: HTMLFormElement;
@@ -26,34 +26,169 @@
update();
};
};
const handleAccountDeletion: SubmitFunction = () => {
loading = true;
if (session.user && session.user.email) {
alert('Check your email to confirm account deletion.');
} else {
alert('Please sign in to delete your account.');
}
return async () => {
loading = false;
};
};
</script>
<div class="form-widget">
<div
class="account-page max-w-4xl mx-auto p-6 bg-base-100 rounded-lg shadow-md space-y-6 overflow-y-auto"
>
<!-- Flex container for two columns -->
<div class="flex flex-col lg:flex-row gap-6">
<!-- Left Column: Account Overview, Subscriptions, and Billing History -->
<div class="flex-1 space-y-6">
<!-- Account Overview -->
<section class="space-y-4">
<h2 class="text-xl font-semibold">Account Overview</h2>
<div class="form-control">
<label for="email" class="label font-medium">Email</label>
<input
id="email"
type="email"
class="input input-bordered"
value={session.user.email}
disabled
/>
</div>
<div class="text-sm text-gray-500">Signed in with magic link.</div>
<form
class="form-widget"
class="form-widget space-y-4"
method="post"
action="?/update"
use:enhance={handleSubmit}
bind:this={profileForm}
>
<div>
<label for="name">Name</label>
<input id="name" name="name" type="text" value={form?.name ?? name} />
<div class="form-control">
<label for="name" class="label font-medium">Name</label>
<input
id="name"
name="name"
type="text"
value={form?.name ?? name}
class="input input-bordered w-full"
placeholder="Enter your name"
/>
</div>
<div>
<input
type="submit"
class="button block primary"
value={loading ? 'Loading...' : 'Update'}
class="btn btn-primary w-full"
value={loading ? 'Loading...' : 'Update Name'}
disabled={loading}
/>
</div>
</form>
</section>
<form method="post" action="?/signout" use:enhance={handleSignOut}>
<!-- Active Subscriptions -->
<section class="space-y-4">
<h2 class="text-xl font-semibold">Subscriptions</h2>
{#if subscriptions.length === 0}
<p class="text-gray-600">You do not have any subscriptions.</p>
{:else}
<ul class="space-y-3">
{#each subscriptions as subscription}
<li class="p-4 border border-gray-200 rounded-md">
<div class="flex items-center justify-between">
<div>
<button class="button block" disabled={loading}>Sign Out</button>
<p class="font-semibold">{subscription.productName}</p>
<p class="text-sm text-gray-500">
Price: {subscription.amount}
{subscription.currency} / {subscription.interval}
</p>
<p class="text-sm text-gray-500">
Expiry Date: {subscription.expiryDate}
</p>
<p class="text-sm text-gray-500">
Status: {subscription.status}
</p>
</div>
<!-- <button class="btn btn-secondary btn-sm">Manage</button> -->
</div>
</li>
{/each}
</ul>
{/if}
</section>
<!-- Billing History -->
<section class="space-y-4">
<h2 class="text-xl font-semibold">Billing History</h2>
<ul class="space-y-3">
{#if transactions}
{#each transactions as transaction}
<li class="flex justify-between p-4 border border-gray-200 rounded-md">
<div>
<p class="text-sm text-gray-500">Date: {transaction.created}</p>
<p class="text-sm text-gray-500">
Amount: {transaction.amount}
{transaction.currency}
</p>
</div>
{#if transaction.receipt_url}
<a href={transaction.receipt_url} class="btn btn-outline btn-sm" target="_blank">
Download Receipt
</a>
{:else}
<span class="text-gray-500">Receipt unavailable</span>
{/if}
</li>
{/each}
{/if}
</ul>
</section>
</div>
<!-- Right Column: Notification Preferences and Feedback -->
<div class="flex-none w-full lg:w-64 space-y-6">
<!-- Notification Preferences -->
<section class="space-y-4">
<h2 class="text-xl font-semibold">Notification Preferences</h2>
<form method="post" action="?/update_notifications" class="space-y-2">
<label class="flex items-center space-x-3">
<input type="checkbox" class="checkbox checkbox-primary" />
<!-- checked={user.notifications.productUpdates} -->
<span>Product Updates</span>
</label>
<label class="flex items-center space-x-3">
<input type="checkbox" class="checkbox checkbox-primary" />
<!-- checked={user.notifications.billingReminders} -->
<span>Billing Reminders</span>
</label>
<label class="flex items-center space-x-3">
<input type="checkbox" class="checkbox checkbox-primary" />
<!-- checked={user.notifications.promotions} -->
<span>Promotional Emails</span>
</label>
</form>
</section>
<!-- Feedback Section -->
<section class="space-y-4">
<h2 class="text-xl font-semibold">Feedback</h2>
<p class="text-gray-600">
We'd love to hear your thoughts! Please feel free to provide any feedback.
</p>
<a href="/contact" class="btn btn-outline w-full">Provide Feedback</a>
</section>
</div>
<form method="post" action="?/delete" use:enhance={handleAccountDeletion}>
<div>
<button class="button block" disabled={loading}>Delete My Account</button>
</div>
</form>
</div>
</div>
+2 -1
View File
@@ -2,6 +2,7 @@ import { type RequestHandler, redirect } from '@sveltejs/kit';
import { stripe as stripeClient } from '$lib/utils/stripe';
import { getStripeCustomerId } from '$lib/utils/supabase/admin';
import type Stripe from 'stripe';
import logger from '$lib/utils/logger/logger';
// Create a checkout session
export const POST: RequestHandler = async ({ request, cookies, locals: { safeGetSession } }) => {
@@ -34,7 +35,7 @@ export const POST: RequestHandler = async ({ request, cookies, locals: { safeGet
try {
stripeCustomerId = await getStripeCustomerId(userEmail!, userId);
} catch (error) {
console.error('Error fetching Stripe customer ID:', error);
logger.error('Error fetching Stripe customer ID:', error);
}
const stripeCheckoutSessionObject = {
@@ -1,16 +0,0 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { upsertCustomerToSupabase } from '$lib/utils/supabase/admin';
export const GET: RequestHandler = async ({ url, locals: { safeGetSession } }) => {
const stripeCustomerId = url.searchParams.get('stripeCustomerId');
const { session } = await safeGetSession();
if (!session || !stripeCustomerId) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
await upsertCustomerToSupabase(session?.user.id, stripeCustomerId);
return json({ message: 'Customer created' });
};
+18
View File
@@ -0,0 +1,18 @@
import type { RequestHandler } from '@sveltejs/kit';
import { deleteAccount } from '$lib/utils/supabase/admin';
export const GET: RequestHandler = async ({ url }) => {
const token = url.searchParams.get('token');
if (!token) {
return new Response('Invalid token', { status: 400 });
}
try {
await deleteAccount(token);
} catch (error) {
console.error(error);
return new Response('Failed to delete account', { status: 500 });
}
return new Response('Account deleted successfully', { status: 200 });
};
@@ -1,9 +0,0 @@
import { json } from '@sveltejs/kit';
export const GET = async () => {
return json(await getSomeData());
};
async function getSomeData() {
// Do your async fetching here
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { type RequestHandler, redirect } from '@sveltejs/kit';
import { stripe as stripeClient } from '$lib/utils/stripe';
import { getStripeCustomerId } from '$lib/utils/supabase/admin';
import type Stripe from 'stripe';
import logger from '$lib/utils/logger/logger';
// Create a subscription checkout session
export const POST: RequestHandler = async ({ request, cookies, locals: { safeGetSession } }) => {
@@ -29,7 +30,7 @@ export const POST: RequestHandler = async ({ request, cookies, locals: { safeGet
try {
stripeCustomerId = await getStripeCustomerId(userEmail!, userId);
} catch (error) {
console.error('Error fetching Stripe customer ID:', error);
logger.error('Error fetching Stripe customer ID:', error);
}
const stripeCheckoutSessionObject = {
+3 -2
View File
@@ -10,6 +10,7 @@ import {
recordProductPurchase
} from '$lib/utils/supabase/admin';
import { stripe as stripeClient } from '$lib/utils/stripe';
import logger from '$lib/utils/logger/logger';
export const POST: RequestHandler = async ({ request }) => {
const body = await request.text();
@@ -26,7 +27,7 @@ export const POST: RequestHandler = async ({ request }) => {
try {
event = stripeClient.webhooks.constructEvent(body, signature, STRIPE_ENDPOINT_SECRET);
} catch (err) {
console.error(`⚠️ Webhook signature verification failed.`, err.message);
logger.error(`⚠️ Webhook signature verification failed.`, err.message);
return {
status: 400,
body: {}
@@ -104,7 +105,7 @@ export const POST: RequestHandler = async ({ request }) => {
break;
}
default:
console.log(`Unhandled event type ${event.type}`);
logger.info(`Unhandled event type ${event.type}`);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'An unknown error has occurred';
@@ -1,81 +0,0 @@
<script lang="ts">
import { general } from '$lib/stores/generalStore.js';
// Access the supabase client from the layout data
export let supabase: any;
let newPassword = '';
async function resetPassword() {
if (newPassword === '') {
general.update((value) => {
return {
...value,
hideToast: false,
toastMessage: 'Please enter a new password',
toastType: 'error'
};
});
setTimeout(() => {
general.update((value) => {
return { ...value, hideToast: true, toastMessage: '', toastType: 'success' };
});
}, 5000);
return;
}
const { data, error } = await supabase.auth.updateUser({
password: newPassword
});
if (error) {
console.error('Error updating password:', error.message);
general.update((value) => {
return {
...value,
hideToast: false,
toastMessage: 'Error updating password. Please try again.',
toastType: 'error'
};
});
setTimeout(() => {
general.update((value) => {
return { ...value, hideToast: true, toastMessage: '', toastType: 'success' };
});
}, 5000);
return;
} else {
general.update((value) => {
return {
...value,
hideToast: false,
toastMessage: 'Password updated successfully. Please sign in with your new password.',
toastType: 'success'
};
});
setTimeout(() => {
general.update((value) => {
return { ...value, hideToast: true, toastMessage: '', toastType: 'success' };
});
}, 5000);
}
}
</script>
<svelte:head>
<title>Update Password</title>
</svelte:head>
<main>
<h1>Update Password</h1>
<form on:submit|preventDefault={resetPassword}>
<label for="newPassword">New Password</label>
<input type="password" id="newPassword" bind:value={newPassword} />
<button type="submit">Update Password</button>
</form>
</main>
+10 -9
View File
@@ -1,11 +1,14 @@
import { redirect } from '@sveltejs/kit';
import Stripe from 'stripe';
import { getProductById } from '$lib/utils/supabase/admin';
import { getProductById, upsertCustomerToSupabase } from '$lib/utils/supabase/admin';
import logger from '$lib/utils/logger/logger';
export const load = async ({ fetch, locals: { safeGetSession } }) => {
const { session } = await safeGetSession();
export const load = async ({ fetch }) => {
const response = await fetch('/api/checkout/status');
if (!response.ok) {
console.error('Failed to fetch checkout status');
logger.error('Failed to fetch checkout status');
return;
}
@@ -24,13 +27,11 @@ export const load = async ({ fetch }) => {
lineItem.productDescription = product.description ?? '';
}
// Save the stripe customer id in the customers table
if (session?.user.id) {
const stripeCustomerId = checkoutSession.customer as string;
const createCustomerResponse = await fetch(
'/api/checkout/create-customer?stripeCustomerId=' + stripeCustomerId
);
if (!createCustomerResponse.ok) {
console.error('Failed to create customer');
await upsertCustomerToSupabase(session.user.id, stripeCustomerId);
} else {
logger.error('User ID is undefined');
}
return {
+38
View File
@@ -0,0 +1,38 @@
import type { Actions } from '@sveltejs/kit';
import { fail } from '@sveltejs/kit';
import { sendEmail } from '$lib/utils/brevo/email';
import { VITE_CONTACT_EMAIL } from '$env/static/private';
import logger from '$lib/utils/logger/logger';
import { isRateLimited } from '$lib/utils/rateLimiter';
export const actions: Actions = {
default: async ({ request }) => {
const ip =
request.headers.get('x-forwarded-for') ?? request.headers.get('remote-addr') ?? 'unknown';
if (isRateLimited(ip)) {
return fail(429, { error: 'Too many requests. Please try again later.' });
}
const formData = await request.formData();
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
const emailContent = `
<p>You have a new contact form submission:</p>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Message:</strong></p>
<p>${message}</p>
`;
try {
await sendEmail('New Contact Form Submission', emailContent, VITE_CONTACT_EMAIL);
return { success: true };
} catch (error) {
logger.error('Failed to send email:', error);
return fail(400, { error });
}
}
};
+103
View File
@@ -0,0 +1,103 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
let name = '';
let email = '';
let message = '';
let submitted = false;
let error = '';
const handleSubmit: SubmitFunction = () => {
let loading = false;
return async ({ result, update }) => {
loading = true;
if (result.data && result.data.success) {
submitted = true;
} else {
error = result.error?.message || 'Form submission failed';
}
loading = false;
await update();
};
};
</script>
{#if submitted}
<div class="alert alert-success shadow-lg">
<div>
<span>Thank you for your message, {name}!</span>
</div>
</div>
{:else}
<form
method="post"
action="?"
use:enhance={handleSubmit}
class="max-w-lg mx-auto p-4 bg-base-200 rounded-lg shadow-md"
aria-labelledby="contact-form-title"
>
<h2 id="contact-form-title" class="text-xl font-bold mb-4">Contact Us</h2>
{#if error}
<div class="alert alert-error shadow-lg mb-4">
<div>
<span>{error}</span>
</div>
</div>
{/if}
<div class="form-control mb-4">
<label for="name" class="label">
<span class="label-text">Name *</span>
</label>
<input
id="name"
name="name"
type="text"
bind:value={name}
class="input input-bordered"
aria-required="true"
minlength="2"
maxlength="100"
required
/>
</div>
<div class="form-control mb-4">
<label for="email" class="label">
<span class="label-text">Email *</span>
</label>
<input
id="email"
name="email"
type="email"
bind:value={email}
class="input input-bordered"
aria-required="true"
minlength="5"
maxlength="100"
required
/>
</div>
<div class="form-control mb-4">
<label for="message" class="label">
<span class="label-text">Message *</span>
</label>
<textarea
id="message"
name="message"
bind:value={message}
class="textarea textarea-bordered"
aria-required="true"
minlength="10"
maxlength="1000"
required
></textarea>
<small class="text-sm mt-1">Maximum 1000 characters</small>
</div>
<div class="form-control">
<button type="submit" class="btn btn-primary">Send</button>
</div>
</form>
{/if}
+25 -7
View File
@@ -1,19 +1,37 @@
import { json } from '@sveltejs/kit';
import { getActiveProductsWithPrices } from '$lib/utils/supabase/admin';
import logger from '$lib/utils/logger/logger';
export const GET = async ({ url }) => {
const limitParam: string | null = url.searchParams.get('limit');
const offsetParam: string | null = url.searchParams.get('offset');
try {
const limitParam = url.searchParams.get('limit');
const offsetParam = url.searchParams.get('offset');
const limit = limitParam ? parseInt(limitParam, 10) : 10;
const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
const limit = limitParam ? Number.parseInt(limitParam, 10) : 10;
const offset = offsetParam ? Number.parseInt(offsetParam, 10) : 0;
const { products, count } = await getActiveProductsWithPrices(limit, offset);
return json({ products, count });
} catch (error) {
console.error(error);
} catch (error: unknown) {
if (error instanceof Error) {
logger.error('Failed to fetch products', {
error: error.message,
stack: error.stack,
params: {
limit: limitParam,
offset: offsetParam
}
});
return json({ error: error.message }, { status: 500 });
} else {
logger.error('Failed to fetch products', {
error,
params: {
limit: limitParam,
offset: offsetParam
}
});
return json({ error }, { status: 500 });
}
}
};
@@ -0,0 +1,46 @@
create table account_deletion_requests (
user_id uuid references auth.users not null primary key,
token text not null,
requested_at timestamp with time zone default now() not null
);
alter table account_deletion_requests enable row level security;
create policy "Can view own deletion request data" on account_deletion_requests
for select using (auth.uid() = user_id);
create policy "Can insert own deletion request data" on account_deletion_requests
for insert with check (auth.uid() = user_id);
ALTER TABLE customers
DROP CONSTRAINT IF EXISTS customers_id_fkey;
ALTER TABLE customers
ADD CONSTRAINT customers_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE subscriptions
DROP CONSTRAINT IF EXISTS subscriptions_user_id_fkey;
ALTER TABLE subscriptions
ADD CONSTRAINT subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE purchases
DROP CONSTRAINT IF EXISTS purchases_user_id_fkey;
ALTER TABLE purchases
ADD CONSTRAINT purchases_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE user_credits
DROP CONSTRAINT IF EXISTS user_credits_user_id_fkey;
ALTER TABLE user_credits
ADD CONSTRAINT user_credits_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE credit_transactions
DROP CONSTRAINT IF EXISTS credit_transactions_user_id_fkey;
ALTER TABLE credit_transactions
ADD CONSTRAINT credit_transactions_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE account_deletion_requests
DROP CONSTRAINT IF EXISTS account_deletion_requests_user_id_fkey;
ALTER TABLE account_deletion_requests
ADD CONSTRAINT account_deletion_requests_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_id_fkey;
ALTER TABLE users
ADD CONSTRAINT users_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE;