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:
1 parent
d86a28bf7f
commit
6bbf263981
17 files changed
+323
-4
No files matched your search
@@ -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=
|
||||
|
||||
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
}
|
||||
+17
@@ -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");
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user