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

feat: add support for sms variants and randomization of phone numbers #1591

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,7 @@ model UserCommunicationJourney {
datetimeCreated DateTime @default(now()) @map("datetime_created")
userCommunications UserCommunication[]
campaignName String? @map("campaign_name")
variantName String? @map("variant_name")

@@index([userId])
@@map("user_communication_journey")
Expand Down
34 changes: 28 additions & 6 deletions src/app/api/public/sms/events/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { bulkCreateCommunicationJourney } from '@/utils/server/sms/communication
import { getUserByPhoneNumber, verifySignature } from '@/utils/server/sms/utils'
import { getLogger } from '@/utils/shared/logger'
import { toBool } from '@/utils/shared/toBool'
import { apiUrls } from '@/utils/shared/urls'

enum SMSEventMessageStatus {
DELIVERED = 'delivered',
Expand Down Expand Up @@ -55,13 +56,12 @@ export const POST = withRouteMiddleware(async (request: NextRequest) => {

logger.info('Request URL:', request.url, body)

const [_, searchParams] = request.url.split('?')
const normalizedParams = normalizeSearchParams(request)

const params = new URLSearchParams(searchParams)
const { hasWelcomeMessageInBody, variantName } = normalizedParams

let journeyType = params.get('journeyType') as UserCommunicationJourneyType | null
let campaignName = params.get('campaignName')
const hasWelcomeMessageInBody = toBool(params.get('hasWelcomeMessageInBody'))
let campaignName = normalizedParams.campaignName
let journeyType = normalizedParams.journeyType

const messageId = body.MessageSid
const messageStatus = body.MessageStatus
Expand Down Expand Up @@ -113,7 +113,7 @@ export const POST = withRouteMiddleware(async (request: NextRequest) => {
}

journeyType = existingMessage.userCommunicationJourney.journeyType
campaignName = existingMessage.userCommunicationJourney.campaignName
campaignName = existingMessage.userCommunicationJourney.campaignName || ''
} else {
if (!journeyType || !campaignName) {
return new NextResponse('missing search params', {
Expand All @@ -132,6 +132,7 @@ export const POST = withRouteMiddleware(async (request: NextRequest) => {
status: newMessageStatus,
},
phoneNumber,
variantName,
})

if (hasWelcomeMessageInBody) {
Expand Down Expand Up @@ -168,6 +169,7 @@ export const POST = withRouteMiddleware(async (request: NextRequest) => {
'Message Id': messageId,
From: from,
To: phoneNumber,
Variant: variantName,
'Campaign Name': campaignName,
'Journey Type': journeyType,
Error: errorCode,
Expand All @@ -180,3 +182,23 @@ export const POST = withRouteMiddleware(async (request: NextRequest) => {
status: 200,
})
})

type SmsStatusCallbackParams = Parameters<typeof apiUrls.smsStatusCallback>[0]

function normalizeSearchParams(request: NextRequest): SmsStatusCallbackParams {
const [_, rawSearchParams] = request.url.split('?')

const searchParams = new URLSearchParams(rawSearchParams)

const campaignName = searchParams.get('campaignName') || ''
const hasWelcomeMessageInBody = toBool(searchParams.get('hasWelcomeMessageInBody'))
const journeyType = searchParams.get('journeyType') as UserCommunicationJourneyType
const variantName = searchParams.get('variantName') || ''

return {
campaignName,
hasWelcomeMessageInBody,
journeyType,
variantName,
}
}
8 changes: 7 additions & 1 deletion src/inngest/functions/eventNotification/logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,13 @@ async function getNotificationInformationForEvents(
messages: [
{
campaignName: `event-reminder-${event.data.slug}-${event.data.state}-${notificationStrategy}`,
smsBody,
variants: [
{
percentage: 100,
smsBody,
},
],
includePendingDoubleOptIn: true,
userWhereInput: {
phoneNumber: {
in: notifications.map(notification => notification.phoneNumber),
Expand Down
137 changes: 110 additions & 27 deletions src/inngest/functions/sms/bulkSMSCommunicationJourney.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from '@prisma/client'
import { addDays, addHours, differenceInMilliseconds, startOfDay } from 'date-fns'
import { NonRetriableError } from 'inngest'
import { chunk, merge, uniq, uniqBy, update } from 'lodash-es'
import { chunk, merge, shuffle, uniq, uniqBy, update } from 'lodash-es'

import { EnqueueMessagePayload, enqueueSMS } from '@/inngest/functions/sms/enqueueMessages'
import { countMessagesAndSegments } from '@/inngest/functions/sms/utils/countMessagesAndSegments'
Expand All @@ -30,11 +30,15 @@ export interface BulkSmsCommunicationJourneyInngestEventSchema {

export interface BulkSMSPayload {
messages: Array<{
smsBody: string
userWhereInput?: GetPhoneNumberOptions['userWhereInput']
includePendingDoubleOptIn?: boolean
variants: Array<{
smsBody: string
media?: string[]
percentage: number
variantName: string
}>
campaignName: string
media?: string[]
includePendingDoubleOptIn?: boolean
userWhereInput?: GetPhoneNumberOptions['userWhereInput']
}>
// default to ET: -5
timezone?: number
Expand Down Expand Up @@ -68,9 +72,31 @@ export const bulkSMSCommunicationJourney = inngest.createFunction(
throw new NonRetriableError('Missing messages to send')
}

messages.forEach(({ smsBody, campaignName }, index) => {
if (!smsBody) {
throw new NonRetriableError(`Missing sms body in message ${index}`)
messages.forEach(({ campaignName, variants }, index) => {
if (variants.length === 0) {
throw new NonRetriableError(`Missing variants in message ${index}`)
}

let fullPercentage = 0

variants.forEach(({ smsBody, percentage, variantName }, variantIndex) => {
if (!smsBody) {
throw new NonRetriableError(
`Missing sms body in variant ${variantIndex}, message ${index}`,
)
}

if (!variantName) {
throw new NonRetriableError(`Missing name in variant ${variantIndex}, message ${index}`)
}

fullPercentage += percentage
})

if (Math.ceil(fullPercentage) !== 100) {
throw new NonRetriableError(
`The total percentages provided (${fullPercentage}%) don't add up to 100%.`,
)
}

if (!campaignName) {
Expand All @@ -89,7 +115,7 @@ export const bulkSMSCommunicationJourney = inngest.createFunction(
const messagesInfo: Record<string, object> = {}

for (const message of messages) {
const { campaignName, smsBody, includePendingDoubleOptIn, media, userWhereInput } = message
const { campaignName, includePendingDoubleOptIn, userWhereInput, variants } = message

logger.info(prettyStringify(message))

Expand Down Expand Up @@ -154,7 +180,7 @@ export const bulkSMSCommunicationJourney = inngest.createFunction(
prettyStringify({
userHasWelcomeMessage,
campaignName,
smsBody,
variants,
}),
)

Expand All @@ -165,6 +191,7 @@ export const bulkSMSCommunicationJourney = inngest.createFunction(
() =>
getPhoneNumberList({
campaignName,
variantNames: variants.map(v => v.variantName),
includePendingDoubleOptIn,
skip,
userWhereInput: customWhere,
Expand All @@ -183,27 +210,55 @@ export const bulkSMSCommunicationJourney = inngest.createFunction(
}
}

logger.info('Got phone numbers, adding to messagesPayload')
logger.info(`Fetched all phone numbers ${allPhoneNumbers.length}`)

// Here we're adding the welcome legalese to the bulk text, when doing this we need to register in our DB that the user received the welcome legalese
const body = !userHasWelcomeMessage ? addWelcomeMessage(smsBody) : smsBody
const phoneNumberVariants = await step.run(
`split-phone-numbers-${String(userHasWelcomeMessage)}-${campaignName}`,
async () => {
// Using uniq outside the while loop, because getPhoneNumberList could return the same phone number in two separate batches
const randomizedPhoneNumbers = shuffle(uniq(allPhoneNumbers))

// Using uniq outside the while loop, because getPhoneNumberList could return the same phone number in two separate batches
return splitArrayByPercentages(
randomizedPhoneNumbers,
variants.map(v => v.percentage), // [60, 40] will return two arrays with 60% and 40% of the phone numbers
)
},
)

logger.info(`Splitted phone numbers into ${phoneNumberVariants.length} variants`)

let variantIndex = 0
// We need to use concat here because using spread is exceeding maximum call stack size
messagesPayload = messagesPayload.concat(
uniq(allPhoneNumbers).map(phoneNumber => ({
phoneNumber,
messages: [
{
body,
campaignName,
journeyType: UserCommunicationJourneyType.BULK_SMS,
media,
// If the user does not have a welcome message, the body must have it
hasWelcomeMessageInBody: !userHasWelcomeMessage,
},
],
})),
variants.reduce<EnqueueMessagePayload[]>((acc, variant) => {
const phoneNumbersPortion = phoneNumberVariants[variantIndex]

logger.info(`Variant ${variantIndex} has ${phoneNumbersPortion.length} phone numbers`)

variantIndex += 1

const { smsBody, media, variantName } = variant

// Here we're adding the welcome legalese to the bulk text, when doing this we need to register in our DB that the user received the welcome legalese
const body = !userHasWelcomeMessage ? addWelcomeMessage(smsBody) : smsBody

return acc.concat(
phoneNumbersPortion.map(phoneNumber => ({
phoneNumber,
messages: [
{
body,
campaignName,
variantName,
journeyType: UserCommunicationJourneyType.BULK_SMS,
media,
// If the user does not have a welcome message, the body must have it
hasWelcomeMessageInBody: !userHasWelcomeMessage,
},
],
})),
)
}, []),
)

logger.info(`messagesPayload.length ${messagesPayload.length}`)
Expand Down Expand Up @@ -356,6 +411,7 @@ export interface GetPhoneNumberOptions {
skip: number
userWhereInput?: Prisma.UserGroupByArgs['where']
campaignName?: string
variantNames?: string[]
}

async function getPhoneNumberList(options: GetPhoneNumberOptions) {
Expand All @@ -373,9 +429,15 @@ async function getPhoneNumberList(options: GetPhoneNumberOptions) {
campaignName: {
not: options.campaignName,
},
variantName: {
notIn: options.variantNames,
},
},
{
campaignName: options.campaignName,
variantName: {
in: options.variantNames,
},
userCommunications: {
every: {
status: {
Expand Down Expand Up @@ -406,3 +468,24 @@ async function getPhoneNumberList(options: GetPhoneNumberOptions) {
})
.then(res => res.map(({ phoneNumber }) => phoneNumber))
}

function splitArrayByPercentages<T>(array: T[], percentages: number[]) {
const totalLength = array.length
const dividedArrays: Array<typeof array> = []

let startIndex = 0

percentages.forEach((percentage, index) => {
let size = Math.floor((percentage / 100) * totalLength)

// If it's the last percentage, we need to add the remaining elements
if (index === percentages.length - 1) {
size = totalLength - startIndex
}

dividedArrays.push(array.slice(startIndex, startIndex + size))
startIndex += size
})

return dividedArrays
}
11 changes: 10 additions & 1 deletion src/inngest/functions/sms/enqueueMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface EnqueueMessagePayload {
body: string
journeyType: UserCommunicationJourneyType
campaignName: string
variantName: string
media?: string[]
hasWelcomeMessageInBody?: boolean
}>
Expand Down Expand Up @@ -150,7 +151,14 @@ export async function enqueueMessages(

const enqueueMessagesPromise = payload.map(async ({ messages, phoneNumber }) => {
for (const message of messages) {
const { body, journeyType, campaignName, media, hasWelcomeMessageInBody } = message
const {
body,
journeyType,
campaignName,
media,
hasWelcomeMessageInBody = false,
variantName,
} = message

const phoneNumberVariables = variables[phoneNumber] ?? {}

Expand All @@ -163,6 +171,7 @@ export async function enqueueMessages(
apiUrls.smsStatusCallback({
journeyType,
campaignName,
variantName,
hasWelcomeMessageInBody,
}),
),
Expand Down
Loading