feat: booking with phone number (#14461)

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
This commit is contained in:
Udit Takkar
2024-09-11 17:56:04 -07:00
committed by GitHub
co-authored by Omar López Joe Au-Yeung Joe Au-Yeung
parent 74a9f3c19e
commit 4a59841b02
85 changed files with 2078 additions and 274 deletions
+25 -17
View File
@@ -14,6 +14,7 @@ import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -322,7 +323,7 @@ function BookingListItem(booking: BookingItemProps) {
const urlSearchParams = new URLSearchParams({
allRemainingBookings: isTabRecurring.toString(),
});
if (booking.attendees[0]) urlSearchParams.set("email", booking.attendees[0].email);
if (booking.attendees[0].email) urlSearchParams.set("email", booking.attendees[0].email);
return `/booking/${booking.uid}?${urlSearchParams.toString()}`;
};
@@ -356,6 +357,7 @@ function BookingListItem(booking: BookingItemProps) {
email: attendee.email,
id: attendee.id,
noShow: attendee.noShow || false,
phoneNumber: attendee.phoneNumber,
};
});
return (
@@ -730,6 +732,7 @@ const FirstAttendee = ({
type AttendeeProps = {
name?: string;
email: string;
phoneNumber: string | null;
id: number;
noShow: boolean;
};
@@ -740,7 +743,7 @@ type NoShowProps = {
};
const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
const { email, name, bookingUid, isBookingInPast, noShow: noShowAttendee } = attendeeProps;
const { email, name, bookingUid, isBookingInPast, noShow: noShowAttendee, phoneNumber } = attendeeProps;
const { t } = useLocale();
const [noShow, setNoShow] = useState(noShowAttendee);
@@ -784,38 +787,43 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem className="focus:outline-none">
<DropdownItem
StartIcon="mail"
href={`mailto:${email}`}
onClick={(e) => {
setOpenDropdown(false);
e.stopPropagation();
}}>
<a href={`mailto:${email}`}>{t("email")}</a>
</DropdownItem>
</DropdownMenuItem>
{!isSmsCalEmail(email) && (
<DropdownMenuItem className="focus:outline-none">
<DropdownItem
StartIcon="mail"
href={`mailto:${email}`}
onClick={(e) => {
setOpenDropdown(false);
e.stopPropagation();
}}>
<a href={`mailto:${email}`}>{t("email")}</a>
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuItem className="focus:outline-none">
<DropdownItem
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
onClick={(e) => {
e.preventDefault();
copyToClipboard(email);
const isEmailCopied = isSmsCalEmail(email);
copyToClipboard(isEmailCopied ? email : phoneNumber ?? "");
setOpenDropdown(false);
showToast(t("email_copied"), "success");
showToast(isEmailCopied ? t("email_copied") : t("phone_number_copied"), "success");
}}>
{!isCopied ? t("copy") : t("copied")}
</DropdownItem>
</DropdownMenuItem>
{isBookingInPast && (
<DropdownMenuItem className="focus:outline-none">
{noShow ? (
<DropdownItem
data-testid="unmark-no-show"
onClick={(e) => {
e.preventDefault();
setOpenDropdown(false);
toggleNoShow({ attendee: { noShow: false, email }, bookingUid });
e.preventDefault();
}}
StartIcon="eye">
{t("unmark_as_no_show")}
@@ -824,9 +832,9 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
<DropdownItem
data-testid="mark-no-show"
onClick={(e) => {
e.preventDefault();
setOpenDropdown(false);
toggleNoShow({ attendee: { noShow: true, email }, bookingUid });
e.preventDefault();
}}
StartIcon="eye-off">
{t("mark_as_no_show")}
+15 -4
View File
@@ -39,6 +39,11 @@ export const getEventTypesFromDB = async (id: number) => {
bookingFields: true,
disableGuests: true,
timeZone: true,
profile: {
select: {
organizationId: true,
},
},
teamId: true,
owner: {
select: userSelect,
@@ -82,11 +87,13 @@ export const getEventTypesFromDB = async (id: number) => {
}
const metadata = EventTypeMetaDataSchema.parse(eventType.metadata);
const { profile, ...restEventType } = eventType;
const isOrgTeamEvent = !!eventType?.team && !!profile?.organizationId;
return {
isDynamic: false,
...eventType,
bookingFields: getBookingFieldsWithSystemFields(eventType),
...restEventType,
bookingFields: getBookingFieldsWithSystemFields({ ...eventType, isOrgTeamEvent }),
metadata,
};
};
@@ -101,7 +108,7 @@ export const handleSeatsEventTypeOnBooking = async (
bookingInfo: Partial<
Prisma.BookingGetPayload<{
include: {
attendees: { select: { name: true; email: true } };
attendees: { select: { name: true; email: true; phoneNumber: true } };
seatsReferences: { select: { referenceUid: true } };
user: {
select: {
@@ -123,6 +130,7 @@ export const handleSeatsEventTypeOnBooking = async (
attendee: {
email: string;
name: string;
phoneNumber: string | null;
};
id: number;
data: Prisma.JsonValue;
@@ -141,6 +149,7 @@ export const handleSeatsEventTypeOnBooking = async (
select: {
name: true,
email: true,
phoneNumber: true,
},
},
},
@@ -158,7 +167,9 @@ export const handleSeatsEventTypeOnBooking = async (
if (!eventType.seatsShowAttendees && !isHost) {
if (seatAttendee) {
const attendee = bookingInfo?.attendees?.find((a) => {
return a.email === seatAttendee?.attendee?.email;
return (
a.email === seatAttendee?.attendee?.email || a.phoneNumber === seatAttendee?.attendee?.phoneNumber
);
});
bookingInfo["attendees"] = attendee ? [attendee] : [];
} else {
@@ -42,6 +42,7 @@ import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import useTheme from "@calcom/lib/hooks/useTheme";
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat";
import { localStorage } from "@calcom/lib/webstorage";
@@ -144,7 +145,7 @@ export default function Success(props: PageProps) {
const attendees = bookingInfo?.attendees;
const isGmail = !!attendees.find((attendee) => attendee.email.includes("gmail.com"));
const isGmail = !!attendees.find((attendee) => attendee?.email?.includes("gmail.com"));
const [is24h, setIs24h] = useState(
props?.userTimeFormat ? props.userTimeFormat === 24 : isBrowserLocale24h()
@@ -551,7 +552,14 @@ export default function Success(props: PageProps) {
{attendee.name && (
<p data-testid={`attendee-name-${attendee.name}`}>{attendee.name}</p>
)}
<p data-testid={`attendee-email-${attendee.email}`}>{attendee.email}</p>
{attendee.phoneNumber && (
<p data-testid={`attendee-phone-${attendee.phoneNumber}`}>
{attendee.phoneNumber}
</p>
)}
{!isSmsCalEmail(attendee.email) && (
<p data-testid={`attendee-email-${attendee.email}`}>{attendee.email}</p>
)}
</div>
))}
</div>
+7 -1
View File
@@ -137,13 +137,19 @@ export async function bookFirstEvent(page: Page) {
await bookEventOnThisPage(page);
}
export const bookTimeSlot = async (page: Page, opts?: { name?: string; email?: string; title?: string }) => {
export const bookTimeSlot = async (
page: Page,
opts?: { name?: string; email?: string; title?: string; attendeePhoneNumber?: string }
) => {
// --- fill form
await page.fill('[name="name"]', opts?.name ?? testName);
await page.fill('[name="email"]', opts?.email ?? testEmail);
if (opts?.title) {
await page.fill('[name="title"]', opts.title);
}
if (opts?.attendeePhoneNumber) {
await page.fill('[name="attendeePhoneNumber"]', opts.attendeePhoneNumber ?? "+918888888888");
}
await page.press('[name="email"]', "Enter");
};
+154 -1
View File
@@ -80,6 +80,63 @@ test.describe("Bookings", () => {
// TODO: Assert whether the user received an email
});
test("Can create a booking for Collective EventType with only phone number", async ({
page,
users,
orgs,
}) => {
const org = await orgs.create({
name: "TestOrg",
});
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(
{
username: "pro-user",
name: "pro-user",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
},
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
}
);
const { team } = await owner.getFirstTeamMembership();
const teamEvent = await owner.getFirstTeamEvent(team.id);
await owner.apiLogin();
await markPhoneNumberAsRequiredAndEmailAsOptional(page, teamEvent.id);
await expectPageToBeNotFound({ page, url: `/team/${team.slug}/${teamEvent.slug}` });
await doOnOrgDomain(
{
orgSlug: org.slug,
page,
},
async () => {
await bookTeamEvent({
page,
team,
event: teamEvent,
opts: { attendeePhoneNumber: "+918888888888" },
});
// All the teammates should be in the booking
for (const teammate of teamMatesObj.concat([{ name: owner.name || "" }])) {
await expect(page.getByText(teammate.name, { exact: true })).toBeVisible();
}
}
);
// TODO: Assert whether the user received an email
});
test("Can create a booking for Round Robin EventType", async ({ page, users, orgs }) => {
const org = await orgs.create({
name: "TestOrg",
@@ -130,6 +187,69 @@ test.describe("Bookings", () => {
// TODO: Assert whether the user received an email
});
test("Can create a booking for Round Robin EventType with both phone number and email required", async ({
page,
users,
orgs,
}) => {
const org = await orgs.create({
name: "TestOrg",
});
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(
{
username: "pro-user",
name: "pro-user",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
},
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.ROUND_ROBIN,
}
);
const { team } = await owner.getFirstTeamMembership();
const teamEvent = await owner.getFirstTeamEvent(team.id);
await owner.apiLogin();
await markPhoneNumberAsRequiredField(page, teamEvent.id);
await expectPageToBeNotFound({ page, url: `/team/${team.slug}/${teamEvent.slug}` });
await doOnOrgDomain(
{
orgSlug: org.slug,
page,
},
async () => {
await bookTeamEvent({
page,
team,
event: teamEvent,
teamMatesObj,
opts: { attendeePhoneNumber: "+918888888888" },
});
// Since all the users have the same leastRecentlyBooked value
// Anyone of the teammates could be the Host of the booking.
const chosenUser = await page.getByTestId("booking-host-name").textContent();
expect(chosenUser).not.toBeNull();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe(
true
);
}
);
// TODO: Assert whether the user received an email
});
test("Can access booking page with event slug and team page in lowercase/uppercase/mixedcase", async ({
page,
orgs,
@@ -485,6 +605,7 @@ async function bookTeamEvent({
team,
event,
teamMatesObj,
opts,
}: {
page: Page;
team: {
@@ -493,6 +614,7 @@ async function bookTeamEvent({
};
event: { slug: string; title: string; schedulingType: SchedulingType | null };
teamMatesObj?: { name: string }[];
opts?: { attendeePhoneNumber?: string };
}) {
// Note that even though the default way to access a team booking in an organization is to not use /team in the URL, but it isn't testable with playwright as the rewrite is taken care of by Next.js config which can't handle on the fly org slug's handling
// So, we are using /team in the URL to access the team booking
@@ -501,7 +623,7 @@ async function bookTeamEvent({
await page.goto(`/team/${team.slug}/${event.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await bookTimeSlot(page, opts);
await expect(page.getByTestId("success-page")).toBeVisible();
// The title of the booking
@@ -526,3 +648,34 @@ async function expectPageToBeNotFound({ page, url }: { page: Page; url: string }
await page.goto(`${url}`);
await expect(page.getByTestId(`404-page`)).toBeVisible();
}
const markPhoneNumberAsRequiredAndEmailAsOptional = async (page: Page, eventId: number) => {
// Make phone as required
await markPhoneNumberAsRequiredField(page, eventId);
// Make email as not required
await page.locator('[data-testid="field-email"] [data-testid="edit-field-action"]').click();
const emailRequiredFiled = await page.locator('[data-testid="field-required"]');
await emailRequiredFiled.locator("> :nth-child(2)").click();
await page.getByTestId("field-add-save").click();
const submitPromise = page.waitForResponse("/api/trpc/eventTypes/update?batch=1");
await page.locator("[data-testid=update-eventtype]").click();
const response = await submitPromise;
expect(response.status()).toBe(200);
};
const markPhoneNumberAsRequiredField = async (page: Page, eventId: number) => {
await page.goto(`/event-types/${eventId}?tabName=advanced`);
await page.locator('[data-testid="field-attendeePhoneNumber"] [data-testid="toggle-field"]').click();
await page.locator('[data-testid="field-attendeePhoneNumber"] [data-testid="edit-field-action"]').click();
const phoneRequiredFiled = await page.locator('[data-testid="field-required"]');
await phoneRequiredFiled.locator("> :nth-child(1)").click();
await page.getByTestId("field-add-save").click();
const submitPromise = page.waitForResponse("/api/trpc/eventTypes/update?batch=1");
await page.locator("[data-testid=update-eventtype]").click();
const response = await submitPromise;
expect(response.status()).toBe(200);
};
@@ -44,6 +44,7 @@
"event_request_cancelled": "Your scheduled event was canceled",
"organizer": "Organizer",
"need_to_reschedule_or_cancel": "Need to reschedule or cancel?",
"you_can_view_booking_details_with_this_url":"You can view the booking details from this url {{url}} and add the event to your calendar",
"no_options_available": "No options available",
"cancellation_reason": "Reason for cancellation (optional)",
"cancellation_reason_placeholder": "Why are you cancelling?",
@@ -181,7 +182,7 @@
"org_upgrade_banner_description": "Thank you for trialing our Organization plan. We noticed your Organization \"{{teamName}}\" needs to be upgraded.",
"org_upgraded_successfully": "Your Organization was upgraded successfully!",
"use_link_to_reset_password": "Use the link below to reset your password",
"hey_there": "Hey there,",
"hey_there": "Hey there",
"forgot_your_password_calcom": "Forgot your password? - {{appName}}",
"delete_webhook_confirmation_message": "Are you sure you want to delete this webhook? You will no longer receive {{appName}} meeting data at a specified URL, in real-time, when an event is scheduled or canceled.",
"confirm_delete_webhook": "Yes, delete webhook",
@@ -1904,6 +1905,7 @@
"not_enough_seats": "Not enough seats",
"form_builder_field_already_exists": "A field with this name already exists",
"show_on_booking_page": "Show on booking page",
"visit_cancelled_booking": "You can visit the canceled booking page",
"get_started_zapier_templates": "Get started with Zapier templates",
"team_is_unpublished": "{{team}} is unpublished",
"org_is_unpublished_description": "This organization link is currently not available. Please contact the organization owner or ask them to publish it.",
@@ -2459,6 +2461,7 @@
"add_emails": "Add Emails",
"add_email_description": "Add an email address to replace your primary or to use as an alternative email on your event types.",
"confirm_email": "Confirm your email",
"confirming_your_booking_sms":"$t(hey_there) {{name}}, confirming your booking on {{date}}.",
"scheduler_first_name": "The first name of the person booking",
"scheduler_last_name": "The last name of the person booking",
"scheduler_name": "Scheduler Name",
@@ -2549,6 +2552,7 @@
"team_select_info": "triggers for all team event types and all team members' personal event types",
"no_show_updated": "No-show status updated for attendees",
"email_copied": "Email copied",
"phone_number_copied": "Phone Number Copied",
"USER_PENDING_MEMBER_OF_THE_ORG": "User is a pending member of the organization",
"USER_ALREADY_INVITED_OR_MEMBER": "User is already invited or a member",
"USER_MEMBER_OF_OTHER_ORGANIZATION": "User is member of an organization that this team is not a part of.",
@@ -4,7 +4,7 @@ import type { Payment, Prisma, PaymentOption, Booking } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import "vitest-fetch-mock";
import { sendAwaitingPaymentEmail } from "@calcom/emails";
import { sendAwaitingPaymentEmailAndSMS } from "@calcom/emails";
import logger from "@calcom/lib/logger";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
@@ -64,7 +64,7 @@ export function getMockPaymentService() {
paymentData: Payment
): Promise<void> {
// TODO: App implementing PaymentService is supposed to send email by itself at the moment.
await sendAwaitingPaymentEmail({
await sendAwaitingPaymentEmailAndSMS({
...event,
paymentInfo: {
link: createPaymentLink(/*{
@@ -27,6 +27,7 @@ import type { SchedulingType, SMSLockState, TimeUnit } from "@calcom/prisma/enum
import type { BookingStatus } from "@calcom/prisma/enums";
import type { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import type { userMetadataType } from "@calcom/prisma/zod-utils";
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { AppMeta } from "@calcom/types/App";
import type { NewCalendarEventType } from "@calcom/types/Calendar";
import type { EventBusyDate, IntervalLimit } from "@calcom/types/Calendar";
@@ -34,6 +35,8 @@ import type { EventBusyDate, IntervalLimit } from "@calcom/types/Calendar";
import { getMockPaymentService } from "./MockPaymentService";
import type { getMockRequestDataForBooking } from "./getMockRequestDataForBooking";
type Fields = z.infer<typeof eventTypeBookingFields>;
logger.settings.minLevel = 1;
const log = logger.getSubLogger({ prefix: ["[bookingScenario]"] });
@@ -179,6 +182,7 @@ type WhiteListedBookingProps = {
status: BookingStatus;
attendees?: {
email: string;
phoneNumber?: string;
bookingSeat?: AttendeeBookingSeatInput | null;
}[];
references?: (Omit<ReturnType<typeof getMockBookingReference>, "credentialId"> & {
@@ -1708,10 +1712,19 @@ export function mockCrmApp(
};
}
export function getBooker({ name, email }: { name: string; email: string }) {
export function getBooker({
name,
email,
attendeePhoneNumber,
}: {
name: string;
email: string;
attendeePhoneNumber?: string;
}) {
return {
name,
email,
attendeePhoneNumber,
};
}
@@ -1770,8 +1783,11 @@ export function getMockBookingReference(
}
export function getMockBookingAttendee(
attendee: Omit<Attendee, "bookingId"> & {
attendee: Omit<Attendee, "bookingId" | "phoneNumber" | "email" | "noShow"> & {
bookingSeat?: AttendeeBookingSeatInput;
phoneNumber?: string | null;
email: string;
noShow?: boolean;
}
) {
return {
@@ -1781,6 +1797,8 @@ export function getMockBookingAttendee(
email: attendee.email,
locale: attendee.locale,
bookingSeat: attendee.bookingSeat || null,
phoneNumber: attendee.phoneNumber ?? undefined,
noShow: attendee.noShow ?? false,
};
}
@@ -1823,3 +1841,89 @@ export const replaceDates = (dates: string[], replacement: Record<string, string
return date.replace(/(.*)T/, (_, group1) => `${replacement[group1]}T`);
});
};
export const getDefaultBookingFields = ({
emailField,
bookingFields = [],
}: {
emailField?: Fields[number];
bookingFields: Fields;
}) => {
return [
{
name: "name",
type: "name",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system",
required: true,
defaultLabel: "your_name",
},
!!emailField
? emailField
: {
name: "email",
type: "email",
label: "",
hidden: false,
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system",
required: true,
placeholder: "",
defaultLabel: "email_address",
},
{
name: "location",
type: "radioInput",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system",
required: false,
defaultLabel: "location",
getOptionsAt: "locations",
optionsInputs: {
phone: { type: "phone", required: true, placeholder: "" },
attendeeInPerson: { type: "address", required: true, placeholder: "" },
},
hideWhenJustOneOption: true,
},
{
name: "title",
type: "text",
hidden: true,
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: true,
defaultLabel: "what_is_this_meeting_about",
defaultPlaceholder: "",
},
{
name: "notes",
type: "textarea",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "additional_notes",
defaultPlaceholder: "share_additional_notes",
},
{
name: "guests",
type: "multiemail",
hidden: false,
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "additional_guests",
defaultPlaceholder: "email",
},
{
name: "rescheduleReason",
type: "textarea",
views: [{ id: "reschedule", label: "Reschedule View" }],
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "reason_for_reschedule",
defaultPlaceholder: "reschedule_placeholder",
},
...bookingFields,
] as Fields;
};
+67 -13
View File
@@ -427,6 +427,16 @@ export async function expectBookingToBeInDatabase(
);
}
export function expectSMSToBeTriggered({ sms, toNumber }: { sms: Fixtures["sms"]; toNumber: string }) {
expect(sms.get()).toEqual(
expect.arrayContaining([
expect.objectContaining({
to: toNumber,
}),
])
);
}
export function expectSuccessfulBookingCreationEmails({
emails,
organizer,
@@ -818,7 +828,7 @@ export function expectBookingRequestedEmails({
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
booker: { email: string; name: string };
booker?: { email: string; name: string };
}) {
expect(emails).toHaveEmail(
{
@@ -829,14 +839,16 @@ export function expectBookingRequestedEmails({
`${organizer.email}`
);
expect(emails).toHaveEmail(
{
titleTag: "booking_submitted_subject",
to: `${booker.email}`,
noIcs: true,
},
`${booker.email}`
);
if (booker) {
expect(emails).toHaveEmail(
{
titleTag: "booking_submitted_subject",
to: `${booker.email}`,
noIcs: true,
},
`${booker.email}`
);
}
}
export function expectBookingRequestRescheduledEmails({
@@ -898,13 +910,17 @@ export function expectBookingRequestedWebhookToHaveBeenFired({
subscriberUrl,
paidEvent,
eventType,
isEmailHidden = false,
isAttendeePhoneNumberHidden = false,
}: {
organizer: { email: string; name: string };
booker: { email: string; name: string };
booker: { email: string; name: string; attendeePhoneNumber?: string };
subscriberUrl: string;
location: string;
paidEvent?: boolean;
eventType: InputEventType;
isEmailHidden?: boolean;
isAttendeePhoneNumberHidden?: boolean;
}) {
// There is an inconsistency in the way we send the data to the webhook for paid events and unpaid events. Fix that and then remove this if statement.
if (!paidEvent) {
@@ -925,8 +941,17 @@ export function expectBookingRequestedWebhookToHaveBeenFired({
email: {
label: "email_address",
value: booker.email,
isHidden: false,
isHidden: isEmailHidden,
},
...(booker.attendeePhoneNumber
? {
attendeePhoneNumber: {
label: "phone_number",
value: booker.attendeePhoneNumber,
isHidden: isAttendeePhoneNumberHidden,
},
}
: null),
location: {
label: "location",
value: { optionValue: "", value: location },
@@ -947,6 +972,14 @@ export function expectBookingRequestedWebhookToHaveBeenFired({
responses: {
name: { label: "name", value: booker.name },
email: { label: "email", value: booker.email },
...(booker.attendeePhoneNumber
? {
attendeePhoneNumber: {
label: "phone_number",
value: booker.attendeePhoneNumber,
},
}
: null),
location: {
label: "location",
value: { optionValue: "", value: location },
@@ -963,13 +996,17 @@ export function expectBookingCreatedWebhookToHaveBeenFired({
subscriberUrl,
paidEvent,
videoCallUrl,
isEmailHidden = false,
isAttendeePhoneNumberHidden = false,
}: {
organizer: { email: string; name: string };
booker: { email: string; name: string };
booker: { email: string; name: string; attendeePhoneNumber?: string };
subscriberUrl: string;
location: string;
paidEvent?: boolean;
videoCallUrl?: string | null;
isEmailHidden?: boolean;
isAttendeePhoneNumberHidden?: boolean;
}) {
if (!paidEvent) {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
@@ -980,7 +1017,16 @@ export function expectBookingCreatedWebhookToHaveBeenFired({
},
responses: {
name: { label: "your_name", value: booker.name, isHidden: false },
email: { label: "email_address", value: booker.email, isHidden: false },
email: { label: "email_address", value: booker.email, isHidden: isEmailHidden },
...(booker.attendeePhoneNumber
? {
attendeePhoneNumber: {
label: "phone_number",
value: booker.attendeePhoneNumber,
isHidden: isAttendeePhoneNumberHidden,
},
}
: null),
location: {
label: "location",
value: { optionValue: "", value: location },
@@ -1004,6 +1050,14 @@ export function expectBookingCreatedWebhookToHaveBeenFired({
label: "email",
value: booker.email,
},
...(booker.attendeePhoneNumber
? {
attendeePhoneNumber: {
label: "phone_number",
value: booker.attendeePhoneNumber,
},
}
: null),
location: {
label: "location",
value: { optionValue: "", value: location },
@@ -33,6 +33,7 @@ export function getMockRequestDataForBooking({
email: string;
name: string;
location?: { optionValue: ""; value: string };
attendeePhoneNumber?: string;
smsReminderNumber?: string;
};
};
@@ -105,8 +105,8 @@ export class PaymentService implements IAbstractPaymentService {
async collectCard(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: number,
_bookerEmail: string,
paymentOption: PaymentOption
paymentOption: PaymentOption,
_bookerEmail?: string | null
): Promise<Payment> {
// Ensure that the payment service can support the passed payment option
if (paymentOptionEnum.parse(paymentOption) !== "HOLD") {
@@ -3,7 +3,7 @@ import Stripe from "stripe";
import { v4 as uuidv4 } from "uuid";
import z from "zod";
import { sendAwaitingPaymentEmail } from "@calcom/emails";
import { sendAwaitingPaymentEmailAndSMS } from "@calcom/emails";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import logger from "@calcom/lib/logger";
@@ -62,8 +62,9 @@ export class PaymentService implements IAbstractPaymentService {
userId: Booking["userId"],
username: string | null,
bookerName: string,
bookerEmail: string,
paymentOption: PaymentOption,
bookerEmail: string,
bookerPhoneNumber?: string | null,
eventTitle?: string,
bookingTitle?: string
) {
@@ -78,8 +79,9 @@ export class PaymentService implements IAbstractPaymentService {
}
const customer = await retrieveOrCreateStripeCustomerByEmail(
this.credentials.stripe_user_id,
bookerEmail,
this.credentials.stripe_user_id
bookerPhoneNumber
);
const params: Stripe.PaymentIntentCreateParams = {
@@ -93,7 +95,8 @@ export class PaymentService implements IAbstractPaymentService {
calAccountId: userId,
calUsername: username,
bookerName,
bookerEmail,
bookerEmail: bookerEmail,
bookerPhoneNumber: bookerPhoneNumber ?? null,
eventTitle: eventTitle || "",
bookingTitle: bookingTitle || "",
},
@@ -142,8 +145,9 @@ export class PaymentService implements IAbstractPaymentService {
async collectCard(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: Booking["id"],
paymentOption: PaymentOption,
bookerEmail: string,
paymentOption: PaymentOption
bookerPhoneNumber?: string | null
): Promise<Payment> {
try {
if (!this.credentials) {
@@ -156,8 +160,9 @@ export class PaymentService implements IAbstractPaymentService {
}
const customer = await retrieveOrCreateStripeCustomerByEmail(
this.credentials.stripe_user_id,
bookerEmail,
this.credentials.stripe_user_id
bookerPhoneNumber
);
const params = {
@@ -165,6 +170,7 @@ export class PaymentService implements IAbstractPaymentService {
payment_method_types: ["card"],
metadata: {
bookingId,
bookerPhoneNumber: bookerPhoneNumber ?? null,
},
};
@@ -340,7 +346,7 @@ export class PaymentService implements IAbstractPaymentService {
paymentData: Payment,
eventTypeMetadata?: EventTypeMetadata
): Promise<void> {
await sendAwaitingPaymentEmail(
await sendAwaitingPaymentEmailAndSMS(
{
...event,
paymentInfo: {
@@ -83,7 +83,11 @@ export async function deleteStripeCustomer(user: UserType): Promise<string | nul
return deletedCustomer.id;
}
export async function retrieveOrCreateStripeCustomerByEmail(email: string, stripeAccountId: string) {
export async function retrieveOrCreateStripeCustomerByEmail(
stripeAccountId: string,
email: string,
phoneNumber?: string | null
) {
const customer = await stripe.customers.list(
{
email,
@@ -98,7 +102,7 @@ export async function retrieveOrCreateStripeCustomerByEmail(email: string, strip
return customer.data[0];
} else {
const newCustomer = await stripe.customers.create(
{ email },
{ email, phone: phoneNumber ?? undefined },
{
stripeAccount: stripeAccountId,
}
+23 -8
View File
@@ -5,7 +5,7 @@ import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builde
import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director";
import { deleteMeeting } from "@calcom/core/videoClient";
import dayjs from "@calcom/dayjs";
import { sendRequestRescheduleEmail } from "@calcom/emails";
import { sendRequestRescheduleEmailAndSMS } from "@calcom/emails";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
@@ -15,7 +15,9 @@ import type { Person } from "@calcom/types/Calendar";
import { getCalendar } from "../../_utils/getCalendar";
type PersonAttendeeCommonFields = Pick<User, "id" | "email" | "name" | "locale" | "timeZone" | "username">;
type PersonAttendeeCommonFields = Pick<User, "id" | "email" | "name" | "locale" | "timeZone" | "username"> & {
phoneNumber?: string | null;
};
const Reschedule = async (bookingUid: string, cancellationReason: string) => {
const bookingToReschedule = await prisma.booking.findFirstOrThrow({
@@ -30,6 +32,16 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
location: true,
attendees: true,
references: true,
eventType: {
include: {
team: {
select: {
id: true,
name: true,
},
},
},
},
user: {
select: {
id: true,
@@ -42,11 +54,6 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
destinationCalendar: true,
},
},
eventType: {
select: {
metadata: true,
},
},
},
where: {
uid: bookingUid,
@@ -93,6 +100,7 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
username: user?.username || "",
language: { translate: selectedLanguage, locale: user.locale || "en" },
timeZone: user?.timeZone,
phoneNumber: user?.phoneNumber,
};
});
};
@@ -110,6 +118,13 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
tAttendees
),
organizer: userOwnerAsPeopleType,
team: !!bookingToReschedule.eventType?.team
? {
name: bookingToReschedule.eventType.team.name,
id: bookingToReschedule.eventType.team.id,
members: [],
}
: undefined,
});
const director = new CalendarEventDirector();
director.setBuilder(builder);
@@ -147,7 +162,7 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
// Send emails
try {
await sendRequestRescheduleEmail(
await sendRequestRescheduleEmailAndSMS(
builder.calendarEvent,
{
rescheduleLink: builder.rescheduleLink,
@@ -5,7 +5,7 @@ import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builde
import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director";
import { deleteMeeting } from "@calcom/core/videoClient";
import dayjs from "@calcom/dayjs";
import { sendRequestRescheduleEmail } from "@calcom/emails";
import { sendRequestRescheduleEmailAndSMS } from "@calcom/emails";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
@@ -15,7 +15,9 @@ import type { Person } from "@calcom/types/Calendar";
import { getCalendar } from "../../_utils/getCalendar";
type PersonAttendeeCommonFields = Pick<User, "id" | "email" | "name" | "locale" | "timeZone" | "username">;
type PersonAttendeeCommonFields = Pick<User, "id" | "email" | "name" | "locale" | "timeZone" | "username"> & {
phoneNumber?: string | null;
};
const Reschedule = async (bookingUid: string, cancellationReason: string) => {
const bookingToReschedule = await prisma.booking.findFirstOrThrow({
@@ -30,6 +32,17 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
location: true,
attendees: true,
references: true,
eventType: {
select: {
metadata: true,
team: {
select: {
id: true,
name: true,
},
},
},
},
user: {
select: {
id: true,
@@ -42,11 +55,6 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
destinationCalendar: true,
},
},
eventType: {
select: {
metadata: true,
},
},
},
where: {
uid: bookingUid,
@@ -94,6 +102,7 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
username: user?.username || "",
language: { translate: selectedLanguage, locale: user.locale || "en" },
timeZone: user?.timeZone,
phoneNumber: user?.phoneNumber,
};
});
};
@@ -111,6 +120,13 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
tAttendees
),
organizer: userOwnerAsPeopleType,
team: !!bookingToReschedule.eventType?.team
? {
name: bookingToReschedule.eventType.team.name,
id: bookingToReschedule.eventType.team.id,
members: [],
}
: undefined,
});
const director = new CalendarEventDirector();
director.setBuilder(builder);
@@ -147,7 +163,7 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
// Send emails
try {
await sendRequestRescheduleEmail(
await sendRequestRescheduleEmailAndSMS(
builder.calendarEvent,
{
rescheduleLink: builder.rescheduleLink,
+79 -27
View File
@@ -12,6 +12,15 @@ import { safeStringify } from "@calcom/lib/safeStringify";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import AwaitingPaymentSMS from "../sms/attendee/awaiting-payment-sms";
import CancelledSeatSMS from "../sms/attendee/cancelled-seat-sms";
import EventCancelledSMS from "../sms/attendee/event-cancelled-sms";
import EventDeclinedSMS from "../sms/attendee/event-declined-sms";
import EventLocationChangedSMS from "../sms/attendee/event-location-changed-sms";
import EventRequestSMS from "../sms/attendee/event-request-sms";
import EventRequestToRescheduleSMS from "../sms/attendee/event-request-to-reschedule-sms";
import EventSuccessfullyReScheduledSMS from "../sms/attendee/event-rescheduled-sms";
import EventSuccessfullyScheduledSMS from "../sms/attendee/event-scheduled-sms";
import type { MonthlyDigestEmailData } from "./src/templates/MonthlyDigestEmail";
import type { OrganizationAdminNoSlotsEmailInput } from "./src/templates/OrganizationAdminNoSlots";
import type { EmailVerifyLink } from "./templates/account-verify-email";
@@ -86,7 +95,7 @@ const eventTypeDisableHostEmail = (metadata?: EventTypeMetadata) => {
return !!metadata?.disableStandardEmails?.all?.host;
};
export const sendScheduledEmails = async (
export const sendScheduledEmailsAndSMS = async (
calEvent: CalendarEvent,
eventNameObject?: EventNameObjectType,
hostEmailDisabled?: boolean,
@@ -129,62 +138,79 @@ export const sendScheduledEmails = async (
}
await Promise.all(emailsToSend);
const successfullyScheduledSms = new EventSuccessfullyScheduledSMS(calEvent);
await successfullyScheduledSms.sendSMSToAttendees();
};
// for rescheduled round robin booking that assigned new members
export const sendRoundRobinScheduledEmails = async (
export const sendRoundRobinScheduledEmailsAndSMS = async (
calEvent: CalendarEvent,
members: Person[],
eventTypeMetadata?: EventTypeMetadata
) => {
if (eventTypeDisableHostEmail(eventTypeMetadata)) return;
const formattedCalEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
const emailsAndSMSToSend: Promise<unknown>[] = [];
const eventScheduledSMS = new EventSuccessfullyScheduledSMS(calEvent);
for (const teamMember of members) {
emailsToSend.push(
emailsAndSMSToSend.push(
sendEmail(() => new OrganizerScheduledEmail({ calEvent: formattedCalEvent, teamMember }))
);
if (teamMember.phoneNumber) {
emailsAndSMSToSend.push(eventScheduledSMS.sendSMSToAttendee(teamMember));
}
}
await Promise.all(emailsToSend);
await Promise.all(emailsAndSMSToSend);
};
export const sendRoundRobinRescheduledEmails = async (
export const sendRoundRobinRescheduledEmailsAndSMS = async (
calEvent: CalendarEvent,
members: Person[],
teamMembersAndAttendees: Person[],
eventTypeMetadata?: EventTypeMetadata
) => {
if (eventTypeDisableHostEmail(eventTypeMetadata)) return;
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
for (const teamMember of members) {
emailsToSend.push(
sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent, teamMember }))
const calendarEvent = formatCalEvent(calEvent);
const emailsAndSMSToSend: Promise<unknown>[] = [];
const successfullyReScheduledSMS = new EventSuccessfullyReScheduledSMS(calEvent);
for (const person of teamMembersAndAttendees) {
emailsAndSMSToSend.push(
sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent, teamMember: person }))
);
if (person.phoneNumber) {
emailsAndSMSToSend.push(successfullyReScheduledSMS.sendSMSToAttendee(person));
}
}
await Promise.all(emailsToSend);
await Promise.all(emailsAndSMSToSend);
};
export const sendRoundRobinCancelledEmails = async (
export const sendRoundRobinCancelledEmailsAndSMS = async (
calEvent: CalendarEvent,
members: Person[],
eventTypeMetadata?: EventTypeMetadata
) => {
if (eventTypeDisableHostEmail(eventTypeMetadata)) return;
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
const emailsAndSMSToSend: Promise<unknown>[] = [];
const successfullyReScheduledSMS = new EventCancelledSMS(calEvent);
for (const teamMember of members) {
emailsToSend.push(sendEmail(() => new OrganizerCancelledEmail({ calEvent: calendarEvent, teamMember })));
emailsAndSMSToSend.push(
sendEmail(() => new OrganizerCancelledEmail({ calEvent: calendarEvent, teamMember }))
);
if (teamMember.phoneNumber) {
emailsAndSMSToSend.push(successfullyReScheduledSMS.sendSMSToAttendee(teamMember));
}
}
await Promise.all(emailsToSend);
await Promise.all(emailsAndSMSToSend);
};
export const sendRescheduledEmails = async (
export const sendRescheduledEmailsAndSMS = async (
calEvent: CalendarEvent,
eventTypeMetadata?: EventTypeMetadata
) => {
@@ -212,9 +238,11 @@ export const sendRescheduledEmails = async (
}
await Promise.all(emailsToSend);
const successfullyReScheduledSms = new EventSuccessfullyReScheduledSMS(calEvent);
await successfullyReScheduledSms.sendSMSToAttendees();
};
export const sendRescheduledSeatEmail = async (
export const sendRescheduledSeatEmailAndSMS = async (
calEvent: CalendarEvent,
attendee: Person,
eventTypeMetadata?: EventTypeMetadata
@@ -229,10 +257,13 @@ export const sendRescheduledSeatEmail = async (
if (!eventTypeDisableAttendeeEmail(eventTypeMetadata))
emailsToSend.push(sendEmail(() => new AttendeeRescheduledEmail(clonedCalEvent, attendee)));
const successfullyReScheduledSMS = new EventSuccessfullyReScheduledSMS(calEvent);
await successfullyReScheduledSMS.sendSMSToAttendee(attendee);
await Promise.all(emailsToSend);
};
export const sendScheduledSeatsEmails = async (
export const sendScheduledSeatsEmailsAndSMS = async (
calEvent: CalendarEvent,
invitee: Person,
newSeat: boolean,
@@ -273,9 +304,11 @@ export const sendScheduledSeatsEmails = async (
);
}
await Promise.all(emailsToSend);
const eventScheduledSMS = new EventSuccessfullyScheduledSMS(calendarEvent);
await eventScheduledSMS.sendSMSToAttendee(invitee);
};
export const sendCancelledSeatEmails = async (
export const sendCancelledSeatEmailsAndSMS = async (
calEvent: CalendarEvent,
cancelledAttendee: Person,
eventTypeMetadata?: EventTypeMetadata
@@ -292,6 +325,8 @@ export const sendCancelledSeatEmails = async (
);
await Promise.all(emailsToSend);
const cancelledSeatSMS = new CancelledSeatSMS(clonedCalEvent);
await cancelledSeatSMS.sendSMSToAttendee(cancelledAttendee);
};
export const sendOrganizerRequestEmail = async (
@@ -314,18 +349,25 @@ export const sendOrganizerRequestEmail = async (
await Promise.all(emailsToSend);
};
export const sendAttendeeRequestEmail = async (
export const sendAttendeeRequestEmailAndSMS = async (
calEvent: CalendarEvent,
attendee: Person,
eventTypeMetadata?: EventTypeMetadata
) => {
if (eventTypeDisableAttendeeEmail(eventTypeMetadata)) return;
const calendarEvent = formatCalEvent(calEvent);
await sendEmail(() => new AttendeeRequestEmail(calendarEvent, attendee));
const eventRequestSms = new EventRequestSMS(calendarEvent);
await eventRequestSms.sendSMSToAttendee(attendee);
};
export const sendDeclinedEmails = async (calEvent: CalendarEvent, eventTypeMetadata?: EventTypeMetadata) => {
export const sendDeclinedEmailsAndSMS = async (
calEvent: CalendarEvent,
eventTypeMetadata?: EventTypeMetadata
) => {
if (eventTypeDisableAttendeeEmail(eventTypeMetadata)) return;
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
@@ -336,9 +378,11 @@ export const sendDeclinedEmails = async (calEvent: CalendarEvent, eventTypeMetad
);
await Promise.all(emailsToSend);
const eventDeclindedSms = new EventDeclinedSMS(calEvent);
await eventDeclindedSms.sendSMSToAttendees();
};
export const sendCancelledEmails = async (
export const sendCancelledEmailsAndSMS = async (
calEvent: CalendarEvent,
eventNameObject: Pick<EventNameObjectType, "eventName">,
eventTypeMetadata?: EventTypeMetadata
@@ -394,6 +438,8 @@ export const sendCancelledEmails = async (
}
await Promise.all(emailsToSend);
const eventCancelledSms = new EventCancelledSMS(calEvent);
await eventCancelledSms.sendSMSToAttendees();
};
export const sendOrganizerRequestReminderEmail = async (
@@ -416,7 +462,7 @@ export const sendOrganizerRequestReminderEmail = async (
}
};
export const sendAwaitingPaymentEmail = async (
export const sendAwaitingPaymentEmailAndSMS = async (
calEvent: CalendarEvent,
eventTypeMetadata?: EventTypeMetadata
) => {
@@ -429,6 +475,8 @@ export const sendAwaitingPaymentEmail = async (
})
);
await Promise.all(emailsToSend);
const awaitingPaymentSMS = new AwaitingPaymentSMS(calEvent);
await awaitingPaymentSMS.sendSMSToAttendees();
};
export const sendOrganizerPaymentRefundFailedEmail = async (calEvent: CalendarEvent) => {
@@ -474,7 +522,7 @@ export const sendChangeOfEmailVerificationLink = async (verificationInput: Chang
await sendEmail(() => new ChangeOfEmailVerifyEmail(verificationInput));
};
export const sendRequestRescheduleEmail = async (
export const sendRequestRescheduleEmailAndSMS = async (
calEvent: CalendarEvent,
metadata: { rescheduleLink: string },
eventTypeMetadata?: EventTypeMetadata
@@ -490,9 +538,11 @@ export const sendRequestRescheduleEmail = async (
}
await Promise.all(emailsToSend);
const eventRequestToReschedule = new EventRequestToRescheduleSMS(calendarEvent);
await eventRequestToReschedule.sendSMSToAttendees();
};
export const sendLocationChangeEmails = async (
export const sendLocationChangeEmailsAndSMS = async (
calEvent: CalendarEvent,
eventTypeMetadata?: EventTypeMetadata
) => {
@@ -521,6 +571,8 @@ export const sendLocationChangeEmails = async (
}
await Promise.all(emailsToSend);
const eventLocationChangedSMS = new EventLocationChangedSMS(calendarEvent);
await eventLocationChangedSMS.sendSMSToAttendees();
};
export const sendAddGuestsEmails = async (calEvent: CalendarEvent, newGuests: string[]) => {
const calendarEvent = formatCalEvent(calEvent);
+12 -8
View File
@@ -1,17 +1,20 @@
import type { TFunction } from "next-i18next";
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { Info } from "./Info";
const PersonInfo = ({ name = "", email = "", role = "" }) => (
const PersonInfo = ({ name = "", email = "", role = "", phoneNumber = "" }) => (
<div style={{ color: "#101010", fontWeight: 400, lineHeight: "24px" }}>
{name} - {role}{" "}
<span style={{ color: "#4B5563" }}>
<a href={`mailto:${email}`} style={{ color: "#4B5563" }}>
{email}
</a>
</span>
{name} - {role} {phoneNumber}
{!isSmsCalEmail(email) && (
<span style={{ color: "#4B5563" }}>
<a href={`mailto:${email}`} style={{ color: "#4B5563" }}>
{email}
</a>
</span>
)}
</div>
);
@@ -28,7 +31,7 @@ export function WhoInfo(props: { calEvent: CalendarEvent; t: TFunction }) {
email={props.calEvent.organizer.email}
/>
{props.calEvent.team?.members.map((member) => (
<PersonInfo key={member.name} name={member.name} role={t("team_member")} email={member.email} />
<PersonInfo key={member.name} name={member.name} role={t("team_member")} email={member?.email} />
))}
{props.calEvent.attendees.map((attendee) => (
<PersonInfo
@@ -36,6 +39,7 @@ export function WhoInfo(props: { calEvent: CalendarEvent; t: TFunction }) {
name={attendee.name}
role={t("guest")}
email={attendee.email}
phoneNumber={attendee.phoneNumber ?? undefined}
/>
))}
</>
+6
View File
@@ -5,6 +5,7 @@ import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
import { serverConfig } from "@calcom/lib/serverConfig";
import { setTestEmail } from "@calcom/lib/testEmails";
import prisma from "@calcom/prisma";
@@ -52,6 +53,11 @@ export default class BaseEmail {
const from = "from" in payload ? (payload.from as string) : "";
const to = "to" in payload ? (payload.to as string) : "";
if (isSmsCalEmail(to)) {
console.log(`Skipped Sending Email to faux email: ${to}`);
return new Promise((r) => r(`Skipped Sending Email to faux email: ${to}`));
}
const sanitizedFrom = sanitizeDisplayName(from);
const sanitizedTo = sanitizeDisplayName(to);
+1 -1
View File
@@ -176,7 +176,7 @@ const BookerComponent = ({
isVerificationCodeSending={isVerificationCodeSending}
isPlatform={isPlatform}>
<>
{verifyCode ? (
{verifyCode && formEmail ? (
<VerifyCodeDialog
isOpenDialog={isEmailVerificationModalVisible}
setIsOpenDialog={setEmailVerificationModalVisible}
@@ -93,7 +93,7 @@ export function useInitialFormValues({
defaults.responses = {
...responses,
name: defaultUserValues.name,
email: defaultUserValues.email,
email: defaultUserValues.email ?? "",
};
setDefaultValues(defaults);
@@ -119,7 +119,7 @@ export function useInitialFormValues({
defaults.responses = {
...responses,
name: defaultUserValues.name,
email: defaultUserValues.email,
email: defaultUserValues.email ?? "",
};
setDefaultValues(defaults);
})();
@@ -9,6 +9,7 @@ export const SystemField = z.enum([
"guests",
"rescheduleReason",
"smsReminderNumber",
"attendeePhoneNumber",
]);
export const SMS_REMINDER_NUMBER_FIELD = "smsReminderNumber";
@@ -58,6 +58,7 @@ export const getSmsReminderNumberSource = ({
export const getBookingFieldsWithSystemFields = ({
bookingFields,
disableGuests,
isOrgTeamEvent = false,
disableBookingTitle,
customInputs,
metadata,
@@ -65,6 +66,7 @@ export const getBookingFieldsWithSystemFields = ({
}: {
bookingFields: Fields | EventType["bookingFields"];
disableGuests: boolean;
isOrgTeamEvent?: boolean;
disableBookingTitle?: boolean;
customInputs: EventTypeCustomInput[] | z.infer<typeof customInputSchema>[];
metadata: EventType["metadata"] | z.infer<typeof EventTypeMetaDataSchema>;
@@ -79,6 +81,7 @@ export const getBookingFieldsWithSystemFields = ({
return ensureBookingInputsHaveSystemFields({
bookingFields: parsedBookingFields,
disableGuests,
isOrgTeamEvent,
disableBookingTitle,
additionalNotesRequired: parsedMetaData?.additionalNotesRequired || false,
customInputs: parsedCustomInputs,
@@ -89,6 +92,7 @@ export const getBookingFieldsWithSystemFields = ({
export const ensureBookingInputsHaveSystemFields = ({
bookingFields,
disableGuests,
isOrgTeamEvent,
disableBookingTitle,
additionalNotesRequired,
customInputs,
@@ -96,6 +100,7 @@ export const ensureBookingInputsHaveSystemFields = ({
}: {
bookingFields: Fields;
disableGuests: boolean;
isOrgTeamEvent: boolean;
disableBookingTitle?: boolean;
additionalNotesRequired: boolean;
customInputs: z.infer<typeof customInputSchema>[];
@@ -130,6 +135,8 @@ export const ensureBookingInputsHaveSystemFields = ({
});
});
const isEmailFieldOptional = !!bookingFields.find((field) => field.name === "email" && !field.required);
// These fields should be added before other user fields
const systemBeforeFields: typeof bookingFields = [
{
@@ -152,8 +159,8 @@ export const ensureBookingInputsHaveSystemFields = ({
defaultLabel: "email_address",
type: "email",
name: "email",
required: true,
editable: "system",
required: !isEmailFieldOptional,
editable: isOrgTeamEvent ? "system-but-optional" : "system",
sources: [
{
label: "Default",
@@ -162,6 +169,7 @@ export const ensureBookingInputsHaveSystemFields = ({
},
],
},
{
defaultLabel: "location",
type: "radioInput",
@@ -196,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 = [
@@ -116,8 +116,8 @@ describe("getBookingResponsesSchema", () => {
//@ts-ignore
expect(parsedResponsesWithJustName.error.issues[0]).toEqual(
expect.objectContaining({
message: ZOD_REQUIRED_FIELD_ERROR_MSG,
path: ["email"],
message: ZOD_REQUIRED_FIELD_ERROR_MSG,
})
);
@@ -117,6 +117,18 @@ function preprocess<T extends z.ZodType>({
// if eventType has been deleted, we won't have bookingFields and thus we can't validate the responses.
return;
}
const attendeePhoneNumberField = bookingFields.find((field) => field.name === "attendeePhoneNumber");
const isAttendeePhoneNumberFieldHidden = attendeePhoneNumberField?.hidden;
const emailField = bookingFields.find((field) => field.name === "email");
const isEmailFieldHidden = !!emailField?.hidden;
// To prevent using user's session email as attendee's email, we set email to empty string
if (isEmailFieldHidden && !isAttendeePhoneNumberFieldHidden) {
responses["email"] = "";
}
for (const bookingField of bookingFields) {
const value = responses[bookingField.name];
const stringSchema = z.string();
@@ -151,7 +163,7 @@ function preprocess<T extends z.ZodType>({
if (bookingField.type === "email") {
// Email RegExp to validate if the input is a valid email
if (!emailSchema.safeParse(value).success) {
if (!bookingField.hidden && !emailSchema.safeParse(value).success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m("email_validation_error"),
@@ -3,6 +3,7 @@ import type z from "zod";
import { SystemField } from "@calcom/features/bookings/lib/SystemField";
import type { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { contructEmailFromPhoneNumber } from "@calcom/lib/contructEmailFromPhoneNumber";
import { getBookingWithResponses } from "@calcom/lib/getBooking";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
@@ -38,6 +39,16 @@ export const getCalEventResponses = ({
responses ?? (booking ? getBookingWithResponses(booking).responses : null);
if (!backwardCompatibleResponses) throw new Error("Couldn't get responses");
// To set placeholder email for the booking
if (!!!backwardCompatibleResponses.email) {
if (typeof backwardCompatibleResponses["attendeePhoneNumber"] !== "string")
throw new Error("Both Phone and Email are missing");
backwardCompatibleResponses.email = contructEmailFromPhoneNumber(
backwardCompatibleResponses["attendeePhoneNumber"]
);
}
if (parsedBookingFields) {
parsedBookingFields.forEach((field) => {
const label = field.label || field.defaultLabel;
@@ -58,6 +69,7 @@ export const getCalEventResponses = ({
isHidden: !!field.hidden,
};
}
calEventResponses[field.name] = {
label,
value: backwardCompatibleResponses[field.name],
@@ -39,6 +39,7 @@ const getUserBooking = async (uid: string) => {
name: true,
email: true,
timeZone: true,
phoneNumber: true,
},
},
eventTypeId: true,
@@ -1,6 +1,6 @@
import type { Prisma } from "@prisma/client";
import { sendAttendeeRequestEmail, sendOrganizerRequestEmail } from "@calcom/emails";
import { sendAttendeeRequestEmailAndSMS, sendOrganizerRequestEmail } from "@calcom/emails";
import { getWebhookPayloadForBooking } from "@calcom/features/bookings/lib/getWebhookPayloadForBooking";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
@@ -41,8 +41,9 @@ export async function handleBookingRequested(args: {
const { evt, booking } = args;
log.debug("Emails: Sending booking requested emails");
await sendOrganizerRequestEmail({ ...evt }, booking?.eventType?.metadata as EventTypeMetadata);
await sendAttendeeRequestEmail(
await sendAttendeeRequestEmailAndSMS(
{ ...evt },
evt.attendees[0],
booking?.eventType?.metadata as EventTypeMetadata
@@ -5,7 +5,7 @@ import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApi
import { DailyLocationType } from "@calcom/app-store/locations";
import EventManager from "@calcom/core/EventManager";
import dayjs from "@calcom/dayjs";
import { sendCancelledEmails } from "@calcom/emails";
import { sendCancelledEmailsAndSMS } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { workflowSelect } from "@calcom/features/ee/workflows/lib/getAllWorkflows";
import { sendCancelledReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
@@ -249,6 +249,7 @@ async function handler(req: CustomRequest) {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
phoneNumber: attendee.phoneNumber,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
@@ -310,9 +311,14 @@ async function handler(req: CustomRequest) {
? [bookingToDelete?.user.destinationCalendar]
: [],
cancellationReason: cancellationReason,
...(teamMembers && {
team: { name: bookingToDelete?.eventType?.team?.name || "Nameless", members: teamMembers, id: teamId! },
}),
...(teamMembers &&
teamId && {
team: {
name: bookingToDelete?.eventType?.team?.name || "Nameless",
members: teamMembers,
id: teamId,
},
}),
seatsPerTimeSlot: bookingToDelete.eventType?.seatsPerTimeSlot,
seatsShowAttendees: bookingToDelete.eventType?.seatsShowAttendees,
iCalUID: bookingToDelete.iCalUID,
@@ -523,7 +529,7 @@ async function handler(req: CustomRequest) {
try {
// TODO: if emails fail try to requeue them
if (!platformClientId || (platformClientId && arePlatformEmailsEnabled))
await sendCancelledEmails(
await sendCancelledEmailsAndSMS(
evt,
{ eventName: bookingToDelete?.eventType?.eventName },
bookingToDelete?.eventType?.metadata as EventTypeMetadata
@@ -3,7 +3,7 @@ import type { Prisma } from "@prisma/client";
import type { EventManagerUser } from "@calcom/core/EventManager";
import EventManager from "@calcom/core/EventManager";
import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/scheduleMandatoryReminder";
import { sendScheduledEmails } from "@calcom/emails";
import { sendScheduledEmailsAndSMS } from "@calcom/emails";
import {
allowDisablingAttendeeConfirmationEmails,
allowDisablingHostConfirmationEmails,
@@ -108,7 +108,7 @@ export async function handleConfirmation(args: {
}
}
await sendScheduledEmails(
await sendScheduledEmailsAndSMS(
{ ...evt, additionalInformation: metadata },
undefined,
isHostConfirmationEmailsDisabled,
@@ -126,6 +126,7 @@ export async function handleConfirmation(args: {
attendees: {
name: string;
email: string;
phoneNumber?: string | null;
}[];
startTime: Date;
endTime: Date;
@@ -20,13 +20,13 @@ import { getEventName } from "@calcom/core/event";
import dayjs from "@calcom/dayjs";
import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/scheduleMandatoryReminder";
import {
sendAttendeeRequestEmail,
sendAttendeeRequestEmailAndSMS,
sendOrganizerRequestEmail,
sendRescheduledEmails,
sendRoundRobinCancelledEmails,
sendRoundRobinRescheduledEmails,
sendRoundRobinScheduledEmails,
sendScheduledEmails,
sendRescheduledEmailsAndSMS,
sendRoundRobinCancelledEmailsAndSMS,
sendRoundRobinRescheduledEmailsAndSMS,
sendRoundRobinScheduledEmailsAndSMS,
sendScheduledEmailsAndSMS,
} from "@calcom/emails";
import getICalUID from "@calcom/emails/lib/getICalUID";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
@@ -215,9 +215,12 @@ async function handler(
!req.body.eventTypeId && !!req.body.eventTypeSlug
? getDefaultEvent(req.body.eventTypeSlug)
: await getEventTypesFromDB(req.body.eventTypeId);
const isOrgTeamEvent = !!eventType?.team && !!eventType?.team?.parentId;
eventType = {
...eventType,
bookingFields: getBookingFieldsWithSystemFields(eventType),
bookingFields: getBookingFieldsWithSystemFields({ ...eventType, isOrgTeamEvent }),
};
const bookingDataSchema = bookingDataSchemaGetter({
@@ -239,6 +242,7 @@ async function handler(
language,
appsStatus: reqAppsStatus,
name: bookerName,
attendeePhoneNumber: bookerPhoneNumber,
email: bookerEmail,
guests: reqGuests,
location,
@@ -726,6 +730,7 @@ async function handler(
{
email: bookerEmail,
name: fullName,
phoneNumber: bookerPhoneNumber,
firstName: (typeof bookerName === "object" && bookerName.firstName) || "",
lastName: (typeof bookerName === "object" && bookerName.lastName) || "",
timeZone: attendeeTimezone,
@@ -963,6 +968,14 @@ async function handler(
const workflows = await getAllWorkflowsFromEventType(eventType, organizerUser.id);
if (isTeamEventType) {
evt.team = {
members: teamMembers,
name: eventType.team?.name || "Nameless",
id: eventType.team?.id ?? 0,
};
}
// For seats, if the booking already exists then we want to add the new attendee to the existing booking
if (eventType.seatsPerTimeSlot) {
const newBooking = await handleSeats({
@@ -975,6 +988,7 @@ async function handler(
organizerUser,
originalRescheduledBooking,
bookerEmail,
bookerPhoneNumber,
tAttendees,
bookingSeat,
reqUserId: req.userId,
@@ -1022,13 +1036,7 @@ async function handler(
});
}
}
if (isTeamEventType) {
evt.team = {
members: teamMembers,
name: eventType.team?.name || "Nameless",
id: eventType.team?.id ?? 0,
};
}
if (reqBody.recurringEventId && eventType.recurringEvent) {
// Overriding the recurring event configuration count to be the actual number of events booked for
// the recurring event (equal or less than recurring event configuration count)
@@ -1105,7 +1113,10 @@ async function handler(
if (booking && booking.id && eventType.seatsPerTimeSlot) {
const currentAttendee = booking.attendees.find(
(attendee) => attendee.email === req.body.responses.email
(attendee) =>
attendee.email === req.body.responses.email ||
(req.body.responses.attendeePhoneNumber &&
attendee.phoneNumber === req.body.responses.attendeePhoneNumber)
);
// Save description to bookingSeat
@@ -1321,6 +1332,7 @@ async function handler(
name: user.name,
email: user.email,
timeZone: user.timeZone,
phoneNumber: user.phoneNumber,
language: { translate, locale: user.locale ?? "en" },
});
}
@@ -1339,26 +1351,39 @@ async function handler(
.concat(copyEvent.organizer)
.concat(copyEvent.attendees) || [];
const matchOriginalMemberWithNewMember = (originalMember: Person, newMember: Person) => {
return originalMember.email === newMember.email;
};
// scheduled Emails
const newBookedMembers = newBookingMemberEmails.filter(
(member) =>
!originalBookingMemberEmails.find((originalMember) => originalMember.email === member.email)
!originalBookingMemberEmails.find((originalMember) =>
matchOriginalMemberWithNewMember(originalMember, member)
)
);
// cancelled Emails
const cancelledMembers = originalBookingMemberEmails.filter(
(member) => !newBookingMemberEmails.find((newMember) => newMember.email === member.email)
(member) =>
!newBookingMemberEmails.find((newMember) => matchOriginalMemberWithNewMember(member, newMember))
);
// rescheduled Emails
const rescheduledMembers = newBookingMemberEmails.filter((member) =>
originalBookingMemberEmails.find((orignalMember) => orignalMember.email === member.email)
originalBookingMemberEmails.find((orignalMember) =>
matchOriginalMemberWithNewMember(orignalMember, member)
)
);
sendRoundRobinRescheduledEmails(copyEventAdditionalInfo, rescheduledMembers, eventType.metadata);
sendRoundRobinScheduledEmails(copyEventAdditionalInfo, newBookedMembers, eventType.metadata);
sendRoundRobinCancelledEmails(copyEventAdditionalInfo, cancelledMembers, eventType.metadata);
sendRoundRobinRescheduledEmailsAndSMS(
copyEventAdditionalInfo,
rescheduledMembers,
eventType.metadata
);
sendRoundRobinScheduledEmailsAndSMS(copyEventAdditionalInfo, newBookedMembers, eventType.metadata);
sendRoundRobinCancelledEmailsAndSMS(copyEventAdditionalInfo, cancelledMembers, eventType.metadata);
} else {
// send normal rescheduled emails (non round robin event, where organizers stay the same)
await sendRescheduledEmails(
await sendRescheduledEmailsAndSMS(
{
...copyEvent,
additionalInformation: metadata,
@@ -1496,7 +1521,7 @@ async function handler(
})
);
await sendScheduledEmails(
await sendScheduledEmailsAndSMS(
{
...evt,
additionalInformation,
@@ -1536,7 +1561,7 @@ async function handler(
})
);
await sendOrganizerRequestEmail({ ...evt, additionalNotes }, eventType.metadata);
await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0], eventType.metadata);
await sendAttendeeRequestEmailAndSMS({ ...evt, additionalNotes }, attendeesList[0], eventType.metadata);
}
if (booking.location?.startsWith("http")) {
@@ -1607,7 +1632,8 @@ async function handler(
eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
booking,
fullName,
bookerEmail
bookerEmail,
bookerPhoneNumber
);
const subscriberOptionsPaymentInitiated: GetSubscriberOptions = {
userId: triggerForUser ? organizerUser.id : null,
@@ -148,6 +148,7 @@ function getAttendeesData(evt: Pick<CalendarEvent, "attendees" | "team">) {
email: attendee.email,
timeZone: attendee.timeZone,
locale: attendee.language.locale,
phoneNumber: attendee.phoneNumber,
}));
if (evt.team?.members) {
@@ -157,6 +158,7 @@ function getAttendeesData(evt: Pick<CalendarEvent, "attendees" | "team">) {
name: member.name,
timeZone: member.timeZone,
locale: member.language.locale,
phoneNumber: member.phoneNumber,
}))
);
}
@@ -51,6 +51,7 @@ export async function getBookingData<T extends z.ZodType>({
calEventUserFieldsResponses: undefined,
calEventResponses: undefined,
customInputs: undefined,
attendeePhoneNumber: undefined,
};
}
if (!reqBody.responses) {
@@ -67,6 +68,7 @@ export async function getBookingData<T extends z.ZodType>({
...reqBody,
name: responses.name,
email: responses.email,
attendeePhoneNumber: responses.attendeePhoneNumber,
guests: responses.guests ? responses.guests : [],
location: responses.location?.optionValue || responses.location?.value || "",
smsReminderNumber: responses.smsReminderNumber,
@@ -24,6 +24,11 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
},
},
slug: true,
profile: {
select: {
organizationId: true,
},
},
teamId: true,
team: {
select: {
@@ -124,13 +129,16 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
},
});
const { profile, ...restEventType } = eventType;
const isOrgTeamEvent = !!eventType?.team && !!profile?.organizationId;
return {
...eventType,
...restEventType,
metadata: EventTypeMetaDataSchema.parse(eventType?.metadata || {}),
recurringEvent: parseRecurringEvent(eventType?.recurringEvent),
customInputs: customInputSchema.array().parse(eventType?.customInputs || []),
locations: (eventType?.locations ?? []) as LocationObject[],
bookingFields: getBookingFieldsWithSystemFields(eventType || {}),
bookingFields: getBookingFieldsWithSystemFields({ ...restEventType, isOrgTeamEvent } || {}),
isDynamic: false,
};
};
@@ -18,6 +18,7 @@ export async function getOriginalRescheduledBooking(uid: string, seatsEventType?
email: true,
locale: true,
timeZone: true,
phoneNumber: true,
...(seatsEventType && { bookingSeat: true, id: true }),
},
},
@@ -17,6 +17,7 @@ import {
getMockBookingAttendee,
getMockFailingAppStatus,
getMockPassingAppStatus,
getDefaultBookingFields,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest";
import {
@@ -36,11 +37,12 @@ import {
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { describe, expect } from "vitest";
import { describe, expect, beforeEach } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated";
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { resetTestSMS } from "@calcom/lib/testSMS";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
@@ -50,6 +52,10 @@ const timeout = process.env.CI ? 5000 : 20000;
describe("handleNewBooking", () => {
setupAndTeardown();
beforeEach(() => {
resetTestSMS();
});
describe("Reschedule", () => {
describe("User event-type", () => {
test(
@@ -2174,6 +2180,210 @@ describe("handleNewBooking", () => {
},
timeout
);
test(
"[Event Type with Both Email and Attendee Phone Number as required fields] should send rescheduling emails when round robin is rescheduled to same host",
async ({ emails }) => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const TEST_ATTENDEE_NUMBER = "+919876543210";
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
attendeePhoneNumber: TEST_ATTENDEE_NUMBER,
});
const roundRobinHost1 = getOrganizer({
name: "RR Host 1",
email: "rrhost1@example.com",
id: 101,
schedules: [TestData.schedules.IstMorningShift],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
teams: [
{
membership: {
accepted: true,
},
team: {
id: 1,
name: "Team 1",
slug: "team-1",
},
},
],
});
const roundRobinHost2 = getOrganizer({
name: "RR Host 2",
email: "rrhost2@example.com",
id: 102,
schedules: [TestData.schedules.IstEveningShift],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
teams: [
{
membership: {
accepted: true,
},
team: {
id: 1,
name: "Team 1",
slug: "team-1",
},
},
],
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 15,
length: 15,
teamId: 1,
users: [
{
id: 101,
},
{
id: 102,
},
],
schedulingType: SchedulingType.ROUND_ROBIN,
bookingFields: getDefaultBookingFields({
emailField: {
name: "email",
type: "email",
label: "",
hidden: false,
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: true,
placeholder: "",
defaultLabel: "email_address",
},
bookingFields: [
{
name: "attendeePhoneNumber",
type: "phone",
hidden: false,
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: true,
defaultLabel: "phone_number",
},
],
}),
},
],
bookings: [
{
uid: uidOfBookingToBeRescheduled,
eventTypeId: 1,
userId: 101,
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:15:00.000Z`,
},
],
organizer: roundRobinHost1,
usersApartFromOrganizer: [roundRobinHost2],
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
});
mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
uid: "MOCK_ID",
},
update: {
uid: "UPDATED_MOCK_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
user: roundRobinHost1.name,
rescheduleUid: uidOfBookingToBeRescheduled,
start: `${plus1DateString}T04:00:00.000Z`,
end: `${plus1DateString}T04:15:00.000Z`,
responses: {
email: booker.email,
name: booker.name,
attendeePhoneNumber: booker.attendeePhoneNumber,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
const createdBooking = await handleNewBooking(req);
const previousBooking = await prismaMock.booking.findUnique({
where: {
uid: uidOfBookingToBeRescheduled,
},
});
logger.silly({
previousBooking,
allBookings: await prismaMock.booking.findMany(),
});
// Expect previous booking to be cancelled
await expectBookingToBeInDatabase({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
uid: uidOfBookingToBeRescheduled,
status: BookingStatus.CANCELLED,
});
expect(previousBooking?.status).toBe(BookingStatus.CANCELLED);
/**
* Booking Time should be new time
*/
expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T04:00:00.000Z`);
expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T04:15:00.000Z`);
await expectBookingInDBToBeRescheduledFromTo({
from: {
uid: uidOfBookingToBeRescheduled,
},
to: {
description: "",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
uid: createdBooking.uid!,
eventTypeId: mockBookingData.eventTypeId,
status: BookingStatus.ACCEPTED,
location: BookingLocations.CalVideo,
responses: expect.objectContaining({
email: booker.email,
attendeePhoneNumber: booker.attendeePhoneNumber,
name: booker.name,
}),
},
});
expectSuccessfulRoundRobinReschedulingEmails({
prevOrganizer: roundRobinHost1,
newOrganizer: roundRobinHost1, // Round robin host 2 is not available and it will be rescheduled to same user
emails,
});
},
timeout
);
test(
"should reschedule event with same round robin host",
async ({ emails }) => {
@@ -2304,7 +2514,7 @@ describe("handleNewBooking", () => {
expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T04:15:00.000Z`);
// Expect both hosts for the event types to be the same
expect(createdBooking.userId).toBe(previousBooking.userId);
expect(createdBooking.userId).toBe(previousBooking?.userId ?? -1);
await expectBookingInDBToBeRescheduledFromTo({
from: {
@@ -37,6 +37,7 @@ export type Invitee = {
firstName: string;
lastName: string;
timeZone: string;
phoneNumber?: string;
language: {
translate: TFunction;
locale: string;
@@ -1,6 +1,6 @@
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { updateMeeting } from "@calcom/core/videoClient";
import { sendCancelledSeatEmails } from "@calcom/emails";
import { sendCancelledSeatEmailsAndSMS } from "@calcom/emails";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import type { EventPayloadType, EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { HttpError } from "@calcom/lib/http-error";
@@ -107,7 +107,7 @@ async function cancelAttendeeSeat(
const tAttendees = await getTranslation(attendee.locale ?? "en", "common");
await sendCancelledSeatEmails(
await sendCancelledSeatEmailsAndSMS(
evt,
{
...attendee,
@@ -3,7 +3,7 @@ import { cloneDeep } from "lodash";
import { uuid } from "short-uuid";
import EventManager from "@calcom/core/EventManager";
import { sendScheduledSeatsEmails } from "@calcom/emails";
import { sendScheduledSeatsEmailsAndSMS } from "@calcom/emails";
import { refreshCredentials } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/refreshCredentials";
import {
allowDisablingAttendeeConfirmationEmails,
@@ -37,6 +37,7 @@ const createNewSeat = async (
bookerEmail,
responses,
workflows,
bookerPhoneNumber,
} = rescheduleSeatedBookingObject;
let { evt } = rescheduleSeatedBookingObject;
let resultBooking: HandleSeatsResultBooking;
@@ -80,6 +81,7 @@ const createNewSeat = async (
attendees: {
create: {
email: inviteeToAdd.email,
phoneNumber: inviteeToAdd.phoneNumber,
name: inviteeToAdd.name,
timeZone: inviteeToAdd.timeZone,
locale: inviteeToAdd.language.locale,
@@ -132,7 +134,7 @@ const createNewSeat = async (
if (isAttendeeConfirmationEmailDisabled) {
isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows);
}
await sendScheduledSeatsEmails(
await sendScheduledSeatsEmailsAndSMS(
copyEvent,
inviteeToAdd,
newSeat,
@@ -187,7 +189,8 @@ const createNewSeat = async (
eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
seatedBooking,
fullName,
bookerEmail
bookerEmail,
bookerPhoneNumber
);
resultBooking = { ...foundBooking };
@@ -77,7 +77,9 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
// See if attendee is already signed up for timeslot
if (
seatedBooking.attendees.find((attendee) => attendee.email === invitee[0].email) &&
seatedBooking.attendees.find((attendee) => {
return attendee.email === invitee[0].email;
}) &&
dayjs.utc(seatedBooking.startTime).format() === evt.startTime
) {
throw new HttpError({ statusCode: 409, message: ErrorCode.AlreadySignedUpForBooking });
@@ -119,6 +121,7 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
},
isNotConfirmed: evt.requiresConfirmation || false,
isRescheduleEvent: !!rescheduleUid,
isFirstRecurringEvent: true,
emailAttendeeSendToOverride: bookerEmail,
seatReferenceUid: evt.attendeeSeatId,
});
@@ -2,7 +2,7 @@
import { cloneDeep } from "lodash";
import type EventManager from "@calcom/core/EventManager";
import { sendRescheduledSeatEmail } from "@calcom/emails";
import { sendRescheduledSeatEmailAndSMS } from "@calcom/emails";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
import type { Person, CalendarEvent } from "@calcom/types/Calendar";
@@ -91,7 +91,7 @@ const attendeeRescheduleSeatedBooking = async (
await eventManager.updateCalendarAttendees(copyEvent, newTimeSlotBooking);
await sendRescheduledSeatEmail(copyEvent, seatAttendee as Person, eventType.metadata);
await sendRescheduledSeatEmailAndSMS(copyEvent, seatAttendee as Person, eventType.metadata);
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
return attendee.email !== bookerEmail;
});
@@ -3,7 +3,7 @@ import { cloneDeep } from "lodash";
import { uuid } from "short-uuid";
import type EventManager from "@calcom/core/EventManager";
import { sendRescheduledEmails } from "@calcom/emails";
import { sendRescheduledEmailsAndSMS } from "@calcom/emails";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
@@ -136,7 +136,7 @@ const combineTwoSeatedBookings = async (
if (noEmail !== true && isConfirmedByDefault) {
// TODO send reschedule emails to attendees of the old booking
loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats");
await sendRescheduledEmails(
await sendRescheduledEmailsAndSMS(
{
...copyEvent,
additionalNotes, // Resets back to the additionalNote input and not the override value
@@ -2,7 +2,7 @@
import { cloneDeep } from "lodash";
import type EventManager from "@calcom/core/EventManager";
import { sendRescheduledEmails } from "@calcom/emails";
import { sendRescheduledEmailsAndSMS } from "@calcom/emails";
import prisma from "@calcom/prisma";
import type { AdditionalInformation, AppsStatus } from "@calcom/types/Calendar";
@@ -90,7 +90,7 @@ const moveSeatedBookingToNewTimeSlot = async (
if (noEmail !== true && isConfirmedByDefault) {
const copyEvent = cloneDeep(evt);
loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats");
await sendRescheduledEmails(
await sendRescheduledEmailsAndSMS(
{
...copyEvent,
additionalNotes, // Resets back to the additionalNote input and not the override value
+1
View File
@@ -18,6 +18,7 @@ export type NewSeatedBookingObject = {
organizerUser: OrganizerUser;
originalRescheduledBooking: OriginalRescheduledBooking;
bookerEmail: string;
bookerPhoneNumber?: string | null;
tAttendees: TFunction;
bookingSeat: BookingSeat;
reqUserId: number | undefined;
@@ -4,7 +4,7 @@ import z from "zod";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { DailyLocationType } from "@calcom/core/location";
import { sendCancelledEmails } from "@calcom/emails";
import { sendCancelledEmailsAndSMS } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { deleteWebhookScheduledTriggers } from "@calcom/features/webhooks/lib/scheduleTrigger";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
@@ -244,6 +244,12 @@ const handleDeleteCredential = async ({
seatsPerTimeSlot: true,
seatsShowAttendees: true,
eventName: true,
team: {
select: {
id: true,
name: true,
},
},
metadata: true,
},
},
@@ -303,7 +309,7 @@ const handleDeleteCredential = async ({
const attendeesList = await Promise.all(attendeesListPromises);
const tOrganizer = await getTranslation(booking?.user?.locale ?? "en", "common");
await sendCancelledEmails(
await sendCancelledEmailsAndSMS(
{
type: booking?.eventType?.title as string,
title: booking.title,
@@ -333,6 +339,13 @@ const handleDeleteCredential = async ({
cancellationReason: "Payment method removed by organizer",
seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot,
seatsShowAttendees: booking.eventType?.seatsShowAttendees,
team: !!booking.eventType?.team
? {
name: booking.eventType.team.name,
id: booking.eventType.team.id,
members: [],
}
: undefined,
},
{
eventName: booking?.eventType?.eventName,
+2 -2
View File
@@ -5,7 +5,7 @@ import type Stripe from "stripe";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import EventManager from "@calcom/core/EventManager";
import { sendAttendeeRequestEmail, sendOrganizerRequestEmail } from "@calcom/emails";
import { sendAttendeeRequestEmailAndSMS, sendOrganizerRequestEmail } from "@calcom/emails";
import { doesBookingRequireConfirmation } from "@calcom/features/bookings/lib/doesBookingRequireConfirmation";
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
import { IS_PRODUCTION } from "@calcom/lib/constants";
@@ -108,7 +108,7 @@ const handleSetupSuccess = async (event: Stripe.Event) => {
});
} else {
await sendOrganizerRequestEmail({ ...evt }, eventType.metadata);
await sendAttendeeRequestEmail({ ...evt }, evt.attendees[0], eventType.metadata);
await sendAttendeeRequestEmailAndSMS({ ...evt }, evt.attendees[0], eventType.metadata);
}
};
@@ -5,7 +5,7 @@ import { OrganizerDefaultConferencingAppType, getLocationValueForDB } from "@cal
import EventManager from "@calcom/core/EventManager";
import { getEventName } from "@calcom/core/event";
import dayjs from "@calcom/dayjs";
import { sendRoundRobinCancelledEmails, sendRoundRobinScheduledEmails } from "@calcom/emails";
import { sendRoundRobinCancelledEmailsAndSMS, sendRoundRobinScheduledEmailsAndSMS } from "@calcom/emails";
import getBookingResponsesSchema from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { ensureAvailableUsers } from "@calcom/features/bookings/lib/handleNewBooking/ensureAvailableUsers";
@@ -202,6 +202,7 @@ export const roundRobinReassignment = async ({ bookingId }: { bookingId: number
name: attendee.name,
timeZone: attendee.timeZone,
language: { translate: tAttendee, locale: attendee.locale ?? "en" },
phoneNumber: attendee.phoneNumber || undefined,
}))
);
}
@@ -369,7 +370,7 @@ export const roundRobinReassignment = async ({ bookingId }: { bookingId: number
});
// Send to new RR host
await sendRoundRobinScheduledEmails(evt, [
await sendRoundRobinScheduledEmailsAndSMS(evt, [
{
...reassignedRRHost,
name: reassignedRRHost.name || "",
@@ -410,7 +411,7 @@ export const roundRobinReassignment = async ({ bookingId }: { bookingId: number
});
}
await sendRoundRobinCancelledEmails(
await sendRoundRobinCancelledEmailsAndSMS(
cancelledRRHostEvt,
[
{
@@ -38,6 +38,8 @@ export const sendSMS = async (
teamId?: number | null,
whatsapp = false
) => {
log.silly("sendSMS", JSON.stringify({ phoneNumber, body, sender, userId, teamId }));
const isSMSSendingLocked = await isLockedForSMSSending(userId, teamId);
if (isSMSSendingLocked) {
@@ -29,6 +29,7 @@ export type AttendeeInBookingInfo = {
firstName?: string;
lastName?: string;
email: string;
phoneNumber?: string | null;
timeZone: string;
language: { locale: string };
};
@@ -15,6 +15,11 @@ async function getEventType(eventTypeId: EventType["id"]) {
},
include: {
customInputs: true,
profile: {
select: {
organizationId: true,
},
},
workflows: {
select: {
workflow: {
@@ -29,9 +34,13 @@ async function getEventType(eventTypeId: EventType["id"]) {
throw new Error(`EventType:${eventTypeId} not found`);
}
const { profile, ...restEventType } = rawEventType;
const isOrgTeamEvent = !!rawEventType?.teamId && !!profile?.organizationId;
const eventType = {
...rawEventType,
bookingFields: getBookingFieldsWithSystemFields(rawEventType),
...restEventType,
bookingFields: getBookingFieldsWithSystemFields({ ...restEventType, isOrgTeamEvent }),
};
return eventType;
}
@@ -168,9 +168,10 @@ export type HandleInstantMeetingResponse = {
async function handler(req: NextApiRequest) {
let eventType = await getEventTypesFromDB(req.body.eventTypeId);
const isOrgTeamEvent = !!eventType?.team && !!eventType?.team?.parentId;
eventType = {
...eventType,
bookingFields: getBookingFieldsWithSystemFields(eventType),
bookingFields: getBookingFieldsWithSystemFields({ ...eventType, isOrgTeamEvent }),
};
if (!eventType.team?.id) {
+5 -2
View File
@@ -6,6 +6,7 @@ import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { WEBAPP_URL } from "./constants";
import getLabelValueMapFromResponses from "./getLabelValueMapFromResponses";
import isSmsCalEmail from "./isSmsCalEmail";
const translator = short();
@@ -39,9 +40,11 @@ export const getWho = (calEvent: CalendarEvent, t: TFunction) => {
.map((attendee) => {
return `
${attendee?.name || t("guest")}
${attendee.email}
`;
${!isSmsCalEmail(attendee.email) ? `${attendee.email}\n` : `${attendee.phoneNumber}\n`}
`;
})
.join("");
const organizer = `
+4 -2
View File
@@ -2,7 +2,7 @@
/// <reference path="../types/ical.d.ts"/>
import type { Prisma } from "@prisma/client";
import ICAL from "ical.js";
import type { Attendee, DateArray, DurationObject, Person } from "ics";
import type { Attendee, DateArray, DurationObject } from "ics";
import { createEvent } from "ics";
import type { DAVAccount, DAVCalendar, DAVObject } from "tsdav";
import {
@@ -18,6 +18,7 @@ import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import sanitizeCalendarObject from "@calcom/lib/sanitizeCalendarObject";
import type { Person as AttendeeInCalendarEvent } from "@calcom/types/Calendar";
import type {
Calendar,
CalendarEvent,
@@ -25,6 +26,7 @@ import type {
EventBusyDate,
IntegrationCalendar,
NewCalendarEventType,
TeamMember,
} from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
@@ -95,7 +97,7 @@ const getDuration = (start: string, end: string): DurationObject => ({
minutes: dayjs(end).diff(dayjs(start), "minute"),
});
const mapAttendees = (attendees: Person[]): Attendee[] =>
const mapAttendees = (attendees: AttendeeInCalendarEvent[] | TeamMember[]): Attendee[] =>
attendees.map(({ email, name }) => ({ name, email, partstat: "NEEDS-ACTION" }));
export default abstract class BaseCalendarService implements Calendar {
@@ -0,0 +1,4 @@
export const contructEmailFromPhoneNumber = (phoneNumber: string) => {
const cleanedPhoneNumber = phoneNumber.replace(/\+/g, "");
return `${cleanedPhoneNumber}@sms.cal.com`;
};
+2 -1
View File
@@ -194,11 +194,12 @@ export const getEventTypeById = async ({
});
}
const isOrgTeamEvent = !!eventType?.teamId && !!eventType.team?.parentId;
const eventTypeObject = Object.assign({}, eventType, {
users: eventTypeUsers,
periodStartDate: eventType.periodStartDate?.toString() ?? null,
periodEndDate: eventType.periodEndDate?.toString() ?? null,
bookingFields: getBookingFieldsWithSystemFields(eventType),
bookingFields: getBookingFieldsWithSystemFields({ ...eventType, isOrgTeamEvent }),
});
const isOrgEventType = !!eventTypeObject.team?.parentId;
+3
View File
@@ -0,0 +1,3 @@
export default function isSmsCalEmail(email: string) {
return email.endsWith("@sms.cal.com");
}
+9
View File
@@ -60,6 +60,8 @@ export async function getBooking(bookingId: number) {
bookingFields: true,
team: {
select: {
id: true,
name: true,
parentId: true,
},
},
@@ -152,6 +154,13 @@ export async function getBooking(bookingId: number) {
language: { translate: t, locale: user.locale ?? "en" },
id: user.id,
},
team: !!booking.eventType?.team
? {
name: booking.eventType.team.name,
id: booking.eventType.team.id,
members: [],
}
: undefined,
attendees: attendeesList,
location: booking.location,
uid: booking.uid,
+6 -3
View File
@@ -25,7 +25,8 @@ const handlePayment = async (
uid: string;
},
bookerName: string,
bookerEmail: string
bookerEmail: string,
bookerPhoneNumber?: string | null
) => {
const paymentApp = (await appStore[
paymentAppCredentials?.app?.dirName as keyof typeof appStore
@@ -50,8 +51,9 @@ const handlePayment = async (
currency: selectedEventType?.metadata?.apps?.[paymentAppCredentials.appId].currency,
},
booking.id,
paymentOption,
bookerEmail,
paymentOption
bookerPhoneNumber
);
} else {
paymentData = await paymentInstance.create(
@@ -63,8 +65,9 @@ const handlePayment = async (
booking.userId,
booking.user?.username ?? null,
bookerName,
bookerEmail,
paymentOption,
bookerEmail,
bookerPhoneNumber,
selectedEventType.title,
evt.title
);
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Prisma } from "@prisma/client";
import EventManager from "@calcom/core/EventManager";
import { sendScheduledEmails } from "@calcom/emails";
import { sendScheduledEmailsAndSMS } from "@calcom/emails";
import { doesBookingRequireConfirmation } from "@calcom/features/bookings/lib/doesBookingRequireConfirmation";
import { handleBookingRequested } from "@calcom/features/bookings/lib/handleBookingRequested";
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
@@ -76,7 +76,7 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number)
log.debug(`handling booking request for eventId ${eventType.id}`);
}
} else {
await sendScheduledEmails({ ...evt }, undefined, undefined, undefined, eventType.metadata);
await sendScheduledEmailsAndSMS({ ...evt }, undefined, undefined, undefined, eventType.metadata);
}
throw new HttpCode({
@@ -142,6 +142,8 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => {
const verifyCode = useVerifyCode({
onSuccess: () => {
if (!bookerForm.formEmail) return;
verifyEmail.setVerifiedEmail(bookerForm.formEmail);
verifyEmail.setEmailVerificationModalVisible(false);
bookings.handleBookEvent();
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Attendee" ADD COLUMN "phoneNumber" TEXT;
+1
View File
@@ -541,6 +541,7 @@ model Attendee {
email String
name String
timeZone String
phoneNumber String?
locale String? @default("en")
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
bookingId Int?
+1
View File
@@ -126,6 +126,7 @@ export type BookingFieldType = FormBuilderFieldType;
export const bookingResponses = z
.object({
email: z.string(),
attendeePhoneNumber: z.string().optional(),
//TODO: Why don't we move name out of bookingResponses and let it be handled like user fields?
name: z.union([
z.string(),
@@ -0,0 +1,20 @@
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import SMSManager from "../sms-manager";
export default class AwaitingPaymentSMS extends SMSManager {
constructor(calEvent: CalendarEvent) {
super(calEvent);
}
getMessage(attendee: Person) {
const t = attendee.language.translate;
return `${t("meeting_awaiting_payment")}: ${t("complete_your_booking_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(attendee.timeZone, attendee.language.locale),
})}. \n\n ${t("you_can_view_booking_details_with_this_url", {
url: `${this.calEvent.bookerUrl ?? WEBAPP_URL}/booking/${this.calEvent.uid}?changes=true`,
})} `;
}
}
@@ -0,0 +1,18 @@
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import SMSManager from "../sms-manager";
export default class CancelledSeatSMS extends SMSManager {
constructor(calEvent: CalendarEvent) {
super(calEvent);
}
getMessage(attendee: Person) {
return `${attendee.language.translate("no_longer_attending", {
name: attendee.name,
})}. \n\n${attendee.language.translate("event_no_longer_attending_subject", {
name: this.calEvent.team?.name || this.calEvent.organizer.name,
date: this.getFormattedDate(attendee.timeZone, attendee.language.locale),
})} `;
}
}
@@ -0,0 +1,23 @@
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import SMSManager from "../sms-manager";
export default class EventCancelledSMS extends SMSManager {
constructor(calEvent: CalendarEvent) {
super(calEvent);
}
getMessage(attendee: Person) {
const t = attendee.language.translate;
return `${t("hey_there")} ${attendee.name}, ${t("event_request_cancelled")}/n/n ${t(
"event_cancelled_subject",
{
title: this.calEvent.title,
date: this.getFormattedDate(attendee.timeZone, attendee.language.locale),
}
)}. /n/n ${t("visit_cancelled_booking")} ${this.calEvent.bookerUrl ?? WEBAPP_URL}/booking/${
this.calEvent.uid
} `;
}
}
@@ -0,0 +1,17 @@
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import SMSManager from "../sms-manager";
export default class EventDeclinedSMS extends SMSManager {
constructor(calEvent: CalendarEvent) {
super(calEvent);
}
getMessage(attendee: Person) {
const t = attendee.language.translate;
return `${t("hey_there")} ${attendee.name}, ${t("event_request_declined")} ${t("event_declined_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(attendee.timeZone, attendee.language.locale),
})}`;
}
}
@@ -0,0 +1,17 @@
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import SMSManager from "../sms-manager";
export default class EventLocationChangedSMS extends SMSManager {
constructor(calEvent: CalendarEvent) {
super(calEvent);
}
getMessage(attendee: Person) {
const t = attendee.language.translate;
return `${t("event_location_changed")}. \n\n ${t("you_can_view_booking_details_with_this_url", {
url: `${this.calEvent.bookerUrl ?? WEBAPP_URL}/booking/${this.calEvent.uid}?changes=true`,
})}`;
}
}
@@ -0,0 +1,21 @@
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import SMSManager from "../sms-manager";
export default class EventRequestSMS extends SMSManager {
constructor(calEvent: CalendarEvent) {
super(calEvent);
}
getMessage(attendee: Person) {
const t = attendee.language.translate;
return `${t("booking_submitted", {
name: attendee.name,
})}. ${t("user_needs_to_confirm_or_reject_booking", {
user: this.calEvent.organizer.name,
})}\n\n ${t("you_can_view_booking_details_with_this_url", {
url: `${this.calEvent.bookerUrl ?? WEBAPP_URL}/booking/${this.calEvent.uid}`,
})}`;
}
}
@@ -0,0 +1,19 @@
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import SMSManager from "../sms-manager";
export default class EventRequestToRescheduleSMS extends SMSManager {
constructor(calEvent: CalendarEvent) {
super(calEvent);
}
getMessage(attendee: Person) {
const t = attendee.language.translate;
return `${t("request_reschedule_booking")}: ${t("request_reschedule_subtitle", {
organizer: this.calEvent.organizer.name,
})} \n\n${t("need_to_reschedule_or_cancel")} ${this.calEvent.bookerUrl ?? WEBAPP_URL}/booking/${
this.calEvent.uid
}?changes=true`;
}
}
@@ -0,0 +1,20 @@
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import SMSManager from "../sms-manager";
export default class EventSuccessfullyReScheduledSMS extends SMSManager {
constructor(calEvent: CalendarEvent) {
super(calEvent);
}
getMessage(attendee: Person) {
const t = attendee.language.translate;
return `${t("hey_there")} ${attendee.name}, ${t("event_type_has_been_rescheduled_on_time_date", {
title: this.calEvent.title,
date: this.getFormattedDate(attendee.timeZone, attendee.language.locale),
})} \n\n ${t("you_can_view_booking_details_with_this_url", {
url: `${this.calEvent.bookerUrl ?? WEBAPP_URL}/booking/${this.calEvent.uid}`,
})}`;
}
}
@@ -0,0 +1,20 @@
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import SMSManager from "../sms-manager";
export default class EventSuccessfullyScheduledSMS extends SMSManager {
constructor(calEvent: CalendarEvent) {
super(calEvent);
}
getMessage(attendee: Person) {
const t = attendee.language.translate;
return `${t("confirming_your_booking_sms", {
name: attendee.name,
date: this.getFormattedDate(attendee.timeZone, attendee.language.locale),
})} \n\n ${t("you_can_view_booking_details_with_this_url", {
url: `${this.calEvent.bookerUrl ?? WEBAPP_URL}/booking/${this.calEvent.uid}`,
})}`;
}
}
+99
View File
@@ -0,0 +1,99 @@
import dayjs from "@calcom/dayjs";
import { getSenderId } from "@calcom/features/ee/workflows/lib/alphanumericSenderIdSupport";
import * as twilio from "@calcom/features/ee/workflows/lib/reminders/providers/twilioProvider";
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
import { SENDER_ID } from "@calcom/lib/constants";
import { TimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
const handleSendingSMS = ({
reminderPhone,
smsMessage,
senderID,
teamId,
}: {
reminderPhone: string;
smsMessage: string;
senderID: string;
teamId: number;
}) => {
return new Promise(async (resolve, reject) => {
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
select: {
parent: {
select: {
isOrganization: true,
},
},
},
});
if (!team?.parent?.isOrganization) return;
await checkSMSRateLimit({ identifier: `handleSendingSMS:team:${teamId}`, rateLimitingType: "sms" });
const sms = twilio.sendSMS(reminderPhone, smsMessage, senderID, teamId);
resolve(sms);
} catch (e) {
reject(console.error(`twilio.sendSMS failed`, e));
}
});
};
export default abstract class SMSManager {
calEvent: CalendarEvent;
isTeamEvent = false;
teamId: number | undefined = undefined;
constructor(calEvent: CalendarEvent) {
this.calEvent = calEvent;
this.teamId = this.calEvent?.team?.id;
this.isTeamEvent = !!this.calEvent?.team?.id;
}
getFormattedTime(
timezone: string,
locale: string,
time: string,
format = `dddd, LL | ${TimeFormat.TWELVE_HOUR}`
) {
return dayjs(time).tz(timezone).locale(locale).format(format);
}
getFormattedDate(timezone: string, locale: string) {
return `${this.getFormattedTime(timezone, locale, this.calEvent.startTime)} - ${this.getFormattedTime(
timezone,
locale,
this.calEvent.endTime
)} (${timezone})`;
}
abstract getMessage(attendee: Person): string;
async sendSMSToAttendee(attendee: Person) {
const teamId = this.teamId;
if (!this.isTeamEvent || !teamId) return;
const attendeePhoneNumber = attendee.phoneNumber;
if (!attendeePhoneNumber) return;
const smsMessage = this.getMessage(attendee);
const senderID = getSenderId(attendeePhoneNumber, SENDER_ID);
return handleSendingSMS({ reminderPhone: attendeePhoneNumber, smsMessage, senderID, teamId });
}
async sendSMSToAttendees() {
if (!this.isTeamEvent) return;
const smsToSend: Promise<unknown>[] = [];
for (const attendee of this.calEvent.attendees) {
smsToSend.push(this.sendSMSToAttendee(attendee));
}
await Promise.all(smsToSend);
}
}
@@ -1,4 +1,4 @@
import { sendScheduledEmails } from "@calcom/emails";
import { sendScheduledEmailsAndSMS } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { isPrismaObjOrUndefined } from "@calcom/lib";
import { getTranslation } from "@calcom/lib/server/i18n";
@@ -128,6 +128,12 @@ export const Handler = async ({ ctx, input }: Options) => {
metadata: true,
customInputs: true,
parentId: true,
team: {
select: {
id: true,
name: true,
},
},
},
},
location: true,
@@ -202,11 +208,18 @@ export const Handler = async ({ ctx, input }: Options) => {
requiresConfirmation: false,
eventTypeId: eventType?.id,
videoCallData,
team: !!updatedBooking.eventType?.team
? {
name: updatedBooking.eventType.team.name,
id: updatedBooking.eventType.team.id,
members: [],
}
: undefined,
};
const eventTypeMetadata = EventTypeMetaDataSchema.parse(updatedBooking?.eventType?.metadata);
await sendScheduledEmails(
await sendScheduledEmailsAndSMS(
{
...evt,
},
@@ -1,13 +1,13 @@
import { Prisma } from "@prisma/client";
import appStore from "@calcom/app-store";
import { getLocationValueForDB } from "@calcom/app-store/locations";
import type { LocationObject } from "@calcom/app-store/locations";
import { workflowSelect } from "@calcom/ee/workflows/lib/getAllWorkflows";
import { sendDeclinedEmails } from "@calcom/emails";
import { getLocationValueForDB } from "@calcom/app-store/locations";
import { sendDeclinedEmailsAndSMS } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
import { workflowSelect } from "@calcom/features/ee/workflows/lib/getAllWorkflows";
import type { EventPayloadType, EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
@@ -74,6 +74,8 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
locations: true,
team: {
select: {
id: true,
name: true,
parentId: true,
members: true,
},
@@ -165,6 +167,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
phoneNumber: attendee.phoneNumber,
language: {
translate,
locale,
@@ -217,6 +220,13 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
: [],
requiresConfirmation: booking?.eventType?.requiresConfirmation ?? false,
eventTypeId: booking.eventType?.id,
team: !!booking.eventType?.team
? {
name: booking.eventType.team.name,
id: booking.eventType.team.id,
members: [],
}
: undefined,
};
const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent);
@@ -371,7 +381,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
});
}
await sendDeclinedEmails(evt, booking.eventType?.metadata as EventTypeMetadata);
await sendDeclinedEmailsAndSMS(evt, booking.eventType?.metadata as EventTypeMetadata);
const teamId = await getTeamIdFromEventType({
eventType: {
@@ -3,7 +3,7 @@ import type { z } from "zod";
import { getEventLocationType, OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations";
import { getAppFromSlug } from "@calcom/app-store/utils";
import EventManager from "@calcom/core/EventManager";
import { sendLocationChangeEmails } from "@calcom/emails";
import { sendLocationChangeEmailsAndSMS } from "@calcom/emails";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import { buildCalEventFromBooking } from "@calcom/lib/buildCalEventFromBooking";
import logger from "@calcom/lib/logger";
@@ -277,7 +277,7 @@ export async function editLocationHandler({ ctx, input }: EditLocationOptions) {
await updateBookingLocationInDb({ booking, evt, referencesToCreate: updatedResult.referencesToCreate });
try {
await sendLocationChangeEmails(
await sendLocationChangeEmailsAndSMS(
{ ...evt, additionalInformation: extractAdditionalInformation(updatedResult.results[0]) },
booking?.eventType?.metadata as EventTypeMetadata
);
@@ -6,7 +6,7 @@ import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builde
import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director";
import { deleteMeeting } from "@calcom/core/videoClient";
import dayjs from "@calcom/dayjs";
import { sendRequestRescheduleEmail } from "@calcom/emails";
import { sendRequestRescheduleEmailAndSMS } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { deleteWebhookScheduledTriggers } from "@calcom/features/webhooks/lib/scheduleTrigger";
@@ -58,6 +58,8 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
include: {
team: {
select: {
id: true,
name: true,
parentId: true,
},
},
@@ -170,6 +172,7 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
username: user?.username || "",
language: { translate: selectedLanguage, locale: user.locale || "en" },
timeZone: user?.timeZone,
phoneNumber: user.phoneNumber,
};
});
};
@@ -198,6 +201,13 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
),
organizer,
iCalUID: bookingToReschedule.iCalUID,
team: !!bookingToReschedule.eventType?.team
? {
name: bookingToReschedule.eventType.team.name,
id: bookingToReschedule.eventType.team.id,
members: [],
}
: undefined,
});
const director = new CalendarEventDirector();
@@ -243,7 +253,7 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
log.debug("builder.calendarEvent", safeStringify(builder.calendarEvent));
// Send emails
await sendRequestRescheduleEmail(
await sendRequestRescheduleEmailAndSMS(
builder.calendarEvent,
{
rescheduleLink: builder.rescheduleLink,
@@ -4,7 +4,7 @@ import { z } from "zod";
export type PersonAttendeeCommonFields = Pick<
User,
"id" | "email" | "name" | "locale" | "timeZone" | "username"
>;
> & { phoneNumber?: string | null };
// Common data for all endpoints under webhook
export const commonBookingSchema = z.object({
@@ -1,16 +1,15 @@
import {
MembershipRole,
type Attendee,
type Booking,
type BookingReference,
type Credential,
type DestinationCalendar,
type EventType,
type User,
import type {
Booking,
EventType,
BookingReference,
Attendee,
Credential,
DestinationCalendar,
User,
} from "@prisma/client";
import { prisma } from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
@@ -25,7 +24,17 @@ export const bookingsProcedure = authedProcedure
const loggedInUser = ctx.user;
const bookingInclude = {
attendees: true,
eventType: true,
eventType: {
include: {
team: {
select: {
id: true,
name: true,
parentId: true,
},
},
},
},
destinationCalendar: true,
references: true,
user: {
@@ -92,7 +101,11 @@ export const bookingsProcedure = authedProcedure
export type BookingsProcedureContext = {
booking: Booking & {
eventType: EventType | null;
eventType:
| (EventType & {
team?: { id: number; name: string; parentId?: number | null } | null;
})
| null;
destinationCalendar: DestinationCalendar | null;
user:
| (User & {
@@ -23,6 +23,7 @@ import type { TUpdateInputSchema } from "./update.schema";
import {
addWeightAdjustmentToNewHosts,
ensureUniqueBookingFields,
ensureEmailOrPhoneNumberIsPresent,
handleCustomInputs,
handlePeriodType,
} from "./util";
@@ -141,6 +142,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
const teamId = input.teamId || eventType.team?.id;
ensureUniqueBookingFields(bookingFields);
ensureEmailOrPhoneNumberIsPresent(bookingFields);
const data: Prisma.EventTypeUpdateInput = {
...rest,
@@ -161,6 +161,31 @@ export function ensureUniqueBookingFields(fields: z.infer<typeof EventTypeUpdate
}, {} as Record<string, true>);
}
export function ensureEmailOrPhoneNumberIsPresent(
fields: z.infer<typeof EventTypeUpdateInput>["bookingFields"]
) {
if (!fields || fields.length === 0) {
return;
}
const attendeePhoneNumberField = fields.find((field) => field.name === "attendeePhoneNumber");
const emailField = fields.find((field) => field.name === "email");
if (emailField?.hidden && attendeePhoneNumberField?.hidden) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Both Email and Attendee Phone Number cannot be hidden`,
});
}
if (!emailField?.required && !attendeePhoneNumberField?.required) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `At least Email or Attendee Phone Number need to be required field.`,
});
}
}
type Host = {
userId: number;
isFixed?: boolean | undefined;
@@ -284,6 +309,7 @@ export async function addWeightAdjustmentToNewHosts({
return hostsWithWeightAdjustments;
}
export const mapEventType = async (eventType: EventType) => ({
...eventType,
safeDescription: eventType?.description ? markdownToSafeHTML(eventType.description) : undefined,
@@ -282,7 +282,9 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType
}
break;
case WorkflowActions.EMAIL_ATTENDEE:
sendTo = bookingInfo.attendees.map((attendee) => attendee.email);
sendTo = bookingInfo.attendees
.map((attendee) => attendee.email)
.filter((email): email is string => !!email);
break;
case WorkflowActions.EMAIL_ADDRESS:
sendTo = step.sendTo ? [step.sendTo] : [];
+2
View File
@@ -36,12 +36,14 @@ export type Person = {
locale?: string | null;
timeFormat?: TimeFormat;
bookingSeat?: BookingSeat | null;
phoneNumber?: string | null;
};
export type TeamMember = {
id?: number;
name: string;
email: string;
phoneNumber?: string | null;
timeZone: string;
language: { translate: TFunction; locale: string };
};
+4 -2
View File
@@ -20,8 +20,9 @@ export interface IAbstractPaymentService {
userId: Booking["userId"],
username: string | null,
bookerName: string | null,
bookerEmail: string,
paymentOption: PaymentOption,
bookerEmail: string,
bookerPhoneNumber?: string | null,
eventTitle?: string,
bookingTitle?: string
): Promise<Payment>;
@@ -29,8 +30,9 @@ export interface IAbstractPaymentService {
collectCard(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: Booking["id"],
paymentOption: PaymentOption,
bookerEmail: string,
paymentOption: PaymentOption
bookerPhoneNumber?: string | null
): Promise<Payment>;
chargeCard(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
@@ -13,6 +13,7 @@ import { Icon } from "../..";
type Attendee = {
id: number;
email: string;
phoneNumber?: string | null;
name: string;
timeZone: string;
locale: string | null;