Add alphanumeric sender ID to SMS workflow actions (#5471)

* add sender id

* add sender to twilio from

* added missing sender

* add migration

* fix design of add action dialog

* add cal as sender when creating new workflow

* fix type errors

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
Carina Wollendorfer 2022-11-11 16:01:17 +01:00 committed by GitHub
parent ef3e7fae20
commit 54f4e665a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 179 additions and 70 deletions

View File

@ -1361,6 +1361,8 @@
"invalid_credential": "Oh no! Looks like permission expired or was revoked. Please reinstall again.",
"choose_common_schedule_team_event": "Choose a common schedule",
"choose_common_schedule_team_event_description": "Enable this if you want to use a common schedule between hosts. When disabled, each host will be booked based on their default schedule.",
"sender_id": "Sender ID",
"sender_id_error_message":"Only letters, numbers and spaces allowed (max. 11 characters)",
"test_routing_form": "Test Routing Form",
"test_preview": "Test Preview",
"route_to": "Route to",

View File

@ -105,7 +105,12 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
break;
}
if (message?.length && message?.length > 0 && sendTo) {
const scheduledSMS = await twilio.scheduleSMS(sendTo, message, reminder.scheduledDate);
const scheduledSMS = await twilio.scheduleSMS(
sendTo,
message,
reminder.scheduledDate,
reminder.workflowStep.sender || "Cal"
);
await prisma.workflowReminder.update({
where: {

View File

@ -6,17 +6,18 @@ import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Checkbox, EmailField, Form, Label } from "@calcom/ui/components";
import { Button, Checkbox, EmailField, Form, Label, TextField } from "@calcom/ui/components";
import PhoneInput from "@calcom/ui/form/PhoneInputLazy";
import { Dialog, DialogClose, DialogContent, DialogFooter, Select } from "@calcom/ui/v2";
import { WORKFLOW_ACTIONS } from "../../lib/constants";
import { getWorkflowActionOptions } from "../../lib/getOptions";
import { onlyLettersNumbersSpaces } from "../../pages/v2/workflow";
interface IAddActionDialog {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
addAction: (action: WorkflowActions, sendTo?: string, numberRequired?: boolean) => void;
addAction: (action: WorkflowActions, sendTo?: string, numberRequired?: boolean, sender?: string) => void;
isFreeUser: boolean;
}
@ -29,6 +30,7 @@ type AddActionFormValues = {
action: WorkflowActions;
sendTo?: string;
numberRequired?: boolean;
sender?: string;
};
const cleanUpActionsForFreeUser = (actions: ISelectActionOption[]) => {
@ -41,6 +43,7 @@ export const AddActionDialog = (props: IAddActionDialog) => {
const { t } = useLocale();
const { isOpenDialog, setIsOpenDialog, addAction, isFreeUser } = props;
const [isPhoneNumberNeeded, setIsPhoneNumberNeeded] = useState(false);
const [isSenderIdNeeded, setIsSenderIdNeeded] = useState(false);
const [isEmailAddressNeeded, setIsEmailAddressNeeded] = useState(false);
const workflowActions = getWorkflowActionOptions(t);
const actionOptions = isFreeUser ? cleanUpActionsForFreeUser(workflowActions) : workflowActions;
@ -52,12 +55,17 @@ export const AddActionDialog = (props: IAddActionDialog) => {
.refine((val) => isValidPhoneNumber(val) || val.includes("@"))
.optional(),
numberRequired: z.boolean().optional(),
sender: z
.string()
.refine((val) => onlyLettersNumbersSpaces(val))
.nullable(),
});
const form = useForm<AddActionFormValues>({
mode: "onSubmit",
defaultValues: {
action: WorkflowActions.EMAIL_HOST,
sender: "Cal",
},
resolver: zodResolver(formSchema),
});
@ -67,11 +75,18 @@ export const AddActionDialog = (props: IAddActionDialog) => {
form.setValue("action", newValue.value);
if (newValue.value === WorkflowActions.SMS_NUMBER) {
setIsPhoneNumberNeeded(true);
setIsSenderIdNeeded(true);
setIsEmailAddressNeeded(false);
} else if (newValue.value === WorkflowActions.EMAIL_ADDRESS) {
setIsEmailAddressNeeded(true);
setIsSenderIdNeeded(false);
setIsPhoneNumberNeeded(false);
} else if (newValue.value === WorkflowActions.SMS_ATTENDEE) {
setIsSenderIdNeeded(true);
setIsEmailAddressNeeded(false);
setIsPhoneNumberNeeded(false);
} else {
setIsSenderIdNeeded(false);
setIsEmailAddressNeeded(false);
setIsPhoneNumberNeeded(false);
}
@ -90,13 +105,14 @@ export const AddActionDialog = (props: IAddActionDialog) => {
<Form
form={form}
handleSubmit={(values) => {
addAction(values.action, values.sendTo, values.numberRequired);
addAction(values.action, values.sendTo, values.numberRequired, values.sender);
form.unregister("sendTo");
form.unregister("action");
form.unregister("numberRequired");
setIsOpenDialog(false);
setIsPhoneNumberNeeded(false);
setIsEmailAddressNeeded(false);
setIsSenderIdNeeded(false);
}}>
<div className="mt-5 space-y-1">
<Label htmlFor="label">{t("action")}:</Label>
@ -119,25 +135,10 @@ export const AddActionDialog = (props: IAddActionDialog) => {
<p className="mt-1 text-sm text-red-500">{form.formState.errors.action.message}</p>
)}
</div>
{form.getValues("action") === WorkflowActions.SMS_ATTENDEE && (
<div className="mt-5">
<Controller
name="numberRequired"
control={form.control}
render={() => (
<Checkbox
defaultChecked={form.getValues("numberRequired") || false}
description={t("make_phone_number_required")}
onChange={(e) => form.setValue("numberRequired", e.target.checked)}
/>
)}
/>
</div>
)}
{isPhoneNumberNeeded && (
<div className="mt-5 space-y-1">
<Label htmlFor="sendTo">{t("phone_number")}</Label>
<div className="mt-1">
<div className="mt-1 mb-5">
<PhoneInput<AddActionFormValues>
control={form.control}
name="sendTo"
@ -157,6 +158,32 @@ export const AddActionDialog = (props: IAddActionDialog) => {
<EmailField required label={t("email_address")} {...form.register("sendTo")} />
</div>
)}
{isSenderIdNeeded && (
<div className="mt-5">
<TextField
label={t("sender_id")}
type="text"
placeholder="Cal"
maxLength={11}
{...form.register(`sender`)}
/>
</div>
)}
{form.getValues("action") === WorkflowActions.SMS_ATTENDEE && (
<div className="mt-5">
<Controller
name="numberRequired"
control={form.control}
render={() => (
<Checkbox
defaultChecked={form.getValues("numberRequired") || false}
description={t("make_phone_number_required")}
onChange={(e) => form.setValue("numberRequired", e.target.checked)}
/>
)}
/>
</div>
)}
<DialogFooter>
<DialogClose asChild>
<Button
@ -168,6 +195,7 @@ export const AddActionDialog = (props: IAddActionDialog) => {
form.unregister("numberRequired");
setIsPhoneNumberNeeded(false);
setIsEmailAddressNeeded(false);
setIsSenderIdNeeded(false);
}}>
{t("cancel")}
</Button>

View File

@ -51,7 +51,7 @@ export default function WorkflowDetailsPage(props: Props) {
[data]
);
const addAction = (action: WorkflowActions, sendTo?: string, numberRequired?: boolean) => {
const addAction = (action: WorkflowActions, sendTo?: string, numberRequired?: boolean, sender?: string) => {
const steps = form.getValues("steps");
const id =
steps?.length > 0
@ -75,6 +75,7 @@ export default function WorkflowDetailsPage(props: Props) {
emailSubject: null,
template: WorkflowTemplates.CUSTOM,
numberRequired: numberRequired || false,
sender: sender || "Cal",
};
steps?.push(step);
form.setValue("steps", steps);

View File

@ -17,7 +17,7 @@ import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger }
import { Icon } from "@calcom/ui/Icon";
import { Button } from "@calcom/ui/components";
import { Checkbox } from "@calcom/ui/components";
import { EmailField, Label, TextArea } from "@calcom/ui/components/form";
import { EmailField, Label, TextArea, TextField } from "@calcom/ui/components/form";
import PhoneInput from "@calcom/ui/form/PhoneInputLazy";
import { DialogClose, DialogContent } from "@calcom/ui/v2";
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
@ -52,6 +52,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
step?.action === WorkflowActions.SMS_NUMBER ? true : false
);
const [isSenderIdNeeded, setIsSenderIdNeeded] = useState(
step?.action === WorkflowActions.SMS_NUMBER || step?.action === WorkflowActions.SMS_ATTENDEE
? true
: false
);
const [isEmailAddressNeeded, setIsEmailAddressNeeded] = useState(
step?.action === WorkflowActions.EMAIL_ADDRESS ? true : false
);
@ -279,13 +285,20 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
if (val) {
if (val.value === WorkflowActions.SMS_NUMBER) {
setIsPhoneNumberNeeded(true);
setIsSenderIdNeeded(true);
setIsEmailAddressNeeded(false);
} else if (val.value === WorkflowActions.EMAIL_ADDRESS) {
setIsEmailAddressNeeded(true);
setIsPhoneNumberNeeded(false);
setIsSenderIdNeeded(false);
} else if (val.value === WorkflowActions.SMS_ATTENDEE) {
setIsSenderIdNeeded(true);
setIsEmailAddressNeeded(false);
setIsPhoneNumberNeeded(false);
} else {
setIsEmailAddressNeeded(false);
setIsPhoneNumberNeeded(false);
setIsSenderIdNeeded(false);
}
form.unregister(`steps.${step.stepNumber - 1}.sendTo`);
form.clearErrors(`steps.${step.stepNumber - 1}.sendTo`);
@ -315,43 +328,64 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
);
}}
/>
{form.getValues(`steps.${step.stepNumber - 1}.action`) === WorkflowActions.SMS_ATTENDEE && (
<div className="mt-5">
<Controller
name={`steps.${step.stepNumber - 1}.numberRequired`}
control={form.control}
render={() => (
<Checkbox
defaultChecked={
form.getValues(`steps.${step.stepNumber - 1}.numberRequired`) || false
}
description={t("make_phone_number_required")}
onChange={(e) =>
form.setValue(`steps.${step.stepNumber - 1}.numberRequired`, e.target.checked)
}
/>
)}
/>
</div>
)}
</div>
{isPhoneNumberNeeded && (
<div className="mt-5 rounded-md bg-gray-50 p-4">
<Label>{t("custom_phone_number")}</Label>
<PhoneInput<FormValues>
{(isPhoneNumberNeeded || isSenderIdNeeded) && (
<div className="mt-2 rounded-md bg-gray-50 p-4 pt-0">
{isPhoneNumberNeeded && (
<>
<Label className="pt-4">{t("custom_phone_number")}</Label>
<PhoneInput<FormValues>
control={form.control}
name={`steps.${step.stepNumber - 1}.sendTo`}
placeholder={t("phone_number")}
id={`steps.${step.stepNumber - 1}.sendTo`}
className="w-full rounded-md"
required
/>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (
<p className="mt-1 text-xs text-red-500">
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
</p>
)}
</>
)}
{isSenderIdNeeded && (
<>
<div className="pt-4">
<TextField
label={t("sender_id")}
type="text"
placeholder="Cal"
maxLength={11}
{...form.register(`steps.${step.stepNumber - 1}.sender`)}
/>
</div>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.sender && (
<p className="mt-1 text-xs text-red-500">{t("sender_id_error_message")}</p>
)}
</>
)}
</div>
)}
{form.getValues(`steps.${step.stepNumber - 1}.action`) === WorkflowActions.SMS_ATTENDEE && (
<div className="mt-2">
<Controller
name={`steps.${step.stepNumber - 1}.numberRequired`}
control={form.control}
name={`steps.${step.stepNumber - 1}.sendTo`}
placeholder={t("phone_number")}
id={`steps.${step.stepNumber - 1}.sendTo`}
className="w-full rounded-md"
required
/>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (
<p className="mt-1 text-sm text-red-500">
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
</p>
render={() => (
<Checkbox
defaultChecked={
form.getValues(`steps.${step.stepNumber - 1}.numberRequired`) || false
}
description={t("make_phone_number_required")}
onChange={(e) =>
form.setValue(`steps.${step.stepNumber - 1}.numberRequired`, e.target.checked)
}
/>
)}
/>
</div>
)}
{isEmailAddressNeeded && (
@ -408,7 +442,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
/>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.emailSubject && (
<p className="mt-1 text-sm text-red-500">
<p className="mt-1 text-xs text-red-500">
{form.formState?.errors?.steps[step.stepNumber - 1]?.emailSubject?.message || ""}
</p>
)}
@ -433,7 +467,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
/>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody && (
<p className="mt-1 text-sm text-red-500">
<p className="mt-1 text-xs text-red-500">
{form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody?.message || ""}
</p>
)}
@ -497,6 +531,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
emailSubject,
reminderBody,
template: step.template,
sender: step.sender || "Cal",
});
} else {
const isNumberValid =

View File

@ -47,7 +47,8 @@ export const scheduleWorkflowReminders = async (
},
step.reminderBody || "",
step.id,
step.template
step.template,
step.sender || "Cal"
);
} else if (
step.action === WorkflowActions.EMAIL_ATTENDEE ||
@ -115,7 +116,8 @@ export const sendCancelledReminders = async (
},
step.reminderBody || "",
step.id,
step.template
step.template,
step.sender || "Cal"
);
} else if (
step.action === WorkflowActions.EMAIL_ATTENDEE ||

View File

@ -19,18 +19,19 @@ function assertTwilio(twilio: TwilioClient.Twilio | undefined): asserts twilio i
if (!twilio) throw new Error("Twilio credentials are missing from the .env file");
}
export const sendSMS = async (phoneNumber: string, body: string) => {
export const sendSMS = async (phoneNumber: string, body: string, sender: string) => {
assertTwilio(twilio);
const response = await twilio.messages.create({
body: body,
messagingServiceSid: process.env.TWILIO_MESSAGING_SID,
to: phoneNumber,
from: sender,
});
return response;
};
export const scheduleSMS = async (phoneNumber: string, body: string, scheduledDate: Date) => {
export const scheduleSMS = async (phoneNumber: string, body: string, scheduledDate: Date, sender: string) => {
assertTwilio(twilio);
const response = await twilio.messages.create({
body: body,
@ -38,6 +39,7 @@ export const scheduleSMS = async (phoneNumber: string, body: string, scheduledDa
to: phoneNumber,
scheduleType: "fixed",
sendAt: scheduledDate,
from: sender,
});
return response;

View File

@ -48,7 +48,8 @@ export const scheduleSMSReminder = async (
},
message: string,
workflowStepId: number,
template: WorkflowTemplates
template: WorkflowTemplates,
sender: string
) => {
const { startTime, endTime } = evt;
const uid = evt.uid as string;
@ -97,7 +98,7 @@ export const scheduleSMSReminder = async (
triggerEvent === WorkflowTriggerEvents.RESCHEDULE_EVENT
) {
try {
await twilio.sendSMS(reminderPhone, message);
await twilio.sendSMS(reminderPhone, message, sender);
} catch (error) {
console.log(`Error sending SMS with error ${error}`);
}
@ -112,7 +113,12 @@ export const scheduleSMSReminder = async (
!scheduledDate.isAfter(currentDate.add(7, "day"))
) {
try {
const scheduledSMS = await twilio.scheduleSMS(reminderPhone, message, scheduledDate.toDate());
const scheduledSMS = await twilio.scheduleSMS(
reminderPhone,
message,
scheduledDate.toDate(),
sender
);
await prisma.workflowReminder.create({
data: {

View File

@ -38,6 +38,13 @@ export type FormValues = {
timeUnit?: TimeUnit;
};
export function onlyLettersNumbersSpaces(str: string) {
if (str.length <= 11 && /^[A-Za-z0-9\s]*$/.test(str)) {
return true;
}
return false;
}
const formSchema = z.object({
name: z.string(),
activeOn: z.object({ value: z.string(), label: z.string() }).array(),
@ -58,6 +65,11 @@ const formSchema = z.object({
.string()
.refine((val) => isValidPhoneNumber(val) || val.includes("@"))
.nullable(),
sender: z
.string()
.refine((val) => onlyLettersNumbersSpaces(val))
.optional()
.nullable(),
})
.array(),
});

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "WorkflowStep" ADD COLUMN "sender" TEXT;

View File

@ -573,6 +573,7 @@ model WorkflowStep {
template WorkflowTemplates @default(REMINDER)
workflowReminders WorkflowReminder[]
numberRequired Boolean?
sender String?
}
model Workflow {

View File

@ -160,6 +160,7 @@ export const workflowsRouter = router({
action: WorkflowActions.EMAIL_HOST,
template: WorkflowTemplates.REMINDER,
workflowId: workflow.id,
sender: "Cal",
},
});
return { workflow };
@ -235,6 +236,7 @@ export const workflowsRouter = router({
emailSubject: z.string().optional().nullable(),
template: z.enum(WORKFLOW_TEMPLATES),
numberRequired: z.boolean().nullable(),
sender: z.string().optional().nullable(),
})
.array(),
trigger: z.enum(WORKFLOW_TRIGGER_EVENTS),
@ -472,7 +474,8 @@ export const workflowsRouter = router({
},
step.reminderBody || "",
step.id,
step.template
step.template,
step.sender || "Cal"
);
}
});
@ -541,6 +544,7 @@ export const workflowsRouter = router({
emailSubject: newStep.template === WorkflowTemplates.CUSTOM ? newStep.emailSubject : null,
template: newStep.template,
numberRequired: newStep.numberRequired,
sender: newStep.sender || "Cal",
},
});
//cancel all reminders of step and create new ones (not for newEventTypes)
@ -651,7 +655,8 @@ export const workflowsRouter = router({
},
newStep.reminderBody || "",
newStep.id || 0,
newStep.template
newStep.template,
newStep.sender || "Cal"
);
}
});
@ -677,6 +682,8 @@ export const workflowsRouter = router({
});
addedSteps.forEach(async (step) => {
if (step) {
const newStep = step;
newStep.sender = step.sender || "Cal";
const createdStep = await ctx.prisma.workflowStep.create({
data: step,
});
@ -764,7 +771,8 @@ export const workflowsRouter = router({
},
step.reminderBody || "",
createdStep.id,
step.template
step.template,
step.sender || "Cal"
);
}
});
@ -812,10 +820,11 @@ export const workflowsRouter = router({
reminderBody: z.string(),
template: z.enum(WORKFLOW_TEMPLATES),
sendTo: z.string().optional(),
sender: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { action, emailSubject, reminderBody, template, sendTo } = input;
const { action, emailSubject, reminderBody, template, sendTo, sender } = input;
try {
const booking = await ctx.prisma.booking.findFirst({
orderBy: {
@ -899,7 +908,8 @@ export const workflowsRouter = router({
{ time: null, timeUnit: null },
reminderBody,
0,
template
template,
sender || "Cal"
);
return { message: "Notification sent" };
}

View File

@ -183,6 +183,9 @@
"$CLOSECOM_API_KEY",
"$SENDGRID_API_KEY",
"$SENDGRID_EMAIL",
"$TWILIO_TOKEN",
"$TWILIO_SID",
"$TWILIO_MESSAGING_SID",
"$CRON_API_KEY",
"$DAILY_API_KEY",
"$DAILY_SCALE_PLAN",