From 4a59841b02ecbd614dcce110d09cf4f3a559a126 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Thu, 12 Sep 2024 06:26:04 +0530 Subject: [PATCH] feat: booking with phone number (#14461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Omar López Co-authored-by: Joe Au-Yeung Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> --- .../components/booking/BookingListItem.tsx | 42 +- apps/web/lib/booking.ts | 19 +- .../bookings/views/bookings-single-view.tsx | 12 +- apps/web/playwright/lib/testUtils.ts | 8 +- .../playwright/organization/booking.e2e.ts | 155 +++- apps/web/public/static/locales/en/common.json | 6 +- .../bookingScenario/MockPaymentService.ts | 4 +- .../utils/bookingScenario/bookingScenario.ts | 108 ++- .../web/test/utils/bookingScenario/expects.ts | 80 +- .../getMockRequestDataForBooking.ts | 1 + .../app-store/paypal/lib/PaymentService.ts | 4 +- .../stripepayment/lib/PaymentService.ts | 20 +- .../app-store/stripepayment/lib/customer.ts | 8 +- packages/app-store/vital/lib/reschedule.ts | 31 +- .../wipemycalother/lib/reschedule.ts | 32 +- packages/emails/email-manager.ts | 106 ++- packages/emails/src/components/WhoInfo.tsx | 20 +- packages/emails/templates/_base-email.ts | 6 + packages/features/bookings/Booker/Booker.tsx | 2 +- .../components/hooks/useInitialFormValues.ts | 4 +- packages/features/bookings/lib/SystemField.ts | 1 + .../features/bookings/lib/getBookingFields.ts | 29 +- .../lib/getBookingResponsesSchema.test.ts | 2 +- .../bookings/lib/getBookingResponsesSchema.ts | 14 +- .../bookings/lib/getCalEventResponses.ts | 12 + .../features/bookings/lib/getUserBooking.ts | 1 + .../bookings/lib/handleBookingRequested.ts | 5 +- .../bookings/lib/handleCancelBooking.ts | 16 +- .../bookings/lib/handleConfirmation.ts | 5 +- .../features/bookings/lib/handleNewBooking.ts | 76 +- .../lib/handleNewBooking/createBooking.ts | 2 + .../lib/handleNewBooking/getBookingData.ts | 2 + .../handleNewBooking/getEventTypesFromDB.ts | 12 +- .../getOriginalRescheduledBooking.ts | 1 + .../handleNewBooking/test/reschedule.test.ts | 214 ++++- .../collective-scheduling.test.ts | 755 ++++++++++++++++-- .../bookings/lib/handleNewBooking/types.ts | 1 + .../handleSeats/cancel/cancelAttendeeSeat.ts | 4 +- .../lib/handleSeats/create/createNewSeat.ts | 9 +- .../bookings/lib/handleSeats/handleSeats.ts | 5 +- .../attendeeRescheduleSeatedBooking.ts | 4 +- .../owner/combineTwoSeatedBookings.ts | 4 +- .../owner/moveSeatedBookingToNewTimeSlot.ts | 4 +- .../bookings/lib/handleSeats/types.d.ts | 1 + .../credentials/handleDeleteCredential.ts | 17 +- packages/features/ee/payments/api/webhook.ts | 4 +- .../ee/round-robin/roundRobinReassignment.ts | 7 +- .../lib/reminders/providers/twilioProvider.ts | 2 + .../lib/reminders/smsReminderManager.ts | 1 + .../eventtypes/lib/bookingFieldsManager.ts | 13 +- .../instant-meeting/handleInstantMeeting.ts | 3 +- packages/lib/CalEventParser.ts | 7 +- packages/lib/CalendarService.ts | 6 +- packages/lib/contructEmailFromPhoneNumber.ts | 4 + packages/lib/event-types/getEventTypeById.ts | 3 +- packages/lib/isSmsCalEmail.ts | 3 + packages/lib/payment/getBooking.ts | 9 + packages/lib/payment/handlePayment.ts | 9 +- packages/lib/payment/handlePaymentSuccess.ts | 4 +- .../atoms/booker/BookerWebWrapper.tsx | 2 + .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + packages/prisma/zod-utils.ts | 1 + packages/sms/attendee/awaiting-payment-sms.ts | 20 + packages/sms/attendee/cancelled-seat-sms.ts | 18 + packages/sms/attendee/event-cancelled-sms.ts | 23 + packages/sms/attendee/event-declined-sms.ts | 17 + .../attendee/event-location-changed-sms.ts | 17 + packages/sms/attendee/event-request-sms.ts | 21 + .../event-request-to-reschedule-sms.ts | 19 + .../sms/attendee/event-rescheduled-sms.ts | 20 + packages/sms/attendee/event-scheduled-sms.ts | 20 + packages/sms/sms-manager.ts | 99 +++ .../loggedInViewer/connectAndJoin.handler.ts | 17 +- .../viewer/bookings/confirm.handler.ts | 18 +- .../viewer/bookings/editLocation.handler.ts | 4 +- .../bookings/requestReschedule.handler.ts | 14 +- .../server/routers/viewer/bookings/types.ts | 2 +- .../server/routers/viewer/bookings/util.ts | 37 +- .../viewer/eventTypes/update.handler.ts | 2 + .../server/routers/viewer/eventTypes/util.ts | 26 + .../workflows/activateEventType.handler.ts | 4 +- packages/types/Calendar.d.ts | 2 + packages/types/PaymentService.d.ts | 6 +- .../popover/MeetingTimeInTimezones.tsx | 1 + 85 files changed, 2078 insertions(+), 274 deletions(-) create mode 100644 packages/lib/contructEmailFromPhoneNumber.ts create mode 100644 packages/lib/isSmsCalEmail.ts create mode 100644 packages/prisma/migrations/20240408155446_add_phone_number_in_attendee/migration.sql create mode 100644 packages/sms/attendee/awaiting-payment-sms.ts create mode 100644 packages/sms/attendee/cancelled-seat-sms.ts create mode 100644 packages/sms/attendee/event-cancelled-sms.ts create mode 100644 packages/sms/attendee/event-declined-sms.ts create mode 100644 packages/sms/attendee/event-location-changed-sms.ts create mode 100644 packages/sms/attendee/event-request-sms.ts create mode 100644 packages/sms/attendee/event-request-to-reschedule-sms.ts create mode 100644 packages/sms/attendee/event-rescheduled-sms.ts create mode 100644 packages/sms/attendee/event-scheduled-sms.ts create mode 100644 packages/sms/sms-manager.ts diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 69c61e2cbd..8076a673e6 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -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) => { - - { - setOpenDropdown(false); - e.stopPropagation(); - }}> - {t("email")} - - + {!isSmsCalEmail(email) && ( + + { + setOpenDropdown(false); + e.stopPropagation(); + }}> + {t("email")} + + + )} + { 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")} + {isBookingInPast && ( {noShow ? ( { + 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) => { { + e.preventDefault(); setOpenDropdown(false); toggleNoShow({ attendee: { noShow: true, email }, bookingUid }); - e.preventDefault(); }} StartIcon="eye-off"> {t("mark_as_no_show")} diff --git a/apps/web/lib/booking.ts b/apps/web/lib/booking.ts index c9033fa5aa..81060f76f4 100644 --- a/apps/web/lib/booking.ts +++ b/apps/web/lib/booking.ts @@ -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 { diff --git a/apps/web/modules/bookings/views/bookings-single-view.tsx b/apps/web/modules/bookings/views/bookings-single-view.tsx index 56a9d2ec40..5079bfa7ff 100644 --- a/apps/web/modules/bookings/views/bookings-single-view.tsx +++ b/apps/web/modules/bookings/views/bookings-single-view.tsx @@ -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 && (

{attendee.name}

)} -

