feat: Phone based bookings for everyone (#21320) (#22024)

* Revert "Revert "feat: Phone based bookings for everyone (#21320)" (#22018)"

This reverts commit 80e2118e68.

* chore: don't use repository

* chore: create getTeamWithOrganizationSettings function

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
This commit is contained in:
Udit Takkar
2025-06-27 10:09:04 +01:00
committed by GitHub
co-authored by Hariom Balhara
parent 0a3b3a69e3
commit 499cbd08ca
7 changed files with 444 additions and 66 deletions
@@ -267,6 +267,8 @@
"guests": "Guests",
"guest": "Guest",
"web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.",
"confirmation":"Confirmation",
"what_booker_should_provide": "What your booker should provide to receive confirmations",
"404_the_user": "The username",
"username": "Username",
"is_still_available": "is still available.",
@@ -1710,6 +1712,8 @@
"show_available_seats_count": "Show the number of available seats",
"how_booking_questions_as_variables": "How to use booking questions as variables?",
"format": "Format",
"questions": "Questions",
"all_info_your_booker_provide": "All the info your booker should provide before booking with you.",
"uppercase_for_letters": "Use uppercase for all letters",
"replace_whitespaces_underscores": "Replace whitespaces with underscores",
"platform_members": "Platform members",
@@ -160,7 +160,22 @@ export const ensureBookingInputsHaveSystemFields = ({
type: "email",
name: "email",
required: !isEmailFieldOptional,
editable: isOrgTeamEvent ? "system-but-optional" : "system",
editable: "system-but-optional",
sources: [
{
label: "Default",
id: "default",
type: "default",
},
],
},
{
defaultLabel: "phone_number",
type: "phone",
name: "attendeePhoneNumber",
required: false,
hidden: true,
editable: "system-but-optional",
sources: [
{
label: "Default",
@@ -169,7 +184,6 @@ export const ensureBookingInputsHaveSystemFields = ({
},
],
},
{
defaultLabel: "location",
type: "radioInput",
@@ -204,23 +218,6 @@ export const ensureBookingInputsHaveSystemFields = ({
],
},
];
if (isOrgTeamEvent) {
systemBeforeFields.splice(2, 0, {
defaultLabel: "phone_number",
type: "phone",
name: "attendeePhoneNumber",
required: false,
hidden: true,
editable: "system-but-optional",
sources: [
{
label: "Default",
id: "default",
type: "default",
},
],
});
}
// These fields should be added after other user fields
const systemAfterFields: typeof bookingFields = [
@@ -577,27 +577,38 @@ export const EventAdvancedTab = ({
/>
)}
<div className="border-subtle space-y-6 rounded-lg border p-6">
<FormBuilder
title={t("booking_questions_title")}
description={t("booking_questions_description")}
addFieldLabel={t("add_a_booking_question")}
formProp="bookingFields"
{...shouldLockDisableProps("bookingFields")}
dataStore={{
options: {
locations: {
// FormBuilder doesn't handle plural for non-english languages. So, use english(Location) only. This is similar to 'Workflow'
source: { label: "Location" },
value: getLocationsOptionsForSelect(formMethods.getValues("locations") ?? [], t),
<div className="border-subtle bg-muted rounded-lg border p-1">
<div className="p-5">
<div className="text-default text-sm font-semibold leading-none ltr:mr-1 rtl:ml-1">
{t("booking_questions_title")}
</div>
<p className="text-subtle mt-1 max-w-[280px] break-words text-sm sm:max-w-[500px]">
{t("booking_questions_description")}
</p>
</div>
<div className="border-subtle bg-default rounded-lg border p-5">
<FormBuilder
showPhoneAndEmailToggle
title={t("confirmation")}
description={t("what_booker_should_provide")}
addFieldLabel={t("add_a_booking_question")}
formProp="bookingFields"
{...shouldLockDisableProps("bookingFields")}
dataStore={{
options: {
locations: {
// FormBuilder doesn't handle plural for non-english languages. So, use english(Location) only. This is similar to 'Workflow'
source: { label: "Location" },
value: getLocationsOptionsForSelect(formMethods.getValues("locations") ?? [], t),
},
},
},
}}
shouldConsiderRequired={(field: BookingField) => {
// Location field has a default value at backend so API can send no location but we don't allow it in UI and thus we want to show it as required to user
return field.name === "location" ? true : field.required;
}}
/>
}}
shouldConsiderRequired={(field: BookingField) => {
// Location field has a default value at backend so API can send no location but we don't allow it in UI and thus we want to show it as required to user
return field.name === "location" ? true : field.required;
}}
/>
</div>
</div>
<RequiresConfirmationController
eventType={eventType}
+66 -1
View File
@@ -15,6 +15,7 @@ import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogHeader, DialogClose } from "@calcom/ui/components/dialog";
import { Editor } from "@calcom/ui/components/editor";
import { ToggleGroup } from "@calcom/ui/components/form";
import {
Switch,
CheckboxField,
@@ -59,6 +60,7 @@ export const FormBuilder = function FormBuilder({
LockedIcon,
dataStore,
shouldConsiderRequired,
showPhoneAndEmailToggle = false,
}: {
formProp: string;
title: string;
@@ -66,6 +68,7 @@ export const FormBuilder = function FormBuilder({
addFieldLabel: string;
disabled: boolean;
LockedIcon: false | JSX.Element;
showPhoneAndEmailToggle?: boolean;
/**
* A readonly dataStore that is used to lookup the options for the fields. It works in conjunction with the field.getOptionAt property which acts as the key in options
*/
@@ -129,7 +132,68 @@ export const FormBuilder = function FormBuilder({
{title}
{LockedIcon}
</div>
<p className="text-subtle mt-0.5 max-w-[280px] break-words text-sm sm:max-w-[500px]">{description}</p>
<div className="flex items-start justify-between">
<p className="text-subtle mt-1 max-w-[280px] break-words text-sm sm:max-w-[500px]">{description}</p>
{showPhoneAndEmailToggle && (
<ToggleGroup
value={(() => {
const phoneField = fields.find((field) => field.name === "attendeePhoneNumber");
const emailField = fields.find((field) => field.name === "email");
if (phoneField && !phoneField.hidden && phoneField.required && !emailField?.required) {
return "phone";
}
return "email";
})()}
options={[
{
value: "email",
label: "Email",
iconLeft: <Icon name="mail" className="h-4 w-4" />,
},
{
value: "phone",
label: "Phone",
iconLeft: <Icon name="phone" className="h-4 w-4" />,
},
]}
onValueChange={(value) => {
const phoneFieldIndex = fields.findIndex((field) => field.name === "attendeePhoneNumber");
const emailFieldIndex = fields.findIndex((field) => field.name === "email");
if (value === "email") {
update(emailFieldIndex, {
...fields[emailFieldIndex],
hidden: false,
required: true,
});
update(phoneFieldIndex, {
...fields[phoneFieldIndex],
hidden: true,
required: false,
});
} else if (value === "phone") {
update(emailFieldIndex, {
...fields[emailFieldIndex],
hidden: true,
required: false,
});
update(phoneFieldIndex, {
...fields[phoneFieldIndex],
hidden: false,
required: true,
});
}
}}
/>
)}
</div>
<p className="text-default mt-5 text-sm font-semibold leading-none ltr:mr-1 rtl:ml-1">
{t("questions")}
</p>
<p className="text-subtle mt-1 max-w-[280px] break-words text-sm sm:max-w-[500px]">
{t("all_info_your_booker_provide")}
</p>
<ul ref={parent} className="border-subtle divide-subtle mt-4 divide-y rounded-md border">
{fields.map((field, index) => {
let options = field.options ?? null;
@@ -480,6 +544,7 @@ function FieldEditDialog({
const variantsConfig = fieldForm.watch("variantsConfig");
const fieldTypes = Object.values(fieldTypesConfigMap);
const fieldName = fieldForm.getValues("name");
return (
<Dialog open={dialog.isOpen} onOpenChange={onOpenChange} modal={false}>
+14
View File
@@ -393,4 +393,18 @@ export class TeamRepository {
inviteToken: inviteTokens.find((token) => token.identifier === `invite-link-for-teamId-${team.id}`),
}));
}
static async findTeamWithOrganizationSettings(teamId: number) {
return await prisma.team.findUnique({
where: { id: teamId },
select: {
parent: {
select: {
isOrganization: true,
organizationSettings: true,
},
},
},
});
}
}
+58 -26
View File
@@ -14,36 +14,25 @@ const handleSendingSMS = async ({
senderID,
teamId,
bookingUid,
organizerUserId,
}: {
reminderPhone: string;
smsMessage: string;
senderID: string;
teamId: number;
teamId?: number;
bookingUid?: string | null;
organizerUserId?: number;
}) => {
const team = await prisma.team.findUnique({
where: { id: teamId },
select: {
parent: {
select: {
isOrganization: true,
organizationSettings: {
select: {
disablePhoneOnlySMSNotifications: true,
},
},
},
},
},
});
if (!team?.parent?.isOrganization || team?.parent?.organizationSettings?.disablePhoneOnlySMSNotifications) {
return; // resolves implicitly (as undefined)
}
try {
// If teamId is provided, we check the rate limit for the team.
// If organizerUserId is provided, we check the rate limit for the organizer.
// If neither is provided(Just in case), we check the rate limit for the reminderPhone.
await checkSMSRateLimit({
identifier: `handleSendingSMS:team:${teamId}`,
identifier: teamId
? `handleSendingSMS:team:${teamId}`
: organizerUserId
? `handleSendingSMS:user:${organizerUserId}`
: `handleSendingSMS:user:${reminderPhone}`,
rateLimitingType: "sms",
});
@@ -52,7 +41,7 @@ const handleSendingSMS = async ({
phoneNumber: reminderPhone,
body: smsMessage,
sender: senderID,
teamId,
...(!!teamId ? { teamId } : { userId: organizerUserId }),
bookingUid,
},
});
@@ -64,15 +53,50 @@ const handleSendingSMS = async ({
}
};
const getTeamWithOrganizationSettings = async (teamId: number) => {
return await prisma.team.findUnique({
where: { id: teamId },
select: {
parent: {
select: {
isOrganization: true,
organizationSettings: true,
},
},
},
});
};
export default abstract class SMSManager {
calEvent: CalendarEvent;
isTeamEvent = false;
teamId: number | undefined = undefined;
organizerUserId: number | undefined = undefined;
private _isSMSNotificationEnabled: boolean | null = null;
constructor(calEvent: CalendarEvent) {
this.calEvent = calEvent;
this.teamId = this.calEvent?.team?.id;
this.isTeamEvent = !!this.calEvent?.team?.id;
this.organizerUserId = this.calEvent?.organizer?.id;
}
private async isSMSNotificationEnabled(): Promise<boolean> {
if (this._isSMSNotificationEnabled !== null) {
return this._isSMSNotificationEnabled;
}
const teamId = this.teamId;
if (teamId) {
const team = await getTeamWithOrganizationSettings(teamId);
this._isSMSNotificationEnabled = !team?.parent?.organizationSettings?.disablePhoneOnlySMSNotifications;
return this._isSMSNotificationEnabled;
}
this._isSMSNotificationEnabled = true;
return true;
}
getFormattedTime(
@@ -99,17 +123,25 @@ export default abstract class SMSManager {
const attendeePhoneNumber = attendee.phoneNumber;
const isPhoneOnlyBooking = attendeePhoneNumber && isSmsCalEmail(attendee.email);
if (!this.isTeamEvent || !teamId || !attendeePhoneNumber || !isPhoneOnlyBooking) return;
if (!attendeePhoneNumber || !isPhoneOnlyBooking || !(await this.isSMSNotificationEnabled())) return;
const smsMessage = this.getMessage(attendee);
const senderID = getSenderId(attendeePhoneNumber, SENDER_ID);
return handleSendingSMS({ reminderPhone: attendeePhoneNumber, smsMessage, senderID, teamId, bookingUid });
return handleSendingSMS({
reminderPhone: attendeePhoneNumber,
smsMessage,
senderID,
teamId,
bookingUid,
organizerUserId: this.organizerUserId,
});
}
async sendSMSToAttendees() {
if (!this.isTeamEvent) return;
const smsToSend: Promise<unknown>[] = [];
if (!(await this.isSMSNotificationEnabled())) return;
for (const attendee of this.calEvent.attendees) {
smsToSend.push(this.sendSMSToAttendee(attendee, this.calEvent.uid));
}
+255
View File
@@ -0,0 +1,255 @@
import type { TFunction } from "i18next";
import { describe, expect, test, vi, beforeEach } from "vitest";
import { sendSmsOrFallbackEmail } from "@calcom/features/ee/workflows/lib/reminders/messageDispatcher";
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
import prisma from "@calcom/prisma";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import SMSManager from "../sms-manager";
vi.mock("@calcom/lib/checkRateLimitAndThrowError");
vi.mock("@calcom/features/ee/workflows/lib/reminders/messageDispatcher");
vi.mock("@calcom/prisma", () => ({
default: {
team: {
findUnique: vi.fn(),
},
},
}));
interface TestAttendee extends Person {
name: string;
email: string;
phoneNumber?: string;
timeZone: string;
language: {
translate: TFunction;
locale: string;
};
}
interface TestCalEvent extends CalendarEvent {
uid: string;
type: string;
title: string;
startTime: string;
endTime: string;
attendees: TestAttendee[];
organizer: {
id: number;
name: string;
email: string;
timeZone: string;
language: {
translate: TFunction;
locale: string;
};
};
team?: {
id: number;
name: string;
members: unknown[];
};
}
// Create a concrete implementation of SMSManager for testing
class TestSMSManager extends SMSManager {
getMessage(attendee: TestAttendee): string {
return `Test message for ${attendee.name}`;
}
}
describe("SMSManager", () => {
const mockTranslate = ((key: string) => key) as TFunction;
const mockCalEvent: TestCalEvent = {
uid: "test-booking-uid",
type: "test",
title: "Test Event",
startTime: "2024-03-20T10:00:00Z",
endTime: "2024-03-20T11:00:00Z",
attendees: [
{
name: "John Doe",
email: "john@sms.cal.com",
phoneNumber: "+1234567890",
timeZone: "America/New_York",
language: { translate: mockTranslate, locale: "en" },
},
{
name: "Jane Smith",
email: "jane@example.com",
phoneNumber: "+1987654321",
timeZone: "America/New_York",
language: { translate: mockTranslate, locale: "en" },
},
],
organizer: {
id: 1,
name: "Organizer",
email: "organizer@example.com",
timeZone: "America/New_York",
language: { translate: mockTranslate, locale: "en" },
},
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("sendSMSToAttendee", () => {
test("should not send SMS if phone number is missing", async () => {
const smsManager = new TestSMSManager(mockCalEvent);
const attendeeWithoutPhone: TestAttendee = {
...mockCalEvent.attendees[0],
phoneNumber: undefined,
timeZone: "America/New_York",
language: { translate: mockTranslate, locale: "en" },
};
await smsManager.sendSMSToAttendee(attendeeWithoutPhone);
expect(sendSmsOrFallbackEmail).not.toHaveBeenCalled();
});
test("should not send SMS if email is not @sms.cal.com", async () => {
const smsManager = new TestSMSManager(mockCalEvent);
const attendeeWithRegularEmail: TestAttendee = {
...mockCalEvent.attendees[0],
email: "john@example.com",
phoneNumber: "+1234567890",
timeZone: "America/New_York",
language: { translate: mockTranslate, locale: "en" },
};
await smsManager.sendSMSToAttendee(attendeeWithRegularEmail);
expect(sendSmsOrFallbackEmail).not.toHaveBeenCalled();
});
test("should send SMS only when both phone number and @sms.cal.com email are present", async () => {
const smsManager = new TestSMSManager(mockCalEvent);
const mockSmsResponse = { success: true };
(sendSmsOrFallbackEmail as jest.Mock).mockResolvedValue(mockSmsResponse);
(checkSMSRateLimit as jest.Mock).mockResolvedValue(undefined);
const result = await smsManager.sendSMSToAttendee(mockCalEvent.attendees[0], "test-booking-uid");
expect(checkSMSRateLimit).toHaveBeenCalledWith({
identifier: "handleSendingSMS:user:1",
rateLimitingType: "sms",
});
expect(sendSmsOrFallbackEmail).toHaveBeenCalledWith({
twilioData: {
phoneNumber: mockCalEvent.attendees[0].phoneNumber,
body: expect.stringContaining(mockCalEvent.attendees[0].name),
sender: expect.any(String),
teamId: undefined,
userId: 1,
bookingUid: "test-booking-uid",
},
});
expect(result).toEqual(mockSmsResponse);
});
test("should not send SMS if SMS notifications are disabled for team", async () => {
const mockTeamId = 123;
const mockTeam = {
id: mockTeamId,
name: "Test Team",
members: [],
};
const smsManager = new TestSMSManager({
...mockCalEvent,
team: mockTeam,
});
// Mock team settings to disable SMS
(prisma.team.findUnique as jest.Mock).mockResolvedValue({
parent: {
isOrganization: true,
organizationSettings: {
disablePhoneOnlySMSNotifications: true,
},
},
});
await smsManager.sendSMSToAttendee(mockCalEvent.attendees[0]);
expect(sendSmsOrFallbackEmail).not.toHaveBeenCalled();
});
test("should handle SMS sending errors", async () => {
const smsManager = new TestSMSManager(mockCalEvent);
const mockError = new Error("SMS sending failed");
(sendSmsOrFallbackEmail as jest.Mock).mockRejectedValue(mockError);
(checkSMSRateLimit as jest.Mock).mockResolvedValue(undefined);
await expect(smsManager.sendSMSToAttendee(mockCalEvent.attendees[0])).rejects.toThrow(mockError);
});
});
describe("sendSMSToAttendees", () => {
test("should send SMS only to attendees with phone number and @sms.cal.com email", async () => {
const smsManager = new TestSMSManager(mockCalEvent);
const mockSmsResponse = { success: true };
(sendSmsOrFallbackEmail as jest.Mock).mockResolvedValue(mockSmsResponse);
(checkSMSRateLimit as jest.Mock).mockResolvedValue(undefined);
await smsManager.sendSMSToAttendees();
// Only one attendee has both phone number and @sms.cal.com email
expect(sendSmsOrFallbackEmail).toHaveBeenCalledTimes(1);
});
test("should not send SMS if notifications are disabled", async () => {
const mockTeamId = 123;
const mockTeam = {
id: mockTeamId,
name: "Test Team",
members: [],
};
const smsManager = new TestSMSManager({
...mockCalEvent,
team: mockTeam,
});
(prisma.team.findUnique as jest.Mock).mockResolvedValue({
parent: {
isOrganization: true,
organizationSettings: {
disablePhoneOnlySMSNotifications: true,
},
},
});
await smsManager.sendSMSToAttendees();
expect(sendSmsOrFallbackEmail).not.toHaveBeenCalled();
});
});
describe("getFormattedTime and getFormattedDate", () => {
test("should format time correctly", () => {
const smsManager = new TestSMSManager(mockCalEvent);
const formattedTime = smsManager.getFormattedTime("America/New_York", "en", "2024-03-20T10:00:00Z");
expect(formattedTime).toContain("2024");
expect(formattedTime).toContain("6:00am");
});
test("should format date range correctly", () => {
const smsManager = new TestSMSManager(mockCalEvent);
const formattedDate = smsManager.getFormattedDate("America/New_York", "en");
expect(formattedDate).toContain("2024");
expect(formattedDate).toContain("6:00am");
expect(formattedDate).toContain("7:00am");
});
});
});