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