From 6bbf263981ae8e8239141b5e5f196c2961587537 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Tue, 22 Apr 2025 17:00:19 -0400 Subject: [PATCH] feat: Optout of SMS workflows (#20769) * Do not call dub if API key isn't present * Add `WorkflowOptOutContact` table * Add `sendTo` column to `WorkflowReminder` * Add `workflowOptOutContactRepository` * Add `WorkflowReminderRepository` * Write `sendTo` number when scheduling SMS messages * Add `p-limit` package * Add `determineOptOutType` to `TwilioProvider` * Add `deleteMultipleScheduledSMS` * Create `WorkflowOptOutService` * Add endpoint handler for Twilio SMS responses * Add `isOptedOut` method to `workflowOptOutContactRepository` * Verify phone number is not opted out before scheduling SMS * Use `smsReminderNumber` instead of `sendTo` * Type fix * Add .env variable if opt out is available * Add opt out message to SMS * Import `pLimit` directly in method * Address select comment * Guard against undefined phone number from form * Add early return to `deleteWorkflowReminders` * Add request validation from Twilio * Add fallback message as i18N string * Only delete SMS attendee scheduled reminders --- .env.example | 1 + apps/web/app/api/customer-card/route.ts | 2 +- .../api/workflows/sms/user-response/route.ts | 5 + apps/web/public/static/locales/en/common.json | 1 + package.json | 1 + .../ee/workflows/api/handleSMSResponse.ts | 28 ++++++ .../ee/workflows/api/scheduleSMSReminders.ts | 5 + .../lib/reminders/providers/twilioProvider.ts | 91 +++++++++++++++++++ .../lib/reminders/smsReminderManager.ts | 15 +++ .../ee/workflows/lib/reminders/utils.ts | 4 + .../lib/repository/workflowOptOutContact.ts | 51 +++++++++++ .../lib/repository/workflowReminder.ts | 34 +++++++ .../lib/service/workflowOptOutService.ts | 32 +++++++ .../migration.sql | 17 ++++ packages/prisma/schema.prisma | 16 ++++ turbo.json | 1 + yarn.lock | 23 ++++- 17 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 apps/web/app/api/workflows/sms/user-response/route.ts create mode 100644 packages/features/ee/workflows/api/handleSMSResponse.ts create mode 100644 packages/features/ee/workflows/lib/repository/workflowOptOutContact.ts create mode 100644 packages/features/ee/workflows/lib/repository/workflowReminder.ts create mode 100644 packages/features/ee/workflows/lib/service/workflowOptOutService.ts create mode 100644 packages/prisma/migrations/20250417131239_add_workflowoptoutcontact_table/migration.sql diff --git a/.env.example b/.env.example index b9b1377e85..32a2738d97 100644 --- a/.env.example +++ b/.env.example @@ -187,6 +187,7 @@ TWILIO_WHATSAPP_PHONE_NUMBER= # For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters) NEXT_PUBLIC_SENDER_ID= TWILIO_VERIFY_SID= +TWILIO_OPT_OUT_ENABLED= # Set it to "1" if you need to run E2E tests locally. NEXT_PUBLIC_IS_E2E= diff --git a/apps/web/app/api/customer-card/route.ts b/apps/web/app/api/customer-card/route.ts index e934b10053..9dcdaef88b 100644 --- a/apps/web/app/api/customer-card/route.ts +++ b/apps/web/app/api/customer-card/route.ts @@ -6,8 +6,8 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import dayjs from "@calcom/dayjs"; -import { UserRepository } from "@calcom/lib/server/repository/user"; import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema"; +import { UserRepository } from "@calcom/lib/server/repository/user"; import { userMetadata } from "@calcom/prisma/zod-utils"; import { CardComponent } from "@lib/plain/card-components"; diff --git a/apps/web/app/api/workflows/sms/user-response/route.ts b/apps/web/app/api/workflows/sms/user-response/route.ts new file mode 100644 index 0000000000..27061a6e95 --- /dev/null +++ b/apps/web/app/api/workflows/sms/user-response/route.ts @@ -0,0 +1,5 @@ +import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; + +import handleSMSResponse from "@calcom/features/ee/workflows/api/handleSMSResponse"; + +export const POST = defaultResponderForAppDir(handleSMSResponse); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 6d110f0167..11bc98d2dd 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3143,5 +3143,6 @@ "you_and_conjunction": "You &", "email_survey_triggered_by_workflow": "This survey was triggered by a Workflow in Cal.", "sms_workflow_consent": "By entering your phone number you consent to receive SMS messages for this event. SMS rates may apply.", + "sms_opt_out_message": "Text STOP to opt-out of SMS messages", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/package.json b/package.json index 4a6e85e35d..ccbfba4d7d 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "@vercel/functions": "^1.4.0", "city-timezones": "^1.2.1", "eslint": "^8.34.0", + "p-limit": "^6.2.0", "turbo": "^1.10.1" }, "resolutions": { diff --git a/packages/features/ee/workflows/api/handleSMSResponse.ts b/packages/features/ee/workflows/api/handleSMSResponse.ts new file mode 100644 index 0000000000..aa500ec8fe --- /dev/null +++ b/packages/features/ee/workflows/api/handleSMSResponse.ts @@ -0,0 +1,28 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { determineOptOutType } from "../lib/reminders/providers/twilioProvider"; +import { WorkflowOptOutContactRepository } from "../lib/repository/workflowOptOutContact"; +import { WorkflowOptOutService } from "../lib/service/workflowOptOutService"; + +const handleSMSResponse = async (request: NextRequest) => { + try { + const optOutResult = await determineOptOutType(request); + if ("error" in optOutResult) { + return NextResponse.json({ message: optOutResult.error }, { status: 400 }); + } + + if (optOutResult.optOutStatus) { + await WorkflowOptOutService.optOutPhoneNumber(optOutResult.phoneNumber); + } else { + await WorkflowOptOutContactRepository.removePhoneNumber(optOutResult.phoneNumber); + } + + return NextResponse.json({ message: "Opt status updated successfully" }, { status: 200 }); + } catch (e) { + console.error("Error processing user response webhook:", e); + return NextResponse.json({ message: "Internal server error" }, { status: 500 }); + } +}; + +export default handleSMSResponse; diff --git a/packages/features/ee/workflows/api/scheduleSMSReminders.ts b/packages/features/ee/workflows/api/scheduleSMSReminders.ts index 8c4f4b0ffb..0713bffbf2 100644 --- a/packages/features/ee/workflows/api/scheduleSMSReminders.ts +++ b/packages/features/ee/workflows/api/scheduleSMSReminders.ts @@ -18,6 +18,7 @@ import * as twilio from "../lib/reminders/providers/twilioProvider"; import type { VariablesType } from "../lib/reminders/templates/customTemplate"; import customTemplate from "../lib/reminders/templates/customTemplate"; import smsReminderTemplate from "../lib/reminders/templates/smsReminderTemplate"; +import { WorkflowOptOutService } from "../lib/service/workflowOptOutService"; export async function handler(req: NextRequest) { const apiKey = req.headers.get("authorization") || req.nextUrl.searchParams.get("apiKey"); @@ -173,6 +174,10 @@ export async function handler(req: NextRequest) { } if (message?.length && message?.length > 0 && sendTo) { + if (process.env.TWILIO_OPT_OUT_ENABLED === "true") { + message = await WorkflowOptOutService.addOptOutMessage(message, locale || "en"); + } + const scheduledSMS = await twilio.scheduleSMS( sendTo, message, diff --git a/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts b/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts index 7fdd6f0cd8..08f0c5a4f5 100644 --- a/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts +++ b/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts @@ -1,7 +1,9 @@ +import type { NextRequest } from "next/server"; import TwilioClient from "twilio"; import { v4 as uuidv4 } from "uuid"; import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError"; +import { WEBAPP_URL } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; import { setTestSMS } from "@calcom/lib/testSMS"; import prisma from "@calcom/prisma"; @@ -196,3 +198,92 @@ async function isLockedForSMSSending(userId?: number | null, teamId?: number | n return user?.smsLockState === SMSLockState.LOCKED; } } + +export async function validateWebhookRequest({ + requestUrl, + params, + signature, +}: { + requestUrl: string; + params: object; + signature: string; +}) { + if (!process.env.TWILIO_TOKEN) { + throw new Error("TWILIO_TOKEN is not set"); + } + + const isSignatureValid = TwilioClient.validateRequest( + process.env.TWILIO_TOKEN, + signature, + requestUrl, + params + ); + return isSignatureValid; +} + +export async function determineOptOutType( + req: NextRequest +): Promise<{ phoneNumber: string; optOutStatus: boolean } | { error: string }> { + const signature = req.headers.get("X-Twilio-Signature"); + const formData = await req.formData(); + const params = Object.fromEntries(formData.entries()); + + if (!signature) { + return { error: "Missing Twilio signature" }; + } + + const fullUrl = `${WEBAPP_URL}${req.nextUrl.pathname}`; + + const isSignatureValid = await validateWebhookRequest({ + requestUrl: fullUrl, + params: params, + signature: signature, + }); + + if (!isSignatureValid) { + return { error: "Invalid signature" }; + } + + const accountSid = params["AccountSid"]?.valueOf(); + + if (accountSid !== process.env.TWILIO_SID) { + return { error: "Invalid account SID" }; + } + + // Twilio returns phone numbers with a + prefix + const phoneNumberRaw = params["From"]?.valueOf(); + + if (!phoneNumberRaw) { + return { error: "No phone number to handle" }; + } + + const phoneNumber = phoneNumberRaw.toString(); + + if (!formData.get("OptOutType")) { + return { error: "No opt out message to handle" }; + } + + const optOutMessage = formData.get("OptOutType")?.valueOf(); + + if (optOutMessage !== "STOP" && optOutMessage !== "START") { + return { error: "Invalid opt out type" }; + } + + const optOutStatus = optOutMessage === "STOP" ? true : false; + + return { phoneNumber, optOutStatus }; +} +export async function deleteMultipleScheduledSMS(referenceIds: string[]) { + const twilio = createTwilioClient(); + + const pLimit = (await import("p-limit")).default; + const limit = pLimit(10); + + await Promise.allSettled( + referenceIds.map((referenceId) => { + return limit(() => twilio.messages(referenceId).update({ status: "canceled" })).catch((error) => { + log.error(`Error canceling scheduled SMS with id ${referenceId}`, error); + }); + }) + ); +} diff --git a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts index 25b9aff77c..e94dbe4734 100644 --- a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts @@ -2,6 +2,7 @@ import dayjs from "@calcom/dayjs"; import { bulkShortenLinks } from "@calcom/ee/workflows/lib/reminders/utils"; import { SENDER_ID, WEBSITE_URL } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; import type { TimeFormat } from "@calcom/lib/timeFormat"; import type { PrismaClient } from "@calcom/prisma"; import prisma from "@calcom/prisma"; @@ -12,6 +13,8 @@ import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; import type { CalEventResponses, RecurringEvent } from "@calcom/types/Calendar"; import { getSenderId } from "../alphanumericSenderIdSupport"; +import { WorkflowOptOutContactRepository } from "../repository/workflowOptOutContact"; +import { WorkflowOptOutService } from "../service/workflowOptOutService"; import type { ScheduleReminderArgs } from "./emailReminderManager"; import * as twilio from "./providers/twilioProvider"; import type { VariablesType } from "./templates/customTemplate"; @@ -100,6 +103,14 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => { return; } + if (reminderPhone && (await WorkflowOptOutContactRepository.isOptedOut(reminderPhone))) { + log.warn( + `Phone number opted out of SMS workflows`, + safeStringify({ workflowStep: workflowStepId, eventUid: evt.uid }) + ); + return; + } + const { startTime, endTime } = evt; const uid = evt.uid as string; const currentDate = dayjs(); @@ -206,6 +217,10 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => { log.debug(`Sending sms for trigger ${triggerEvent}`, smsMessage); if (smsMessage.length > 0 && reminderPhone && isNumberVerified) { + if (process.env.TWILIO_OPT_OUT_ENABLED === "true") { + smsMessage = await WorkflowOptOutService.addOptOutMessage(smsMessage, evt.organizer.language.locale); + } + //send SMS when event is booked/cancelled/rescheduled if ( triggerEvent === WorkflowTriggerEvents.NEW_EVENT || diff --git a/packages/features/ee/workflows/lib/reminders/utils.ts b/packages/features/ee/workflows/lib/reminders/utils.ts index 850122e9f0..78d1d2d9b4 100644 --- a/packages/features/ee/workflows/lib/reminders/utils.ts +++ b/packages/features/ee/workflows/lib/reminders/utils.ts @@ -1,6 +1,10 @@ import { dub } from "@calcom/features/auth/lib/dub"; export const bulkShortenLinks = async (links: string[]) => { + if (!process.env.DUB_API_KEY) { + return links.map((link) => ({ shortLink: link })); + } + const linksToShorten = links.filter((link) => link); const results = await dub.links.createMany( linksToShorten.map((link) => ({ diff --git a/packages/features/ee/workflows/lib/repository/workflowOptOutContact.ts b/packages/features/ee/workflows/lib/repository/workflowOptOutContact.ts new file mode 100644 index 0000000000..90ba2c6449 --- /dev/null +++ b/packages/features/ee/workflows/lib/repository/workflowOptOutContact.ts @@ -0,0 +1,51 @@ +import prisma from "@calcom/prisma"; +import { WorkflowContactType } from "@calcom/prisma/enums"; + +export class WorkflowOptOutContactRepository { + static async addPhoneNumber(phoneNumber: string) { + await prisma.workflowOptOutContact.upsert({ + where: { + type_value: { + type: WorkflowContactType.PHONE, + value: phoneNumber, + }, + }, + update: { + optedOut: true, + }, + create: { + type: WorkflowContactType.PHONE, + optedOut: true, + value: phoneNumber, + }, + }); + } + + static async removePhoneNumber(phoneNumber: string) { + await prisma.workflowOptOutContact.update({ + where: { + type_value: { + type: WorkflowContactType.PHONE, + value: phoneNumber, + }, + }, + data: { + optedOut: false, + }, + }); + } + + static async isOptedOut(phoneNumber: string) { + const optOutContact = await prisma.workflowOptOutContact.findFirst({ + where: { + type: WorkflowContactType.PHONE, + value: phoneNumber, + optedOut: true, + }, + select: { + optedOut: true, + }, + }); + return !!optOutContact?.optedOut; + } +} diff --git a/packages/features/ee/workflows/lib/repository/workflowReminder.ts b/packages/features/ee/workflows/lib/repository/workflowReminder.ts new file mode 100644 index 0000000000..e235d7084c --- /dev/null +++ b/packages/features/ee/workflows/lib/repository/workflowReminder.ts @@ -0,0 +1,34 @@ +import prisma from "@calcom/prisma"; +import { WorkflowMethods, WorkflowActions } from "@calcom/prisma/enums"; + +export class WorkflowReminderRepository { + static getFutureScheduledAttendeeSMSReminders(phoneNumber: string) { + return prisma.workflowReminder.findMany({ + where: { + method: WorkflowMethods.SMS, + cancelled: null, + scheduledDate: { gte: new Date() }, + booking: { + smsReminderNumber: phoneNumber, + }, + workflowStep: { + action: WorkflowActions.SMS_ATTENDEE, + }, + }, + }); + } + + static deleteWorkflowReminders(ids: number[]) { + if (!ids.length) { + return []; + } + + return prisma.workflowReminder.deleteMany({ + where: { + id: { + in: ids, + }, + }, + }); + } +} diff --git a/packages/features/ee/workflows/lib/service/workflowOptOutService.ts b/packages/features/ee/workflows/lib/service/workflowOptOutService.ts new file mode 100644 index 0000000000..728588c096 --- /dev/null +++ b/packages/features/ee/workflows/lib/service/workflowOptOutService.ts @@ -0,0 +1,32 @@ +import { getTranslation } from "@calcom/lib/server/i18n"; + +import { deleteMultipleScheduledSMS } from "../reminders/providers/twilioProvider"; +import { WorkflowOptOutContactRepository } from "../repository/workflowOptOutContact"; +import { WorkflowReminderRepository } from "../repository/workflowReminder"; + +export class WorkflowOptOutService { + static async optOutPhoneNumber(phoneNumber: string) { + await WorkflowOptOutContactRepository.addPhoneNumber(phoneNumber); + // Delete scheduled workflows + const scheduledReminders = await WorkflowReminderRepository.getFutureScheduledAttendeeSMSReminders( + phoneNumber + ); + + // Get twilio scheduled workflows reminders + await deleteMultipleScheduledSMS( + scheduledReminders + .filter((reminder) => !!reminder.referenceId) + .map((reminder) => reminder.referenceId as string) + ); + + // Get not twilio scheduled yet workflows + await WorkflowReminderRepository.deleteWorkflowReminders( + scheduledReminders.filter((reminder) => !reminder.referenceId).map((reminder) => reminder.id) + ); + } + + static async addOptOutMessage(message: string, locale: string) { + const t = await getTranslation(locale, "common"); + return `${message}\n\n${t("sms_opt_out_message")}`; + } +} diff --git a/packages/prisma/migrations/20250417131239_add_workflowoptoutcontact_table/migration.sql b/packages/prisma/migrations/20250417131239_add_workflowoptoutcontact_table/migration.sql new file mode 100644 index 0000000000..7edce288e6 --- /dev/null +++ b/packages/prisma/migrations/20250417131239_add_workflowoptoutcontact_table/migration.sql @@ -0,0 +1,17 @@ +-- CreateEnum +CREATE TYPE "WorkflowContactType" AS ENUM ('PHONE', 'EMAIL'); + +-- CreateTable +CREATE TABLE "WorkflowOptOutContact" ( + "id" SERIAL NOT NULL, + "type" "WorkflowContactType" NOT NULL, + "value" TEXT NOT NULL, + "optedOut" BOOLEAN NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WorkflowOptOutContact_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "WorkflowOptOutContact_type_value_key" ON "WorkflowOptOutContact"("type", "value"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 55d0ba5b99..ce0c50878e 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -2064,3 +2064,19 @@ model BookingInternalNote { @@unique([bookingId, notePresetId]) @@index([bookingId]) } + +enum WorkflowContactType { + PHONE + EMAIL +} + +model WorkflowOptOutContact { + id Int @id @default(autoincrement()) + type WorkflowContactType + value String + optedOut Boolean + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([type, value]) +} diff --git a/turbo.json b/turbo.json index fa0230ffeb..eaf40a91fe 100644 --- a/turbo.json +++ b/turbo.json @@ -408,6 +408,7 @@ "TEAM_ID_VERCEL", "TELEMETRY_DEBUG", "TWILIO_MESSAGING_SID", + "TWILIO_OPT_OUT_ENABLED", "TWILIO_PHONE_NUMBER", "TWILIO_WHATSAPP_PHONE_NUMBER", "TWILIO_SID", diff --git a/yarn.lock b/yarn.lock index a5e10bc244..7143886ac0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20754,6 +20754,7 @@ __metadata: next-router-mock: ^0.9.12 node-gyp: ^10.2.0 node-ical: ^0.16.1 + p-limit: ^6.2.0 prettier: ^2.8.6 prismock: ^1.33.4 resize-observer-polyfill: ^1.5.1 @@ -35881,6 +35882,15 @@ __metadata: languageName: node linkType: hard +"p-limit@npm:^6.2.0": + version: 6.2.0 + resolution: "p-limit@npm:6.2.0" + dependencies: + yocto-queue: ^1.1.1 + checksum: ea46ba4a3013daa3335833013156bdcdd8f5c67b49ad2f33b34832b0d528f003d29b55bf5beeedc39ba9d9ada3dab7b1f4b890db3ebc8260a044a011126c6fc9 + languageName: node + linkType: hard + "p-locate@npm:^2.0.0": version: 2.0.0 resolution: "p-locate@npm:2.0.0" @@ -43396,8 +43406,8 @@ __metadata: linkType: hard "twilio@npm:^3.80.1": - version: 3.80.1 - resolution: "twilio@npm:3.80.1" + version: 3.84.1 + resolution: "twilio@npm:3.84.1" dependencies: axios: ^0.26.1 dayjs: ^1.8.29 @@ -43410,7 +43420,7 @@ __metadata: scmp: ^2.1.0 url-parse: ^1.5.9 xmlbuilder: ^13.0.2 - checksum: 223655b5d20229f1a0a179596319284381d3524018229ad6eabdede4c7a9828c58d616c57fb7e14b80e372560cd97f4a270241c1598baea0e6f42e616d2ba6e7 + checksum: 29bf7e75aa513bc47d3559f4011580cf1a5a994fe07fa4780af542b666e73b101cdc260f6ee96222c84c29638dc25584815535d20476ef3c11c562cc263a601a languageName: node linkType: hard @@ -45906,6 +45916,13 @@ __metadata: languageName: node linkType: hard +"yocto-queue@npm:^1.1.1": + version: 1.2.1 + resolution: "yocto-queue@npm:1.2.1" + checksum: 0843d6c2c0558e5c06e98edf9c17942f25c769e21b519303a5c2adefd5b738c9b2054204dc856ac0cd9d134b1bc27d928ce84fd23c9e2423b7e013d5a6f50577 + languageName: node + linkType: hard + "yoctocolors-cjs@npm:^2.1.2": version: 2.1.2 resolution: "yoctocolors-cjs@npm:2.1.2"