🎹 add email login and ts stuff

This commit is contained in:
vincanger
2023-04-17 16:39:04 +02:00
parent 2bc1afdae1
commit 4a82d1a49f
21 changed files with 337 additions and 178 deletions

View File

@@ -3,11 +3,10 @@
<img src='src/client/static/gptsaastemplate.png' width='700px'/>
## Running it locally
Before you being, install [Wasp](https://wasp-lang.dev) by running `curl -sSL https://get.wasp-lang.dev/installer.sh | sh` in your terminal.
1. First clone this repo.
2. Create a `.env.server` file in the root of the project
3. Copy the `env.server.example` file contents to `.env.server` and fill in your API keys
1. Make sure you have the latest version of [Wasp](https://wasp-lang.dev) installed by running `curl -sSL https://get.wasp-lang.dev/installer.sh | sh` in your terminal.
2. Run `wasp new <project-name> -t saas` to create a new app using this template.
3. Rename the `env.server.example` file to `.env.server` and fill in your API keys
4. Make sure you have a Database connected and running. Here are two quick options:
- run `wasp start db` if you have Docker installed (if not, on MacOS run `brew install docker-machine docker`). This will start a Postgres database for you. No need to do anything else! 🤯
- or provision a Postgres database on [Railway](https://railway.app), go to settings and copy the `connection url`. Past it as `DATABASE_URL=<your-postgres-connection-url>` into your `env.server` file.

View File

@@ -15,10 +15,26 @@ app SaaSTemplate {
userEntity: User,
externalAuthEntity: SocialLogin,
methods: {
email: {
fromField: {
name: "SaaS App",
// make sure this address is the same you registered your SendGrid or MailGun account with!
email: "vince@wasp-lang.dev"
},
emailVerification: {
clientRoute: EmailVerificationRoute,
getEmailContentFn: import { getVerificationEmailContent } from "@server/auth/email.js",
},
passwordReset: {
clientRoute: PasswordResetRoute,
getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js",
},
},
google: { // Guide for setting up Auth via Google https://wasp-lang.dev/docs/integrations/google
getUserFieldsFn: import { getUserFields } from "@server/auth/google.js",
configFn: import { config } from "@server/auth/google.js",
},
// gitHub: {} // Guide for setting up Auth via Github https://wasp-lang.dev/docs/integrations/github
},
onAuthFailedRedirectTo: "/",
},
@@ -31,10 +47,15 @@ app SaaSTemplate {
emailSender: {
provider: SendGrid,
defaultFrom: {
name: "MySaaSApp",
// make sure this address is the same you registered your SendGrid account with!
email: "email@mysaasapp.com"
name: "SaaS App",
// make sure this address is the same you registered your SendGrid or MailGun account with!
email: "vince@wasp-lang.dev"
},
// defaultFrom: {
// name: "MySaaSApp",
// // make sure this address is the same you registered your SendGrid or MailGun account with!
// email: "email@mysaasapp.com"
// },
},
dependencies: [
("@headlessui/react", "1.7.13"),
@@ -55,15 +76,19 @@ app SaaSTemplate {
*/
entity User {=psl
id Int @id @default(autoincrement())
email String @unique
stripeId String?
checkoutSessionId String?
hasPaid Boolean @default(false)
sendEmail Boolean @default(false)
datePaid DateTime?
credits Int @default(3)
relatedObject RelatedObject[]
id Int @id @default(autoincrement())
email String? @unique
password String?
isEmailVerified Boolean @default(false)
emailVerificationSentAt DateTime?
passwordResetSentAt DateTime?
stripeId String?
checkoutSessionId String?
hasPaid Boolean @default(false)
sendEmail Boolean @default(false)
datePaid DateTime?
credits Int @default(3)
relatedObject RelatedObject[]
externalAuthAssociations SocialLogin[]
psl=}
@@ -99,7 +124,27 @@ page MainPage {
route LoginRoute { path: "/login", to: LoginPage }
page LoginPage {
component: import Login from "@client/Login"
component: import Login from "@client/auth/LoginPage"
}
route SignupRoute { path: "/signup", to: SignupPage }
page SignupPage {
component: import { Signup } from "@client/auth/SignupPage"
}
route RequestPasswordResetRoute { path: "/request-password-reset", to: RequestPasswordResetPage }
page RequestPasswordResetPage {
component: import { RequestPasswordReset } from "@client/auth/RequestPasswordReset",
}
route PasswordResetRoute { path: "/password-reset", to: PasswordResetPage }
page PasswordResetPage {
component: import { PasswordReset } from "@client/auth/PasswordReset",
}
route EmailVerificationRoute { path: "/email-verification", to: EmailVerificationPage }
page EmailVerificationPage {
component: import { EmailVerification } from "@client/auth/EmailVerification",
}
route GptRoute { path: "/gpt", to: GptPage }
@@ -159,6 +204,7 @@ query getRelatedObjects {
/*
* 📡 These are custom Wasp API Endpoints. Use them for callbacks, webhooks, etc.
* https://wasp-lang.dev/docs/language/features#apis
*/
api stripeWebhook {

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "emailVerificationSentAt" TIMESTAMP(3),
ADD COLUMN "isEmailVerified" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "password" TEXT,
ADD COLUMN "passwordResetSentAt" TIMESTAMP(3),
ALTER COLUMN "email" DROP NOT NULL;

View File

@@ -72,7 +72,7 @@ function BuyMoreButton({ isLoading, setIsLoading }: { isLoading: boolean, setIsL
const handleClick = async () => {
setIsLoading(true);
const stripeResults = await stripePayment();
if (stripeResults) {
if (stripeResults?.sessionUrl) {
window.open(stripeResults.sessionUrl, '_self');
}
setIsLoading(false);

View File

@@ -8,7 +8,6 @@ export default function CheckoutPage({ user }: { user: User }) {
const history = useHistory();
useEffect(() => {
function delayedRedirect() {
return setTimeout(() => {
history.push('/account');
@@ -33,19 +32,23 @@ export default function CheckoutPage({ user }: { user: User }) {
}, []);
return (
<>
<h1>
{hasPaid === 'paid'
? '🥳 Payment Successful!'
: hasPaid === 'canceled'
? '😢 Payment Canceled'
: hasPaid === 'error' && '🙄 Payment Error'}
</h1>
{hasPaid !== 'loading' && (
<span className='text-center'>
You are being redirected to your account page... <br />
</span>
)}
</>
<div className='flex min-h-full flex-col justify-center mt-10 sm:px-6 lg:px-8'>
<div className='sm:mx-auto sm:w-full sm:max-w-md'>
<div className='bg-white py-8 px-4 shadow-xl ring-1 ring-gray-900/10 sm:rounded-lg sm:px-10'>
<h1>
{hasPaid === 'paid'
? '🥳 Payment Successful!'
: hasPaid === 'canceled'
? '😢 Payment Canceled'
: hasPaid === 'error' && '🙄 Payment Error'}
</h1>
{hasPaid !== 'loading' && (
<span className='text-center'>
You are being redirected to your account page... <br />
</span>
)}
</div>
</div>
</div>
);
}

View File

@@ -17,7 +17,7 @@ export default function GptPage() {
const { data: user } = useAuth();
const onSubmit = async ({ instructions, command, temperature }: any) => {
console.log('user, ', !!user)
console.log('user, ', !!user);
if (!user) {
alert('You must be logged in to use this feature.');
return;
@@ -25,7 +25,7 @@ export default function GptPage() {
try {
const response = (await generateGptResponse({ instructions, command, temperature })) as RelatedObject;
if (response) {
setResponse(response.content)
setResponse(response.content);
}
} catch (e) {
alert('Something went wrong. Please try again.');
@@ -41,97 +41,103 @@ export default function GptPage() {
} = useForm();
return (
<div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className='space-y-6 mt-10 sm:w-[90%] md:w-[50%] mx-auto border-b border-gray-900/10 px-6 pb-12'>
<div className='col-span-full'>
<label htmlFor='instructions' className='block text-sm font-medium leading-6 text-gray-900'>
Instructions -- How should GPT behave?
</label>
<div className='mt-2'>
<textarea
id='instructions'
placeholder='You are a career advice assistant. You are given a prompt and you must respond with of career advice and 10 actionable items.'
rows={3}
className='block w-full rounded-md border-0 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:py-1.5 sm:text-sm sm:leading-6'
defaultValue={''}
{...register('instructions', {
required: 'This is required',
minLength: {
value: 5,
message: 'Minimum length should be 5',
},
})}
/>
</div>
<span className='text-sm text-red-500'>{formErrors.instructions && formErrors.instructions.message}</span>
</div>
<div className='col-span-full'>
<label htmlFor='command' className='block text-sm font-medium leading-6 text-gray-900'>
Command -- What should GPT do?
</label>
<div className='mt-2'>
<textarea
id='command'
placeholder='How should I prepare for opening my own speciatly-coffee shop?'
rows={3}
className='block w-full rounded-md border-0 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:py-1.5 sm:text-sm sm:leading-6'
defaultValue={''}
{...register('command', {
required: 'This is required',
minLength: {
value: 5,
message: 'Minimum length should be 5',
},
})}
/>
</div>
<span className='text-sm text-red-500'>{formErrors.command && formErrors.command.message}</span>
</div>
<div className='mt-10 px-6'>
<div className='overflow-hidden bg-white ring-1 ring-gray-900/10 shadow-lg sm:rounded-lg lg:m-8'>
<div className='m-4 py-4 sm:px-6 lg:px-8'>
<form onSubmit={handleSubmit(onSubmit)}>
<div className='space-y-6 sm:w-[90%] md:w-[60%] mx-auto border-b border-gray-900/10 px-6 pb-12'>
<div className='col-span-full'>
<label htmlFor='instructions' className='block text-sm font-medium leading-6 text-gray-900'>
Instructions -- How should GPT behave?
</label>
<div className='mt-2'>
<textarea
id='instructions'
placeholder='You are a career advice assistant. You are given a prompt and you must respond with of career advice and 10 actionable items.'
rows={3}
className='block w-full rounded-md border-0 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:py-1.5 sm:text-sm sm:leading-6'
defaultValue={''}
{...register('instructions', {
required: 'This is required',
minLength: {
value: 5,
message: 'Minimum length should be 5',
},
})}
/>
</div>
<span className='text-sm text-red-500'>
{formErrors.instructions && formErrors.instructions.message}
</span>
</div>
<div className='col-span-full'>
<label htmlFor='command' className='block text-sm font-medium leading-6 text-gray-900'>
Command -- What should GPT do?
</label>
<div className='mt-2'>
<textarea
id='command'
placeholder='How should I prepare for opening my own speciatly-coffee shop?'
rows={3}
className='block w-full rounded-md border-0 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:py-1.5 sm:text-sm sm:leading-6'
defaultValue={''}
{...register('command', {
required: 'This is required',
minLength: {
value: 5,
message: 'Minimum length should be 5',
},
})}
/>
</div>
<span className='text-sm text-red-500'>{formErrors.command && formErrors.command.message}</span>
</div>
<div className='h-10 '>
<label htmlFor='temperature' className='w-full text-gray-700 text-sm font-semibold'>
Temperature Input -- Controls How Random GPT's Output is
</label>
<div className='w-32 mt-2'>
<div className='flex flex-row h-10 w-full rounded-lg relative rounded-md border-0 ring-1 ring-inset ring-gray-300 bg-transparent mt-1'>
<input
type='number'
className='outline-none focus:outline-none border-0 rounded-md ring-1 ring-inset ring-gray-300 text-center w-full font-semibold text-md hover:text-black focus:text-black md:text-basecursor-default flex items-center text-gray-700 outline-none'
value={temperature}
min='0'
max='2'
step='0.1'
{...register('temperature', {
onChange: (e) => {
console.log(e.target.value);
setTemperature(Number(e.target.value));
},
required: 'This is required',
})}
></input>
<div className='h-10 '>
<label htmlFor='temperature' className='w-full text-gray-700 text-sm font-semibold'>
Temperature Input -- Controls How Random GPT's Output is
</label>
<div className='w-32 mt-2'>
<div className='flex flex-row h-10 w-full rounded-lg relative rounded-md border-0 ring-1 ring-inset ring-gray-300 bg-transparent mt-1'>
<input
type='number'
className='outline-none focus:outline-none border-0 rounded-md ring-1 ring-inset ring-gray-300 text-center w-full font-semibold text-md hover:text-black focus:text-black md:text-basecursor-default flex items-center text-gray-700 outline-none'
value={temperature}
min='0'
max='2'
step='0.1'
{...register('temperature', {
onChange: (e) => {
console.log(e.target.value);
setTemperature(Number(e.target.value));
},
required: 'This is required',
})}
></input>
</div>
</div>
</div>
</div>
</div>
</div>
<div className='mt-6 flex justify-end gap-x-6 sm:w-[90%] md:w-[50%] mx-auto'>
<button
type='submit'
<div className='mt-6 flex justify-end gap-x-6 sm:w-[90%] md:w-[50%] mx-auto'>
<button
type='submit'
className={`${
isSubmitting && 'animate-puls'
} rounded-md bg-yellow-500 py-2 px-3 text-sm font-semibold text-white shadow-sm hover:bg-yellow-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600`}
>
{!isSubmitting ? 'Submit' : 'Loading...'}
</button>
</div>
</form>
<div
className={`${
isSubmitting && 'animate-puls'
} rounded-md bg-yellow-500 py-2 px-3 text-sm font-semibold text-white shadow-sm hover:bg-yellow-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600`}
isSubmitting && 'animate-pulse'
} mt-2 mx-6 flex justify-center rounded-lg border border-dashed border-gray-900/25 mt-10 sm:w-[90%] md:w-[50%] mx-auto mt-12 px-6 py-10`}
>
{!isSubmitting ? 'Submit' : 'Loading...'}
</button>
</div>
</form>
<div
className={`${
isSubmitting && 'animate-pulse'
} mt-2 mx-6 flex justify-center rounded-lg border border-dashed border-gray-900/25 mt-10 sm:w-[90%] md:w-[50%] mx-auto mt-12 px-6 py-10`}
>
<div className='space-y-2 text-center'>
<p className='text-sm text-gray-500'>{response ? response : 'GPT Response will load here'}</p>
<div className='space-y-2 text-center'>
<p className='text-sm text-gray-500'>{response ? response : 'GPT Response will load here'}</p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,43 +0,0 @@
import { signInUrl } from '@wasp/auth/helpers/Google';
import { AiOutlineGoogle } from 'react-icons/ai';
import { useHistory } from 'react-router-dom';
import { useEffect } from 'react';
import useAuth from '@wasp/auth/useAuth';
export default function Login() {
const history = useHistory();
const { data: user } = useAuth();
useEffect(() => {
if (user) {
history.push('/');
}
}, [user, history]);
return (
<>
<div className='flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8'>
<div className='sm:mx-auto sm:w-full sm:max-w-md'>
<h2 className='mt-6 text-center text-3xl font-bold tracking-tight text-gray-900'>Sign in to your account</h2>
</div>
<div className='mt-8 sm:mx-auto sm:w-full sm:max-w-md'>
<div className='bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10'>
<div className='mt-6'>
<div>
<a
href={signInUrl}
className='inline-flex w-full justify-center items-center rounded-md bg-white py-2 px-4 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0'
>
<AiOutlineGoogle className='h-5 w-5 mr-2' />
<span >Sign in with Google</span>
</a>
</div>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -2,4 +2,11 @@
@tailwind components;
@tailwind utilities;
/* rest of content below */
a {
color: rgb(79 70 229 / var(--tw-text-opacity));
}
a:hover {
color: rgb(99 102 241 / var(--tw-text-opacity));
}

View File

@@ -39,7 +39,7 @@ export default function MainPage() {
<span className='text-sm font-semibold leading-6 text-gray-900'>Made with Wasp &nbsp; {' = }'}</span>
<a
href='https://wasp-lang.dev/docs'
className='rounded-md bg-yellow-500 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-yellow-6 00 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500'
className='rounded-md bg-yellow-500 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-yellow-400 hover:text-black/70 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500'
>
Read the Wasp Docs
</a>

View File

@@ -29,7 +29,7 @@ export default function PricingPage() {
setIsLoading(true);
try {
const response = await stripePayment();
if (response) {
if (response?.sessionUrl) {
window.open(response.sessionUrl, '_self');
}
} catch (e) {

View File

@@ -0,0 +1,15 @@
import { Link } from 'react-router-dom';
import { VerifyEmailForm } from '@wasp/auth/forms/VerifyEmail';
import { AuthWrapper } from './authWrapper';
export function EmailVerification() {
return (
<AuthWrapper>
<VerifyEmailForm />
<br />
<span className='text-sm font-medium text-gray-900'>
If everything is okay, <Link to='/login' className='underline'>go to login</Link>
</span>
</AuthWrapper>
);
}

View File

@@ -0,0 +1,40 @@
import { useHistory } from 'react-router-dom';
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { LoginForm } from '@wasp/auth/forms/Login';
import { AuthWrapper } from './authWrapper';
import useAuth from '@wasp/auth/useAuth';
export default function Login() {
const history = useHistory();
const { data: user } = useAuth();
useEffect(() => {
if (user) {
history.push('/');
}
}, [user, history]);
return (
<AuthWrapper>
<LoginForm />
<br />
<span className='text-sm font-medium text-gray-900'>
Don't have an account yet?{' '}
<Link to='/signup' className='underline'>
go to signup
</Link>
.
</span>
<br />
<span className='text-sm font-medium text-gray-900'>
Forgot your password?{' '}
<Link to='/request-password-reset' className='underline'>
reset it
</Link>
.
</span>
</AuthWrapper>
);
}

View File

@@ -0,0 +1,15 @@
import { Link } from 'react-router-dom';
import { ResetPasswordForm } from '@wasp/auth/forms/ResetPassword';
import { AuthWrapper } from './authWrapper';
export function PasswordReset() {
return (
<AuthWrapper>
<ResetPasswordForm />
<br />
<span className='text-sm font-medium text-gray-900'>
If everything is okay, <Link to='/login'>go to login</Link>
</span>
</AuthWrapper>
);
}

View File

@@ -0,0 +1,10 @@
import { ForgotPasswordForm } from '@wasp/auth/forms/ForgotPassword';
import { AuthWrapper } from './authWrapper';
export function RequestPasswordReset() {
return (
<AuthWrapper>
<ForgotPasswordForm />
</AuthWrapper>
);
}

View File

@@ -0,0 +1,20 @@
import { Link } from 'react-router-dom';
import { SignupForm } from '@wasp/auth/forms/Signup';
import { AuthWrapper } from './authWrapper';
export function Signup() {
return (
<AuthWrapper>
<SignupForm />
<br />
<span className='text-sm font-medium text-gray-900'>
I already have an account (
<Link to='/login' className='underline'>
go to login
</Link>
).
</span>
<br />
</AuthWrapper>
);
}

View File

@@ -0,0 +1,15 @@
import { ReactNode } from 'react';
export function AuthWrapper({children} : {children: ReactNode }) {
return (
<div className='flex min-h-full flex-col justify-center mt-10 sm:px-6 lg:px-8'>
<div className='sm:mx-auto sm:w-full sm:max-w-md'>
<div className='bg-white py-8 px-4 shadow-xl ring-1 ring-gray-900/10 sm:rounded-lg sm:px-10'>
<div className='-mt-8'>
{ children }
</div>
</div>
</div>
</div>
);
}

View File

@@ -12,18 +12,18 @@ const stripe = new Stripe(process.env.STRIPE_KEY!, {
// WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp-lang.dev/docs/deploying
const DOMAIN = process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000';
export const stripePayment: StripePayment<string, StripePaymentResult> = async (_args, context) => {
export const stripePayment: StripePayment<void, StripePaymentResult> = async (_args, context) => {
if (!context.user) {
throw new HttpError(401);
}
let customer: Stripe.Customer;
const stripeCustomers = await stripe.customers.list({
email: context.user.email,
email: context.user.email!,
});
if (!stripeCustomers.data.length) {
console.log('creating customer');
customer = await stripe.customers.create({
email: context.user.email,
email: context.user.email!,
});
} else {
console.log('using existing customer');

19
src/server/auth/email.ts Normal file
View File

@@ -0,0 +1,19 @@
import { GetVerificationEmailContentFn, GetPasswordResetEmailContentFn } from '@wasp/types';
export const getVerificationEmailContent: GetVerificationEmailContentFn = ({ verificationLink }) => ({
subject: 'Verify your email',
text: `Click the link below to verify your email: ${verificationLink}`,
html: `
<p>Click the link below to verify your email</p>
<a href="${verificationLink}">Verify email</a>
`,
});
export const getPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({ passwordResetLink }) => ({
subject: 'Password reset',
text: `Click the link below to reset your password: ${passwordResetLink}`,
html: `
<p>Click the link below to reset your password</p>
<a href="${passwordResetLink}">Reset password</a>
`,
});

View File

@@ -1,4 +1,5 @@
import { User, Prisma } from '@prisma/client';
import { User } from '@wasp/entities'
import { Prisma } from '@prisma/client'
export type Context = {
user: User;

View File

@@ -94,7 +94,7 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
if (subscription.cancel_at_period_end) {
console.log('Subscription canceled at period end');
const customerEmail = await context.entities.User.findFirst({
const customer = await context.entities.User.findFirst({
where: {
stripeId: userStripeId,
},
@@ -103,9 +103,9 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
},
});
if (customerEmail) {
if (customer?.email) {
await emailSender.send({
to: customerEmail.email,
to: customer.email,
subject: 'We hate to see you go :(',
text: 'We hate to see you go. Here is a sweet offer...',
html: 'We hate to see you go. Here is a sweet offer...',

View File

@@ -28,7 +28,7 @@ export async function checkAndQueueEmails(_args: unknown, context: Context) {
const currentDate = new Date();
const twoWeeksFromNow = new Date(currentDate.getTime() + 14 * 24 * 60 * 60 * 1000);
console.log('Starting CRON JOB: \n\nSending expiration notices...');
console.log('Starting CRON JOB: \n\nSending notices...');
const users = await context.entities.User.findMany({
where: {
@@ -39,10 +39,10 @@ export async function checkAndQueueEmails(_args: unknown, context: Context) {
},
}) as User[];
console.log('Sending expiration notices to users: ', users.length);
console.log('Sending notices to users: ', users.length);
if (users.length === 0) {
console.log('No users to send expiration notices to.');
console.log('No users to send notices to.');
return;
}
await Promise.allSettled(
@@ -52,7 +52,7 @@ export async function checkAndQueueEmails(_args: unknown, context: Context) {
emailToSend.to = user.email;
await emailSender.send(emailToSend);
} catch (error) {
console.error('Error sending expiration notice to user: ', user.id, error);
console.error('Error sending notice to user: ', user.id, error);
}
}
})