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
This commit is contained in:
Joe Au-Yeung authored and GitHub committed 2025-04-22 22:00:19 +01:00
1 parent d86a28bf7f
commit 6bbf263981
17 files changed
+323 -4

No files matched your search

+1
View File
@@ -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=
+1 -1
View File
@@ -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";
@@ -0,0 +1,5 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import handleSMSResponse from "@calcom/features/ee/workflows/api/handleSMSResponse";
export const POST = defaultResponderForAppDir(handleSMSResponse);
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+1
View File
@@ -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": {
@@ -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;
@@ -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,
@@ -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);
});
})
);
}
@@ -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 ||
@@ -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) => ({
@@ -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;
}
}
@@ -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,
},
},
});
}
}
@@ -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")}`;
}
}
@@ -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");
+16
View File
@@ -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])
}
+1
View File
@@ -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",
+20 -3
View File
@@ -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"