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 TakkarandHariom Balhara authored and GitHub committed 2025-06-27 10:09:04 +01:00
1 parent 0a3b3a69e3
commit 499cbd08ca
7 files changed
+444 -66

No files matched your search

+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");
});
});
});