🎹 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'/> <img src='src/client/static/gptsaastemplate.png' width='700px'/>
## Running it locally ## 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. 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. Create a `.env.server` file in the root of the project 2. Run `wasp new <project-name> -t saas` to create a new app using this template.
3. Copy the `env.server.example` file contents to `.env.server` and fill in your API keys 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: 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! 🤯 - 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. - 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, userEntity: User,
externalAuthEntity: SocialLogin, externalAuthEntity: SocialLogin,
methods: { 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 google: { // Guide for setting up Auth via Google https://wasp-lang.dev/docs/integrations/google
getUserFieldsFn: import { getUserFields } from "@server/auth/google.js", getUserFieldsFn: import { getUserFields } from "@server/auth/google.js",
configFn: import { config } 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: "/", onAuthFailedRedirectTo: "/",
}, },
@@ -31,10 +47,15 @@ app SaaSTemplate {
emailSender: { emailSender: {
provider: SendGrid, provider: SendGrid,
defaultFrom: { defaultFrom: {
name: "MySaaSApp", name: "SaaS App",
// make sure this address is the same you registered your SendGrid account with! // make sure this address is the same you registered your SendGrid or MailGun account with!
email: "email@mysaasapp.com" 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: [ dependencies: [
("@headlessui/react", "1.7.13"), ("@headlessui/react", "1.7.13"),
@@ -55,15 +76,19 @@ app SaaSTemplate {
*/ */
entity User {=psl entity User {=psl
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
email String @unique email String? @unique
stripeId String? password String?
checkoutSessionId String? isEmailVerified Boolean @default(false)
hasPaid Boolean @default(false) emailVerificationSentAt DateTime?
sendEmail Boolean @default(false) passwordResetSentAt DateTime?
datePaid DateTime? stripeId String?
credits Int @default(3) checkoutSessionId String?
relatedObject RelatedObject[] hasPaid Boolean @default(false)
sendEmail Boolean @default(false)
datePaid DateTime?
credits Int @default(3)
relatedObject RelatedObject[]
externalAuthAssociations SocialLogin[] externalAuthAssociations SocialLogin[]
psl=} psl=}
@@ -99,7 +124,27 @@ page MainPage {
route LoginRoute { path: "/login", to: LoginPage } route LoginRoute { path: "/login", to: LoginPage }
page 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 } route GptRoute { path: "/gpt", to: GptPage }
@@ -159,6 +204,7 @@ query getRelatedObjects {
/* /*
* 📡 These are custom Wasp API Endpoints. Use them for callbacks, webhooks, etc. * 📡 These are custom Wasp API Endpoints. Use them for callbacks, webhooks, etc.
* https://wasp-lang.dev/docs/language/features#apis
*/ */
api stripeWebhook { 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 () => { const handleClick = async () => {
setIsLoading(true); setIsLoading(true);
const stripeResults = await stripePayment(); const stripeResults = await stripePayment();
if (stripeResults) { if (stripeResults?.sessionUrl) {
window.open(stripeResults.sessionUrl, '_self'); window.open(stripeResults.sessionUrl, '_self');
} }
setIsLoading(false); setIsLoading(false);

View File

@@ -8,7 +8,6 @@ export default function CheckoutPage({ user }: { user: User }) {
const history = useHistory(); const history = useHistory();
useEffect(() => { useEffect(() => {
function delayedRedirect() { function delayedRedirect() {
return setTimeout(() => { return setTimeout(() => {
history.push('/account'); history.push('/account');
@@ -33,19 +32,23 @@ export default function CheckoutPage({ user }: { user: User }) {
}, []); }, []);
return ( return (
<> <div className='flex min-h-full flex-col justify-center mt-10 sm:px-6 lg:px-8'>
<h1> <div className='sm:mx-auto sm:w-full sm:max-w-md'>
{hasPaid === 'paid' <div className='bg-white py-8 px-4 shadow-xl ring-1 ring-gray-900/10 sm:rounded-lg sm:px-10'>
? '🥳 Payment Successful!' <h1>
: hasPaid === 'canceled' {hasPaid === 'paid'
? '😢 Payment Canceled' ? '🥳 Payment Successful!'
: hasPaid === 'error' && '🙄 Payment Error'} : hasPaid === 'canceled'
</h1> ? '😢 Payment Canceled'
{hasPaid !== 'loading' && ( : hasPaid === 'error' && '🙄 Payment Error'}
<span className='text-center'> </h1>
You are being redirected to your account page... <br /> {hasPaid !== 'loading' && (
</span> <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 { data: user } = useAuth();
const onSubmit = async ({ instructions, command, temperature }: any) => { const onSubmit = async ({ instructions, command, temperature }: any) => {
console.log('user, ', !!user) console.log('user, ', !!user);
if (!user) { if (!user) {
alert('You must be logged in to use this feature.'); alert('You must be logged in to use this feature.');
return; return;
@@ -25,7 +25,7 @@ export default function GptPage() {
try { try {
const response = (await generateGptResponse({ instructions, command, temperature })) as RelatedObject; const response = (await generateGptResponse({ instructions, command, temperature })) as RelatedObject;
if (response) { if (response) {
setResponse(response.content) setResponse(response.content);
} }
} catch (e) { } catch (e) {
alert('Something went wrong. Please try again.'); alert('Something went wrong. Please try again.');
@@ -41,97 +41,103 @@ export default function GptPage() {
} = useForm(); } = useForm();
return ( return (
<div> <div className='mt-10 px-6'>
<form onSubmit={handleSubmit(onSubmit)}> <div className='overflow-hidden bg-white ring-1 ring-gray-900/10 shadow-lg sm:rounded-lg lg:m-8'>
<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='m-4 py-4 sm:px-6 lg:px-8'>
<div className='col-span-full'> <form onSubmit={handleSubmit(onSubmit)}>
<label htmlFor='instructions' className='block text-sm font-medium leading-6 text-gray-900'> <div className='space-y-6 sm:w-[90%] md:w-[60%] mx-auto border-b border-gray-900/10 px-6 pb-12'>
Instructions -- How should GPT behave? <div className='col-span-full'>
</label> <label htmlFor='instructions' className='block text-sm font-medium leading-6 text-gray-900'>
<div className='mt-2'> Instructions -- How should GPT behave?
<textarea </label>
id='instructions' <div className='mt-2'>
placeholder='You are a career advice assistant. You are given a prompt and you must respond with of career advice and 10 actionable items.' <textarea
rows={3} id='instructions'
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' placeholder='You are a career advice assistant. You are given a prompt and you must respond with of career advice and 10 actionable items.'
defaultValue={''} rows={3}
{...register('instructions', { 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'
required: 'This is required', defaultValue={''}
minLength: { {...register('instructions', {
value: 5, required: 'This is required',
message: 'Minimum length should be 5', 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>
<div className='col-span-full'> <span className='text-sm text-red-500'>
<label htmlFor='command' className='block text-sm font-medium leading-6 text-gray-900'> {formErrors.instructions && formErrors.instructions.message}
Command -- What should GPT do? </span>
</label> </div>
<div className='mt-2'> <div className='col-span-full'>
<textarea <label htmlFor='command' className='block text-sm font-medium leading-6 text-gray-900'>
id='command' Command -- What should GPT do?
placeholder='How should I prepare for opening my own speciatly-coffee shop?' </label>
rows={3} <div className='mt-2'>
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' <textarea
defaultValue={''} id='command'
{...register('command', { placeholder='How should I prepare for opening my own speciatly-coffee shop?'
required: 'This is required', rows={3}
minLength: { 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'
value: 5, defaultValue={''}
message: 'Minimum length should be 5', {...register('command', {
}, required: 'This is required',
})} minLength: {
/> value: 5,
</div> message: 'Minimum length should be 5',
<span className='text-sm text-red-500'>{formErrors.command && formErrors.command.message}</span> },
</div> })}
/>
</div>
<span className='text-sm text-red-500'>{formErrors.command && formErrors.command.message}</span>
</div>
<div className='h-10 '> <div className='h-10 '>
<label htmlFor='temperature' className='w-full text-gray-700 text-sm font-semibold'> <label htmlFor='temperature' className='w-full text-gray-700 text-sm font-semibold'>
Temperature Input -- Controls How Random GPT's Output is Temperature Input -- Controls How Random GPT's Output is
</label> </label>
<div className='w-32 mt-2'> <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'> <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 <input
type='number' 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' 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} value={temperature}
min='0' min='0'
max='2' max='2'
step='0.1' step='0.1'
{...register('temperature', { {...register('temperature', {
onChange: (e) => { onChange: (e) => {
console.log(e.target.value); console.log(e.target.value);
setTemperature(Number(e.target.value)); setTemperature(Number(e.target.value));
}, },
required: 'This is required', required: 'This is required',
})} })}
></input> ></input>
</div>
</div>
</div> </div>
</div> </div>
</div> <div className='mt-6 flex justify-end gap-x-6 sm:w-[90%] md:w-[50%] mx-auto'>
</div> <button
<div className='mt-6 flex justify-end gap-x-6 sm:w-[90%] md:w-[50%] mx-auto'> type='submit'
<button className={`${
type='submit' 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={`${ className={`${
isSubmitting && 'animate-puls' isSubmitting && 'animate-pulse'
} 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`} } 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...'} <div className='space-y-2 text-center'>
</button> <p className='text-sm text-gray-500'>{response ? response : 'GPT Response will load here'}</p>
</div> </div>
</form> </div>
<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> </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 components;
@tailwind utilities; @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> <span className='text-sm font-semibold leading-6 text-gray-900'>Made with Wasp &nbsp; {' = }'}</span>
<a <a
href='https://wasp-lang.dev/docs' 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 Read the Wasp Docs
</a> </a>

View File

@@ -29,7 +29,7 @@ export default function PricingPage() {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await stripePayment(); const response = await stripePayment();
if (response) { if (response?.sessionUrl) {
window.open(response.sessionUrl, '_self'); window.open(response.sessionUrl, '_self');
} }
} catch (e) { } 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 // 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'; 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) { if (!context.user) {
throw new HttpError(401); throw new HttpError(401);
} }
let customer: Stripe.Customer; let customer: Stripe.Customer;
const stripeCustomers = await stripe.customers.list({ const stripeCustomers = await stripe.customers.list({
email: context.user.email, email: context.user.email!,
}); });
if (!stripeCustomers.data.length) { if (!stripeCustomers.data.length) {
console.log('creating customer'); console.log('creating customer');
customer = await stripe.customers.create({ customer = await stripe.customers.create({
email: context.user.email, email: context.user.email!,
}); });
} else { } else {
console.log('using existing customer'); 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 = { export type Context = {
user: User; user: User;

View File

@@ -94,7 +94,7 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
if (subscription.cancel_at_period_end) { if (subscription.cancel_at_period_end) {
console.log('Subscription canceled 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: { where: {
stripeId: userStripeId, stripeId: userStripeId,
}, },
@@ -103,9 +103,9 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
}, },
}); });
if (customerEmail) { if (customer?.email) {
await emailSender.send({ await emailSender.send({
to: customerEmail.email, to: customer.email,
subject: 'We hate to see you go :(', subject: 'We hate to see you go :(',
text: 'We hate to see you go. Here is a sweet offer...', text: 'We hate to see you go. Here is a sweet offer...',
html: '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 currentDate = new Date();
const twoWeeksFromNow = new Date(currentDate.getTime() + 14 * 24 * 60 * 60 * 1000); 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({ const users = await context.entities.User.findMany({
where: { where: {
@@ -39,10 +39,10 @@ export async function checkAndQueueEmails(_args: unknown, context: Context) {
}, },
}) as User[]; }) as User[];
console.log('Sending expiration notices to users: ', users.length); console.log('Sending notices to users: ', users.length);
if (users.length === 0) { if (users.length === 0) {
console.log('No users to send expiration notices to.'); console.log('No users to send notices to.');
return; return;
} }
await Promise.allSettled( await Promise.allSettled(
@@ -52,7 +52,7 @@ export async function checkAndQueueEmails(_args: unknown, context: Context) {
emailToSend.to = user.email; emailToSend.to = user.email;
await emailSender.send(emailToSend); await emailSender.send(emailToSend);
} catch (error) { } catch (error) {
console.error('Error sending expiration notice to user: ', user.id, error); console.error('Error sending notice to user: ', user.id, error);
} }
} }
}) })