* 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:
co-authored by
Hariom Balhara
parent
0a3b3a69e3
commit
499cbd08ca
@@ -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}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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