From 866a0e259655bde9eb1fd1ef2d481c782d22206d Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Fri, 20 Jun 2025 22:12:32 +0530 Subject: [PATCH] feat: Phone based bookings for everyone (#21320) * feat: Phone based bookings for everyone * chore: save progress * feat: add UI * chore: UI * chore: remove sms notification * chore: remove none * chore: remove ligs * chore: remove log * test: add unit tests * test: add unit tests * chore: cache _isSMSNotificationEnabled * chore: cache _isSMSNotificationEnabled * refactor: get value from fields * fix: type err * chore: also pass user id * fix: unit test for sms manager --------- Co-authored-by: Benny Joo Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Co-authored-by: Keith Williams --- apps/web/public/static/locales/en/common.json | 4 + .../features/bookings/lib/getBookingFields.ts | 35 ++- .../tabs/advanced/EventAdvancedTab.tsx | 52 ++-- .../features/form-builder/FormBuilder.tsx | 67 ++++- packages/lib/server/repository/team.ts | 14 + packages/sms/sms-manager.ts | 72 +++-- packages/sms/test/sms-manager.test.ts | 255 ++++++++++++++++++ 7 files changed, 432 insertions(+), 67 deletions(-) create mode 100644 packages/sms/test/sms-manager.test.ts diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 3a4bf3671a..34d83757ca 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/features/bookings/lib/getBookingFields.ts b/packages/features/bookings/lib/getBookingFields.ts index 05c1d1bc35..6fce4bc9ea 100644 --- a/packages/features/bookings/lib/getBookingFields.ts +++ b/packages/features/bookings/lib/getBookingFields.ts @@ -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 = [ diff --git a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx index 97aec65090..0c19806fbb 100644 --- a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx +++ b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx @@ -576,27 +576,39 @@ export const EventAdvancedTab = ({ isUserLoading={isUserLoading} /> )} -
- +
+
+ {t("booking_questions_title")} +
+

+ {t("booking_questions_description")} +

+
+
+ { - // 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; + }} + /> +
-

{description}

+
+

{description}

+ {showPhoneAndEmailToggle && ( + { + 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: , + }, + { + value: "phone", + label: "Phone", + iconLeft: , + }, + ]} + 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, + }); + } + }} + /> + )} +
+

+ {t("questions")} +

+

+ {t("all_info_your_booker_provide")} +

    {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 ( diff --git a/packages/lib/server/repository/team.ts b/packages/lib/server/repository/team.ts index cf4a9e8836..e22b9653dd 100644 --- a/packages/lib/server/repository/team.ts +++ b/packages/lib/server/repository/team.ts @@ -360,4 +360,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, + }, + }, + }, + }); + } } diff --git a/packages/sms/sms-manager.ts b/packages/sms/sms-manager.ts index 602cd8d737..fe391a1847 100644 --- a/packages/sms/sms-manager.ts +++ b/packages/sms/sms-manager.ts @@ -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,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, }, }); @@ -68,11 +57,32 @@ 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 { + 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( @@ -99,17 +109,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[] = []; + if (!(await this.isSMSNotificationEnabled())) return; + for (const attendee of this.calEvent.attendees) { smsToSend.push(this.sendSMSToAttendee(attendee, this.calEvent.uid)); } diff --git a/packages/sms/test/sms-manager.test.ts b/packages/sms/test/sms-manager.test.ts new file mode 100644 index 0000000000..65ece393a4 --- /dev/null +++ b/packages/sms/test/sms-manager.test.ts @@ -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"); + }); + }); +});