From f5ca644649c8183aefa9fdd6808e181c8ccc8cd3 Mon Sep 17 00:00:00 2001 From: Lucas Bernardi Date: Wed, 23 Oct 2024 17:06:38 -0300 Subject: [PATCH 1/6] refactor: create communication journey in sms status webhook --- src/app/api/public/sms/events/status/route.ts | 129 ++++++++++-------- .../sms/bulkSMSCommunicationJourney.ts | 23 ++-- src/inngest/functions/sms/enqueueMessages.ts | 79 +++-------- .../sms/tests/enqueueMessages.test.ts | 61 ++++----- .../sms/utils/communicationJourney.ts | 103 -------------- src/utils/server/sms/communicationJourney.ts | 100 ++++++++++++++ src/utils/server/sms/sendSMS.ts | 6 +- src/utils/shared/urls/index.ts | 11 +- 8 files changed, 238 insertions(+), 274 deletions(-) delete mode 100644 src/inngest/functions/sms/utils/communicationJourney.ts create mode 100644 src/utils/server/sms/communicationJourney.ts diff --git a/src/app/api/public/sms/events/status/route.ts b/src/app/api/public/sms/events/status/route.ts index c0e4a6aaf..6874674d9 100644 --- a/src/app/api/public/sms/events/status/route.ts +++ b/src/app/api/public/sms/events/status/route.ts @@ -1,7 +1,6 @@ import 'server-only' -import { User, UserCommunication, UserCommunicationJourney } from '@prisma/client' -// import * as Sentry from '@sentry/nextjs' +import { UserCommunicationJourneyType } from '@prisma/client' import { waitUntil } from '@vercel/functions' import { NextRequest, NextResponse } from 'next/server' @@ -9,8 +8,9 @@ import { prismaClient } from '@/utils/server/prismaClient' import { getServerAnalytics, getServerPeopleAnalytics } from '@/utils/server/serverAnalytics' import { getLocalUserFromUser } from '@/utils/server/serverLocalUser' import { withRouteMiddleware } from '@/utils/server/serverWrappers/withRouteMiddleware' -import { verifySignature } from '@/utils/server/sms/utils' -import { sleep } from '@/utils/shared/sleep' +import { bulkCreateCommunicationJourney } from '@/utils/server/sms/communicationJourney' +import { getUserByPhoneNumber, verifySignature } from '@/utils/server/sms/utils' +import { getLogger } from '@/utils/shared/logger' interface SMSStatusEvent { ErrorCode?: string @@ -25,17 +25,7 @@ interface SMSStatusEvent { AccountSid: string } -export const maxDuration = 30 - -const MAX_RETRY_COUNT = 3 - -type UserCommunicationWithRelations = - | (UserCommunication & { - userCommunicationJourney: UserCommunicationJourney & { - user: User - } - }) - | null +const logger = getLogger('smsStatus') export const POST = withRouteMiddleware(async (request: NextRequest) => { const [isVerified, body] = await verifySignature(request) @@ -46,48 +36,69 @@ export const POST = withRouteMiddleware(async (request: NextRequest) => { }) } - let userCommunication: UserCommunicationWithRelations = null - - for (let i = 1; i <= MAX_RETRY_COUNT; i += 1) { - userCommunication = await prismaClient.userCommunication.findFirst({ - where: { - messageId: body.MessageSid, - }, - orderBy: { - userCommunicationJourney: { - user: { - datetimeUpdated: 'desc', - }, - }, - }, - include: { - userCommunicationJourney: { - include: { - user: true, - }, - }, - }, - }) + logger.info('Request URL:', request.url) - // Calls to this webhook are being received before the messages are registered in our database. Therefore, we need to implement a retry mechanism for fetching the messages. - if (!userCommunication) { - await sleep(1000 * (i * i)) - } + const [_, searchParams] = request.url.split('?') + + const params = new URLSearchParams(searchParams) + + const journeyType = params.get('journeyType') as UserCommunicationJourneyType | null + const campaignName = params.get('campaignName') + const hasWelcomeMessageInBody = params.has('hasWelcomeMessageInBody') + + const messageId = body.MessageSid + const messageStatus = body.MessageStatus + + const errorCode = body.ErrorCode + const from = body.From + const phoneNumber = body.To + + if (!journeyType || !campaignName) { + return new NextResponse('missing url params', { + status: 400, + }) } - if (!userCommunication) { - // TODO: Uncomment this when we fix this problem https://github.com/Stand-With-Crypto/swc-internal/issues/260 - // Sentry.captureMessage(`Received message status update but couldn't find user_communication`, { - // extra: { body }, - // tags: { - // domain: 'smsMessageStatusWebhook', - // }, - // }) - // If we return 4xx or 5xx Twilio will trigger our fails webhook with this warning -> https://www.twilio.com/docs/api/errors/11200 and it's flooding Sentry - return new NextResponse('success', { status: 200 }) + logger.info(`Searching user with phone number ${phoneNumber}`) + + const user = await getUserByPhoneNumber(phoneNumber) + + if (!user) { + return new NextResponse('success', { + status: 200, + }) } - const user = userCommunication?.userCommunicationJourney.user + const existingMessage = await prismaClient.userCommunication.findFirst({ + where: { + messageId, + }, + }) + + if (existingMessage) { + logger.info(`Found existing message with id ${messageId}`) + // TODO: update message status + } else { + logger.info( + `Creating communication journey of type ${journeyType} for campaign ${campaignName} and user communication with message ${messageId}`, + ) + await bulkCreateCommunicationJourney({ + campaignName, + journeyType, + messageId, + phoneNumber, + }) + + if (hasWelcomeMessageInBody) { + logger.info(`Creating bulk-welcome communication journey`) + + await bulkCreateCommunicationJourney({ + campaignName: 'bulk-welcome', + journeyType: UserCommunicationJourneyType.WELCOME_SMS, + phoneNumber, + }) + } + } waitUntil( Promise.all([ @@ -104,13 +115,13 @@ export const POST = withRouteMiddleware(async (request: NextRequest) => { userId: user.id, }) .track('SMS Communication Event', { - 'Message Status': body.MessageStatus, - 'Message Id': body.MessageSid, - From: body.From, - To: body.To, - 'Campaign Name': userCommunication.userCommunicationJourney.campaignName, - 'Journey Type': userCommunication.userCommunicationJourney.journeyType, - Error: body.ErrorCode, + 'Message Status': messageStatus, + 'Message Id': messageId, + From: from, + To: phoneNumber, + 'Campaign Name': campaignName, + 'Journey Type': journeyType, + Error: errorCode, }) .flush(), ]), diff --git a/src/inngest/functions/sms/bulkSMSCommunicationJourney.ts b/src/inngest/functions/sms/bulkSMSCommunicationJourney.ts index 6c5ed911d..f6181844a 100644 --- a/src/inngest/functions/sms/bulkSMSCommunicationJourney.ts +++ b/src/inngest/functions/sms/bulkSMSCommunicationJourney.ts @@ -106,11 +106,11 @@ export const bulkSMSCommunicationJourney = inngest.createFunction( // We need to keep this order as true -> false because later we use groupBy, which keeps the first occurrence of each element. In this case, that would be the user who already received the welcome message, so we don’t need to send the legal disclaimer again. // This happens because of how where works with groupBy, and there are cases where different users with the same phone number show up in both groups. So, in those cases, we don’t want to send two messages—just one. - for (const hasWelcomeMessage of [true, false]) { + for (const userHasWelcomeMessage of [true, false]) { const customWhere = mergeWhereParams( { ...userWhereInput }, { - UserCommunicationJourney: hasWelcomeMessage + UserCommunicationJourney: userHasWelcomeMessage ? { some: { journeyType: UserCommunicationJourneyType.WELCOME_SMS, @@ -146,7 +146,7 @@ export const bulkSMSCommunicationJourney = inngest.createFunction( logger.info( 'Fetching phone numbers', prettyStringify({ - hasWelcomeMessage, + userHasWelcomeMessage, campaignName, smsBody, }), @@ -155,7 +155,7 @@ export const bulkSMSCommunicationJourney = inngest.createFunction( let index = 0 while (hasNumbersLeft) { const phoneNumberList = await step.run( - `fetching-phone-numbers-welcome-${String(hasWelcomeMessage)}-${index}`, + `fetching-phone-numbers-welcome-${String(userHasWelcomeMessage)}-${index}`, () => getPhoneNumberList({ campaignName, @@ -181,7 +181,8 @@ export const bulkSMSCommunicationJourney = inngest.createFunction( logger.info('Got phone numbers, adding to messagesPayload') - const body = !hasWelcomeMessage ? addWelcomeMessage(smsBody) : smsBody + // 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 // Using uniq outside the while loop, because getPhoneNumberList could return the same phone number in two separate batches // We need to use concat here because using spread is exceeding maximum call stack size @@ -189,21 +190,13 @@ export const bulkSMSCommunicationJourney = inngest.createFunction( uniq(allPhoneNumbers).map(phoneNumber => ({ phoneNumber, messages: [ - ...(!hasWelcomeMessage - ? [ - { - journeyType: UserCommunicationJourneyType.WELCOME_SMS, - campaignName: 'bulk-welcome', - }, - ] - : []), { - // Here we're adding the welcome legalese to the bulk text, when doing this we need to add an empty message with - // WELCOME_SMS as journey type so that enqueueSMS register in our DB that the user received the welcome legalese body, campaignName, journeyType: UserCommunicationJourneyType.BULK_SMS, media, + // If the user does not have a welcome message, the body must have it + hasWelcomeMessageInBody: !userHasWelcomeMessage, }, ], })), diff --git a/src/inngest/functions/sms/enqueueMessages.ts b/src/inngest/functions/sms/enqueueMessages.ts index 862dcad70..34122c5d0 100644 --- a/src/inngest/functions/sms/enqueueMessages.ts +++ b/src/inngest/functions/sms/enqueueMessages.ts @@ -3,10 +3,6 @@ import * as Sentry from '@sentry/node' import { NonRetriableError } from 'inngest' import { update } from 'lodash-es' -import { - bulkCreateCommunicationJourney, - BulkCreateCommunicationJourneyPayload, -} from '@/inngest/functions/sms/utils/communicationJourney' import { flagInvalidPhoneNumbers } from '@/inngest/functions/sms/utils/flagInvalidPhoneNumbers' import { getSMSVariablesByPhoneNumbers } from '@/inngest/functions/sms/utils/getSMSVariablesByPhoneNumbers' import { inngest } from '@/inngest/inngest' @@ -15,6 +11,7 @@ import { optOutUser } from '@/utils/server/sms/actions' import { countSegments, getUserByPhoneNumber } from '@/utils/server/sms/utils' import { applySMSVariables, UserSMSVariables } from '@/utils/server/sms/utils/variables' import { getLogger } from '@/utils/shared/logger' +import { apiUrls, fullUrl } from '@/utils/shared/urls' export const ENQUEUE_SMS_INNGEST_EVENT_NAME = 'app/enqueue.sms' const ENQUEUE_SMS_INNGEST_FUNCTION_ID = 'app.enqueue-sms' @@ -24,10 +21,11 @@ const MAX_RETRY_COUNT = 0 export interface EnqueueMessagePayload { phoneNumber: string messages: Array<{ - body?: string + body: string journeyType: UserCommunicationJourneyType campaignName: string media?: string[] + hasWelcomeMessageInBody?: boolean }> } @@ -142,10 +140,6 @@ export async function enqueueMessages( ) { const invalidPhoneNumbers: string[] = [] const failedPhoneNumbers: Record = {} - // Messages grouped by journey type and campaign name, Ex: BULK_SMS -> campaign-name: ["messageId"] - const messagesSentByJourneyType: { - [key in UserCommunicationJourneyType]?: BulkCreateCommunicationJourneyPayload - } = {} const unsubscribedUsers: string[] = [] let segmentsSent = 0 @@ -153,43 +147,27 @@ export async function enqueueMessages( const enqueueMessagesPromise = payload.map(async ({ messages, phoneNumber }) => { for (const message of messages) { - const { body, journeyType, campaignName, media } = message + const { body, journeyType, campaignName, media, hasWelcomeMessageInBody } = message const phoneNumberVariables = variables[phoneNumber] ?? {} try { - if (body) { - const queuedMessage = await sendSMS({ - body: applySMSVariables(body, phoneNumberVariables), - to: phoneNumber, - media, - }) + const queuedMessage = await sendSMS({ + body: applySMSVariables(body, phoneNumberVariables), + to: phoneNumber, + media, + statusCallbackUrl: fullUrl( + apiUrls.smsStatusCallback({ + journeyType, + campaignName, + hasWelcomeMessageInBody, + }), + ), + }) - if (queuedMessage) { - update( - messagesSentByJourneyType, - [journeyType, campaignName], - (existingPayload = []) => [ - ...existingPayload, - { - messageId: queuedMessage.sid, - phoneNumber, - }, - ], - ) - - segmentsSent += countSegments(queuedMessage.body) - queuedMessages += 1 - } - } else { - // Bulk-SMS have logic to check if the phone number already received a welcome message, if it didn't it includes the welcome message at the end of the bulk message. - // When doing this, it also adds a WELCOME_SMS journeyType to enqueueSMS payload, so we need to register that the user received the welcome legalese inside the bulk message - update(messagesSentByJourneyType, [journeyType, campaignName], (existingPayload = []) => [ - ...existingPayload, - { - phoneNumber, - }, - ]) + if (queuedMessage) { + segmentsSent += countSegments(queuedMessage.body) + queuedMessages += 1 } } catch (error) { if (error instanceof NonRetriableError) { @@ -235,7 +213,6 @@ export async function enqueueMessages( return { invalidPhoneNumbers, failedPhoneNumbers, - messagesSentByJourneyType, unsubscribedUsers, segmentsSent, queuedMessages, @@ -245,25 +222,9 @@ export async function enqueueMessages( const defaultLogger = getLogger('persistEnqueueMessagesResults') export async function persistEnqueueMessagesResults( - { - invalidPhoneNumbers, - messagesSentByJourneyType, - unsubscribedUsers, - }: Awaited>, + { invalidPhoneNumbers, unsubscribedUsers }: Awaited>, logger = defaultLogger, ) { - // messagesSentByJourneyType have messages grouped by journeyType and campaignName - for (const journeyTypeKey of Object.keys(messagesSentByJourneyType)) { - const journeyType = journeyTypeKey as UserCommunicationJourneyType - - logger.info(`Creating ${journeyType} communication journey`) - - // We need to bulk create both communication and communication journey for better performance - await bulkCreateCommunicationJourney(journeyType, messagesSentByJourneyType[journeyType]!) - - logger.info(`Created ${journeyType} communication journey`) - } - if (invalidPhoneNumbers.length > 0) { logger.info(`Found ${invalidPhoneNumbers.length} invalid phone numbers`) await flagInvalidPhoneNumbers(invalidPhoneNumbers) diff --git a/src/inngest/functions/sms/tests/enqueueMessages.test.ts b/src/inngest/functions/sms/tests/enqueueMessages.test.ts index acd565e0b..79019835a 100644 --- a/src/inngest/functions/sms/tests/enqueueMessages.test.ts +++ b/src/inngest/functions/sms/tests/enqueueMessages.test.ts @@ -7,7 +7,6 @@ import { enqueueMessages, persistEnqueueMessagesResults, } from '@/inngest/functions/sms/enqueueMessages' -import { bulkCreateCommunicationJourney } from '@/inngest/functions/sms/utils/communicationJourney' import { countMessagesAndSegments } from '@/inngest/functions/sms/utils/countMessagesAndSegments' import { flagInvalidPhoneNumbers } from '@/inngest/functions/sms/utils/flagInvalidPhoneNumbers' import { fakerFields } from '@/mocks/fakerUtils' @@ -20,16 +19,12 @@ import { TOO_MANY_REQUESTS_CODE, } from '@/utils/server/sms/SendSMSError' import { UserSMSVariables } from '@/utils/server/sms/utils/variables' -import { fullUrl } from '@/utils/shared/urls' +import { apiUrls, fullUrl } from '@/utils/shared/urls' jest.mock('@/inngest/functions/sms/utils/flagInvalidPhoneNumbers', () => ({ flagInvalidPhoneNumbers: jest.fn(), })) -jest.mock('@/inngest/functions/sms/utils/communicationJourney', () => ({ - bulkCreateCommunicationJourney: jest.fn(), -})) - jest.mock('@/inngest/functions/sms/utils/getSMSVariablesByPhoneNumbers', () => ({ getSMSVariablesByPhoneNumbers: jest.fn().mockImplementation(() => Promise.resolve({})), })) @@ -91,8 +86,18 @@ describe('enqueueMessages', () => { expect(sendSMS).toBeCalledTimes(totalMessages) mockedPayload.forEach(({ messages, phoneNumber }) => - messages.forEach(({ body, media }) => - expect(sendSMS).toBeCalledWith({ to: phoneNumber, body, media }), + messages.forEach(({ body, media, campaignName, journeyType }) => + expect(sendSMS).toBeCalledWith({ + to: phoneNumber, + body, + media, + statusCallbackUrl: fullUrl( + apiUrls.smsStatusCallback({ + journeyType, + campaignName, + }), + ), + }), ), ) }) @@ -156,10 +161,13 @@ describe('enqueueMessages', () => { ])('should correctly parse the sms body with custom variables', async (input, output) => { const phoneNumber = fakerFields.phoneNumber() + const journeyType = 'BULK_SMS' + const campaignName = 'unit-tests' + await enqueueMessages( [ { - messages: [{ body: input, journeyType: 'BULK_SMS', campaignName: 'unit-tests' }], + messages: [{ body: input, journeyType, campaignName }], phoneNumber, }, ], @@ -168,7 +176,16 @@ describe('enqueueMessages', () => { }, ) - expect(sendSMS).toHaveBeenCalledWith({ body: output, to: phoneNumber }) + expect(sendSMS).toBeCalledWith({ + body: output, + to: phoneNumber, + statusCallbackUrl: fullUrl( + apiUrls.smsStatusCallback({ + journeyType, + campaignName, + }), + ), + }) }) }) @@ -180,7 +197,6 @@ describe('persistEnqueueMessagesResults', () => { const mockedPayload: Parameters['0'] = { failedPhoneNumbers: {}, invalidPhoneNumbers: [], - messagesSentByJourneyType: {}, queuedMessages: 0, segmentsSent: 0, unsubscribedUsers: [], @@ -204,27 +220,4 @@ describe('persistEnqueueMessagesResults', () => { expect(optOutUser).toBeCalledTimes(unsubscribedUsers.length) }) - - it('Should create user communication journey and user communication ', async () => { - const messagesSentByJourneyType: (typeof mockedPayload)['messagesSentByJourneyType'] = { - BULK_SMS: { - [faker.string.alpha()]: randomArray(() => ({ - messageId: faker.string.uuid(), - phoneNumber: fakerFields.phoneNumber(), - })), - }, - WELCOME_SMS: { - [faker.string.alpha()]: randomArray(() => ({ - messageId: faker.string.uuid(), - phoneNumber: fakerFields.phoneNumber(), - })), - }, - } - - await persistEnqueueMessagesResults({ ...mockedPayload, messagesSentByJourneyType }) - - expect(bulkCreateCommunicationJourney).toBeCalledTimes( - Object.keys(messagesSentByJourneyType).length, - ) - }) }) diff --git a/src/inngest/functions/sms/utils/communicationJourney.ts b/src/inngest/functions/sms/utils/communicationJourney.ts deleted file mode 100644 index 8dd00109b..000000000 --- a/src/inngest/functions/sms/utils/communicationJourney.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { CommunicationType, Prisma, UserCommunicationJourneyType } from '@prisma/client' -import { NonRetriableError } from 'inngest' - -import { prismaClient } from '@/utils/server/prismaClient' - -// this journey types should have only one UserCommunicationJourney per user -const journeyTypesWithSingleJourney = [ - UserCommunicationJourneyType.GOODBYE_SMS, - UserCommunicationJourneyType.UNSTOP_CONFIRMATION_SMS, -] - -export type BulkCreateCommunicationJourneyPayload = Record< - string, // campaign name || BULK_WELCOME_CAMPAIGN_NAME - Array<{ phoneNumber: string; messageId: string }> -> - -export async function bulkCreateCommunicationJourney( - journeyType: UserCommunicationJourneyType, - payload: BulkCreateCommunicationJourneyPayload, -) { - for (const campaignName of Object.keys(payload)) { - const users = await prismaClient.user.findMany({ - where: { - phoneNumber: { - in: payload[campaignName].map(({ phoneNumber }) => phoneNumber), - }, - }, - select: { - id: true, - phoneNumber: true, - }, - }) - - if (!campaignName && !journeyTypesWithSingleJourney.includes(journeyType)) { - throw new NonRetriableError(`Please inform a campaign name for journey ${journeyType}`) - } - - const usersWithExistingCommunicationJourney = ( - await prismaClient.userCommunicationJourney.findMany({ - where: { - userId: { - in: users.map(({ id }) => id), - }, - journeyType, - campaignName, - }, - select: { - userId: true, - }, - }) - ).map(({ userId }) => userId) - - await prismaClient.userCommunicationJourney.createMany({ - data: users - .filter(({ id }) => !usersWithExistingCommunicationJourney.includes(id)) - .map(({ id }) => ({ - journeyType, - campaignName, - userId: id, - })), - }) - - const createdCommunicationJourneys = await prismaClient.userCommunicationJourney.findMany({ - where: { - userId: { - in: users.map(({ id }) => id), - }, - journeyType, - campaignName, - }, - select: { - id: true, - userId: true, - }, - }) - - const createCommunicationPayload = users - .map(user => { - // Using phone number here because multiple users can have the same phone number - const message = payload[campaignName].find( - ({ phoneNumber }) => phoneNumber === user.phoneNumber, - ) - const communicationJourney = createdCommunicationJourneys.find( - ({ userId }) => userId === user.id, - ) - - if (!message?.messageId || !communicationJourney?.id) { - return - } - - return { - messageId: message.messageId, - userCommunicationJourneyId: communicationJourney.id, - communicationType: CommunicationType.SMS, - } - }) - .filter(communication => !!communication) as Prisma.UserCommunicationCreateManyArgs['data'] - - await prismaClient.userCommunication.createMany({ - data: createCommunicationPayload, - }) - } -} diff --git a/src/utils/server/sms/communicationJourney.ts b/src/utils/server/sms/communicationJourney.ts new file mode 100644 index 000000000..d3edb7f71 --- /dev/null +++ b/src/utils/server/sms/communicationJourney.ts @@ -0,0 +1,100 @@ +import { CommunicationType, Prisma, UserCommunicationJourneyType } from '@prisma/client' + +import { prismaClient } from '@/utils/server/prismaClient' + +// this journey types should have only one UserCommunicationJourney per user +const journeyTypesWithSingleJourney = [ + UserCommunicationJourneyType.GOODBYE_SMS, + UserCommunicationJourneyType.UNSTOP_CONFIRMATION_SMS, +] + +interface BulkCreateCommunicationJourneyPayload { + journeyType: UserCommunicationJourneyType + phoneNumber: string + messageId?: string + campaignName: string +} + +export async function bulkCreateCommunicationJourney({ + campaignName, + journeyType, + messageId, + phoneNumber, +}: BulkCreateCommunicationJourneyPayload) { + const users = await prismaClient.user.findMany({ + where: { + phoneNumber, + }, + select: { + id: true, + phoneNumber: true, + }, + }) + + if (!campaignName && !journeyTypesWithSingleJourney.includes(journeyType)) { + throw new Error(`Please inform a campaign name for journey ${journeyType}`) + } + + const usersWithExistingCommunicationJourney = ( + await prismaClient.userCommunicationJourney.findMany({ + where: { + userId: { + in: users.map(({ id }) => id), + }, + journeyType, + campaignName, + }, + select: { + userId: true, + }, + }) + ).map(({ userId }) => userId) + + await prismaClient.userCommunicationJourney.createMany({ + data: users + .filter(({ id }) => !usersWithExistingCommunicationJourney.includes(id)) + .map(({ id }) => ({ + journeyType, + campaignName, + userId: id, + })), + }) + + const createdCommunicationJourneys = await prismaClient.userCommunicationJourney.findMany({ + where: { + userId: { + in: users.map(({ id }) => id), + }, + journeyType, + campaignName, + }, + select: { + id: true, + userId: true, + }, + }) + + if (!messageId) return + + const createCommunicationPayload = users + .map(user => { + const communicationJourney = createdCommunicationJourneys.find( + ({ userId }) => userId === user.id, + ) + + if (!communicationJourney?.id) { + throw new Error(`Couldn't find communicationJourney id for user ${user.id}`) + } + + return { + messageId, + userCommunicationJourneyId: communicationJourney.id, + communicationType: CommunicationType.SMS, + } + }) + .filter(communication => !!communication) + + await prismaClient.userCommunication.createMany({ + data: createCommunicationPayload, + }) +} diff --git a/src/utils/server/sms/sendSMS.ts b/src/utils/server/sms/sendSMS.ts index 54176b498..ca4fa3605 100644 --- a/src/utils/server/sms/sendSMS.ts +++ b/src/utils/server/sms/sendSMS.ts @@ -3,7 +3,6 @@ import { z } from 'zod' import { isPhoneNumberSupported } from '@/utils/server/sms/utils' import { requiredEnv } from '@/utils/shared/requiredEnv' import { NEXT_PUBLIC_ENVIRONMENT } from '@/utils/shared/sharedEnv' -import { apiUrls, fullUrl } from '@/utils/shared/urls' import { messagingClient } from './messagingClient' import { SendSMSError } from './SendSMSError' @@ -16,6 +15,7 @@ const TWILIO_MESSAGING_SERVICE_SID = requiredEnv( const zodSendSMSSchema = z.object({ to: z.string(), body: z.string(), + statusCallbackUrl: z.string().optional(), media: z.array(z.string()).optional(), }) @@ -28,7 +28,7 @@ export const sendSMS = async (payload: SendSMSPayload) => { throw new Error('Invalid sendSMS payload') } - const { body, to, media } = validatedInput.data + const { body, to, media, statusCallbackUrl } = validatedInput.data if (!isPhoneNumberSupported(to)) { return @@ -42,7 +42,7 @@ export const sendSMS = async (payload: SendSMSPayload) => { const message = await messagingClient.messages.create({ messagingServiceSid: TWILIO_MESSAGING_SERVICE_SID, body, - statusCallback: fullUrl(apiUrls.smsStatusCallback()), + statusCallback: statusCallbackUrl, to, mediaUrl: media, }) diff --git a/src/utils/shared/urls/index.ts b/src/utils/shared/urls/index.ts index 9fe516a1c..8335f6ba0 100644 --- a/src/utils/shared/urls/index.ts +++ b/src/utils/shared/urls/index.ts @@ -141,5 +141,14 @@ export const apiUrls = { stateCode: string district: number }) => `/api/public/dtsi/races/usa/${stateCode}/${district}`, - smsStatusCallback: () => `/api/public/sms/events/status`, + smsStatusCallback: ({ + campaignName, + journeyType, + hasWelcomeMessageInBody, + }: { + campaignName: string + journeyType: string + hasWelcomeMessageInBody?: boolean + }) => + `/api/public/sms/events/status?campaignName=${campaignName}&journeyType=${journeyType}${hasWelcomeMessageInBody ? '&hasWelcomeMessageInBody' : ''}`, } From a876219cea3c3295e20c7673f80d9e9593040a52 Mon Sep 17 00:00:00 2001 From: Lucas Bernardi Date: Thu, 24 Oct 2024 10:24:42 -0300 Subject: [PATCH 2/6] fix: hasWelcomeMessageInBody search param --- src/app/api/public/sms/events/status/route.ts | 3 ++- src/utils/shared/urls/index.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/api/public/sms/events/status/route.ts b/src/app/api/public/sms/events/status/route.ts index 6874674d9..96196af34 100644 --- a/src/app/api/public/sms/events/status/route.ts +++ b/src/app/api/public/sms/events/status/route.ts @@ -11,6 +11,7 @@ import { withRouteMiddleware } from '@/utils/server/serverWrappers/withRouteMidd import { bulkCreateCommunicationJourney } from '@/utils/server/sms/communicationJourney' import { getUserByPhoneNumber, verifySignature } from '@/utils/server/sms/utils' import { getLogger } from '@/utils/shared/logger' +import { toBool } from '@/utils/shared/toBool' interface SMSStatusEvent { ErrorCode?: string @@ -44,7 +45,7 @@ export const POST = withRouteMiddleware(async (request: NextRequest) => { const journeyType = params.get('journeyType') as UserCommunicationJourneyType | null const campaignName = params.get('campaignName') - const hasWelcomeMessageInBody = params.has('hasWelcomeMessageInBody') + const hasWelcomeMessageInBody = toBool(params.get('hasWelcomeMessageInBody')) const messageId = body.MessageSid const messageStatus = body.MessageStatus diff --git a/src/utils/shared/urls/index.ts b/src/utils/shared/urls/index.ts index 8335f6ba0..4eaae4b0a 100644 --- a/src/utils/shared/urls/index.ts +++ b/src/utils/shared/urls/index.ts @@ -150,5 +150,5 @@ export const apiUrls = { journeyType: string hasWelcomeMessageInBody?: boolean }) => - `/api/public/sms/events/status?campaignName=${campaignName}&journeyType=${journeyType}${hasWelcomeMessageInBody ? '&hasWelcomeMessageInBody' : ''}`, + `/api/public/sms/events/status?campaignName=${campaignName}&journeyType=${journeyType}&hasWelcomeMessageInBody=${String(hasWelcomeMessageInBody ?? false)}`, } From e94d74bc509a18d4311150e9fad80ba640dc91d2 Mon Sep 17 00:00:00 2001 From: Lucas Bernardi Date: Thu, 24 Oct 2024 10:49:47 -0300 Subject: [PATCH 3/6] fix: update response error message --- src/app/api/public/sms/events/status/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/public/sms/events/status/route.ts b/src/app/api/public/sms/events/status/route.ts index 96196af34..e849f90a8 100644 --- a/src/app/api/public/sms/events/status/route.ts +++ b/src/app/api/public/sms/events/status/route.ts @@ -55,7 +55,7 @@ export const POST = withRouteMiddleware(async (request: NextRequest) => { const phoneNumber = body.To if (!journeyType || !campaignName) { - return new NextResponse('missing url params', { + return new NextResponse('missing search params', { status: 400, }) } From 87a3f1a7273b9a0ffd0d64620c768b2e7284e5bc Mon Sep 17 00:00:00 2001 From: Lucas Bernardi Date: Wed, 6 Nov 2024 16:57:56 -0300 Subject: [PATCH 4/6] feat: add support for sms variants and randomization of phone numbers --- .../sms/bulkSMSCommunicationJourney.ts | 116 ++++++++++++++---- src/utils/server/sms/sendSMS.ts | 14 +++ 2 files changed, 104 insertions(+), 26 deletions(-) diff --git a/src/inngest/functions/sms/bulkSMSCommunicationJourney.ts b/src/inngest/functions/sms/bulkSMSCommunicationJourney.ts index 317b85448..87c50d1f7 100644 --- a/src/inngest/functions/sms/bulkSMSCommunicationJourney.ts +++ b/src/inngest/functions/sms/bulkSMSCommunicationJourney.ts @@ -1,7 +1,7 @@ import { Prisma, SMSStatus, UserCommunicationJourneyType } 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' @@ -25,11 +25,14 @@ export interface BulkSmsCommunicationJourneyInngestEventSchema { export interface BulkSMSPayload { messages: Array<{ - smsBody: string - userWhereInput?: GetPhoneNumberOptions['userWhereInput'] - includePendingDoubleOptIn?: boolean + variants: Array<{ + smsBody: string + media?: string[] + percentage: number + }> campaignName: string - media?: string[] + includePendingDoubleOptIn?: boolean + userWhereInput?: GetPhoneNumberOptions['userWhereInput'] }> // default to ET: -5 timezone?: number @@ -63,9 +66,27 @@ 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 }, variantIndex) => { + if (!smsBody) { + throw new NonRetriableError( + `Missing sms body 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) { @@ -84,7 +105,7 @@ export const bulkSMSCommunicationJourney = inngest.createFunction( const messagesInfo: Record = {} for (const message of messages) { - const { campaignName, smsBody, includePendingDoubleOptIn, media, userWhereInput } = message + const { campaignName, includePendingDoubleOptIn, userWhereInput, variants } = message logger.info(prettyStringify(message)) @@ -134,7 +155,7 @@ export const bulkSMSCommunicationJourney = inngest.createFunction( prettyStringify({ userHasWelcomeMessage, campaignName, - smsBody, + variants, }), ) @@ -163,27 +184,49 @@ 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 + logger.info(`Shuffling phone numbers`) // Using uniq outside the while loop, because getPhoneNumberList could return the same phone number in two separate batches + const phoneNumberVariants = splitArrayByPercentages( + shuffle(uniq(allPhoneNumbers)), + variants.map(v => v.percentage), + ) + + 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((acc, variant) => { + const phoneNumbersPortion = phoneNumberVariants[variantIndex] + + logger.info(`Variant ${variantIndex} has ${phoneNumbersPortion.length} phone numbers`) + + variantIndex += 1 + + const { smsBody, media } = 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, + 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}`) @@ -372,3 +415,24 @@ async function getPhoneNumberList(options: GetPhoneNumberOptions) { }) .then(res => res.map(({ phoneNumber }) => phoneNumber)) } + +function splitArrayByPercentages(array: T[], percentages: number[]) { + const totalLength = array.length + const dividedArrays: 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/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 +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 } From 49af4f448dcc1c0305cb53f41f2796a481a760c1 Mon Sep 17 00:00:00 2001 From: Lucas Bernardi Date: Wed, 6 Nov 2024 18:16:32 -0300 Subject: [PATCH 5/6] fix: event reminder payload --- src/inngest/functions/eventNotification/logic.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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), From 7aa34c8fc8f6ba545974cbeb297ba9854db2f058 Mon Sep 17 00:00:00 2001 From: Lucas Bernardi Date: Tue, 19 Nov 2024 15:01:51 -0300 Subject: [PATCH 6/6] feat: add variant name --- prisma/schema.prisma | 1 + src/app/api/public/sms/events/status/route.ts | 34 ++++++++++++++---- .../sms/bulkSMSCommunicationJourney.ts | 33 +++++++++++++---- src/inngest/functions/sms/enqueueMessages.ts | 11 +++++- src/utils/server/sms/communicationJourney.ts | 35 ++++++++++--------- src/utils/shared/urls/index.ts | 17 ++++----- 6 files changed, 93 insertions(+), 38 deletions(-) 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[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/sms/bulkSMSCommunicationJourney.ts b/src/inngest/functions/sms/bulkSMSCommunicationJourney.ts index 96be5beb9..c3b5cb984 100644 --- a/src/inngest/functions/sms/bulkSMSCommunicationJourney.ts +++ b/src/inngest/functions/sms/bulkSMSCommunicationJourney.ts @@ -34,6 +34,7 @@ export interface BulkSMSPayload { smsBody: string media?: string[] percentage: number + variantName: string }> campaignName: string includePendingDoubleOptIn?: boolean @@ -78,13 +79,17 @@ export const bulkSMSCommunicationJourney = inngest.createFunction( let fullPercentage = 0 - variants.forEach(({ smsBody, percentage }, variantIndex) => { + 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 }) @@ -186,6 +191,7 @@ export const bulkSMSCommunicationJourney = inngest.createFunction( () => getPhoneNumberList({ campaignName, + variantNames: variants.map(v => v.variantName), includePendingDoubleOptIn, skip, userWhereInput: customWhere, @@ -206,12 +212,17 @@ export const bulkSMSCommunicationJourney = inngest.createFunction( logger.info(`Fetched all phone numbers ${allPhoneNumbers.length}`) - logger.info(`Shuffling phone numbers`) + 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 - const phoneNumberVariants = splitArrayByPercentages( - shuffle(uniq(allPhoneNumbers)), - variants.map(v => v.percentage), + 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`) @@ -226,7 +237,7 @@ export const bulkSMSCommunicationJourney = inngest.createFunction( variantIndex += 1 - const { smsBody, media } = variant + 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 @@ -238,6 +249,7 @@ export const bulkSMSCommunicationJourney = inngest.createFunction( { body, campaignName, + variantName, journeyType: UserCommunicationJourneyType.BULK_SMS, media, // If the user does not have a welcome message, the body must have it @@ -399,6 +411,7 @@ export interface GetPhoneNumberOptions { skip: number userWhereInput?: Prisma.UserGroupByArgs['where'] campaignName?: string + variantNames?: string[] } async function getPhoneNumberList(options: GetPhoneNumberOptions) { @@ -416,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: { 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(user => { - const communicationJourney = createdCommunicationJourneys.find( - ({ userId }) => userId === user.id, - ) + const createCommunicationPayload = users.map(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/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}`,