diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5602163dc..e74a2eaa8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/app/api/public/sms/events/status/route.ts b/src/app/api/public/sms/events/status/route.ts index 8d3fafc87..d1481c721 100644 --- a/src/app/api/public/sms/events/status/route.ts +++ b/src/app/api/public/sms/events/status/route.ts @@ -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', @@ -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 @@ -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', { @@ -132,6 +132,7 @@ export const POST = withRouteMiddleware(async (request: NextRequest) => { status: newMessageStatus, }, phoneNumber, + variantName, }) if (hasWelcomeMessageInBody) { @@ -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, @@ -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, + } +} diff --git a/src/inngest/functions/eventNotification/logic.ts b/src/inngest/functions/eventNotification/logic.ts index f72d4dcf4..18cfc728a 100644 --- a/src/inngest/functions/eventNotification/logic.ts +++ b/src/inngest/functions/eventNotification/logic.ts @@ -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), diff --git a/src/inngest/functions/sms/bulkSMSCommunicationJourney.ts b/src/inngest/functions/sms/bulkSMSCommunicationJourney.ts index 9d19c1f8a..c3b5cb984 100644 --- a/src/inngest/functions/sms/bulkSMSCommunicationJourney.ts +++ b/src/inngest/functions/sms/bulkSMSCommunicationJourney.ts @@ -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' @@ -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 @@ -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) { @@ -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)) @@ -154,7 +180,7 @@ export const bulkSMSCommunicationJourney = inngest.createFunction( prettyStringify({ userHasWelcomeMessage, campaignName, - smsBody, + variants, }), ) @@ -165,6 +191,7 @@ export const bulkSMSCommunicationJourney = inngest.createFunction( () => getPhoneNumberList({ campaignName, + variantNames: variants.map(v => v.variantName), includePendingDoubleOptIn, skip, userWhereInput: customWhere, @@ -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}`) @@ -356,6 +411,7 @@ export interface GetPhoneNumberOptions { skip: number userWhereInput?: Prisma.UserGroupByArgs['where'] campaignName?: string + variantNames?: string[] } async function getPhoneNumberList(options: GetPhoneNumberOptions) { @@ -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: { @@ -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 +} diff --git a/src/inngest/functions/sms/enqueueMessages.ts b/src/inngest/functions/sms/enqueueMessages.ts index 39492b084..ccbfd7b2f 100644 --- a/src/inngest/functions/sms/enqueueMessages.ts +++ b/src/inngest/functions/sms/enqueueMessages.ts @@ -24,6 +24,7 @@ export interface EnqueueMessagePayload { body: string journeyType: UserCommunicationJourneyType campaignName: string + variantName: string media?: string[] hasWelcomeMessageInBody?: boolean }> @@ -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] ?? {} @@ -163,6 +171,7 @@ export async function enqueueMessages( apiUrls.smsStatusCallback({ journeyType, campaignName, + variantName, hasWelcomeMessageInBody, }), ), diff --git a/src/utils/server/sms/communicationJourney.ts b/src/utils/server/sms/communicationJourney.ts index 08b99dacd..0b1f1ca08 100644 --- a/src/utils/server/sms/communicationJourney.ts +++ b/src/utils/server/sms/communicationJourney.ts @@ -21,6 +21,7 @@ interface BulkCreateCommunicationJourneyPayload { status?: CommunicationMessageStatus } campaignName: string + variantName?: string } export async function bulkCreateCommunicationJourney({ @@ -28,6 +29,7 @@ export async function bulkCreateCommunicationJourney({ journeyType, message, phoneNumber, + variantName, }: BulkCreateCommunicationJourneyPayload) { const users = await prismaClient.user.findMany({ where: { @@ -51,6 +53,7 @@ export async function bulkCreateCommunicationJourney({ }, journeyType, campaignName, + variantName, }, select: { userId: true, @@ -65,6 +68,7 @@ export async function bulkCreateCommunicationJourney({ journeyType, campaignName, userId: id, + variantName, })), }) @@ -75,6 +79,7 @@ export async function bulkCreateCommunicationJourney({ }, journeyType, campaignName, + variantName, }, select: { id: true, @@ -84,24 +89,22 @@ export async function bulkCreateCommunicationJourney({ if (!message) return - const createCommunicationPayload = users - .map<Prisma.UserCommunicationCreateManyInput>(user => { - const communicationJourney = createdCommunicationJourneys.find( - ({ userId }) => userId === user.id, - ) + const createCommunicationPayload = users.map<Prisma.UserCommunicationCreateManyInput>(user => { + const communicationJourney = createdCommunicationJourneys.find( + ({ userId }) => userId === user.id, + ) - if (!communicationJourney?.id) { - throw new Error(`Couldn't find communicationJourney id for user ${user.id}`) - } + if (!communicationJourney?.id) { + throw new Error(`Couldn't find communicationJourney id for user ${user.id}`) + } - return { - messageId: message.id, - status: message.status, - userCommunicationJourneyId: communicationJourney.id, - communicationType: CommunicationType.SMS, - } - }) - .filter(communication => !!communication) + return { + messageId: message.id, + status: message.status, + userCommunicationJourneyId: communicationJourney.id, + communicationType: CommunicationType.SMS, + } + }) await prismaClient.userCommunication.createMany({ data: createCommunicationPayload, diff --git a/src/utils/server/sms/sendSMS.ts b/src/utils/server/sms/sendSMS.ts index ca4fa3605..3a06766d6 100644 --- a/src/utils/server/sms/sendSMS.ts +++ b/src/utils/server/sms/sendSMS.ts @@ -1,6 +1,8 @@ import { z } from 'zod' import { isPhoneNumberSupported } from '@/utils/server/sms/utils' +import { getLogger } from '@/utils/shared/logger' +import { prettyStringify } from '@/utils/shared/prettyLog' import { requiredEnv } from '@/utils/shared/requiredEnv' import { NEXT_PUBLIC_ENVIRONMENT } from '@/utils/shared/sharedEnv' @@ -21,6 +23,8 @@ const zodSendSMSSchema = z.object({ export type SendSMSPayload = z.infer<typeof zodSendSMSSchema> +const logger = getLogger('sendSMS') + export const sendSMS = async (payload: SendSMSPayload) => { const validatedInput = zodSendSMSSchema.safeParse(payload) @@ -35,6 +39,16 @@ export const sendSMS = async (payload: SendSMSPayload) => { } if (NEXT_PUBLIC_ENVIRONMENT === 'local') { + logger.info( + 'sendSMS localhost', + prettyStringify({ + messagingServiceSid: TWILIO_MESSAGING_SERVICE_SID, + body, + statusCallback: statusCallbackUrl, + to, + mediaUrl: media, + }), + ) return } diff --git a/src/utils/shared/urls/index.ts b/src/utils/shared/urls/index.ts index 0a3078260..46ae29cb9 100644 --- a/src/utils/shared/urls/index.ts +++ b/src/utils/shared/urls/index.ts @@ -1,3 +1,5 @@ +import { UserCommunicationJourneyType } from '@prisma/client' + import { RecentActivityAndLeaderboardTabs } from '@/components/app/pageHome/recentActivityAndLeaderboardTabs' import { DEFAULT_LOCALE, SupportedLocale } from '@/intl/locales' import { NormalizedDTSIDistrictId } from '@/utils/dtsi/dtsiPersonRoleUtils' @@ -156,16 +158,15 @@ export const apiUrls = { stateCode: string district: number }) => `/api/public/dtsi/races/usa/${stateCode}/${district}`, - smsStatusCallback: ({ - campaignName, - journeyType, - hasWelcomeMessageInBody, - }: { + smsStatusCallback: (params: { campaignName: string - journeyType: string - hasWelcomeMessageInBody?: boolean + journeyType: UserCommunicationJourneyType + variantName: string + hasWelcomeMessageInBody: boolean }) => - `/api/public/sms/events/status?campaignName=${campaignName}&journeyType=${journeyType}&hasWelcomeMessageInBody=${String(hasWelcomeMessageInBody ?? false)}`, + `/api/public/sms/events/status?${Object.entries(params) + .map(([key, value]) => `${key}=${String(value)}`) + .join('&')}`, decisionDeskPresidentialData: () => '/api/public/decision-desk/usa/presidential', decisionDeskStateData: ({ stateCode }: { stateCode: string }) => `/api/public/decision-desk/usa/state/${stateCode}`,