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

This reverts commit 866a0e2596.
This commit is contained in:
Benny Joo
2025-06-25 02:32:09 +00:00
committed by GitHub
parent ed5086df6b
commit 80e2118e68
7 changed files with 67 additions and 431 deletions
@@ -267,8 +267,6 @@
"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.",
@@ -1712,8 +1710,6 @@
"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,22 +160,7 @@ export const ensureBookingInputsHaveSystemFields = ({
type: "email",
name: "email",
required: !isEmailFieldOptional,
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",
editable: isOrgTeamEvent ? "system-but-optional" : "system",
sources: [
{
label: "Default",
@@ -184,6 +169,7 @@ export const ensureBookingInputsHaveSystemFields = ({
},
],
},
{
defaultLabel: "location",
type: "radioInput",
@@ -218,6 +204,23 @@ 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,38 +577,27 @@ export const EventAdvancedTab = ({
/>
)}
<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),
},
<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),
},
}}
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>
},
}}
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>
<RequiresConfirmationController
eventType={eventType}
+1 -66
View File
@@ -15,7 +15,6 @@ 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,
@@ -60,7 +59,6 @@ export const FormBuilder = function FormBuilder({
LockedIcon,
dataStore,
shouldConsiderRequired,
showPhoneAndEmailToggle = false,
}: {
formProp: string;
title: string;
@@ -68,7 +66,6 @@ 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
*/
@@ -132,68 +129,7 @@ export const FormBuilder = function FormBuilder({
{title}
{LockedIcon}
</div>
<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>
<p className="text-subtle mt-0.5 max-w-[280px] break-words text-sm sm:max-w-[500px]">{description}</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;
@@ -544,7 +480,6 @@ 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
@@ -360,18 +360,4 @@ 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,
},
},
},
});
}
}
+27 -45
View File
@@ -4,8 +4,8 @@ import { sendSmsOrFallbackEmail } from "@calcom/features/ee/workflows/lib/remind
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
import { SENDER_ID } from "@calcom/lib/constants";
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
const handleSendingSMS = async ({
@@ -14,25 +14,36 @@ 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: teamId
? `handleSendingSMS:team:${teamId}`
: organizerUserId
? `handleSendingSMS:user:${organizerUserId}`
: `handleSendingSMS:user:${reminderPhone}`,
identifier: `handleSendingSMS:team:${teamId}`,
rateLimitingType: "sms",
});
@@ -41,7 +52,7 @@ const handleSendingSMS = async ({
phoneNumber: reminderPhone,
body: smsMessage,
sender: senderID,
...(!!teamId ? { teamId } : { userId: organizerUserId }),
teamId,
bookingUid,
},
});
@@ -57,32 +68,11 @@ 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 TeamRepository.findTeamWithOrganizationSettings(teamId);
this._isSMSNotificationEnabled = !team?.parent?.organizationSettings?.disablePhoneOnlySMSNotifications;
return this._isSMSNotificationEnabled;
}
this._isSMSNotificationEnabled = true;
return true;
}
getFormattedTime(
@@ -109,25 +99,17 @@ export default abstract class SMSManager {
const attendeePhoneNumber = attendee.phoneNumber;
const isPhoneOnlyBooking = attendeePhoneNumber && isSmsCalEmail(attendee.email);
if (!attendeePhoneNumber || !isPhoneOnlyBooking || !(await this.isSMSNotificationEnabled())) return;
if (!this.isTeamEvent || !teamId || !attendeePhoneNumber || !isPhoneOnlyBooking) return;
const smsMessage = this.getMessage(attendee);
const senderID = getSenderId(attendeePhoneNumber, SENDER_ID);
return handleSendingSMS({
reminderPhone: attendeePhoneNumber,
smsMessage,
senderID,
teamId,
bookingUid,
organizerUserId: this.organizerUserId,
});
return handleSendingSMS({ reminderPhone: attendeePhoneNumber, smsMessage, senderID, teamId, bookingUid });
}
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
@@ -1,255 +0,0 @@
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");
});
});
});