* 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:
1 parent
0a3b3a69e3
commit
499cbd08ca
7 files changed
+444
-66
No files matched your search
+58
-26
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user