Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: migrate EmailVerification to pg #9492

Merged
merged 4 commits into from
Jul 25, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 getRethink from '../database/rethinkDriver'
import createEmailVerification from '../email/createEmailVerification'
import getKysely from '../postgres/getKysely'
import {getUserTeams, sendIntranet, sendPublic} from './common'

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(),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was getting TS failures here

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))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and here...

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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once again, id is now managed by PG

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
20 changes: 9 additions & 11 deletions packages/server/graphql/public/mutations/signUpWithPassword.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import bcrypt from 'bcryptjs'
import {AuthenticationError, Security} from 'parabol-client/types/constEnums'
import {URLSearchParams} from 'url'
import getRethink from '../../../database/rethinkDriver'
import {RValue} from '../../../database/stricterR'
import createEmailVerification from '../../../email/createEmailVerification'
import {USER_PREFERRED_NAME_LIMIT} from '../../../postgres/constants'
import getKysely from '../../../postgres/getKysely'
import createNewLocalUser from '../../../utils/createNewLocalUser'
import encodeAuthToken from '../../../utils/encodeAuthToken'
import isEmailVerificationRequired from '../../../utils/isEmailVerificationRequired'
Expand All @@ -21,7 +19,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 +47,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
mattkrick marked this conversation as resolved.
Show resolved Hide resolved
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,8 +1,8 @@
import {AuthIdentityTypeEnum} from '../../../../client/types/constEnums'
import getRethink from '../../../database/rethinkDriver'
import AuthIdentityLocal from '../../../database/types/AuthIdentityLocal'
import AuthToken from '../../../database/types/AuthToken'
import EmailVerification from '../../../database/types/EmailVerification'
import getKysely from '../../../postgres/getKysely'
import {getUserByEmail} from '../../../postgres/queries/getUsersByEmails'
import updateUser from '../../../postgres/queries/updateUser'
import createNewLocalUser from '../../../utils/createNewLocalUser'
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 no need to coerce to EmailVerification || null since kysely knows the return type


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)
}

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 {}
}
Loading