{attendee.email}

+ {attendee.phoneNumber && ( +

+ {attendee.phoneNumber} +

+ )} + {!isSmsCalEmail(attendee.email) && ( +

{attendee.email}

+ )} ))} diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index 7420bb5c40..2cdfb451cf 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -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"); }; diff --git a/apps/web/playwright/organization/booking.e2e.ts b/apps/web/playwright/organization/booking.e2e.ts index 8864798ef8..684cccfc0a 100644 --- a/apps/web/playwright/organization/booking.e2e.ts +++ b/apps/web/playwright/organization/booking.e2e.ts @@ -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); +}; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 0834ae2293..b8028e7790 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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.", diff --git a/apps/web/test/utils/bookingScenario/MockPaymentService.ts b/apps/web/test/utils/bookingScenario/MockPaymentService.ts index 2eef10444a..a69c90b431 100644 --- a/apps/web/test/utils/bookingScenario/MockPaymentService.ts +++ b/apps/web/test/utils/bookingScenario/MockPaymentService.ts @@ -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 { // TODO: App implementing PaymentService is supposed to send email by itself at the moment. - await sendAwaitingPaymentEmail({ + await sendAwaitingPaymentEmailAndSMS({ ...event, paymentInfo: { link: createPaymentLink(/*{ diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 5a98727ae0..7f35c254c9 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -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; + 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, "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: Omit & { 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 `${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; +}; diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index 48f6f818e0..901b2c7687 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -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 }, diff --git a/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts b/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts index 9a5581c6c8..bf64ffc9d0 100644 --- a/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts +++ b/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts @@ -33,6 +33,7 @@ export function getMockRequestDataForBooking({ email: string; name: string; location?: { optionValue: ""; value: string }; + attendeePhoneNumber?: string; smsReminderNumber?: string; }; }; diff --git a/packages/app-store/paypal/lib/PaymentService.ts b/packages/app-store/paypal/lib/PaymentService.ts index ce3b1862bb..23cb891af3 100644 --- a/packages/app-store/paypal/lib/PaymentService.ts +++ b/packages/app-store/paypal/lib/PaymentService.ts @@ -105,8 +105,8 @@ export class PaymentService implements IAbstractPaymentService { async collectCard( payment: Pick, bookingId: number, - _bookerEmail: string, - paymentOption: PaymentOption + paymentOption: PaymentOption, + _bookerEmail?: string | null ): Promise { // Ensure that the payment service can support the passed payment option if (paymentOptionEnum.parse(paymentOption) !== "HOLD") { diff --git a/packages/app-store/stripepayment/lib/PaymentService.ts b/packages/app-store/stripepayment/lib/PaymentService.ts index 89c78ddb85..d3c3a3816e 100644 --- a/packages/app-store/stripepayment/lib/PaymentService.ts +++ b/packages/app-store/stripepayment/lib/PaymentService.ts @@ -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, bookingId: Booking["id"], + paymentOption: PaymentOption, bookerEmail: string, - paymentOption: PaymentOption + bookerPhoneNumber?: string | null ): Promise { 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 { - await sendAwaitingPaymentEmail( + await sendAwaitingPaymentEmailAndSMS( { ...event, paymentInfo: { diff --git a/packages/app-store/stripepayment/lib/customer.ts b/packages/app-store/stripepayment/lib/customer.ts index 3d68140aa5..78fbfef3bb 100644 --- a/packages/app-store/stripepayment/lib/customer.ts +++ b/packages/app-store/stripepayment/lib/customer.ts @@ -83,7 +83,11 @@ export async function deleteStripeCustomer(user: UserType): Promise; +type PersonAttendeeCommonFields = Pick & { + 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, diff --git a/packages/app-store/wipemycalother/lib/reschedule.ts b/packages/app-store/wipemycalother/lib/reschedule.ts index 91f3da7140..a0740bc0ab 100644 --- a/packages/app-store/wipemycalother/lib/reschedule.ts +++ b/packages/app-store/wipemycalother/lib/reschedule.ts @@ -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; +type PersonAttendeeCommonFields = Pick & { + 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, diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 85a2a2c219..226be2c67b 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -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[] = []; + const emailsAndSMSToSend: Promise[] = []; + 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[] = []; - for (const teamMember of members) { - emailsToSend.push( - sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent, teamMember })) + const calendarEvent = formatCalEvent(calEvent); + const emailsAndSMSToSend: Promise[] = []; + 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[] = []; + const emailsAndSMSToSend: Promise[] = []; + 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[] = []; @@ -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, 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); diff --git a/packages/emails/src/components/WhoInfo.tsx b/packages/emails/src/components/WhoInfo.tsx index c710028d0b..ec852d9120 100644 --- a/packages/emails/src/components/WhoInfo.tsx +++ b/packages/emails/src/components/WhoInfo.tsx @@ -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 = "" }) => (
- {name} - {role}{" "} - - - {email} - - + {name} - {role} {phoneNumber} + {!isSmsCalEmail(email) && ( + + + {email} + + + )}
); @@ -28,7 +31,7 @@ export function WhoInfo(props: { calEvent: CalendarEvent; t: TFunction }) { email={props.calEvent.organizer.email} /> {props.calEvent.team?.members.map((member) => ( - + ))} {props.calEvent.attendees.map((attendee) => ( ))} diff --git a/packages/emails/templates/_base-email.ts b/packages/emails/templates/_base-email.ts index d5574f1e54..74c3a6e6f7 100644 --- a/packages/emails/templates/_base-email.ts +++ b/packages/emails/templates/_base-email.ts @@ -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); diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index bbc798d44d..1155156f5f 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -176,7 +176,7 @@ const BookerComponent = ({ isVerificationCodeSending={isVerificationCodeSending} isPlatform={isPlatform}> <> - {verifyCode ? ( + {verifyCode && formEmail ? ( []; metadata: EventType["metadata"] | z.infer; @@ -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[]; @@ -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 = [ diff --git a/packages/features/bookings/lib/getBookingResponsesSchema.test.ts b/packages/features/bookings/lib/getBookingResponsesSchema.test.ts index 8e0529773a..0e5d21503c 100644 --- a/packages/features/bookings/lib/getBookingResponsesSchema.test.ts +++ b/packages/features/bookings/lib/getBookingResponsesSchema.test.ts @@ -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, }) ); diff --git a/packages/features/bookings/lib/getBookingResponsesSchema.ts b/packages/features/bookings/lib/getBookingResponsesSchema.ts index 6260bbc126..9e0a565b13 100644 --- a/packages/features/bookings/lib/getBookingResponsesSchema.ts +++ b/packages/features/bookings/lib/getBookingResponsesSchema.ts @@ -117,6 +117,18 @@ function preprocess({ // 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({ 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"), diff --git a/packages/features/bookings/lib/getCalEventResponses.ts b/packages/features/bookings/lib/getCalEventResponses.ts index 51625e8826..29e1e508ed 100644 --- a/packages/features/bookings/lib/getCalEventResponses.ts +++ b/packages/features/bookings/lib/getCalEventResponses.ts @@ -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], diff --git a/packages/features/bookings/lib/getUserBooking.ts b/packages/features/bookings/lib/getUserBooking.ts index 71468a45cc..1a6d50e6ed 100644 --- a/packages/features/bookings/lib/getUserBooking.ts +++ b/packages/features/bookings/lib/getUserBooking.ts @@ -39,6 +39,7 @@ const getUserBooking = async (uid: string) => { name: true, email: true, timeZone: true, + phoneNumber: true, }, }, eventTypeId: true, diff --git a/packages/features/bookings/lib/handleBookingRequested.ts b/packages/features/bookings/lib/handleBookingRequested.ts index 9252315690..5c3c321fc3 100644 --- a/packages/features/bookings/lib/handleBookingRequested.ts +++ b/packages/features/bookings/lib/handleBookingRequested.ts @@ -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 diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index a54a98f4ca..a309a96fdc 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -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 diff --git a/packages/features/bookings/lib/handleConfirmation.ts b/packages/features/bookings/lib/handleConfirmation.ts index 373a0b6807..0c15709420 100644 --- a/packages/features/bookings/lib/handleConfirmation.ts +++ b/packages/features/bookings/lib/handleConfirmation.ts @@ -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; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index a7ab6b65bd..066bb65ef2 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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, diff --git a/packages/features/bookings/lib/handleNewBooking/createBooking.ts b/packages/features/bookings/lib/handleNewBooking/createBooking.ts index 8246e194b9..f36244586b 100644 --- a/packages/features/bookings/lib/handleNewBooking/createBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking/createBooking.ts @@ -148,6 +148,7 @@ function getAttendeesData(evt: Pick) { 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) { name: member.name, timeZone: member.timeZone, locale: member.language.locale, + phoneNumber: member.phoneNumber, })) ); } diff --git a/packages/features/bookings/lib/handleNewBooking/getBookingData.ts b/packages/features/bookings/lib/handleNewBooking/getBookingData.ts index 316630a1b8..1a2fffa542 100644 --- a/packages/features/bookings/lib/handleNewBooking/getBookingData.ts +++ b/packages/features/bookings/lib/handleNewBooking/getBookingData.ts @@ -51,6 +51,7 @@ export async function getBookingData({ calEventUserFieldsResponses: undefined, calEventResponses: undefined, customInputs: undefined, + attendeePhoneNumber: undefined, }; } if (!reqBody.responses) { @@ -67,6 +68,7 @@ export async function getBookingData({ ...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, diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index ccd0841b78..80f5dd52f5 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -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, }; }; diff --git a/packages/features/bookings/lib/handleNewBooking/getOriginalRescheduledBooking.ts b/packages/features/bookings/lib/handleNewBooking/getOriginalRescheduledBooking.ts index 55930a095f..021541a037 100644 --- a/packages/features/bookings/lib/handleNewBooking/getOriginalRescheduledBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking/getOriginalRescheduledBooking.ts @@ -18,6 +18,7 @@ export async function getOriginalRescheduledBooking(uid: string, seatsEventType? email: true, locale: true, timeZone: true, + phoneNumber: true, ...(seatsEventType && { bookingSeat: true, id: true }), }, }, diff --git a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts index e6b106662d..678dc7c641 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts @@ -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: { diff --git a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts index ecb0986394..65caaf31a6 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts @@ -1,6 +1,6 @@ -import { createOrganization } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; import { createBookingScenario, + createOrganization, getGoogleCalendarCredential, TestData, getOrganizer, @@ -13,6 +13,7 @@ import { getExpectedCalEventForBookingRequest, BookingLocations, getZoomAppCredential, + getDefaultBookingFields, } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest"; import { @@ -22,18 +23,23 @@ import { expectBookingCreatedWebhookToHaveBeenFired, expectSuccessfulCalendarEventCreationInCalendar, expectSuccessfulVideoMeetingCreation, + expectSMSToBeTriggered, + expectBookingRequestedEmails, + expectBookingRequestedWebhookToHaveBeenFired, } from "@calcom/web/test/utils/bookingScenario/expects"; import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; import type { Request, Response } from "express"; import type { NextApiRequest, NextApiResponse } from "next"; -import { describe, expect } from "vitest"; +import { describe, expect, beforeEach } from "vitest"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations"; import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants"; +import { contructEmailFromPhoneNumber } from "@calcom/lib/contructEmailFromPhoneNumber"; import { ErrorCode } from "@calcom/lib/errorCodes"; +import { resetTestSMS } from "@calcom/lib/testSMS"; import { SchedulingType } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; import { test } from "@calcom/web/test/fixtures/fixtures"; @@ -46,6 +52,10 @@ const timeout = process.env.CI ? 5000 : 20000; describe("handleNewBooking", () => { setupAndTeardown(); + beforeEach(() => { + resetTestSMS(); + }); + describe("Team Events", () => { describe("Collective Assignment", () => { describe("When there is no schedule set on eventType - Hosts schedules would be used", () => { @@ -374,7 +384,6 @@ describe("handleNewBooking", () => { email: "booker@example.com", name: "Booker", }); - const otherTeamMembers = [ { name: "Other Team Member 1", @@ -393,7 +402,6 @@ describe("handleNewBooking", () => { }, }, ]; - const organizer = getOrganizer({ name: "Organizer", email: "organizer@example.com", @@ -408,7 +416,6 @@ describe("handleNewBooking", () => { externalId: "organizer@google-calendar.com", }, }); - await createBookingScenario( getScenarioData({ webhooks: [ @@ -449,6 +456,232 @@ describe("handleNewBooking", () => { }) ); + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + const mockBookingData = getMockRequestDataForBooking({ + data: { + // Try booking the first available free timeslot in both the users' schedules + start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + const createdBooking = await handleNewBooking(req); + await expectBookingToBeInDatabase({ + description: "", + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + type: TestData.apps["google-calendar"].type, + uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], + }); + // expectWorkflowToBeTriggered(); + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { + destinationCalendars: [ + { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + ], + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }); + expectSuccessfulBookingCreationEmails({ + booking: { + uid: createdBooking.uid!, + }, + booker, + organizer, + otherTeamMembers, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`, + }); + }, + timeout + ); + test( + `[Event Type with both Attendee Phone number and Email as required fields] succesfully creates a booking when the users are available as per the common schedule selected in the event-type + - Destination calendars for event-type and non-first hosts are used to create calendar events + `, + async ({ emails, sms }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const org = await createOrganization({ + name: "Test Org", + slug: "testorg", + }); + const TEST_ATTENDEE_NUMBER = "+918888888888"; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + attendeePhoneNumber: TEST_ATTENDEE_NUMBER, + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + defaultScheduleId: null, + email: "other-team-member-1@example.com", + id: 102, + schedules: [TestData.schedules.IstEveningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + defaultScheduleId: null, + organizationId: org.id, + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + teams: [ + { + membership: { + accepted: true, + }, + team: { + id: 1, + name: "Team 1", + slug: "team-1", + parentId: org.id, + }, + }, + ], + }); + + await createBookingScenario( + getScenarioData( + { + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + teamId: 1, + slotInterval: 15, + schedulingType: SchedulingType.COLLECTIVE, + length: 15, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + // Both Email and Attendee Phone Number Fields are required + 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", + }, + ], + }), + // Common schedule is the morning shift + schedule: TestData.schedules.IstMorningShift, + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }, + { id: org.id } + ) + ); + mockSuccessfulVideoMeetingCreation({ metadataLookupKey: appStoreMetadata.dailyvideo.dirName, videoMeetingData: { @@ -474,6 +707,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, + attendeePhoneNumber: booker.attendeePhoneNumber, location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, @@ -529,10 +763,11 @@ describe("handleNewBooking", () => { ], videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", }); - + const WEBSITE_PROTOCOL = new URL(WEBSITE_URL).protocol; expectSuccessfulBookingCreationEmails({ booking: { uid: createdBooking.uid!, + urlOrigin: `${WEBSITE_PROTOCOL}//team-1.cal.local:3000`, }, booker, organizer, @@ -548,6 +783,454 @@ describe("handleNewBooking", () => { subscriberUrl: "http://my-webhook.example.com", videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`, }); + + expectSMSToBeTriggered({ sms, toNumber: TEST_ATTENDEE_NUMBER }); + }, + timeout + ); + + test( + `[Event Type with only Attendee Phone number as required field and Email as hidden field] succesfully creates a booking when the users are available as per the common schedule selected in the event-type + - Destination calendars for event-type and non-first hosts are used to create calendar events + `, + async ({ emails, sms }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const org = await createOrganization({ + name: "Test Org", + slug: "testorg", + }); + + const TEST_ATTENDEE_NUMBER = "+918888888888"; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + attendeePhoneNumber: TEST_ATTENDEE_NUMBER, + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + defaultScheduleId: null, + email: "other-team-member-1@example.com", + id: 102, + schedules: [TestData.schedules.IstEveningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + defaultScheduleId: null, + organizationId: org.id, + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + teams: [ + { + membership: { + accepted: true, + }, + team: { + id: 1, + name: "Team 1", + slug: "team-1", + parentId: org.id, + }, + }, + ], + }); + + await createBookingScenario( + getScenarioData( + { + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + teamId: 1, + slotInterval: 15, + schedulingType: SchedulingType.COLLECTIVE, + length: 15, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + // Both Email and Attendee Phone Number Fields are required + bookingFields: getDefaultBookingFields({ + emailField: { + name: "email", + type: "email", + label: "", + hidden: true, + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: false, + 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", + }, + ], + }), + // Common schedule is the morning shift + schedule: TestData.schedules.IstMorningShift, + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }, + { id: org.id } + ) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + // Try booking the first available free timeslot in both the users' schedules + start: `${getDate({ dateIncrement: 1 }).dateString}T09:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T09:15:00.000Z`, + eventTypeId: 1, + 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); + + await expectBookingToBeInDatabase({ + description: "", + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + attendeePhoneNumber: booker.attendeePhoneNumber, + name: booker.name, + }), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + type: TestData.apps["google-calendar"].type, + uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], + }); + + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { + destinationCalendars: [ + { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + ], + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }); + + const WEBSITE_PROTOCOL = new URL(WEBSITE_URL).protocol; + expectSuccessfulBookingCreationEmails({ + booking: { + uid: createdBooking.uid!, + urlOrigin: `${WEBSITE_PROTOCOL}//team-1.cal.local:3000`, + }, + booker: { email: contructEmailFromPhoneNumber(TEST_ATTENDEE_NUMBER), name: booker.name }, + organizer, + otherTeamMembers, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker: { email: contructEmailFromPhoneNumber(TEST_ATTENDEE_NUMBER), name: booker.name }, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`, + isEmailHidden: true, + isAttendeePhoneNumberHidden: false, + }); + + expectSMSToBeTriggered({ sms, toNumber: TEST_ATTENDEE_NUMBER }); + }, + timeout + ); + test( + `[Event Type that requires confirmation with only Attendee Phone number as required field and Email as optional field] succesfully creates a booking when the users are available as per the common schedule selected in the event-type + - Destination calendars for event-type and non-first hosts are used to create calendar events + `, + async ({ emails, sms }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const org = await createOrganization({ + name: "Test Org", + slug: "testorg", + }); + const subscriberUrl = "http://my-webhook.example.com"; + const TEST_ATTENDEE_NUMBER = "+918888888888"; + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + attendeePhoneNumber: TEST_ATTENDEE_NUMBER, + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + defaultScheduleId: null, + email: "other-team-member-1@example.com", + id: 102, + schedules: [TestData.schedules.IstEveningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + defaultScheduleId: null, + organizationId: org.id, + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + teams: [ + { + membership: { + accepted: true, + }, + team: { + id: 1, + name: "Team 1", + slug: "team-1", + parentId: org.id, + }, + }, + ], + }); + + const scenarioData = getScenarioData( + { + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + teamId: 1, + slotInterval: 15, + requiresConfirmation: true, + schedulingType: SchedulingType.COLLECTIVE, + length: 15, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + // Both Email and Attendee Phone Number Fields are required + bookingFields: getDefaultBookingFields({ + emailField: { + name: "email", + type: "email", + label: "", + hidden: true, + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: false, + 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", + }, + ], + }), + // Common schedule is the morning shift + schedule: TestData.schedules.IstMorningShift, + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }, + { id: org.id } + ); + await createBookingScenario(scenarioData); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + // Try booking the first available free timeslot in both the users' schedules + start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`, + eventTypeId: 1, + // No Email Passed + responses: { + name: booker.name, + email: "", + attendeePhoneNumber: booker.attendeePhoneNumber, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + + await expectBookingToBeInDatabase({ + description: "", + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + attendeePhoneNumber: booker.attendeePhoneNumber, + name: booker.name, + }), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.PENDING, + }); + + expectBookingRequestedEmails({ + organizer, + emails, + }); + + expectBookingRequestedWebhookToHaveBeenFired({ + booker: { name: booker.name, email: contructEmailFromPhoneNumber(TEST_ATTENDEE_NUMBER) }, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl, + eventType: scenarioData.eventTypes[0], + isEmailHidden: true, + }); + + expectSMSToBeTriggered({ sms, toNumber: TEST_ATTENDEE_NUMBER }); }, timeout ); @@ -560,7 +1243,6 @@ describe("handleNewBooking", () => { email: "booker@example.com", name: "Booker", }); - const otherTeamMembers = [ { name: "Other Team Member 1", @@ -579,7 +1261,6 @@ describe("handleNewBooking", () => { }, }, ]; - const organizer = getOrganizer({ name: "Organizer", email: "organizer@example.com", @@ -594,7 +1275,6 @@ describe("handleNewBooking", () => { externalId: "organizer@google-calendar.com", }, }); - await createBookingScenario( getScenarioData({ webhooks: [ @@ -633,7 +1313,6 @@ describe("handleNewBooking", () => { apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], }) ); - mockSuccessfulVideoMeetingCreation({ metadataLookupKey: appStoreMetadata.dailyvideo.dirName, videoMeetingData: { @@ -642,14 +1321,12 @@ describe("handleNewBooking", () => { url: `http://mock-dailyvideo.example.com/meeting-1`, }, }); - mockCalendarToHaveNoBusySlots("googlecalendar", { create: { id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }, }); - const mockBookingData = getMockRequestDataForBooking({ data: { start: `${getDate({ dateIncrement: 1 }).dateString}T03:30:00.000Z`, @@ -662,12 +1339,10 @@ describe("handleNewBooking", () => { }, }, }); - const { req } = createMockNextJsRequest({ method: "POST", body: mockBookingData, }); - await expect(async () => { await handleNewBooking(req); }).rejects.toThrowError(ErrorCode.NoAvailableUsersFound); @@ -684,7 +1359,6 @@ describe("handleNewBooking", () => { email: "booker@example.com", name: "Booker", }); - const otherTeamMembers = [ { name: "Other Team Member 1", @@ -702,7 +1376,6 @@ describe("handleNewBooking", () => { }, }, ]; - const organizer = getOrganizer({ name: "Organizer", email: "organizer@example.com", @@ -716,7 +1389,6 @@ describe("handleNewBooking", () => { externalId: "organizer@google-calendar.com", }, }); - const { eventTypes } = await createBookingScenario( getScenarioData({ webhooks: [ @@ -754,7 +1426,6 @@ describe("handleNewBooking", () => { apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], }) ); - const videoMock = mockSuccessfulVideoMeetingCreation({ metadataLookupKey: appStoreMetadata.dailyvideo.dirName, videoMeetingData: { @@ -763,14 +1434,12 @@ describe("handleNewBooking", () => { url: `http://mock-dailyvideo.example.com/meeting-1`, }, }); - const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { create: { id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }, }); - const mockBookingData = getMockRequestDataForBooking({ data: { start: `${getDate({ dateIncrement: 1 }).dateString}T05:00:00.000Z`, @@ -783,14 +1452,11 @@ describe("handleNewBooking", () => { }, }, }); - const { req } = createMockNextJsRequest({ method: "POST", body: mockBookingData, }); - const createdBooking = await handleNewBooking(req); - await expectBookingToBeInDatabase({ description: "", location: BookingLocations.CalVideo, @@ -819,7 +1485,6 @@ describe("handleNewBooking", () => { }, ], }); - // expectWorkflowToBeTriggered(); expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { destinationCalendars: [ @@ -834,7 +1499,6 @@ describe("handleNewBooking", () => { ], videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", }); - expectSuccessfulVideoMeetingCreation(videoMock, { credential: expect.objectContaining({ appId: "daily-video", @@ -849,7 +1513,6 @@ describe("handleNewBooking", () => { }) ), }); - expectSuccessfulBookingCreationEmails({ booking: { uid: createdBooking.uid!, @@ -860,7 +1523,6 @@ describe("handleNewBooking", () => { emails, iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }); - expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, @@ -880,7 +1542,6 @@ describe("handleNewBooking", () => { email: "booker@example.com", name: "Booker", }); - const otherTeamMembers = [ { name: "Other Team Member 1", @@ -904,7 +1565,6 @@ describe("handleNewBooking", () => { }, }, ]; - const organizer = getOrganizer({ name: "Organizer", email: "organizer@example.com", @@ -926,7 +1586,6 @@ describe("handleNewBooking", () => { externalId: "organizer@google-calendar.com", }, }); - const { eventTypes } = await createBookingScenario( getScenarioData({ webhooks: [ @@ -970,7 +1629,6 @@ describe("handleNewBooking", () => { apps: [TestData.apps["google-calendar"], TestData.apps["zoomvideo"]], }) ); - const videoMock = mockSuccessfulVideoMeetingCreation({ metadataLookupKey: "zoomvideo", videoMeetingData: { @@ -979,14 +1637,12 @@ describe("handleNewBooking", () => { url: `http://mock-zoomvideo.example.com/meeting-1`, }, }); - const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { create: { id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }, }); - const mockBookingData = getMockRequestDataForBooking({ data: { start: `${getDate({ dateIncrement: 1 }).dateString}T05:00:00.000Z`, @@ -999,14 +1655,11 @@ describe("handleNewBooking", () => { }, }, }); - const { req } = createMockNextJsRequest({ method: "POST", body: mockBookingData, }); - const createdBooking = await handleNewBooking(req); - await expectBookingToBeInDatabase({ description: "", location: BookingLocations.ZoomVideo, @@ -1034,7 +1687,6 @@ describe("handleNewBooking", () => { }, ], }); - // expectWorkflowToBeTriggered(); expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { destinationCalendars: [ @@ -1049,7 +1701,6 @@ describe("handleNewBooking", () => { ], videoCallUrl: "http://mock-zoomvideo.example.com/meeting-1", }); - expectSuccessfulVideoMeetingCreation(videoMock, { credential: expect.objectContaining({ appId: TestData.apps.zoomvideo.slug, @@ -1066,7 +1717,6 @@ describe("handleNewBooking", () => { }) ), }); - expectSuccessfulBookingCreationEmails({ booking: { uid: createdBooking.uid!, @@ -1077,7 +1727,6 @@ describe("handleNewBooking", () => { emails, iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }); - expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, @@ -1097,7 +1746,6 @@ describe("handleNewBooking", () => { email: "booker@example.com", name: "Booker", }); - const otherTeamMembers = [ { name: "Other Team Member 1", @@ -1121,7 +1769,6 @@ describe("handleNewBooking", () => { }, }, ]; - const organizer = getOrganizer({ name: "Organizer", email: "organizer@example.com", @@ -1144,7 +1791,6 @@ describe("handleNewBooking", () => { externalId: "organizer@google-calendar.com", }, }); - const { eventTypes } = await createBookingScenario( getScenarioData({ webhooks: [ @@ -1191,7 +1837,6 @@ describe("handleNewBooking", () => { ], }) ); - mockSuccessfulVideoMeetingCreation({ metadataLookupKey: appStoreMetadata.dailyvideo.dirName, videoMeetingData: { @@ -1200,14 +1845,12 @@ describe("handleNewBooking", () => { url: `http://mock-dailyvideo.example.com/meeting-1`, }, }); - const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { create: { id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }, }); - const mockBookingData = getMockRequestDataForBooking({ data: { start: `${getDate({ dateIncrement: 1 }).dateString}T05:00:00.000Z`, @@ -1220,14 +1863,11 @@ describe("handleNewBooking", () => { }, }, }); - const { req } = createMockNextJsRequest({ method: "POST", body: mockBookingData, }); - const createdBooking = await handleNewBooking(req); - await expectBookingToBeInDatabase({ description: "", location: BookingLocations.CalVideo, @@ -1257,8 +1897,6 @@ describe("handleNewBooking", () => { ], }); - // expectWorkflowToBeTriggered(); - expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { destinationCalendars: [ { @@ -1272,7 +1910,6 @@ describe("handleNewBooking", () => { ], videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", }); - expectSuccessfulBookingCreationEmails({ booking: { uid: createdBooking.uid!, @@ -1283,7 +1920,6 @@ describe("handleNewBooking", () => { emails, iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }); - expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, @@ -1307,12 +1943,10 @@ describe("handleNewBooking", () => { name: "Test Org", slug: "testorg", }); - const booker = getBooker({ email: "booker@example.com", name: "Booker", }); - const otherTeamMembers = [ { name: "Other Team Member 1", @@ -1332,7 +1966,6 @@ describe("handleNewBooking", () => { }, }, ]; - const organizer = getOrganizer({ name: "Organizer", email: "organizer@example.com", @@ -1361,7 +1994,6 @@ describe("handleNewBooking", () => { externalId: "organizer@google-calendar.com", }, }); - await createBookingScenario( getScenarioData({ webhooks: [ @@ -1401,7 +2033,6 @@ describe("handleNewBooking", () => { apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], }) ); - mockSuccessfulVideoMeetingCreation({ metadataLookupKey: appStoreMetadata.dailyvideo.dirName, videoMeetingData: { @@ -1410,14 +2041,12 @@ describe("handleNewBooking", () => { url: `http://mock-dailyvideo.example.com/meeting-1`, }, }); - const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { create: { id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }, }); - const mockBookingData = getMockRequestDataForBooking({ data: { // Try booking the first available free timeslot in both the users' schedules @@ -1431,14 +2060,11 @@ describe("handleNewBooking", () => { }, }, }); - const { req } = createMockNextJsRequest({ method: "POST", body: mockBookingData, }); - const createdBooking = await handleNewBooking(req); - await expectBookingToBeInDatabase({ description: "", location: BookingLocations.CalVideo, @@ -1467,7 +2093,6 @@ describe("handleNewBooking", () => { }, ], }); - // expectWorkflowToBeTriggered(); expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { destinationCalendars: [ @@ -1482,7 +2107,6 @@ describe("handleNewBooking", () => { ], videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", }); - expectSuccessfulBookingCreationEmails({ booking: { uid: createdBooking.uid!, @@ -1495,7 +2119,6 @@ describe("handleNewBooking", () => { emails, iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }); - expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, diff --git a/packages/features/bookings/lib/handleNewBooking/types.ts b/packages/features/bookings/lib/handleNewBooking/types.ts index 2b42e2de13..315b2606d0 100644 --- a/packages/features/bookings/lib/handleNewBooking/types.ts +++ b/packages/features/bookings/lib/handleNewBooking/types.ts @@ -37,6 +37,7 @@ export type Invitee = { firstName: string; lastName: string; timeZone: string; + phoneNumber?: string; language: { translate: TFunction; locale: string; diff --git a/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts b/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts index 2f77deaded..dd164370e9 100644 --- a/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts +++ b/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts @@ -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, diff --git a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts index 6bedcc2f2e..ef12b0255b 100644 --- a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts +++ b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts @@ -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 }; diff --git a/packages/features/bookings/lib/handleSeats/handleSeats.ts b/packages/features/bookings/lib/handleSeats/handleSeats.ts index 1a342abee3..7c83472709 100644 --- a/packages/features/bookings/lib/handleSeats/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats/handleSeats.ts @@ -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, }); diff --git a/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts index c3a8369f79..8eb88a60aa 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts @@ -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; }); diff --git a/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts b/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts index 8b009e7ee0..5d1132f475 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts @@ -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 diff --git a/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts b/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts index 42222aa929..524fc243f3 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts @@ -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 diff --git a/packages/features/bookings/lib/handleSeats/types.d.ts b/packages/features/bookings/lib/handleSeats/types.d.ts index 4390ff7414..ef639438c6 100644 --- a/packages/features/bookings/lib/handleSeats/types.d.ts +++ b/packages/features/bookings/lib/handleSeats/types.d.ts @@ -18,6 +18,7 @@ export type NewSeatedBookingObject = { organizerUser: OrganizerUser; originalRescheduledBooking: OriginalRescheduledBooking; bookerEmail: string; + bookerPhoneNumber?: string | null; tAttendees: TFunction; bookingSeat: BookingSeat; reqUserId: number | undefined; diff --git a/packages/features/credentials/handleDeleteCredential.ts b/packages/features/credentials/handleDeleteCredential.ts index c5343fdf7c..ebb46b8296 100644 --- a/packages/features/credentials/handleDeleteCredential.ts +++ b/packages/features/credentials/handleDeleteCredential.ts @@ -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, diff --git a/packages/features/ee/payments/api/webhook.ts b/packages/features/ee/payments/api/webhook.ts index 3992966b8d..2474b010dd 100644 --- a/packages/features/ee/payments/api/webhook.ts +++ b/packages/features/ee/payments/api/webhook.ts @@ -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); } }; diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts index b3223467fa..98f3bc8adb 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -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, [ { diff --git a/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts b/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts index 99cc6c8c21..806aeb3375 100644 --- a/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts +++ b/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts @@ -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) { diff --git a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts index 3bff4f7b1f..08fab335bd 100644 --- a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts @@ -29,6 +29,7 @@ export type AttendeeInBookingInfo = { firstName?: string; lastName?: string; email: string; + phoneNumber?: string | null; timeZone: string; language: { locale: string }; }; diff --git a/packages/features/eventtypes/lib/bookingFieldsManager.ts b/packages/features/eventtypes/lib/bookingFieldsManager.ts index f9a4656f1d..780005747c 100644 --- a/packages/features/eventtypes/lib/bookingFieldsManager.ts +++ b/packages/features/eventtypes/lib/bookingFieldsManager.ts @@ -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; } diff --git a/packages/features/instant-meeting/handleInstantMeeting.ts b/packages/features/instant-meeting/handleInstantMeeting.ts index bc3c4076c4..0e45ad6ba0 100644 --- a/packages/features/instant-meeting/handleInstantMeeting.ts +++ b/packages/features/instant-meeting/handleInstantMeeting.ts @@ -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) { diff --git a/packages/lib/CalEventParser.ts b/packages/lib/CalEventParser.ts index 9dfdea42ee..8a03f029f9 100644 --- a/packages/lib/CalEventParser.ts +++ b/packages/lib/CalEventParser.ts @@ -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 = ` diff --git a/packages/lib/CalendarService.ts b/packages/lib/CalendarService.ts index 5aeca7ee1b..d32c98af9b 100644 --- a/packages/lib/CalendarService.ts +++ b/packages/lib/CalendarService.ts @@ -2,7 +2,7 @@ /// 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 { diff --git a/packages/lib/contructEmailFromPhoneNumber.ts b/packages/lib/contructEmailFromPhoneNumber.ts new file mode 100644 index 0000000000..63d73a4e38 --- /dev/null +++ b/packages/lib/contructEmailFromPhoneNumber.ts @@ -0,0 +1,4 @@ +export const contructEmailFromPhoneNumber = (phoneNumber: string) => { + const cleanedPhoneNumber = phoneNumber.replace(/\+/g, ""); + return `${cleanedPhoneNumber}@sms.cal.com`; +}; diff --git a/packages/lib/event-types/getEventTypeById.ts b/packages/lib/event-types/getEventTypeById.ts index 935284697b..cc661b058e 100644 --- a/packages/lib/event-types/getEventTypeById.ts +++ b/packages/lib/event-types/getEventTypeById.ts @@ -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; diff --git a/packages/lib/isSmsCalEmail.ts b/packages/lib/isSmsCalEmail.ts new file mode 100644 index 0000000000..e4f3ccbfc9 --- /dev/null +++ b/packages/lib/isSmsCalEmail.ts @@ -0,0 +1,3 @@ +export default function isSmsCalEmail(email: string) { + return email.endsWith("@sms.cal.com"); +} diff --git a/packages/lib/payment/getBooking.ts b/packages/lib/payment/getBooking.ts index e5b7a5d667..8e1a93ff42 100644 --- a/packages/lib/payment/getBooking.ts +++ b/packages/lib/payment/getBooking.ts @@ -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, diff --git a/packages/lib/payment/handlePayment.ts b/packages/lib/payment/handlePayment.ts index 2288dff7aa..a2093051e0 100644 --- a/packages/lib/payment/handlePayment.ts +++ b/packages/lib/payment/handlePayment.ts @@ -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 ); diff --git a/packages/lib/payment/handlePaymentSuccess.ts b/packages/lib/payment/handlePaymentSuccess.ts index 1e5eaa8fcf..84db4bd57b 100644 --- a/packages/lib/payment/handlePaymentSuccess.ts +++ b/packages/lib/payment/handlePaymentSuccess.ts @@ -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({ diff --git a/packages/platform/atoms/booker/BookerWebWrapper.tsx b/packages/platform/atoms/booker/BookerWebWrapper.tsx index d89e42787f..ac3a272e24 100644 --- a/packages/platform/atoms/booker/BookerWebWrapper.tsx +++ b/packages/platform/atoms/booker/BookerWebWrapper.tsx @@ -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(); diff --git a/packages/prisma/migrations/20240408155446_add_phone_number_in_attendee/migration.sql b/packages/prisma/migrations/20240408155446_add_phone_number_in_attendee/migration.sql new file mode 100644 index 0000000000..0f9f527cf3 --- /dev/null +++ b/packages/prisma/migrations/20240408155446_add_phone_number_in_attendee/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Attendee" ADD COLUMN "phoneNumber" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 7c48c344c0..3a6c9d3747 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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? diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 6e8995dad6..e212a94556 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -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(), diff --git a/packages/sms/attendee/awaiting-payment-sms.ts b/packages/sms/attendee/awaiting-payment-sms.ts new file mode 100644 index 0000000000..931d915246 --- /dev/null +++ b/packages/sms/attendee/awaiting-payment-sms.ts @@ -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`, + })} `; + } +} diff --git a/packages/sms/attendee/cancelled-seat-sms.ts b/packages/sms/attendee/cancelled-seat-sms.ts new file mode 100644 index 0000000000..35d9d033f0 --- /dev/null +++ b/packages/sms/attendee/cancelled-seat-sms.ts @@ -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), + })} `; + } +} diff --git a/packages/sms/attendee/event-cancelled-sms.ts b/packages/sms/attendee/event-cancelled-sms.ts new file mode 100644 index 0000000000..b24b369e1a --- /dev/null +++ b/packages/sms/attendee/event-cancelled-sms.ts @@ -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 + } `; + } +} diff --git a/packages/sms/attendee/event-declined-sms.ts b/packages/sms/attendee/event-declined-sms.ts new file mode 100644 index 0000000000..3b14099e9c --- /dev/null +++ b/packages/sms/attendee/event-declined-sms.ts @@ -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), + })}`; + } +} diff --git a/packages/sms/attendee/event-location-changed-sms.ts b/packages/sms/attendee/event-location-changed-sms.ts new file mode 100644 index 0000000000..0dab9aa173 --- /dev/null +++ b/packages/sms/attendee/event-location-changed-sms.ts @@ -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`, + })}`; + } +} diff --git a/packages/sms/attendee/event-request-sms.ts b/packages/sms/attendee/event-request-sms.ts new file mode 100644 index 0000000000..2341b435a2 --- /dev/null +++ b/packages/sms/attendee/event-request-sms.ts @@ -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}`, + })}`; + } +} diff --git a/packages/sms/attendee/event-request-to-reschedule-sms.ts b/packages/sms/attendee/event-request-to-reschedule-sms.ts new file mode 100644 index 0000000000..5b99d43b59 --- /dev/null +++ b/packages/sms/attendee/event-request-to-reschedule-sms.ts @@ -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`; + } +} diff --git a/packages/sms/attendee/event-rescheduled-sms.ts b/packages/sms/attendee/event-rescheduled-sms.ts new file mode 100644 index 0000000000..02d8b8361e --- /dev/null +++ b/packages/sms/attendee/event-rescheduled-sms.ts @@ -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}`, + })}`; + } +} diff --git a/packages/sms/attendee/event-scheduled-sms.ts b/packages/sms/attendee/event-scheduled-sms.ts new file mode 100644 index 0000000000..73c91f846f --- /dev/null +++ b/packages/sms/attendee/event-scheduled-sms.ts @@ -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}`, + })}`; + } +} diff --git a/packages/sms/sms-manager.ts b/packages/sms/sms-manager.ts new file mode 100644 index 0000000000..b08e32f0d6 --- /dev/null +++ b/packages/sms/sms-manager.ts @@ -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[] = []; + + for (const attendee of this.calEvent.attendees) { + smsToSend.push(this.sendSMSToAttendee(attendee)); + } + + await Promise.all(smsToSend); + } +} diff --git a/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts b/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts index 99dea2cc96..9d658e4fe5 100644 --- a/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts @@ -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, }, diff --git a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts index b0011a32c8..1bd0203b64 100644 --- a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @@ -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: { diff --git a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts index cb6ae56738..3bf85bbf7d 100644 --- a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts @@ -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 ); diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts index dbb77d48a3..d1e78ee570 100644 --- a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts @@ -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, diff --git a/packages/trpc/server/routers/viewer/bookings/types.ts b/packages/trpc/server/routers/viewer/bookings/types.ts index 1cca8e0ef0..7596d28ac4 100644 --- a/packages/trpc/server/routers/viewer/bookings/types.ts +++ b/packages/trpc/server/routers/viewer/bookings/types.ts @@ -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({ diff --git a/packages/trpc/server/routers/viewer/bookings/util.ts b/packages/trpc/server/routers/viewer/bookings/util.ts index fdf5ad7b88..de6f54305c 100644 --- a/packages/trpc/server/routers/viewer/bookings/util.ts +++ b/packages/trpc/server/routers/viewer/bookings/util.ts @@ -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 & { diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts index f1ceb7ec47..91ed0377cb 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -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, diff --git a/packages/trpc/server/routers/viewer/eventTypes/util.ts b/packages/trpc/server/routers/viewer/eventTypes/util.ts index b464229f51..d58bbdc83e 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/util.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/util.ts @@ -161,6 +161,31 @@ export function ensureUniqueBookingFields(fields: z.infer); } +export function ensureEmailOrPhoneNumberIsPresent( + fields: z.infer["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, diff --git a/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts b/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts index 0379145e48..d42b291653 100644 --- a/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts @@ -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] : []; diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index 494442484f..8e0d1c6d40 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -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 }; }; diff --git a/packages/types/PaymentService.d.ts b/packages/types/PaymentService.d.ts index da9d1869cb..6e316eeb19 100644 --- a/packages/types/PaymentService.d.ts +++ b/packages/types/PaymentService.d.ts @@ -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; @@ -29,8 +30,9 @@ export interface IAbstractPaymentService { collectCard( payment: Pick, bookingId: Booking["id"], + paymentOption: PaymentOption, bookerEmail: string, - paymentOption: PaymentOption + bookerPhoneNumber?: string | null ): Promise; chargeCard( payment: Pick, diff --git a/packages/ui/components/popover/MeetingTimeInTimezones.tsx b/packages/ui/components/popover/MeetingTimeInTimezones.tsx index 5c429d0874..af711ee836 100644 --- a/packages/ui/components/popover/MeetingTimeInTimezones.tsx +++ b/packages/ui/components/popover/MeetingTimeInTimezones.tsx @@ -13,6 +13,7 @@ import { Icon } from "../.."; type Attendee = { id: number; email: string; + phoneNumber?: string | null; name: string; timeZone: string; locale: string | null;