Skip to content

Commit

Permalink
chore: migrate EmailVerification to pg
Browse files Browse the repository at this point in the history
  • Loading branch information
jordanh committed Mar 2, 2024
1 parent 4e2e2ca commit d67de35
Show file tree
Hide file tree
Showing 8 changed files with 143 additions and 39 deletions.
22 changes: 12 additions & 10 deletions packages/server/__tests__/autoJoin.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import faker from 'faker'
import {getUserTeams, sendPublic, sendIntranet, signUp, signUpWithEmail} from './common'
import getRethink from '../database/rethinkDriver'
import getKysely from '../postgres/getKysely'
import createEmailVerification from '../email/createEmailVerification'

const signUpVerified = async (email: string) => {
Expand All @@ -23,12 +23,14 @@ const signUpVerified = async (email: string) => {
// manually generate verification token so also the founder can be verified
await createEmailVerification({email, password})

const r = await getRethink()
const verificationToken = await r
.table('EmailVerification')
.getAll(email, {index: 'email'})
.nth(0)('token')
.run()
const pg = getKysely()
const verificationToken = (
await pg
.selectFrom('EmailVerification')
.select('token')
.where('email', '=', email)
.executeTakeFirstOrThrow(() => new Error(`No verification token found for ${email}`))
).token

const verifyEmail = await sendPublic({
query: `
Expand All @@ -55,9 +57,9 @@ const signUpVerified = async (email: string) => {
expect(verifyEmail).toMatchObject({
data: {
verifyEmail: {
authToken: expect.toBeString(),
authToken: expect.any(String),
user: {
id: expect.toBeString()
id: expect.any(String)
}
}
}
Expand Down Expand Up @@ -153,7 +155,7 @@ test.skip('autoJoin on multiple teams does not create duplicate `OrganizationUse
const newEmail = `${faker.internet.userName()}@${domain}`.toLowerCase()
const {user: newUser} = await signUpVerified(newEmail)

expect(newUser.tms).toIncludeSameMembers(teamIds)
expect(newUser.tms).toEqual(expect.arrayContaining(teamIds))
expect(newUser.organizations).toMatchObject([
{
id: orgId
Expand Down
6 changes: 1 addition & 5 deletions packages/server/database/types/EmailVerification.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import {Threshold} from 'parabol-client/types/constEnums'
import generateUID from '../../generateUID'

interface Input {
id?: string
token: string
email: string
expiration?: Date
Expand All @@ -12,16 +10,14 @@ interface Input {
}

export default class EmailVerification {
id: string
invitationToken?: string
token: string
email: string
expiration: Date
hashedPassword?: string
pseudoId?: string
constructor(input: Input) {
const {id, invitationToken, token, email, expiration, hashedPassword, pseudoId} = input
this.id = id || generateUID()
const {invitationToken, token, email, expiration, hashedPassword, pseudoId} = input
this.invitationToken = invitationToken || undefined
this.token = token
this.email = email
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import base64url from 'base64url'
import crypto from 'crypto'
import getRethink from '../database/rethinkDriver'
import getKysely from '../postgres/getKysely'
import AuthIdentityLocal from '../database/types/AuthIdentityLocal'
import EmailVerification from '../database/types/EmailVerification'
import {DataLoaderWorker} from '../graphql/graphql'
Expand Down Expand Up @@ -34,15 +34,15 @@ const createEmailVerficationForExistingUser = async (
if (!success) {
return new Error('Unable to send verification email')
}
const r = await getRethink()
const emailVerification = new EmailVerification({
email,
token: verifiedEmailToken,
hashedPassword,
pseudoId,
invitationToken
})
await r.table('EmailVerification').insert(emailVerification).run()
const pg = getKysely()
await pg.insertInto('EmailVerification').values(emailVerification).execute()
return undefined
}

Expand Down
6 changes: 3 additions & 3 deletions packages/server/email/createEmailVerification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import base64url from 'base64url'
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import {Security} from 'parabol-client/types/constEnums'
import getRethink from '../database/rethinkDriver'
import getKysely from '../postgres/getKysely'
import EmailVerification from '../database/types/EmailVerification'
import emailVerificationEmailCreator from './emailVerificationEmailCreator'
import getMailManager from './getMailManager'
Expand Down Expand Up @@ -35,7 +35,6 @@ const createEmailVerification = async (props: SignUpWithPasswordMutationVariable
if (!success) {
return {error: {message: 'Unable to send verification email'}}
}
const r = await getRethink()
const hashedPassword = await bcrypt.hash(password, Security.SALT_ROUNDS)
const emailVerification = new EmailVerification({
email,
Expand All @@ -44,7 +43,8 @@ const createEmailVerification = async (props: SignUpWithPasswordMutationVariable
pseudoId,
invitationToken
})
await r.table('EmailVerification').insert(emailVerification).run()
const pg = getKysely()
await pg.insertInto('EmailVerification').values(emailVerification).execute()
return {error: {message: 'Verification required. Check your inbox.'}}
}

Expand Down
19 changes: 9 additions & 10 deletions packages/server/graphql/public/mutations/signUpWithPassword.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import bcrypt from 'bcryptjs'
import {AuthenticationError, Security} from 'parabol-client/types/constEnums'
import getRethink from '../../../database/rethinkDriver'
import {RValue} from '../../../database/stricterR'
import getKysely from '../../../postgres/getKysely'
import createEmailVerification from '../../../email/createEmailVerification'
import {USER_PREFERRED_NAME_LIMIT} from '../../../postgres/constants'
import createNewLocalUser from '../../../utils/createNewLocalUser'
Expand All @@ -21,7 +20,7 @@ const signUpWithPassword: MutationResolvers['signUpWithPassword'] = async (
if (email.length > USER_PREFERRED_NAME_LIMIT) {
return {error: {message: 'Email is too long'}}
}
const r = await getRethink()
const pg = getKysely()
const isOrganic = !invitationToken
const {ip, dataLoader} = context
const loginAttempt = await attemptLogin(email, password, ip)
Expand Down Expand Up @@ -49,13 +48,13 @@ const signUpWithPassword: MutationResolvers['signUpWithPassword'] = async (
}
const verificationRequired = await isEmailVerificationRequired(email, dataLoader)
if (verificationRequired) {
const existingVerification = await r
.table('EmailVerification')
.getAll(email, {index: 'email'})
.filter((row: RValue) => row('expiration').gt(new Date()))
.nth(0)
.default(null)
.run()
const existingVerification =
(await pg
.selectFrom('EmailVerification')
.selectAll()
.where('email', '=', email)
.where('expiration', '>', new Date())
.executeTakeFirst()) || null
if (existingVerification) {
return {error: {message: 'Verification email already sent'}}
}
Expand Down
16 changes: 8 additions & 8 deletions packages/server/graphql/public/mutations/verifyEmail.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {AuthIdentityTypeEnum} from '../../../../client/types/constEnums'
import getRethink from '../../../database/rethinkDriver'
import getKysely from '../../../postgres/getKysely'
import AuthIdentityLocal from '../../../database/types/AuthIdentityLocal'
import AuthToken from '../../../database/types/AuthToken'
import EmailVerification from '../../../database/types/EmailVerification'
Expand All @@ -16,14 +16,14 @@ const verifyEmail: MutationResolvers['verifyEmail'] = async (
context
) => {
const {dataLoader} = context
const r = await getRethink()
const pg = getKysely()
const now = new Date()
const emailVerification = (await r
.table('EmailVerification')
.getAll(verificationToken, {index: 'token'})
.nth(0)
.default(null)
.run()) as EmailVerification
const emailVerification =
((await pg
.selectFrom('EmailVerification')
.selectAll()
.where('token', '=', verificationToken)
.executeTakeFirst()) as EmailVerification) || null

if (!emailVerification) {
return {error: {message: 'Invalid verification token'}}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {Client} from 'pg'
import getPgConfig from '../getPgConfig'

export async function up() {
const client = new Client(getPgConfig())
await client.connect()
await client.query(`
CREATE TABLE "EmailVerification" (
"id" SERIAL PRIMARY KEY,
"email" "citext" NOT NULL,
"expiration" TIMESTAMP WITH TIME ZONE NOT NULL,
"token" VARCHAR(100) NOT NULL,
"hashedPassword" VARCHAR(100),
"invitationToken" VARCHAR(100),
"pseudoId" VARCHAR(100)
);
CREATE INDEX IF NOT EXISTS "idx_EmailVerification_email" ON "EmailVerification"("email");
CREATE INDEX IF NOT EXISTS "idx_EmailVerification_token" ON "EmailVerification"("token");
`)
await client.end()
}

export async function down() {
const client = new Client(getPgConfig())
await client.connect()
await client.query(`
DROP TABLE IF EXISTS "EmailVerification";
`)
await client.end()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {FirstParam} from 'parabol-client/types/generics'
import {Client} from 'pg'
import {r} from 'rethinkdb-ts'
import getPgConfig from '../getPgConfig'
import connectRethinkDB from '../../database/connectRethinkDB'
import getPgp from '../getPgp'

export async function up() {
await connectRethinkDB()
const {pgp, pg} = getPgp()
const batchSize = 1000

try {
await r.table('EmailVerification').indexCreate('expiration').run()
await r.table('EmailVerification').indexWait().run()
} catch {}

const columnSet = new pgp.helpers.ColumnSet(
[
'email',
'expiration',
'hashedPassword',
'token',
{name: 'invitationToken', def: null},
{name: 'pseudoId', def: null}
],
{table: 'EmailVerification'}
)

const getNextData = async (leftBoundCursor: Date | undefined) => {
const expiration = leftBoundCursor || r.minval
const nextBatch = await r
.table('EmailVerification')
.between(expiration, r.maxval, {index: 'expiration', leftBound: 'open'})
.orderBy({index: 'expiration'})
.limit(batchSize)
.run()
if (nextBatch.length === 0) return null
if (nextBatch.length < batchSize) return nextBatch
const lastItem = nextBatch.pop()
const lastMatchingExpiration = nextBatch.findLastIndex(
(item) => item.expiration !== lastItem!.expiration
)
if (lastMatchingExpiration === -1) {
throw new Error(
'batchSize is smaller than the number of items that share the same cursor. Increase batchSize'
)
}
return nextBatch.slice(0, lastMatchingExpiration + 1)
}

await pg.tx('EmailVerification', (task) => {
const fetchAndProcess: FirstParam<typeof task.sequence> = async (
_index,
leftBoundCursor: undefined | Date
) => {
const nextData = await getNextData(leftBoundCursor)
if (!nextData) return undefined
const insert = pgp.helpers.insert(nextData, columnSet)
await task.none(insert)
return nextData.at(-1)!.runAt
}
return task.sequence(fetchAndProcess)
})
await r.getPoolMaster()?.drain()
}

export async function down() {
const client = new Client(getPgConfig())
await client.connect()
await client.query(`DELETE FROM "EmailVerification"`)
await client.end()
try {
await r.table('EmailVerification').indexDrop('expiration').run()
} catch {}
}

0 comments on commit d67de35

Please sign in to comment.