Skip to content

Commit

Permalink
Add verified phone number
Browse files Browse the repository at this point in the history
  • Loading branch information
IanPhilips committed Jan 24, 2025
1 parent e58ff0f commit ee32f3c
Show file tree
Hide file tree
Showing 3 changed files with 124 additions and 87 deletions.
6 changes: 6 additions & 0 deletions backend/api/src/request-phone-otp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ export const requestOTP: APIHandler<'request-otp'> = rateLimitByUser(
async (props, auth) => {
const pg = createSupabaseDirectClient()
const { phoneNumber } = props

// Early return for verified phone number
if (phoneNumber === process.env.VERIFIED_PHONE_NUMBER) {
return { status: 'success' }
}

const userHasPhoneNumber = await pg.oneOrNone(
`select phone_number from private_user_phone_numbers where user_id = $1
or phone_number = $2
Expand Down
204 changes: 117 additions & 87 deletions backend/api/src/verify-phone-number.ts
Original file line number Diff line number Diff line change
@@ -1,113 +1,143 @@
import { APIError, APIHandler } from 'api/helpers/endpoint'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { APIError, APIHandler, AuthedUser } from 'api/helpers/endpoint'
import {
createSupabaseDirectClient,
SupabaseTransaction,
} from 'shared/supabase/init'
import { getPrivateUser, getUser, log } from 'shared/utils'
import { PHONE_VERIFICATION_BONUS, SUS_STARTING_BALANCE } from 'common/economy'
import { SignupBonusTxn } from 'common/txn'
import { runTxnFromBank } from 'shared/txn/run-txn'
import { rateLimitByUser } from './helpers/rate-limit'
import { updateUser } from 'shared/supabase/users'
import { HOUR_MS } from 'common/util/time'
// eslint-disable-next-line @typescript-eslint/no-require-imports
const twilio = require('twilio')

export const verifyPhoneNumber: APIHandler<'verify-phone-number'> =
rateLimitByUser(async (props, auth) => {
const pg = createSupabaseDirectClient()
rateLimitByUser(
async (props, auth) => {
const pg = createSupabaseDirectClient()
const { phoneNumber, code: otpCode } = props

// TODO: transaction over this sql query rather than over user properties
const { phoneNumber, code: otpCode } = props
log('verifyPhoneNumber', {
phoneNumber,
code: otpCode,
})
// Special handling for verified phone number
if (phoneNumber === process.env.VERIFIED_PHONE_NUMBER) {
await pg.tx(async (tx) => {
await handlePhoneVerification(tx, auth, phoneNumber, true)
})
return { status: 'success' }
}

await pg.tx(async (tx) => {
const userHasPhoneNumber = await tx.oneOrNone(
`select phone_number from private_user_phone_numbers where user_id = $1
await pg.tx(async (tx) => {
const userHasPhoneNumber = await tx.oneOrNone(
`select phone_number from private_user_phone_numbers where user_id = $1
or phone_number = $2
limit 1
`,
[auth.uid, phoneNumber]
)
if (userHasPhoneNumber) {
throw new APIError(400, 'User verified phone number already.')
}
const authToken = process.env.TWILIO_AUTH_TOKEN
const client = new twilio(process.env.TWILIO_SID, authToken)
const lookup = await client.lookups.v2
.phoneNumbers(phoneNumber)
.fetch({ fields: 'line_type_intelligence' })
if (
lookup.lineTypeIntelligence.type !== 'mobile' &&
lookup.lineTypeIntelligence.type !== null &&
lookup.countryCode !== 'CA'
) {
throw new APIError(400, 'Only mobile carriers allowed')
}
const verification = await client.verify.v2
.services(process.env.TWILIO_VERIFY_SID)
.verificationChecks.create({ to: phoneNumber, code: otpCode })
if (verification.status !== 'approved') {
throw new APIError(400, 'Invalid code. Please try again.')
}

await tx
.none(
`insert into private_user_phone_numbers (user_id, phone_number) values ($1, $2)
on conflict (user_id) do nothing `,
[auth.uid, phoneNumber]
)
.catch((e) => {
log(e)
throw new APIError(400, 'Phone number already exists')
})
.then(() => {
log(verification.status, { phoneNumber, otpCode })
})
if (userHasPhoneNumber) {
throw new APIError(400, 'User verified phone number already.')
}

const privateUser = await getPrivateUser(auth.uid, tx)
if (!privateUser)
throw new APIError(401, `Private user ${auth.uid} not found`)
const isPrivateUserWithMatchingDeviceToken = async (
deviceToken: string
) => {
const data = await tx.oneOrNone<1>(
`select 1 from private_users where (data->'initialDeviceToken')::text = $1 and id != $2`,
[deviceToken, auth.uid]
)
const authToken = process.env.TWILIO_AUTH_TOKEN
const client = new twilio(process.env.TWILIO_SID, authToken)
const lookup = await client.lookups.v2
.phoneNumbers(phoneNumber)
.fetch({ fields: 'line_type_intelligence' })
if (
lookup.lineTypeIntelligence.type !== 'mobile' &&
lookup.lineTypeIntelligence.type !== null &&
lookup.countryCode !== 'CA'
) {
throw new APIError(400, 'Only mobile carriers allowed')
}

return !!data
}
const { initialDeviceToken: deviceToken } = privateUser
const verification = await client.verify.v2
.services(process.env.TWILIO_VERIFY_SID)
.verificationChecks.create({ to: phoneNumber, code: otpCode })
if (verification.status !== 'approved') {
throw new APIError(400, 'Invalid code. Please try again.')
}

const deviceUsedBefore =
!deviceToken ||
(await isPrivateUserWithMatchingDeviceToken(deviceToken))
log(verification.status, { phoneNumber, otpCode })
await handlePhoneVerification(tx, auth, phoneNumber, false)
})

const amount = deviceUsedBefore
? SUS_STARTING_BALANCE
: PHONE_VERIFICATION_BONUS
return { status: 'success' }
},
{ maxCalls: 10, windowMs: 6 * HOUR_MS }
)

const user = await getUser(auth.uid, tx)
if (!user) throw new APIError(401, `User ${auth.uid} not found`)
async function handlePhoneVerification(
tx: SupabaseTransaction,
auth: AuthedUser,
phoneNumber: string,
shouldDeleteExisting: boolean
) {
if (shouldDeleteExisting) {
// Delete any existing phone number for this user
await tx.none(
`delete from private_user_phone_numbers where phone_number = $1`,
[phoneNumber]
)
log(`Deleted existing phone number for user ${auth.uid}`)
}

const { verifiedPhone } = user
if (!verifiedPhone) {
await updateUser(tx, auth.uid, {
verifiedPhone: true,
})
// Insert the new phone number
await tx.none(
`insert into private_user_phone_numbers (user_id, phone_number) values ($1, $2)
on conflict (user_id) do nothing`,
[auth.uid, phoneNumber]
)

const signupBonusTxn: Omit<
SignupBonusTxn,
'fromId' | 'id' | 'createdTime'
> = {
fromType: 'BANK',
amount: amount,
category: 'SIGNUP_BONUS',
toId: auth.uid,
token: 'M$',
toType: 'USER',
description: 'Phone number verification bonus',
}
await runTxnFromBank(tx, signupBonusTxn)
log(`Sent phone verification bonus to user ${auth.uid}`)
}
const privateUser = await getPrivateUser(auth.uid, tx)
if (!privateUser)
throw new APIError(401, `Private user ${auth.uid} not found`)

const user = await getUser(auth.uid, tx)
if (!user) throw new APIError(401, `User ${auth.uid} not found`)

const { verifiedPhone } = user
if (!verifiedPhone) {
await updateUser(tx, auth.uid, {
verifiedPhone: true,
})

return { status: 'success' }
})
const isPrivateUserWithMatchingDeviceToken = async (
deviceToken: string
) => {
const data = await tx.oneOrNone(
`select 1 from private_users where (data->'initialDeviceToken')::text = $1 and id != $2`,
[deviceToken, auth.uid]
)
return !!data
}

const { initialDeviceToken: deviceToken } = privateUser
const deviceUsedBefore =
!deviceToken || (await isPrivateUserWithMatchingDeviceToken(deviceToken))

const amount = deviceUsedBefore
? SUS_STARTING_BALANCE
: PHONE_VERIFICATION_BONUS

const signupBonusTxn: Omit<
SignupBonusTxn,
'fromId' | 'id' | 'createdTime'
> = {
fromType: 'BANK',
amount,
category: 'SIGNUP_BONUS',
toId: auth.uid,
token: 'M$',
toType: 'USER',
description: 'Phone number verification bonus',
}
await runTxnFromBank(tx, signupBonusTxn)
log(`Sent phone verification bonus to user ${auth.uid}`)
}
}
1 change: 1 addition & 0 deletions common/src/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const secrets = (
'PERPLEXITY_API_KEY',
'FIRECRAWL_API_KEY',
'SPORTSDB_KEY',
'VERIFIED_PHONE_NUMBER',
// Some typescript voodoo to keep the string literal types while being not readonly.
] as const
).concat()
Expand Down

0 comments on commit ee32f3c

Please sign in to comment.