Feature/reschedule bookings (#2351)

* WIP bookings page ui changes, created api endpoint

* Ui changes mobile/desktop

* Added translations

* Fix lib import and common names

* WIP reschedule

* WIP

* Save wip

* [WIP] builder and class for CalendarEvent, email for attende

* update rescheduled emails, booking view and availability page view

* Working version reschedule

* Fix for req.user as array

* Added missing translation and refactor dialog to self component

* Test for reschedule

* update on types

* Update lib no required

* Update type on createBooking

* fix types

* remove preview stripe sub

* remove unused file

* remove unused import

* Fix reschedule test

* Refactor and cleaning up code

* Email reschedule title fixes

* Adding calendar delete and recreate placeholder of cancelled

* Add translation

* Removed logs, notes, fixed types

* Fixes process.env types

* Use strict compare

* Fixes type inference

* Type fixing is my middle name

* Update apps/web/components/booking/BookingListItem.tsx

* Update apps/web/components/dialog/RescheduleDialog.tsx

* Update packages/core/builders/CalendarEvent/director.ts

* Update apps/web/pages/success.tsx

* Updates rescheduling labels

* Update packages/core/builders/CalendarEvent/builder.ts

* Type fixes

* Update packages/core/builders/CalendarEvent/builder.ts

* Only validating input blocked once

* E2E fixes

* Stripe tests fixes

Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
alannnc 2022-04-14 15:25:24 -06:00 committed by GitHub
parent 6bb4b2e938
commit 3c6ac395cc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 2129 additions and 241 deletions

@ -1 +1 @@
Subproject commit a1dcfa59bc43d3f71af62ae438f96a667e807913 Subproject commit 378cbf8f3a67ea7877296f1da02edb2b6e3efbce

View file

@ -1,18 +1,21 @@
import { BanIcon, CheckIcon, ClockIcon, XIcon } from "@heroicons/react/outline"; import { BanIcon, CheckIcon, ClockIcon, XIcon } from "@heroicons/react/outline";
import { PaperAirplaneIcon } from "@heroicons/react/outline";
import { BookingStatus } from "@prisma/client"; import { BookingStatus } from "@prisma/client";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useState } from "react"; import { useState } from "react";
import { useMutation } from "react-query"; import { useMutation } from "react-query";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button"; import Button from "@calcom/ui/Button";
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog"; import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
import { TextArea } from "@calcom/ui/form/fields"; import { TextArea } from "@calcom/ui/form/fields";
import { HttpError } from "@lib/core/http/error"; import { HttpError } from "@lib/core/http/error";
import { useLocale } from "@lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@lib/trpc"; import { inferQueryOutput, trpc } from "@lib/trpc";
import { useMeQuery } from "@components/Shell"; import { useMeQuery } from "@components/Shell";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
import TableActions, { ActionType } from "@components/ui/TableActions"; import TableActions, { ActionType } from "@components/ui/TableActions";
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number]; type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
@ -80,15 +83,42 @@ function BookingListItem(booking: BookingItem) {
{ {
id: "reschedule", id: "reschedule",
label: t("reschedule"), label: t("reschedule"),
href: `/reschedule/${booking.uid}`,
icon: ClockIcon, icon: ClockIcon,
actions: [
{
id: "edit",
label: t("reschedule_booking"),
href: `/reschedule/${booking.uid}`,
},
{
id: "reschedule_request",
label: t("send_reschedule_request"),
onClick: () => setIsOpenRescheduleDialog(true),
},
],
}, },
]; ];
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY"); const RequestSentMessage = () => {
return (
<div className="ml-1 mr-8 flex text-gray-500" data-testid="request_reschedule_sent">
<PaperAirplaneIcon className="-mt-[1px] w-4 rotate-45" />
<p className="ml-2 ">{t("reschedule_request_sent")}</p>
</div>
);
};
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
return ( return (
<> <>
<RescheduleDialog
isOpenDialog={isOpenRescheduleDialog}
setIsOpenDialog={setIsOpenRescheduleDialog}
bookingUId={booking.uid}
/>
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}> <Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
<DialogContent> <DialogContent>
<DialogHeader title={t("rejection_reason_title")} /> <DialogHeader title={t("rejection_reason_title")} />
@ -146,7 +176,10 @@ function BookingListItem(booking: BookingItem) {
</div> </div>
<div <div
title={booking.title} title={booking.title}
className="max-w-56 truncate text-sm font-medium leading-6 text-neutral-900 md:max-w-max"> className={classNames(
"max-w-56 truncate text-sm font-medium leading-6 text-neutral-900 md:max-w-max",
isCancelled ? "line-through" : ""
)}>
{booking.eventType?.team && <strong>{booking.eventType.team.name}: </strong>} {booking.eventType?.team && <strong>{booking.eventType.team.name}: </strong>}
{booking.title} {booking.title}
{!!booking?.eventType?.price && !booking.paid && ( {!!booking?.eventType?.price && !booking.paid && (
@ -161,11 +194,17 @@ function BookingListItem(booking: BookingItem) {
&quot;{booking.description}&quot; &quot;{booking.description}&quot;
</div> </div>
)} )}
{booking.attendees.length !== 0 && ( {booking.attendees.length !== 0 && (
<div className="text-sm text-gray-900 hover:text-blue-500"> <div className="text-sm text-gray-900 hover:text-blue-500">
<a href={"mailto:" + booking.attendees[0].email}>{booking.attendees[0].email}</a> <a href={"mailto:" + booking.attendees[0].email}>{booking.attendees[0].email}</a>
</div> </div>
)} )}
{isCancelled && booking.rescheduled && (
<div className="mt-2 inline-block text-left text-sm md:hidden">
<RequestSentMessage />
</div>
)}
</td> </td>
<td className="whitespace-nowrap py-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4"> <td className="whitespace-nowrap py-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4">
@ -180,6 +219,11 @@ function BookingListItem(booking: BookingItem) {
)} )}
</> </>
) : null} ) : null}
{isCancelled && booking.rescheduled && (
<div className="hidden h-full items-center md:flex">
<RequestSentMessage />
</div>
)}
</td> </td>
</tr> </tr>
</> </>

View file

@ -1,11 +1,13 @@
// Get router variables // Get router variables
import { import {
ArrowLeftIcon, ArrowLeftIcon,
CalendarIcon,
ChevronDownIcon, ChevronDownIcon,
ChevronUpIcon, ChevronUpIcon,
ClockIcon, ClockIcon,
CreditCardIcon, CreditCardIcon,
GlobeIcon, GlobeIcon,
InformationCircleIcon,
} from "@heroicons/react/solid"; } from "@heroicons/react/solid";
import * as Collapsible from "@radix-ui/react-collapsible"; import * as Collapsible from "@radix-ui/react-collapsible";
import { useContracts } from "contexts/contractsContext"; import { useContracts } from "contexts/contractsContext";
@ -18,14 +20,15 @@ import { FormattedNumber, IntlProvider } from "react-intl";
import { useEmbedStyles, useIsEmbed, useIsBackgroundTransparent, sdkActionManager } from "@calcom/embed-core"; import { useEmbedStyles, useIsEmbed, useIsBackgroundTransparent, sdkActionManager } from "@calcom/embed-core";
import classNames from "@calcom/lib/classNames"; import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { asStringOrNull } from "@lib/asStringOrNull"; import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock"; import { timeZone } from "@lib/clock";
import { BASE_URL } from "@lib/config/constants"; import { BASE_URL } from "@lib/config/constants";
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally"; import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme"; import useTheme from "@lib/hooks/useTheme";
import { isBrandingHidden } from "@lib/isBrandingHidden"; import { isBrandingHidden } from "@lib/isBrandingHidden";
import { parseDate } from "@lib/parseDate";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { detectBrowserTimeFormat } from "@lib/timeFormat"; import { detectBrowserTimeFormat } from "@lib/timeFormat";
@ -45,12 +48,12 @@ dayjs.extend(customParseFormat);
type Props = AvailabilityTeamPageProps | AvailabilityPageProps; type Props = AvailabilityTeamPageProps | AvailabilityPageProps;
const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage }: Props) => { const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage, booking }: Props) => {
const router = useRouter(); const router = useRouter();
const isEmbed = useIsEmbed(); const isEmbed = useIsEmbed();
const { rescheduleUid } = router.query; const { rescheduleUid } = router.query;
const { isReady, Theme } = useTheme(profile.theme); const { isReady, Theme } = useTheme(profile.theme);
const { t } = useLocale(); const { t, i18n } = useLocale();
const { contracts } = useContracts(); const { contracts } = useContracts();
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker"); const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
let isBackgroundTransparent = useIsBackgroundTransparent(); let isBackgroundTransparent = useIsBackgroundTransparent();
@ -179,15 +182,21 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
/> />
<div className="mt-4 sm:-mt-2"> <div className="mt-4 sm:-mt-2">
<p className="text-sm font-medium text-black dark:text-white">{profile.name}</p> <p className="text-sm font-medium text-black dark:text-white">{profile.name}</p>
<div className="text-bookingmedian flex gap-2 text-xs font-medium dark:text-gray-100"> <div className="mt-2 flex gap-2 text-xl font-medium dark:text-gray-100">
{eventType.title} {eventType.title}
{eventType?.description && (
<p className="mb-2 text-gray-600 dark:text-white">
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
{eventType.description}
</p>
)}
<div> <div>
<ClockIcon className="mr-1 -mt-1 inline-block h-4 w-4" /> <ClockIcon className="mr-[10px] -mt-1 inline-block h-4 w-4" />
{eventType.length} {t("minutes")} {eventType.length} {t("minutes")}
</div> </div>
{eventType.price > 0 && ( {eventType.price > 0 && (
<div> <div className="text-gray-600 dark:text-white">
<CreditCardIcon className="mr-1 -mt-1 inline-block h-4 w-4" /> <CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 dark:text-gray-400" />
<IntlProvider locale="en"> <IntlProvider locale="en">
<FormattedNumber <FormattedNumber
value={eventType.price / 100.0} value={eventType.price / 100.0}
@ -200,7 +209,6 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
</div> </div>
</div> </div>
</div> </div>
<p className="mt-3 text-gray-600 dark:text-gray-200">{eventType.description}</p>
</div> </div>
<div className="px-4 sm:flex sm:p-4 sm:py-5"> <div className="px-4 sm:flex sm:p-4 sm:py-5">
@ -226,17 +234,23 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
size={10} size={10}
truncateAfter={3} truncateAfter={3}
/> />
<h2 className="dark:text-bookinglight mt-3 font-medium text-gray-500">{profile.name}</h2> <h2 className="mt-3 font-medium text-gray-500 dark:text-gray-300">{profile.name}</h2>
<h1 className="font-cal text-bookingdark mb-4 text-3xl font-semibold dark:text-white"> <h1 className="font-cal mb-4 text-xl font-semibold text-gray-900 dark:text-white">
{eventType.title} {eventType.title}
</h1> </h1>
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1"> {eventType?.description && (
<ClockIcon className="mr-1 -mt-1 inline-block h-4 w-4" /> <p className="mb-2 text-gray-600 dark:text-white">
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
{eventType.description}
</p>
)}
<p className="mb-1 -ml-2 px-2 py-1 text-gray-600 dark:text-white">
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
{eventType.length} {t("minutes")} {eventType.length} {t("minutes")}
</p> </p>
{eventType.price > 0 && ( {eventType.price > 0 && (
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1"> <p className="mb-1 -ml-2 px-2 py-1 text-gray-600 dark:text-white">
<CreditCardIcon className="mr-1 -mt-1 inline-block h-4 w-4" /> <CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
<IntlProvider locale="en"> <IntlProvider locale="en">
<FormattedNumber <FormattedNumber
value={eventType.price / 100.0} value={eventType.price / 100.0}
@ -248,8 +262,6 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
)} )}
<TimezoneDropdown /> <TimezoneDropdown />
<p className="mt-3 mb-8 text-gray-600 dark:text-gray-200">{eventType.description}</p>
{previousPage === `${BASE_URL}/${profile.slug}` && ( {previousPage === `${BASE_URL}/${profile.slug}` && (
<div className="flex h-full flex-col justify-end"> <div className="flex h-full flex-col justify-end">
<ArrowLeftIcon <ArrowLeftIcon
@ -259,6 +271,17 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
<p className="sr-only">Go Back</p> <p className="sr-only">Go Back</p>
</div> </div>
)} )}
{booking?.startTime && rescheduleUid && (
<div>
<p className="mt-8 mb-2 text-gray-600 dark:text-white" data-testid="former_time_p">
{t("former_time")}
</p>
<p className="text-gray-500 line-through dark:text-white">
<CalendarIcon className="mr-[10px] -mt-1 inline-block h-4 w-4 text-gray-400" />
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
</p>
</div>
)}
</div> </div>
<DatePicker <DatePicker
date={selectedDate} date={selectedDate}
@ -305,8 +328,8 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
function TimezoneDropdown() { function TimezoneDropdown() {
return ( return (
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}> <Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
<Collapsible.Trigger className="min-w-32 text-bookinglight mb-1 -ml-2 px-2 py-1 text-left"> <Collapsible.Trigger className="min-w-32 mb-1 -ml-2 px-2 py-1 text-left text-gray-600 dark:text-white">
<GlobeIcon className="mr-1 -mt-1 inline-block h-4 w-4" /> <GlobeIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
{timeZone()} {timeZone()}
{isTimeOptionsOpen ? ( {isTimeOptionsOpen ? (
<ChevronUpIcon className="ml-1 -mt-1 inline-block h-4 w-4" /> <ChevronUpIcon className="ml-1 -mt-1 inline-block h-4 w-4" />

View file

@ -1,4 +1,10 @@
import { CalendarIcon, ClockIcon, CreditCardIcon, ExclamationIcon } from "@heroicons/react/solid"; import {
CalendarIcon,
ClockIcon,
CreditCardIcon,
ExclamationIcon,
InformationCircleIcon,
} from "@heroicons/react/solid";
import { EventTypeCustomInputType } from "@prisma/client"; import { EventTypeCustomInputType } from "@prisma/client";
import { useContracts } from "contexts/contractsContext"; import { useContracts } from "contexts/contractsContext";
import dayjs from "dayjs"; import dayjs from "dayjs";
@ -12,7 +18,7 @@ import { FormattedNumber, IntlProvider } from "react-intl";
import { ReactMultiEmail } from "react-multi-email"; import { ReactMultiEmail } from "react-multi-email";
import { useMutation } from "react-query"; import { useMutation } from "react-query";
import { useIsEmbed, useEmbedStyles, useIsBackgroundTransparent } from "@calcom/embed-core"; import { useIsEmbed, useIsBackgroundTransparent } from "@calcom/embed-core";
import classNames from "@calcom/lib/classNames"; import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error"; import { HttpError } from "@calcom/lib/http-error";
@ -26,10 +32,9 @@ import { ensureArray } from "@lib/ensureArray";
import useTheme from "@lib/hooks/useTheme"; import useTheme from "@lib/hooks/useTheme";
import { LocationType } from "@lib/location"; import { LocationType } from "@lib/location";
import createBooking from "@lib/mutations/bookings/create-booking"; import createBooking from "@lib/mutations/bookings/create-booking";
import { parseZone } from "@lib/parseZone"; import { parseDate } from "@lib/parseDate";
import slugify from "@lib/slugify"; import slugify from "@lib/slugify";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { detectBrowserTimeFormat } from "@lib/timeFormat";
import CustomBranding from "@components/CustomBranding"; import CustomBranding from "@components/CustomBranding";
import AvatarGroup from "@components/ui/AvatarGroup"; import AvatarGroup from "@components/ui/AvatarGroup";
@ -147,6 +152,10 @@ const BookingPage = ({
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type); const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
const loggedInIsOwner = eventType?.users[0]?.name === session?.user?.name; const loggedInIsOwner = eventType?.users[0]?.name === session?.user?.name;
const guestListEmails = !isDynamicGroupBooking
? booking?.attendees.slice(1).map((attendee) => attendee.email)
: [];
const defaultValues = () => { const defaultValues = () => {
if (!rescheduleUid) { if (!rescheduleUid) {
return { return {
@ -173,7 +182,8 @@ const BookingPage = ({
return { return {
name: primaryAttendee.name || "", name: primaryAttendee.name || "",
email: primaryAttendee.email || "", email: primaryAttendee.email || "",
guests: !isDynamicGroupBooking ? booking.attendees.slice(1).map((attendee) => attendee.email) : [], guests: guestListEmails,
notes: booking.description || "",
}; };
}; };
@ -212,14 +222,6 @@ const BookingPage = ({
} }
}; };
const parseDate = (date: string | null) => {
if (!date) return "No date";
const parsedZone = parseZone(date);
if (!parsedZone?.isValid()) return "Invalid date";
const formattedTime = parsedZone?.format(detectBrowserTimeFormat);
return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" });
};
const bookEvent = (booking: BookingFormValues) => { const bookEvent = (booking: BookingFormValues) => {
telemetry.withJitsu((jitsu) => telemetry.withJitsu((jitsu) =>
jitsu.track( jitsu.track(
@ -273,6 +275,8 @@ const BookingPage = ({
}); });
}; };
const disableInput = !!rescheduleUid;
return ( return (
<div> <div>
<Theme /> <Theme />
@ -322,16 +326,22 @@ const BookingPage = ({
<h2 className="font-cal text-bookinglight mt-2 font-medium dark:text-gray-300"> <h2 className="font-cal text-bookinglight mt-2 font-medium dark:text-gray-300">
{profile.name} {profile.name}
</h2> </h2>
<h1 className="text-bookingdark mb-4 text-3xl font-semibold dark:text-white"> <h1 className="text-bookingdark mb-4 text-xl font-semibold dark:text-white">
{eventType.title} {eventType.title}
</h1> </h1>
<p className="text-bookinglight mb-2"> {eventType?.description && (
<ClockIcon className="mr-1 -mt-1 inline-block h-4 w-4" /> <p className="text-bookinglight mb-2 dark:text-white">
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
{eventType.description}
</p>
)}
<p className="text-bookinglight mb-2 dark:text-white">
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
{eventType.length} {t("minutes")} {eventType.length} {t("minutes")}
</p> </p>
{eventType.price > 0 && ( {eventType.price > 0 && (
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1"> <p className="text-bookinglight mb-1 -ml-2 px-2 py-1 dark:text-white">
<CreditCardIcon className="mr-1 -mt-1 inline-block h-4 w-4" /> <CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
<IntlProvider locale="en"> <IntlProvider locale="en">
<FormattedNumber <FormattedNumber
value={eventType.price / 100.0} value={eventType.price / 100.0}
@ -342,15 +352,25 @@ const BookingPage = ({
</p> </p>
)} )}
<p className="text-bookinghighlight mb-4"> <p className="text-bookinghighlight mb-4">
<CalendarIcon className="mr-1 -mt-1 inline-block h-4 w-4" /> <CalendarIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
{parseDate(date)} {parseDate(date, i18n)}
</p> </p>
{eventTypeDetail.isWeb3Active && eventType.metadata.smartContractAddress && ( {eventTypeDetail.isWeb3Active && eventType.metadata.smartContractAddress && (
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1"> <p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
{t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress} {t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress}
</p> </p>
)} )}
<p className="mb-8 text-gray-600 dark:text-white">{eventType.description}</p> {booking?.startTime && rescheduleUid && (
<div>
<p className="mt-8 mb-2 text-gray-600 dark:text-white" data-testid="former_time_p">
{t("former_time")}
</p>
<p className="text-gray-500 line-through dark:text-white">
<CalendarIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
</p>
</div>
)}
</div> </div>
<div className="sm:w-1/2 sm:pl-8 sm:pr-4"> <div className="sm:w-1/2 sm:pl-8 sm:pr-4">
<Form form={bookingForm} handleSubmit={bookEvent}> <Form form={bookingForm} handleSubmit={bookEvent}>
@ -365,8 +385,12 @@ const BookingPage = ({
name="name" name="name"
id="name" id="name"
required required
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm" className={classNames(
"focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm",
disableInput ? "bg-gray-200 dark:text-gray-500" : ""
)}
placeholder={t("example_name")} placeholder={t("example_name")}
disabled={disableInput}
/> />
</div> </div>
</div> </div>
@ -380,9 +404,13 @@ const BookingPage = ({
<EmailInput <EmailInput
{...bookingForm.register("email")} {...bookingForm.register("email")}
required required
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm" className={classNames(
"focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm",
disableInput ? "bg-gray-200 dark:text-gray-500" : ""
)}
placeholder="you@example.com" placeholder="you@example.com"
type="search" // Disables annoying 1password intrusive popup (non-optimal, I know I know...) type="search" // Disables annoying 1password intrusive popup (non-optimal, I know I know...)
disabled={disableInput}
/> />
</div> </div>
</div> </div>
@ -399,6 +427,7 @@ const BookingPage = ({
{...bookingForm.register("locationType", { required: true })} {...bookingForm.register("locationType", { required: true })}
value={location.type} value={location.type}
defaultChecked={selectedLocation === location.type} defaultChecked={selectedLocation === location.type}
disabled={disableInput}
/> />
<span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-gray-500"> <span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-gray-500">
{locationLabels[location.type]} {locationLabels[location.type]}
@ -421,6 +450,7 @@ const BookingPage = ({
placeholder={t("enter_phone_number")} placeholder={t("enter_phone_number")}
id="phone" id="phone"
required required
disabled={disableInput}
/> />
</div> </div>
</div> </div>
@ -443,8 +473,12 @@ const BookingPage = ({
})} })}
id={"custom_" + input.id} id={"custom_" + input.id}
rows={3} rows={3}
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm" className={classNames(
"focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm",
disableInput ? "bg-gray-200 dark:text-gray-500" : ""
)}
placeholder={input.placeholder} placeholder={input.placeholder}
disabled={disableInput}
/> />
)} )}
{input.type === EventTypeCustomInputType.TEXT && ( {input.type === EventTypeCustomInputType.TEXT && (
@ -456,6 +490,7 @@ const BookingPage = ({
id={"custom_" + input.id} id={"custom_" + input.id}
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm" className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm"
placeholder={input.placeholder} placeholder={input.placeholder}
disabled={disableInput}
/> />
)} )}
{input.type === EventTypeCustomInputType.NUMBER && ( {input.type === EventTypeCustomInputType.NUMBER && (
@ -507,6 +542,7 @@ const BookingPage = ({
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white"> className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
{t("guests")} {t("guests")}
</label> </label>
{!disableInput && (
<Controller <Controller
control={bookingForm.control} control={bookingForm.control}
name="guests" name="guests"
@ -522,17 +558,33 @@ const BookingPage = ({
removeEmail: (index: number) => void removeEmail: (index: number) => void
) => { ) => {
return ( return (
<div data-tag key={index}> <div data-tag key={index} className="cursor-pointer">
{email} {email}
{!disableInput && (
<span data-tag-handle onClick={() => removeEmail(index)}> <span data-tag-handle onClick={() => removeEmail(index)}>
× ×
</span> </span>
)}
</div> </div>
); );
}} }}
/> />
)} )}
/> />
)}
{/* Custom code when guest emails should not be editable */}
{disableInput && guestListEmails && guestListEmails.length > 0 && (
<div data-tag className="react-multi-email">
{/* // @TODO: user owners are appearing as guest here when should be only user input */}
{guestListEmails.map((email, index) => {
return (
<div key={index} className="cursor-pointer">
<span data-tag>{email}</span>
</div>
);
})}
</div>
)}
</div> </div>
)} )}
</div> </div>
@ -546,9 +598,14 @@ const BookingPage = ({
<textarea <textarea
{...bookingForm.register("notes")} {...bookingForm.register("notes")}
id="notes" id="notes"
name="notes"
rows={3} rows={3}
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm" className={classNames(
"focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm",
disableInput ? "bg-gray-200 dark:text-gray-500" : ""
)}
placeholder={t("share_additional_notes")} placeholder={t("share_additional_notes")}
disabled={disableInput}
/> />
</div> </div>
<div className="flex items-start space-x-2 rtl:space-x-reverse"> <div className="flex items-start space-x-2 rtl:space-x-reverse">

View file

@ -0,0 +1,102 @@
import { ClockIcon, XIcon } from "@heroicons/react/outline";
import { RescheduleResponse } from "pages/api/book/request-reschedule";
import React, { useState, Dispatch, SetStateAction } from "react";
import { useMutation } from "react-query";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import Button from "@calcom/ui/Button";
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
import { TextArea } from "@calcom/ui/form/fields";
import * as fetchWrapper from "@lib/core/http/fetch-wrapper";
import { trpc } from "@lib/trpc";
interface IRescheduleDialog {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
bookingUId: string;
}
export const RescheduleDialog = (props: IRescheduleDialog) => {
const { t } = useLocale();
const utils = trpc.useContext();
const { isOpenDialog, setIsOpenDialog, bookingUId: bookingId } = props;
const [rescheduleReason, setRescheduleReason] = useState("");
const [isLoading, setIsLoading] = useState(false);
const rescheduleApi = useMutation(
async () => {
setIsLoading(true);
try {
const result = await fetchWrapper.post<
{ bookingId: string; rescheduleReason: string },
RescheduleResponse
>("/api/book/request-reschedule", {
bookingId,
rescheduleReason,
});
if (result) {
showToast(t("reschedule_request_sent"), "success");
setIsOpenDialog(false);
}
} catch (error) {
showToast(t("unexpected_error_try_again"), "error");
// @TODO: notify sentry
}
setIsLoading(false);
},
{
async onSettled() {
await utils.invalidateQueries(["viewer.bookings"]);
},
}
);
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent>
<DialogClose asChild>
<div className="fixed top-1 right-1 flex h-8 w-8 justify-center rounded-full hover:bg-gray-200">
<XIcon className="w-4" />
</div>
</DialogClose>
<div style={{ display: "flex", flexDirection: "row" }}>
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
<ClockIcon className="m-auto h-6 w-6"></ClockIcon>
</div>
<div className="px-4 pt-1">
<DialogHeader title={t("send_reschedule_request")} />
<p className="-mt-8 text-sm text-gray-500">{t("reschedule_modal_description")}</p>
<p className="mt-6 mb-2 text-sm font-bold text-black">
{t("reason_for_reschedule_request")}
<span className="font-normal text-gray-500"> (Optional)</span>
</p>
<TextArea
data-testid="reschedule_reason"
name={t("reschedule_reason")}
value={rescheduleReason}
onChange={(e) => setRescheduleReason(e.target.value)}
className="mb-5 sm:mb-6"
/>
<DialogFooter>
<DialogClose>
<Button color="secondary">{t("cancel")}</Button>
</DialogClose>
<Button
data-testid="send_request"
disabled={isLoading}
onClick={() => {
rescheduleApi.mutate();
}}>
{t("send_reschedule_request")}
</Button>
</DialogFooter>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -1,7 +1,6 @@
import { DotsHorizontalIcon } from "@heroicons/react/solid"; import { ChevronDownIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
import React, { FC } from "react"; import React, { FC } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button"; import Button from "@calcom/ui/Button";
import Dropdown, { DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@calcom/ui/Dropdown"; import Dropdown, { DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@calcom/ui/Dropdown";
@ -9,56 +8,76 @@ import { SVGComponent } from "@lib/types/SVGComponent";
export type ActionType = { export type ActionType = {
id: string; id: string;
icon: SVGComponent; icon?: SVGComponent;
label: string; label: string;
disabled?: boolean; disabled?: boolean;
color?: "primary" | "secondary"; color?: "primary" | "secondary";
} & ({ href?: never; onClick: () => any } | { href: string; onClick?: never }); } & ({ href?: never; onClick: () => any } | { href?: string; onClick?: never }) & {
actions?: ActionType[];
};
interface Props { interface Props {
actions: ActionType[]; actions: ActionType[];
} }
const TableActions: FC<Props> = ({ actions }) => { const DropdownActions = ({ actions, actionTrigger }: { actions: ActionType[]; actionTrigger?: any }) => {
const { t } = useLocale();
return ( return (
<>
<div className="hidden space-x-2 rtl:space-x-reverse lg:block">
{actions.map((action) => (
<Button
key={action.id}
data-testid={action.id}
href={action.href}
onClick={action.onClick}
StartIcon={action.icon}
disabled={action.disabled}
color={action.color || "secondary"}>
{action.label}
</Button>
))}
</div>
<div className="inline-block text-left lg:hidden">
<Dropdown> <Dropdown>
{!actionTrigger ? (
<DropdownMenuTrigger className="h-[38px] w-[38px] cursor-pointer rounded-sm border border-transparent text-neutral-500 hover:border-gray-300 hover:text-neutral-900"> <DropdownMenuTrigger className="h-[38px] w-[38px] cursor-pointer rounded-sm border border-transparent text-neutral-500 hover:border-gray-300 hover:text-neutral-900">
<DotsHorizontalIcon className="h-5 w-5 group-hover:text-gray-800" /> <DotsHorizontalIcon className="h-5 w-5 group-hover:text-gray-800" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
) : (
<DropdownMenuTrigger asChild>{actionTrigger}</DropdownMenuTrigger>
)}
<DropdownMenuContent portalled> <DropdownMenuContent portalled>
{actions.map((action) => ( {actions.map((action) => (
<DropdownMenuItem key={action.id}> <DropdownMenuItem key={action.id} className="focus-visible:outline-none">
<Button <Button
type="button" type="button"
size="lg" size="sm"
color="minimal" color="minimal"
className="w-full rounded-none font-normal" className="w-full rounded-none font-normal"
href={action.href} href={action.href}
StartIcon={action.icon} StartIcon={action.icon}
onClick={action.onClick}> onClick={action.onClick}
data-testid={action.id}>
{action.label} {action.label}
</Button> </Button>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
</Dropdown> </Dropdown>
);
};
const TableActions: FC<Props> = ({ actions }) => {
return (
<>
<div className="hidden space-x-2 rtl:space-x-reverse lg:block">
{actions.map((action) => {
const button = (
<Button
key={action.id}
data-testid={action.id}
href={action.href}
onClick={action.onClick}
StartIcon={action.icon}
{...(action?.actions ? { EndIcon: ChevronDownIcon } : null)}
disabled={action.disabled}
color={action.color || "secondary"}>
{action.label}
</Button>
);
if (!action.actions) {
return button;
}
return <DropdownActions key={action.id} actions={action.actions} actionTrigger={button} />;
})}
</div>
<div className="inline-block text-left lg:hidden">
<DropdownActions actions={actions} />
</div> </div>
</> </>
); );

View file

@ -20,7 +20,8 @@ function PhoneInput<FormValues>({ control, name, ...rest }: PhoneInputProps<Form
name={name} name={name}
control={control} control={control}
className={classNames( className={classNames(
"border-1 focus-within:border-brand block w-full rounded-sm border border-gray-300 py-px px-3 shadow-sm ring-black focus-within:ring-1 dark:border-black dark:bg-black dark:text-white" "border-1 focus-within:border-brand block w-full rounded-sm border border-gray-300 py-px px-3 shadow-sm ring-black focus-within:ring-1 dark:border-black dark:bg-black dark:text-white",
rest.disabled ? "bg-gray-200 dark:text-gray-500" : ""
)} )}
/> />
); );

View file

@ -14,6 +14,7 @@ import { PaymentPageProps } from "@ee/pages/payment/[uid]";
import { useLocale } from "@lib/hooks/useLocale"; import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme"; import useTheme from "@lib/hooks/useTheme";
import { LocationOptionsToString } from "@lib/locationOptions";
import { isBrowserLocale24h } from "@lib/timeFormat"; import { isBrowserLocale24h } from "@lib/timeFormat";
dayjs.extend(utc); dayjs.extend(utc);
@ -58,6 +59,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100"> <div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<CreditCardIcon className="h-8 w-8 text-green-600" /> <CreditCardIcon className="h-8 w-8 text-green-600" />
</div> </div>
<div className="mt-3 text-center sm:mt-5"> <div className="mt-3 text-center sm:mt-5">
<h3 <h3
className="text-2xl font-semibold leading-6 text-neutral-900 dark:text-white" className="text-2xl font-semibold leading-6 text-neutral-900 dark:text-white"
@ -84,7 +86,9 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
{props.booking.location && ( {props.booking.location && (
<> <>
<div className="font-medium">{t("where")}</div> <div className="font-medium">{t("where")}</div>
<div className="col-span-2 mb-6">{props.booking.location}</div> <div className="col-span-2 mb-6">
{LocationOptionsToString(props.booking.location, t)}
</div>
</> </>
)} )}
<div className="font-medium">{t("price")}</div> <div className="font-medium">{t("price")}</div>

View file

@ -0,0 +1,16 @@
import { TFunction } from "next-i18next";
import { Attendee } from "@calcom/prisma/client";
import { Person } from "@calcom/types/Calendar";
export const attendeeToPersonConversionType = (attendees: Attendee[], t: TFunction): Person[] => {
return attendees.map((attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
locale: attendee.locale || "en",
language: { translate: t, locale: attendee.locale || "en" },
};
});
};

View file

@ -4,6 +4,7 @@ import AttendeeAwaitingPaymentEmail from "@lib/emails/templates/attendee-awaitin
import AttendeeCancelledEmail from "@lib/emails/templates/attendee-cancelled-email"; import AttendeeCancelledEmail from "@lib/emails/templates/attendee-cancelled-email";
import AttendeeDeclinedEmail from "@lib/emails/templates/attendee-declined-email"; import AttendeeDeclinedEmail from "@lib/emails/templates/attendee-declined-email";
import AttendeeRequestEmail from "@lib/emails/templates/attendee-request-email"; import AttendeeRequestEmail from "@lib/emails/templates/attendee-request-email";
import AttendeeRequestRescheduledEmail from "@lib/emails/templates/attendee-request-reschedule-email";
import AttendeeRescheduledEmail from "@lib/emails/templates/attendee-rescheduled-email"; import AttendeeRescheduledEmail from "@lib/emails/templates/attendee-rescheduled-email";
import AttendeeScheduledEmail from "@lib/emails/templates/attendee-scheduled-email"; import AttendeeScheduledEmail from "@lib/emails/templates/attendee-scheduled-email";
import ForgotPasswordEmail, { PasswordReset } from "@lib/emails/templates/forgot-password-email"; import ForgotPasswordEmail, { PasswordReset } from "@lib/emails/templates/forgot-password-email";
@ -11,6 +12,7 @@ import OrganizerCancelledEmail from "@lib/emails/templates/organizer-cancelled-e
import OrganizerPaymentRefundFailedEmail from "@lib/emails/templates/organizer-payment-refund-failed-email"; import OrganizerPaymentRefundFailedEmail from "@lib/emails/templates/organizer-payment-refund-failed-email";
import OrganizerRequestEmail from "@lib/emails/templates/organizer-request-email"; import OrganizerRequestEmail from "@lib/emails/templates/organizer-request-email";
import OrganizerRequestReminderEmail from "@lib/emails/templates/organizer-request-reminder-email"; import OrganizerRequestReminderEmail from "@lib/emails/templates/organizer-request-reminder-email";
import OrganizerRequestRescheduleEmail from "@lib/emails/templates/organizer-request-reschedule-email";
import OrganizerRescheduledEmail from "@lib/emails/templates/organizer-rescheduled-email"; import OrganizerRescheduledEmail from "@lib/emails/templates/organizer-rescheduled-email";
import OrganizerScheduledEmail from "@lib/emails/templates/organizer-scheduled-email"; import OrganizerScheduledEmail from "@lib/emails/templates/organizer-scheduled-email";
import TeamInviteEmail, { TeamInvite } from "@lib/emails/templates/team-invite-email"; import TeamInviteEmail, { TeamInvite } from "@lib/emails/templates/team-invite-email";
@ -208,3 +210,34 @@ export const sendTeamInviteEmail = async (teamInviteEvent: TeamInvite) => {
} }
}); });
}; };
export const sendRequestRescheduleEmail = async (
calEvent: CalendarEvent,
metadata: { rescheduleLink: string }
) => {
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(
new Promise((resolve, reject) => {
try {
const requestRescheduleEmail = new AttendeeRequestRescheduledEmail(calEvent, metadata);
resolve(requestRescheduleEmail.sendEmail());
} catch (e) {
reject(console.error("AttendeeRequestRescheduledEmail.sendEmail failed", e));
}
})
);
emailsToSend.push(
new Promise((resolve, reject) => {
try {
const requestRescheduleEmail = new OrganizerRequestRescheduleEmail(calEvent, metadata);
resolve(requestRescheduleEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerRequestRescheduledEmail.sendEmail failed", e));
}
})
);
await Promise.all(emailsToSend);
};

View file

@ -0,0 +1,210 @@
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { createEvent, DateArray, Person } from "ics";
import { getCancelLink } from "@calcom/lib/CalEventParser";
import { Attendee } from "@calcom/prisma/client";
import { CalendarEvent } from "@calcom/types/Calendar";
import {
emailHead,
emailSchedulingBodyHeader,
emailBodyLogo,
emailScheduledBodyHeaderContent,
emailSchedulingBodyDivider,
} from "./common";
import OrganizerScheduledEmail from "./organizer-scheduled-email";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class AttendeeRequestRescheduledEmail extends OrganizerScheduledEmail {
private metadata: { rescheduleLink: string };
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
super(calEvent);
this.metadata = metadata;
}
protected getNodeMailerPayload(): Record<string, unknown> {
const toAddresses = [this.calEvent.attendees[0].email];
return {
icalEvent: {
filename: "event.ics",
content: this.getiCalEventAsString(),
},
from: `Cal.com <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: `${this.calEvent.organizer.language.translate("requested_to_reschedule_subject_attendee", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
})}`,
html: this.getHtmlBody(),
text: this.getTextBody(),
};
}
// @OVERRIDE
protected getiCalEventAsString(): string | undefined {
console.log("overriding");
const icsEvent = createEvent({
start: dayjs(this.calEvent.startTime)
.utc()
.toArray()
.slice(0, 6)
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
startInputType: "utc",
productId: "calendso/ics",
title: this.calEvent.organizer.language.translate("ics_event_title", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
}),
description: this.getTextBody(),
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
attendees: this.calEvent.attendees.map((attendee: Person) => ({
name: attendee.name,
email: attendee.email,
})),
status: "CANCELLED",
method: "CANCEL",
});
if (icsEvent.error) {
throw icsEvent.error;
}
return icsEvent.value;
}
// @OVERRIDE
protected getWhen(): string {
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.organizer.language.translate("when")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;text-decoration: line-through;">
${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format(
"YYYY"
)} | ${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)} <span style="color: #888888">(${this.getTimezone()})</span>
</p>
</div>`;
}
protected getTextBody(): string {
return `
${this.calEvent.organizer.language.translate("request_reschedule_title_attendee")}
${this.calEvent.organizer.language.translate("request_reschedule_subtitle", {
organizer: this.calEvent.organizer.name,
})},
${this.getWhat()}
${this.getWhen()}
${this.getAdditionalNotes()}
${this.calEvent.organizer.language.translate("need_to_reschedule_or_cancel")}
${getCancelLink(this.calEvent)}
`.replace(/(<([^>]+)>)/gi, "");
}
protected getHtmlBody(): string {
const headerContent = this.calEvent.organizer.language.translate("rescheduled_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)}, ${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
});
return `
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
${emailHead(headerContent)}
<body style="word-spacing:normal;background-color:#F5F5F5;">
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("calendarCircle")}
${emailScheduledBodyHeaderContent(
this.calEvent.organizer.language.translate("request_reschedule_title_attendee"),
this.calEvent.organizer.language.translate("request_reschedule_subtitle", {
organizer: this.calEvent.organizer.name,
})
)}
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 40px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
${this.getWhat()}
${this.getWhen()}
${this.getWho()}
${this.getAdditionalNotes()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-bottom:1px solid #E1E1E1;border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;text-align:center;color:#3E3E3E;">
<a style="padding: 8px 16px;background-color: #292929;color: white;border-radius: 2px;display: inline-block;margin-bottom: 16px;"
href="${this.metadata.rescheduleLink}" target="_blank"
>
Book a new time
<img src="https://app.cal.com/emails/linkIcon.png" style="width:16px; margin-left: 5px;filter: brightness(0) invert(1); vertical-align: top;" />
</a>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailBodyLogo()}
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
`;
}
}

View file

@ -40,7 +40,7 @@ export const emailSchedulingBodyHeader = (headerType: BodyHeadType): string => {
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;"> <table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody> <tbody>
<tr> <tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;border-top:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:30px 20px 0 20px;text-align:center;"> <td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;border-top:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:30px 30px 0 30px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:558px;" ><![endif]--> <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:558px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> <div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"> <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">

View file

@ -0,0 +1,189 @@
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { createEvent, DateArray, Person } from "ics";
import { getCancelLink } from "@calcom/lib/CalEventParser";
import { CalendarEvent } from "@calcom/types/Calendar";
import {
emailHead,
emailSchedulingBodyHeader,
emailBodyLogo,
emailScheduledBodyHeaderContent,
emailSchedulingBodyDivider,
} from "./common";
import OrganizerScheduledEmail from "./organizer-scheduled-email";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class OrganizerRequestRescheduledEmail extends OrganizerScheduledEmail {
private metadata: { rescheduleLink: string };
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
super(calEvent);
this.metadata = metadata;
}
protected getNodeMailerPayload(): Record<string, unknown> {
const toAddresses = [this.calEvent.organizer.email];
return {
icalEvent: {
filename: "event.ics",
content: this.getiCalEventAsString(),
},
from: `Cal.com <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: `${this.calEvent.organizer.language.translate("rescheduled_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)}, ${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
})}`,
html: this.getHtmlBody(),
text: this.getTextBody(),
};
}
// @OVERRIDE
protected getiCalEventAsString(): string | undefined {
console.log("overriding");
const icsEvent = createEvent({
start: dayjs(this.calEvent.startTime)
.utc()
.toArray()
.slice(0, 6)
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
startInputType: "utc",
productId: "calendso/ics",
title: this.calEvent.organizer.language.translate("ics_event_title", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
}),
description: this.getTextBody(),
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
attendees: this.calEvent.attendees.map((attendee: Person) => ({
name: attendee.name,
email: attendee.email,
})),
status: "CANCELLED",
method: "CANCEL",
});
if (icsEvent.error) {
throw icsEvent.error;
}
return icsEvent.value;
}
// @OVERRIDE
protected getWhen(): string {
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.organizer.language.translate("when")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;text-decoration: line-through;">
${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format(
"YYYY"
)} | ${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)} <span style="color: #888888">(${this.getTimezone()})</span>
</p>
</div>`;
}
protected getTextBody(): string {
return `
${this.calEvent.organizer.language.translate("request_reschedule_title_organizer", {
attendee: this.calEvent.attendees[0].name,
})}
${this.calEvent.organizer.language.translate("request_reschedule_subtitle_organizer", {
attendee: this.calEvent.attendees[0].name,
})},
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getAdditionalNotes()}
${this.calEvent.organizer.language.translate("need_to_reschedule_or_cancel")}
${getCancelLink(this.calEvent)}
`.replace(/(<([^>]+)>)/gi, "");
}
protected getHtmlBody(): string {
const headerContent = this.calEvent.organizer.language.translate("rescheduled_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)}, ${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
});
return `
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
${emailHead(headerContent)}
<body style="word-spacing:normal;background-color:#F5F5F5;">
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("calendarCircle")}
${emailScheduledBodyHeaderContent(
this.calEvent.organizer.language.translate("request_reschedule_title_organizer", {
attendee: this.calEvent.attendees[0].name,
}),
this.calEvent.organizer.language.translate("request_reschedule_subtitle_organizer", {
attendee: this.calEvent.attendees[0].name,
})
)}
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 40px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
${this.getWhat()}
${this.getWhen()}
${this.getWho()}
${this.getAdditionalNotes()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailBodyLogo()}
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
`;
}
}

View file

@ -0,0 +1,33 @@
import { PrismaClient } from "@prisma/client";
import { Prisma } from "@calcom/prisma/client";
async function getBooking(prisma: PrismaClient, uid: string) {
const booking = await prisma.booking.findFirst({
where: {
uid,
},
select: {
startTime: true,
description: true,
attendees: {
select: {
email: true,
name: true,
},
},
},
});
if (booking) {
// @NOTE: had to do this because Server side cant return [Object objects]
// probably fixable with json.stringify -> json.parse
booking["startTime"] = (booking?.startTime as Date)?.toISOString() as unknown as Date;
}
return booking;
}
export type GetBookingType = Prisma.PromiseReturnType<typeof getBooking>;
export default getBooking;

View file

@ -0,0 +1,30 @@
import { TFunction } from "next-i18next";
import { LocationType } from "./location";
export const LocationOptionsToString = (location: string, t: TFunction) => {
switch (location) {
case LocationType.InPerson:
return t("set_address_place");
case LocationType.Link:
return t("set_link_meeting");
case LocationType.Phone:
return t("cal_invitee_phone_number_scheduling");
case LocationType.GoogleMeet:
return t("cal_provide_google_meet_location");
case LocationType.Zoom:
return t("cal_provide_zoom_meeting_url");
case LocationType.Daily:
return t("cal_provide_video_meeting_url");
case LocationType.Jitsi:
return t("cal_provide_jitsi_meeting_url");
case LocationType.Huddle01:
return t("cal_provide_huddle01_meeting_url");
case LocationType.Tandem:
return t("cal_provide_tandem_meeting_url");
case LocationType.Teams:
return t("cal_provide_teams_meeting_url");
default:
return null;
}
};

14
apps/web/lib/parseDate.ts Normal file
View file

@ -0,0 +1,14 @@
import dayjs, { Dayjs } from "dayjs";
import { I18n } from "next-i18next";
import { detectBrowserTimeFormat } from "@lib/timeFormat";
import { parseZone } from "./parseZone";
export const parseDate = (date: string | null | Dayjs, i18n: I18n) => {
if (!date) return "No date";
const parsedZone = parseZone(date);
if (!parsedZone?.isValid()) return "Invalid date";
const formattedTime = parsedZone?.format(detectBrowserTimeFormat);
return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" });
};

View file

@ -8,6 +8,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { asStringOrNull } from "@lib/asStringOrNull"; import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability"; import { getWorkingHours } from "@lib/availability";
import getBooking, { GetBookingType } from "@lib/getBooking";
import prisma from "@lib/prisma"; import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps"; import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -48,6 +49,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const userParam = asStringOrNull(context.query.user); const userParam = asStringOrNull(context.query.user);
const typeParam = asStringOrNull(context.query.type); const typeParam = asStringOrNull(context.query.type);
const dateParam = asStringOrNull(context.query.date); const dateParam = asStringOrNull(context.query.date);
const rescheduleUid = asStringOrNull(context.query.rescheduleUid);
if (!userParam || !typeParam) { if (!userParam || !typeParam) {
throw new Error(`File is not named [type]/[user]`); throw new Error(`File is not named [type]/[user]`);
@ -261,6 +263,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
eventTypeObject.schedule = null; eventTypeObject.schedule = null;
eventTypeObject.availability = []; eventTypeObject.availability = [];
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBooking(prisma, rescheduleUid);
}
const dynamicNames = isDynamicGroup const dynamicNames = isDynamicGroup
? users.map((user) => { ? users.map((user) => {
return user.name || ""; return user.name || "";
@ -302,6 +309,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
workingHours, workingHours,
trpcState: ssr.dehydrate(), trpcState: ssr.dehydrate(),
previousPage: context.req.headers.referer ?? null, previousPage: context.req.headers.referer ?? null,
booking,
}, },
}; };
}; };

View file

@ -1,4 +1,3 @@
import { Prisma } from "@prisma/client";
import dayjs from "dayjs"; import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
@ -15,6 +14,7 @@ import {
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { asStringOrThrow } from "@lib/asStringOrNull"; import { asStringOrThrow } from "@lib/asStringOrNull";
import getBooking, { GetBookingType } from "@lib/getBooking";
import prisma from "@lib/prisma"; import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps"; import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -102,6 +102,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
disableGuests: true, disableGuests: true,
users: { users: {
select: { select: {
id: true,
username: true, username: true,
name: true, name: true,
email: true, email: true,
@ -147,28 +148,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}; };
})[0]; })[0];
async function getBooking() { let booking: GetBookingType | null = null;
return prisma.booking.findFirst({
where: {
uid: asStringOrThrow(context.query.rescheduleUid),
},
select: {
description: true,
attendees: {
select: {
email: true,
name: true,
},
},
},
});
}
type Booking = Prisma.PromiseReturnType<typeof getBooking>;
let booking: Booking | null = null;
if (context.query.rescheduleUid) { if (context.query.rescheduleUid) {
booking = await getBooking(); booking = await getBooking(prisma, context.query.rescheduleUid as string);
} }
const isDynamicGroupBooking = users.length > 1; const isDynamicGroupBooking = users.length > 1;

View file

@ -169,9 +169,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
metadata.conferenceData = results[0].createdEvent?.conferenceData; metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints; metadata.entryPoints = results[0].createdEvent?.entryPoints;
} }
try {
await sendScheduledEmails({ ...evt, additionInformation: metadata }); await sendScheduledEmails({ ...evt, additionInformation: metadata });
} catch (error) {
log.error(error);
}
} }
// @NOTE: be careful with this as if any error occurs before this booking doesn't get confirmed
// Should perform update on booking (confirm) -> then trigger the rest handlers
await prisma.booking.update({ await prisma.booking.update({
where: { where: {
id: bookingId, id: bookingId,

View file

@ -1,4 +1,11 @@
import { Credential, Prisma, SchedulingType, WebhookTriggerEvents } from "@prisma/client"; import {
BookingStatus,
Credential,
Payment,
Prisma,
SchedulingType,
WebhookTriggerEvents,
} from "@prisma/client";
import async from "async"; import async from "async";
import dayjs from "dayjs"; import dayjs from "dayjs";
import dayjsBusinessTime from "dayjs-business-time"; import dayjsBusinessTime from "dayjs-business-time";
@ -12,12 +19,12 @@ import { v5 as uuidv5 } from "uuid";
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager"; import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
import EventManager from "@calcom/core/EventManager"; import EventManager from "@calcom/core/EventManager";
import { getBusyVideoTimes } from "@calcom/core/videoClient"; import { getBusyVideoTimes } from "@calcom/core/videoClient";
import { getDefaultEvent, getUsernameList } from "@calcom/lib/defaultEvents"; import { getDefaultEvent, getUsernameList, getGroupName } from "@calcom/lib/defaultEvents";
import { getErrorFromUnknown } from "@calcom/lib/errors"; import { getErrorFromUnknown } from "@calcom/lib/errors";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import notEmpty from "@calcom/lib/notEmpty"; import notEmpty from "@calcom/lib/notEmpty";
import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime"; import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime";
import type { AdditionInformation, CalendarEvent, EventBusyDate } from "@calcom/types/Calendar"; import type { AdditionInformation, CalendarEvent, EventBusyDate, Person } from "@calcom/types/Calendar";
import type { EventResult, PartialReference } from "@calcom/types/EventManager"; import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import { handlePayment } from "@ee/lib/stripe/server"; import { handlePayment } from "@ee/lib/stripe/server";
@ -223,7 +230,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const reqBody = req.body as BookingCreateBody; const reqBody = req.body as BookingCreateBody;
// handle dynamic user // handle dynamic user
const dynamicUserList = getUsernameList(reqBody?.user); const dynamicUserList = Array.isArray(reqBody.user)
? getGroupName(req.body.user)
: getUsernameList(reqBody.user as string);
const eventTypeSlug = reqBody.eventTypeSlug; const eventTypeSlug = reqBody.eventTypeSlug;
const eventTypeId = reqBody.eventTypeId; const eventTypeId = reqBody.eventTypeId;
const tAttendees = await getTranslation(reqBody.language ?? "en", "common"); const tAttendees = await getTranslation(reqBody.language ?? "en", "common");
@ -344,6 +353,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
(str, input) => str + "<br /><br />" + input.label + ":<br />" + input.value, (str, input) => str + "<br /><br />" + input.label + ":<br />" + input.value,
"" ""
); );
const evt: CalendarEvent = { const evt: CalendarEvent = {
type: eventType.title, type: eventType.title,
title: getEventName(eventNameObject), //this needs to be either forced in english, or fetched for each attendee and organizer separately title: getEventName(eventNameObject), //this needs to be either forced in english, or fetched for each attendee and organizer separately
@ -373,6 +383,41 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Initialize EventManager with credentials // Initialize EventManager with credentials
const rescheduleUid = reqBody.rescheduleUid; const rescheduleUid = reqBody.rescheduleUid;
async function getOriginalRescheduledBooking(uid: string) {
return prisma.booking.findFirst({
where: {
uid,
status: {
in: [BookingStatus.ACCEPTED, BookingStatus.CANCELLED],
},
},
include: {
attendees: {
select: {
name: true,
email: true,
locale: true,
timeZone: true,
},
},
user: {
select: {
id: true,
name: true,
email: true,
locale: true,
timeZone: true,
},
},
payment: true,
},
});
}
type BookingType = Prisma.PromiseReturnType<typeof getOriginalRescheduledBooking>;
let originalRescheduledBooking: BookingType = null;
if (rescheduleUid) {
originalRescheduledBooking = await getOriginalRescheduledBooking(rescheduleUid);
}
async function createBooking() { async function createBooking() {
// @TODO: check as metadata // @TODO: check as metadata
@ -381,6 +426,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await verifyAccount(web3Details.userSignature, web3Details.userWallet); await verifyAccount(web3Details.userSignature, web3Details.userWallet);
} }
if (originalRescheduledBooking) {
evt.title = originalRescheduledBooking?.title || evt.title;
evt.description = originalRescheduledBooking?.description || evt.additionalNotes;
evt.location = originalRescheduledBooking?.location;
}
const eventTypeRel = !eventTypeId const eventTypeRel = !eventTypeId
? {} ? {}
: { : {
@ -392,14 +443,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null; const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null;
const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null; const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null;
return prisma.booking.create({ const newBookingData: Prisma.BookingCreateInput = {
include: {
user: {
select: { email: true, name: true, timeZone: true },
},
attendees: true,
},
data: {
uid, uid,
title: evt.title, title: evt.title,
startTime: dayjs(evt.startTime).toDate(), startTime: dayjs(evt.startTime).toDate(),
@ -435,8 +479,36 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
connect: { id: evt.destinationCalendar.id }, connect: { id: evt.destinationCalendar.id },
} }
: undefined, : undefined,
};
if (originalRescheduledBooking) {
newBookingData["paid"] = originalRescheduledBooking.paid;
newBookingData["fromReschedule"] = originalRescheduledBooking.uid;
if (newBookingData.attendees?.createMany?.data) {
newBookingData.attendees.createMany.data = originalRescheduledBooking.attendees;
}
}
const createBookingObj = {
include: {
user: {
select: { email: true, name: true, timeZone: true },
}, },
}); attendees: true,
payment: true,
},
data: newBookingData,
};
if (originalRescheduledBooking?.paid && originalRescheduledBooking?.payment) {
const bookingPayment = originalRescheduledBooking?.payment?.find((payment) => payment.success);
if (bookingPayment) {
createBookingObj.data.payment = {
connect: { id: bookingPayment.id },
};
}
}
return prisma.booking.create(createBookingObj);
} }
let results: EventResult[] = []; let results: EventResult[] = [];
@ -569,9 +641,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const credentials = await refreshCredentials(user.credentials); const credentials = await refreshCredentials(user.credentials);
const eventManager = new EventManager({ ...user, credentials }); const eventManager = new EventManager({ ...user, credentials });
if (rescheduleUid) { if (originalRescheduledBooking?.uid) {
// Use EventManager to conditionally use all needed integrations. // Use EventManager to conditionally use all needed integrations.
const updateManager = await eventManager.update(evt, rescheduleUid); const updateManager = await eventManager.update(evt, originalRescheduledBooking.uid, booking.id);
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back // This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails. // to the default description when we are sending the emails.
evt.description = eventType.description; evt.description = eventType.description;
@ -615,7 +687,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
results = createManager.results; results = createManager.results;
referencesToCreate = createManager.referencesToCreate; referencesToCreate = createManager.referencesToCreate;
if (results.length > 0 && results.every((res) => !res.success)) { if (results.length > 0 && results.every((res) => !res.success)) {
const error = { const error = {
errorCode: "BookingCreatingMeetingFailed", errorCode: "BookingCreatingMeetingFailed",
@ -641,9 +712,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await sendAttendeeRequestEmail(evt, attendeesList[0]); await sendAttendeeRequestEmail(evt, attendeesList[0]);
} }
if (typeof eventType.price === "number" && eventType.price > 0) { if (typeof eventType.price === "number" && eventType.price > 0 && !originalRescheduledBooking?.paid) {
try { try {
const [firstStripeCredential] = user.credentials.filter((cred) => cred.type == "stripe_payment"); const [firstStripeCredential] = user.credentials.filter((cred) => cred.type == "stripe_payment");
if (!booking.user) booking.user = user; if (!booking.user) booking.user = user;
const payment = await handlePayment(evt, eventType, firstStripeCredential, booking); const payment = await handlePayment(evt, eventType, firstStripeCredential, booking);

View file

@ -0,0 +1,217 @@
import { BookingStatus, User, Booking, Attendee, BookingReference } from "@prisma/client";
import dayjs from "dayjs";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "next-auth/react";
import type { TFunction } from "next-i18next";
import { z, ZodError } from "zod";
import { getCalendar } from "@calcom/core/CalendarManager";
import EventManager from "@calcom/core/EventManager";
import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder";
import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director";
import { deleteMeeting } from "@calcom/core/videoClient";
import { getTranslation } from "@calcom/lib/server/i18n";
import { Person } from "@calcom/types/Calendar";
import { sendRequestRescheduleEmail } from "@lib/emails/email-manager";
import prisma from "@lib/prisma";
export type RescheduleResponse = Booking & {
attendees: Attendee[];
};
export type PersonAttendeeCommonFields = Pick<
User,
"id" | "email" | "name" | "locale" | "timeZone" | "username"
>;
const rescheduleSchema = z.object({
bookingId: z.string(),
rescheduleReason: z.string().optional(),
});
const findUserOwnerByUserId = async (userId: number) => {
return await prisma.user.findUnique({
rejectOnNotFound: true,
where: {
id: userId,
},
select: {
id: true,
name: true,
username: true,
email: true,
timeZone: true,
locale: true,
credentials: true,
destinationCalendar: true,
},
});
};
const handler = async (
req: NextApiRequest,
res: NextApiResponse
): Promise<RescheduleResponse | NextApiResponse | void> => {
const session = await getSession({ req });
const {
bookingId,
rescheduleReason: cancellationReason,
}: { bookingId: string; rescheduleReason: string; cancellationReason: string } = req.body;
let userOwner: Awaited<ReturnType<typeof findUserOwnerByUserId>>;
try {
if (session?.user?.id) {
userOwner = await findUserOwnerByUserId(session?.user.id);
} else {
return res.status(501);
}
const bookingToReschedule = await prisma.booking.findFirst({
select: {
id: true,
uid: true,
title: true,
startTime: true,
endTime: true,
eventTypeId: true,
location: true,
attendees: true,
references: true,
},
rejectOnNotFound: true,
where: {
uid: bookingId,
NOT: {
status: {
in: [BookingStatus.CANCELLED, BookingStatus.REJECTED],
},
},
},
});
if (bookingToReschedule && bookingToReschedule.eventTypeId && userOwner) {
const event = await prisma.eventType.findFirst({
select: {
title: true,
users: true,
schedulingType: true,
},
rejectOnNotFound: true,
where: {
id: bookingToReschedule.eventTypeId,
},
});
await prisma.booking.update({
where: {
id: bookingToReschedule.id,
},
data: {
rescheduled: true,
cancellationReason,
status: BookingStatus.CANCELLED,
updatedAt: dayjs().toISOString(),
},
});
const [mainAttendee] = bookingToReschedule.attendees;
// @NOTE: Should we assume attendees language?
const tAttendees = await getTranslation(mainAttendee.locale ?? "en", "common");
const usersToPeopleType = (
users: PersonAttendeeCommonFields[],
selectedLanguage: TFunction
): Person[] => {
return users?.map((user) => {
return {
email: user.email || "",
name: user.name || "",
username: user?.username || "",
language: { translate: selectedLanguage, locale: user.locale || "en" },
timeZone: user?.timeZone,
};
});
};
const userOwnerTranslation = await getTranslation(userOwner.locale ?? "en", "common");
const [userOwnerAsPeopleType] = usersToPeopleType([userOwner], userOwnerTranslation);
const builder = new CalendarEventBuilder();
builder.init({
title: bookingToReschedule.title,
type: event.title,
startTime: bookingToReschedule.startTime.toISOString(),
endTime: bookingToReschedule.endTime.toISOString(),
attendees: usersToPeopleType(
// username field doesn't exists on attendee but could be in the future
bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[],
tAttendees
),
organizer: userOwnerAsPeopleType,
});
const director = new CalendarEventDirector();
director.setBuilder(builder);
director.setExistingBooking(bookingToReschedule as unknown as Booking);
director.setCancellationReason(cancellationReason);
await director.buildForRescheduleEmail();
// Handling calendar and videos cancellation
// This can set previous time as available, until virtual calendar is done
const credentialsMap = new Map();
userOwner.credentials.forEach((credential) => {
credentialsMap.set(credential.type, credential);
});
const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter(
(ref) => !!credentialsMap.get(ref.type)
);
bookingRefsFiltered.forEach((bookingRef) => {
if (bookingRef.uid) {
if (bookingRef.type.endsWith("_calendar")) {
const calendar = getCalendar(credentialsMap.get(bookingRef.type));
return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent);
} else if (bookingRef.type.endsWith("_video")) {
return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid);
}
}
});
// Creating cancelled event as placeholders in calendars, remove when virtual calendar handles it
const eventManager = new EventManager({
credentials: userOwner.credentials,
destinationCalendar: userOwner.destinationCalendar,
});
builder.calendarEvent.title = `Cancelled: ${builder.calendarEvent.title}`;
await eventManager.updateAndSetCancelledPlaceholder(builder.calendarEvent, bookingToReschedule);
// Send emails
await sendRequestRescheduleEmail(builder.calendarEvent, {
rescheduleLink: builder.rescheduleLink,
});
}
return res.status(200).json(bookingToReschedule);
} catch (error) {
throw new Error("Error.request.reschedule");
}
};
function validate(
handler: (req: NextApiRequest, res: NextApiResponse) => Promise<RescheduleResponse | NextApiResponse | void>
) {
return async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "POST") {
try {
rescheduleSchema.parse(req.body);
} catch (error) {
if (error instanceof ZodError && error?.name === "ZodError") {
return res.status(400).json(error?.issues);
}
return res.status(402);
}
} else {
return res.status(405);
}
await handler(req, res);
};
}
export default validate(handler);

View file

@ -2,11 +2,11 @@ import { CalendarIcon } from "@heroicons/react/outline";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Fragment } from "react"; import { Fragment } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert } from "@calcom/ui/Alert"; import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button"; import Button from "@calcom/ui/Button";
import { useInViewObserver } from "@lib/hooks/useInViewObserver"; import { useInViewObserver } from "@lib/hooks/useInViewObserver";
import { useLocale } from "@lib/hooks/useLocale";
import { inferQueryInput, trpc } from "@lib/trpc"; import { inferQueryInput, trpc } from "@lib/trpc";
import BookingsShell from "@components/BookingsShell"; import BookingsShell from "@components/BookingsShell";

View file

@ -101,8 +101,8 @@ export default function Type(props: inferSSRProps<typeof getServerSideProps>) {
className="mb-5 sm:mb-6" className="mb-5 sm:mb-6"
/> />
<div className="space-x-2 text-center rtl:space-x-reverse"> <div className="space-x-2 text-center rtl:space-x-reverse">
<Button color="secondary" onClick={() => router.push("/reschedule/" + uid)}> <Button color="secondary" onClick={() => router.back()}>
{t("reschedule_this")} {t("back_to_bookings")}
</Button> </Button>
<Button <Button
data-testid="cancel" data-testid="cancel"

View file

@ -1,5 +1,5 @@
import { CheckIcon } from "@heroicons/react/outline"; import { CheckIcon } from "@heroicons/react/outline";
import { ArrowRightIcon } from "@heroicons/react/solid"; import { ArrowLeftIcon } from "@heroicons/react/solid";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -52,7 +52,7 @@ export default function CancelSuccess() {
<div className="mt-5"> <div className="mt-5">
{!loading && !session?.user && <Button href={eventPage as string}>Pick another</Button>} {!loading && !session?.user && <Button href={eventPage as string}>Pick another</Button>}
{!loading && session?.user && ( {!loading && session?.user && (
<Button data-testid="back-to-bookings" href="/bookings" EndIcon={ArrowRightIcon}> <Button data-testid="back-to-bookings" href="/bookings" StartIcon={ArrowLeftIcon}>
{t("back_to_bookings")} {t("back_to_bookings")}
</Button> </Button>
)} )}

View file

@ -1,5 +1,5 @@
import { CheckIcon } from "@heroicons/react/outline"; import { CheckIcon } from "@heroicons/react/outline";
import { ClockIcon, XIcon } from "@heroicons/react/solid"; import { ArrowLeftIcon, ClockIcon, XIcon } from "@heroicons/react/solid";
import classNames from "classnames"; import classNames from "classnames";
import dayjs from "dayjs"; import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
@ -7,6 +7,7 @@ import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import { createEvent } from "ics"; import { createEvent } from "ics";
import { GetServerSidePropsContext } from "next"; import { GetServerSidePropsContext } from "next";
import { useSession } from "next-auth/react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
@ -133,6 +134,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
const { location: _location, name, reschedule } = router.query; const { location: _location, name, reschedule } = router.query;
const location = Array.isArray(_location) ? _location[0] : _location; const location = Array.isArray(_location) ? _location[0] : _location;
const [is24h, setIs24h] = useState(isBrowserLocale24h()); const [is24h, setIs24h] = useState(isBrowserLocale24h());
const { data: session } = useSession();
const [date, setDate] = useState(dayjs.utc(asStringOrThrow(router.query.date))); const [date, setDate] = useState(dayjs.utc(asStringOrThrow(router.query.date)));
const { isReady, Theme } = useTheme(props.profile.theme); const { isReady, Theme } = useTheme(props.profile.theme);
@ -200,7 +202,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
return encodeURIComponent(event.value ? event.value : false); return encodeURIComponent(event.value ? event.value : false);
} }
const userIsOwner = !!(session?.user?.id && eventType.users.find((user) => (user.id = session.user.id)));
return ( return (
(isReady && ( (isReady && (
<div <div
@ -268,8 +270,8 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
</div> </div>
{location && ( {location && (
<> <>
<div className="font-medium">{t("where")}</div> <div className="mt-6 font-medium">{t("where")}</div>
<div className="col-span-2"> <div className="col-span-2 mt-6">
{location.startsWith("http") ? ( {location.startsWith("http") ? (
<a title="Meeting Link" href={location}> <a title="Meeting Link" href={location}>
{location} {location}
@ -382,7 +384,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
</div> </div>
</div> </div>
)} )}
{!props.hideBranding && ( {!(userIsOwner || props.hideBranding) && (
<div className="border-bookinglightest text-booking-lighter pt-4 text-center text-xs dark:border-gray-900 dark:text-white"> <div className="border-bookinglightest text-booking-lighter pt-4 text-center text-xs dark:border-gray-900 dark:text-white">
<a href="https://cal.com/signup">{t("create_booking_link_with_calcom")}</a> <a href="https://cal.com/signup">{t("create_booking_link_with_calcom")}</a>
@ -405,6 +407,15 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
</form> </form>
</div> </div>
)} )}
{userIsOwner && (
<div className="mt-4">
<Link href="/bookings">
<a className="flex items-center text-black dark:text-white">
<ArrowLeftIcon className="mr-1 h-4 w-4" /> {t("back_to_bookings")}
</a>
</Link>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@ -432,6 +443,7 @@ const getEventTypesFromDB = async (typeId: number) => {
successRedirectUrl: true, successRedirectUrl: true,
users: { users: {
select: { select: {
id: true,
name: true, name: true,
hideBranding: true, hideBranding: true,
plan: true, plan: true,
@ -478,6 +490,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
id: eventType.userId, id: eventType.userId,
}, },
select: { select: {
id: true,
name: true, name: true,
hideBranding: true, hideBranding: true,
plan: true, plan: true,

View file

@ -5,6 +5,7 @@ import { UserPlan } from "@calcom/prisma/client";
import { asStringOrNull } from "@lib/asStringOrNull"; import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability"; import { getWorkingHours } from "@lib/availability";
import getBooking, { GetBookingType } from "@lib/getBooking";
import prisma from "@lib/prisma"; import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps"; import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -20,6 +21,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const slugParam = asStringOrNull(context.query.slug); const slugParam = asStringOrNull(context.query.slug);
const typeParam = asStringOrNull(context.query.type); const typeParam = asStringOrNull(context.query.type);
const dateParam = asStringOrNull(context.query.date); const dateParam = asStringOrNull(context.query.date);
const rescheduleUid = asStringOrNull(context.query.rescheduleUid);
if (!slugParam || !typeParam) { if (!slugParam || !typeParam) {
throw new Error(`File is not named [idOrSlug]/[user]`); throw new Error(`File is not named [idOrSlug]/[user]`);
@ -110,6 +112,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
eventTypeObject.availability = []; eventTypeObject.availability = [];
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBooking(prisma, rescheduleUid);
}
return { return {
props: { props: {
// Team is always pro // Team is always pro
@ -127,6 +134,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
eventType: eventTypeObject, eventType: eventTypeObject,
workingHours, workingHours,
previousPage: context.req.headers.referer ?? null, previousPage: context.req.headers.referer ?? null,
booking,
}, },
}; };
}; };

View file

@ -5,6 +5,7 @@ import { JSONObject } from "superjson/dist/types";
import { getLocationLabels } from "@calcom/app-store/utils"; import { getLocationLabels } from "@calcom/app-store/utils";
import { asStringOrThrow } from "@lib/asStringOrNull"; import { asStringOrThrow } from "@lib/asStringOrNull";
import getBooking, { GetBookingType } from "@lib/getBooking";
import prisma from "@lib/prisma"; import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps"; import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -56,6 +57,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}, },
users: { users: {
select: { select: {
id: true,
avatar: true, avatar: true,
name: true, name: true,
}, },
@ -74,28 +76,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}; };
})[0]; })[0];
async function getBooking() { let booking: GetBookingType | null = null;
return prisma.booking.findFirst({
where: {
uid: asStringOrThrow(context.query.rescheduleUid),
},
select: {
description: true,
attendees: {
select: {
email: true,
name: true,
},
},
},
});
}
type Booking = Prisma.PromiseReturnType<typeof getBooking>;
let booking: Booking | null = null;
if (context.query.rescheduleUid) { if (context.query.rescheduleUid) {
booking = await getBooking(); booking = await getBooking(prisma, context.query.rescheduleUid as string);
} }
const t = await getTranslation(context.locale ?? "en", "common"); const t = await getTranslation(context.locale ?? "en", "common");

View file

@ -109,6 +109,7 @@ test.describe("pro user", () => {
await page.goto("/bookings/upcoming"); await page.goto("/bookings/upcoming");
await page.locator('[data-testid="reschedule"]').click(); await page.locator('[data-testid="reschedule"]').click();
await page.locator('[data-testid="edit"]').click();
await page.waitForNavigation({ await page.waitForNavigation({
url: (url) => { url: (url) => {
const bookingId = url.searchParams.get("rescheduleUid"); const bookingId = url.searchParams.get("rescheduleUid");

View file

@ -43,6 +43,7 @@ test.describe("dynamic booking", () => {
// Logged in // Logged in
await page.goto("/bookings/upcoming"); await page.goto("/bookings/upcoming");
await page.locator('[data-testid="reschedule"]').click(); await page.locator('[data-testid="reschedule"]').click();
await page.locator('[data-testid="edit"]').click();
await page.waitForNavigation({ await page.waitForNavigation({
url: (url) => { url: (url) => {
const bookingId = url.searchParams.get("rescheduleUid"); const bookingId = url.searchParams.get("rescheduleUid");

View file

@ -13,6 +13,7 @@ test.describe.serial("Stripe integration", () => {
test.afterAll(() => { test.afterAll(() => {
teardown.deleteAllPaymentsByEmail("pro@example.com"); teardown.deleteAllPaymentsByEmail("pro@example.com");
teardown.deleteAllBookingsByEmail("pro@example.com"); teardown.deleteAllBookingsByEmail("pro@example.com");
teardown.deleteAllPaymentCredentialsByEmail("pro@example.com");
}); });
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed"); test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");

View file

@ -0,0 +1,80 @@
import { Booking } from "@prisma/client";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import short from "short-uuid";
import { v5 as uuidv5, v4 as uuidv4 } from "uuid";
dayjs.extend(utc);
const translator = short();
const TestUtilCreateBookingOnUserId = async (
userId: number,
username: string,
eventTypeId: number,
{ confirmed = true, rescheduled = false, paid = false, status = "ACCEPTED" }: Partial<Booking>
) => {
const startDate = dayjs().add(1, "day").toDate();
const seed = `${username}:${dayjs(startDate).utc().format()}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
return await prisma?.booking.create({
data: {
uid: uid,
title: "30min",
startTime: startDate,
endTime: dayjs().add(1, "day").add(30, "minutes").toDate(),
user: {
connect: {
id: userId,
},
},
attendees: {
create: {
email: "attendee@example.com",
name: "Attendee Example",
timeZone: "Europe/London",
},
},
eventType: {
connect: {
id: eventTypeId,
},
},
confirmed,
rescheduled,
paid,
status,
},
select: {
id: true,
uid: true,
user: true,
},
});
};
const TestUtilCreatePayment = async (
bookingId: number,
{ success = false, refunded = false }: { success?: boolean; refunded?: boolean }
) => {
return await prisma?.payment.create({
data: {
uid: uuidv4(),
amount: 20000,
fee: 160,
currency: "usd",
success,
refunded,
type: "STRIPE",
data: {},
externalId: "DEMO_PAYMENT_FROM_DB",
booking: {
connect: {
id: bookingId,
},
},
},
});
};
export { TestUtilCreateBookingOnUserId, TestUtilCreatePayment };

View file

@ -1,11 +1,17 @@
import { Prisma } from "@prisma/client";
import prisma from "@lib/prisma"; import prisma from "@lib/prisma";
export const deleteAllBookingsByEmail = async (email: string) => export const deleteAllBookingsByEmail = async (
email: string,
whereConditional: Prisma.BookingWhereInput = {}
) =>
prisma.booking.deleteMany({ prisma.booking.deleteMany({
where: { where: {
user: { user: {
email, email,
}, },
...whereConditional,
}, },
}); });
@ -38,3 +44,20 @@ export const deleteAllPaymentsByEmail = async (email: string) => {
}, },
}); });
}; };
export const deleteAllPaymentCredentialsByEmail = async (email: string) => {
await prisma.user.update({
where: {
email,
},
data: {
credentials: {
deleteMany: {
type: {
endsWith: "_payment",
},
},
},
},
});
};

View file

@ -0,0 +1,246 @@
import { expect, test } from "@playwright/test";
import { BookingStatus } from "@prisma/client";
import dayjs from "dayjs";
import { TestUtilCreateBookingOnUserId, TestUtilCreatePayment } from "./lib/dbSetup";
import { deleteAllBookingsByEmail } from "./lib/teardown";
import { selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
const IS_STRIPE_ENABLED = !!(
process.env.STRIPE_CLIENT_ID &&
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
process.env.STRIPE_PRIVATE_KEY
);
const findUserByEmail = async (email: string) => {
return await prisma?.user.findFirst({
select: {
id: true,
email: true,
username: true,
credentials: true,
},
where: {
email,
},
});
};
test.describe("Reschedule Tests", async () => {
let currentUser: Awaited<ReturnType<typeof findUserByEmail>>;
// Using logged in state from globalSetup
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
test.beforeAll(async () => {
currentUser = await findUserByEmail("pro@example.com");
});
test.afterEach(async () => {
try {
await deleteAllBookingsByEmail("pro@example.com", {
createdAt: { gte: dayjs().startOf("day").toISOString() },
});
} catch (error) {
console.log("Error while trying to delete all bookings from pro user");
}
});
test("Should do a booking request reschedule from /bookings", async ({ page }) => {
const user = currentUser;
const eventType = await prisma?.eventType.findFirst({
where: {
userId: user?.id,
slug: "30min",
},
});
let originalBooking;
if (user && user.id && user.username && eventType) {
originalBooking = await TestUtilCreateBookingOnUserId(user?.id, user?.username, eventType?.id, {
status: BookingStatus.ACCEPTED,
});
}
await page.goto("/bookings/upcoming");
await page.locator('[data-testid="reschedule"]').click();
await page.locator('[data-testid="reschedule_request"]').click();
await page.fill('[data-testid="reschedule_reason"]', "I can't longer have it");
await page.locator('button[data-testid="send_request"]').click();
await page.goto("/bookings/cancelled");
// Find booking that was recently cancelled
const booking = await prisma?.booking.findFirst({
select: {
id: true,
uid: true,
cancellationReason: true,
status: true,
rescheduled: true,
},
where: { id: originalBooking?.id },
});
expect(booking?.rescheduled).toBe(true);
expect(booking?.cancellationReason).toBe("I can't longer have it");
expect(booking?.status).toBe(BookingStatus.CANCELLED);
});
test("Should display former time when rescheduling availability", async ({ page }) => {
const user = currentUser;
const eventType = await prisma?.eventType.findFirst({
where: {
userId: user?.id,
slug: "30min",
},
});
let originalBooking;
if (user && user.id && user.username && eventType) {
originalBooking = await TestUtilCreateBookingOnUserId(user?.id, user?.username, eventType?.id, {
status: BookingStatus.CANCELLED,
rescheduled: true,
});
}
await page.goto(
`/${originalBooking?.user?.username}/${eventType?.slug}?rescheduleUid=${originalBooking?.uid}`
);
const formerTimeElement = await page.locator('[data-testid="former_time_p"]');
await expect(formerTimeElement).toBeVisible();
});
test("Should display request reschedule send on bookings/cancelled", async ({ page }) => {
const user = currentUser;
const eventType = await prisma?.eventType.findFirst({
where: {
userId: user?.id,
slug: "30min",
},
});
if (user && user.id && user.username && eventType) {
await TestUtilCreateBookingOnUserId(user?.id, user?.username, eventType?.id, {
status: BookingStatus.CANCELLED,
rescheduled: true,
});
}
await page.goto("/bookings/cancelled");
const requestRescheduleSentElement = await page.locator('[data-testid="request_reschedule_sent"]').nth(1);
await expect(requestRescheduleSentElement).toBeVisible();
});
test("Should do a reschedule from user owner", async ({ page }) => {
const user = currentUser;
const eventType = await prisma?.eventType.findFirst({
where: {
userId: user?.id,
},
});
if (user?.id && user?.username && eventType?.id) {
const booking = await TestUtilCreateBookingOnUserId(user?.id, user?.username, eventType?.id, {
rescheduled: true,
status: BookingStatus.CANCELLED,
});
await page.goto(`/${user?.username}/${eventType?.slug}?rescheduleUid=${booking?.uid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await expect(page.locator('[name="name"]')).toBeDisabled();
await expect(page.locator('[name="email"]')).toBeDisabled();
await expect(page.locator('[name="notes"]')).toBeDisabled();
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/success");
},
});
await expect(page).toHaveURL(/.*success/);
// NOTE: remove if old booking should not be deleted
const oldBooking = await prisma?.booking.findFirst({ where: { id: booking?.id } });
expect(oldBooking).toBeNull();
const newBooking = await prisma?.booking.findFirst({ where: { fromReschedule: booking?.uid } });
expect(newBooking).not.toBeNull();
}
});
test("Unpaid rescheduling should go to payment page", async ({ page }) => {
let user = currentUser;
test.skip(
IS_STRIPE_ENABLED && !(user && user.credentials.length > 0),
"Skipped as stripe is not installed and user is missing credentials"
);
const eventType = await prisma?.eventType.findFirst({
where: {
userId: user?.id,
slug: "paid",
},
});
if (user?.id && user?.username && eventType?.id) {
const booking = await TestUtilCreateBookingOnUserId(user?.id, user?.username, eventType?.id, {
rescheduled: true,
status: BookingStatus.CANCELLED,
paid: false,
});
if (booking?.id) {
await TestUtilCreatePayment(booking.id, {});
await page.goto(`/${user?.username}/${eventType?.slug}?rescheduleUid=${booking?.uid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await page.waitForNavigation({
url(url) {
return url.pathname.indexOf("/payment") > -1;
},
});
await expect(page).toHaveURL(/.*payment/);
}
}
});
test("Paid rescheduling should go to success page", async ({ page }) => {
let user = currentUser;
try {
const eventType = await prisma?.eventType.findFirst({
where: {
userId: user?.id,
slug: "paid",
},
});
if (user?.id && user?.username && eventType?.id) {
const booking = await TestUtilCreateBookingOnUserId(user?.id, user?.username, eventType?.id, {
rescheduled: true,
status: BookingStatus.CANCELLED,
paid: true,
});
if (booking?.id) {
await TestUtilCreatePayment(booking.id, {});
await page.goto(`/${user?.username}/${eventType?.slug}?rescheduleUid=${booking?.uid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await expect(page).toHaveURL(/.*success/);
}
}
} catch (error) {
await prisma?.payment.delete({
where: {
externalId: "DEMO_PAYMENT_FROM_DB",
},
});
}
});
});

View file

@ -66,6 +66,11 @@
"your_meeting_has_been_booked": "Your meeting has been booked", "your_meeting_has_been_booked": "Your meeting has been booked",
"event_type_has_been_rescheduled_on_time_date": "Your {{eventType}} with {{name}} has been rescheduled to {{time}} ({{timeZone}}) on {{date}}.", "event_type_has_been_rescheduled_on_time_date": "Your {{eventType}} with {{name}} has been rescheduled to {{time}} ({{timeZone}}) on {{date}}.",
"event_has_been_rescheduled": "Updated - Your event has been rescheduled", "event_has_been_rescheduled": "Updated - Your event has been rescheduled",
"request_reschedule_title_attendee": "Request to reschedule your booking",
"request_reschedule_subtitle": "{{organizer}} has cancelled the booking and requested you to pick another time.",
"request_reschedule_title_organizer": "You have requested {{attendee}} to reschedule",
"request_reschedule_subtitle_organizer": "You have cancelled the booking and {{attendee}} should be pick a new booking time with you.",
"reschedule_reason": "Reason for reschedule",
"hi_user_name": "Hi {{name}}", "hi_user_name": "Hi {{name}}",
"ics_event_title": "{{eventType}} with {{name}}", "ics_event_title": "{{eventType}} with {{name}}",
"new_event_subject": "New event: {{attendeeName}} - {{date}} - {{eventType}}", "new_event_subject": "New event: {{attendeeName}} - {{date}} - {{eventType}}",
@ -84,6 +89,7 @@
"meeting_url": "Meeting URL", "meeting_url": "Meeting URL",
"meeting_request_rejected": "Your meeting request has been rejected", "meeting_request_rejected": "Your meeting request has been rejected",
"rescheduled_event_type_subject": "Rescheduled: {{eventType}} with {{name}} at {{date}}", "rescheduled_event_type_subject": "Rescheduled: {{eventType}} with {{name}} at {{date}}",
"requested_to_reschedule_subject_attendee": "Action Required Reschedule: Please book a new to time for {{eventType}} with {{name}}",
"rejected_event_type_with_organizer": "Rejected: {{eventType}} with {{organizer}} on {{date}}", "rejected_event_type_with_organizer": "Rejected: {{eventType}} with {{organizer}} on {{date}}",
"hi": "Hi", "hi": "Hi",
"join_team": "Join team", "join_team": "Join team",
@ -411,7 +417,7 @@
"booking_confirmation": "Confirm your {{eventTypeTitle}} with {{profileName}}", "booking_confirmation": "Confirm your {{eventTypeTitle}} with {{profileName}}",
"booking_reschedule_confirmation": "Reschedule your {{eventTypeTitle}} with {{profileName}}", "booking_reschedule_confirmation": "Reschedule your {{eventTypeTitle}} with {{profileName}}",
"in_person_meeting": "In-person meeting", "in_person_meeting": "In-person meeting",
"link_meeting":"Link meeting", "link_meeting": "Link meeting",
"phone_call": "Phone call", "phone_call": "Phone call",
"phone_number": "Phone Number", "phone_number": "Phone Number",
"enter_phone_number": "Enter phone number", "enter_phone_number": "Enter phone number",
@ -720,5 +726,15 @@
"external_redirect_url": "https://example.com/redirect-to-my-success-page", "external_redirect_url": "https://example.com/redirect-to-my-success-page",
"redirect_url_upgrade_description": "In order to use this feature, you need to upgrade to a Pro account.", "redirect_url_upgrade_description": "In order to use this feature, you need to upgrade to a Pro account.",
"duplicate": "Duplicate", "duplicate": "Duplicate",
"you_can_manage_your_schedules": "You can manage your schedules on the Availability page." "you_can_manage_your_schedules": "You can manage your schedules on the Availability page.",
"request_reschedule_booking": "Request to reschedule your booking",
"reason_for_reschedule": "Reason for reschedule",
"book_a_new_time": "Book a new time",
"reschedule_request_sent": "Reschedule request sent",
"reschedule_modal_description": "This will cancel the scheduled meeting, notify the scheduler and ask them to pick a new time.",
"reason_for_reschedule_request": "Reason for reschedule request",
"send_reschedule_request": "Send reschedule request",
"edit_booking": "Edit booking",
"reschedule_booking": "Reschedule booking",
"former_time": "Former time"
} }

View file

@ -393,6 +393,7 @@ const loggedInViewerRouter = createProtectedRouter()
id: true, id: true,
}, },
}, },
rescheduled: true,
}, },
orderBy, orderBy,
take: take + 1, take: take + 1,

View file

@ -13,20 +13,20 @@
* Override the default tailwindcss-forms styling (default is: 'colors.blue.600') * Override the default tailwindcss-forms styling (default is: 'colors.blue.600')
* @see: https://github.com/tailwindlabs/tailwindcss-forms/issues/14#issuecomment-1005376006 * @see: https://github.com/tailwindlabs/tailwindcss-forms/issues/14#issuecomment-1005376006
*/ */
[type='text']:focus, [type="text"]:focus,
[type='email']:focus, [type="email"]:focus,
[type='url']:focus, [type="url"]:focus,
[type='password']:focus, [type="password"]:focus,
[type='number']:focus, [type="number"]:focus,
[type='date']:focus, [type="date"]:focus,
[type='datetime-local']:focus, [type="datetime-local"]:focus,
[type='month']:focus, [type="month"]:focus,
[type='search']:focus, [type="search"]:focus,
[type='tel']:focus, [type="tel"]:focus,
[type='checkbox']:focus, [type="checkbox"]:focus,
[type='radio']:focus, [type="radio"]:focus,
[type='time']:focus, [type="time"]:focus,
[type='week']:focus, [type="week"]:focus,
[multiple]:focus, [multiple]:focus,
textarea:focus, textarea:focus,
select:focus { select:focus {
@ -217,7 +217,7 @@ button[role="switch"][data-state="checked"] span {
} }
.react-multi-email > [type="text"] { .react-multi-email > [type="text"] {
@apply block w-full rounded-md border-gray-300 shadow-sm dark:border-gray-900 dark:bg-gray-700 dark:text-white sm:text-sm; @apply focus:border-brand block w-full rounded-[2px] border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white sm:text-sm;
} }
.react-multi-email [data-tag] { .react-multi-email [data-tag] {

@ -1 +1 @@
Subproject commit e3409f6aff7e615e061826184a9de8eea3f38cbe Subproject commit fb5ce134e57d708cb46036c53d91bbb1f33072af

View file

@ -161,7 +161,11 @@ export default class EventManager {
* *
* @param event * @param event
*/ */
public async update(event: CalendarEvent, rescheduleUid: string): Promise<CreateUpdateResult> { public async update(
event: CalendarEvent,
rescheduleUid: string,
newBookingId?: number
): Promise<CreateUpdateResult> {
const evt = processLocation(event); const evt = processLocation(event);
if (!rescheduleUid) { if (!rescheduleUid) {
@ -187,6 +191,7 @@ export default class EventManager {
}, },
}, },
destinationCalendar: true, destinationCalendar: true,
payment: true,
}, },
}); });
@ -210,6 +215,23 @@ export default class EventManager {
// Update all calendar events. // Update all calendar events.
results.push(...(await this.updateAllCalendarEvents(evt, booking))); results.push(...(await this.updateAllCalendarEvents(evt, booking)));
const bookingPayment = booking?.payment;
// Updating all payment to new
if (bookingPayment && newBookingId) {
const paymentIds = bookingPayment.map((payment) => payment.id);
await prisma.payment.updateMany({
where: {
id: {
in: paymentIds,
},
},
data: {
bookingId: newBookingId,
},
});
}
// Now we can delete the old booking and its references. // Now we can delete the old booking and its references.
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({ const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: { where: {
@ -345,4 +367,16 @@ export default class EventManager {
return Promise.reject("No suitable credentials given for the requested integration name."); return Promise.reject("No suitable credentials given for the requested integration name.");
} }
} }
/**
* Update event to set a cancelled event placeholder on users calendar
* remove if virtual calendar is already done and user availability its read from there
* and not only in their calendars
* @param event
* @param booking
* @public
*/
public async updateAndSetCancelledPlaceholder(event: CalendarEvent, booking: PartialBooking) {
await this.updateAllCalendarEvents(event, booking);
}
} }

View file

@ -0,0 +1,283 @@
import { Prisma } from "@prisma/client";
import dayjs from "dayjs";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
import { CalendarEventClass } from "./class";
const translator = short();
const userSelect = Prisma.validator<Prisma.UserArgs>()({
select: {
id: true,
email: true,
name: true,
username: true,
timeZone: true,
credentials: true,
bufferTime: true,
destinationCalendar: true,
locale: true,
},
});
type User = Prisma.UserGetPayload<typeof userSelect>;
type PersonAttendeeCommonFields = Pick<User, "id" | "email" | "name" | "locale" | "timeZone" | "username">;
interface ICalendarEventBuilder {
calendarEvent: CalendarEventClass;
eventType: Awaited<ReturnType<CalendarEventBuilder["getEventFromEventId"]>>;
users: Awaited<ReturnType<CalendarEventBuilder["getUserById"]>>[];
attendeesList: PersonAttendeeCommonFields[];
teamMembers: Awaited<ReturnType<CalendarEventBuilder["getTeamMembers"]>>;
rescheduleLink: string;
}
export class CalendarEventBuilder implements ICalendarEventBuilder {
calendarEvent!: CalendarEventClass;
eventType!: ICalendarEventBuilder["eventType"];
users!: ICalendarEventBuilder["users"];
attendeesList: ICalendarEventBuilder["attendeesList"] = [];
teamMembers: ICalendarEventBuilder["teamMembers"] = [];
rescheduleLink!: string;
constructor() {
this.reset();
}
private reset() {
this.calendarEvent = new CalendarEventClass();
}
public init(initProps: CalendarEventClass) {
this.calendarEvent = new CalendarEventClass(initProps);
}
public setEventType(eventType: ICalendarEventBuilder["eventType"]) {
this.eventType = eventType;
}
public async buildEventObjectFromInnerClass(eventId: number) {
const resultEvent = await this.getEventFromEventId(eventId);
if (resultEvent) {
this.eventType = resultEvent;
}
}
public async buildUsersFromInnerClass() {
if (!this.eventType) {
throw new Error("exec BuildEventObjectFromInnerClass before calling this function");
}
let users = this.eventType.users;
/* If this event was pre-relationship migration */
if (!users.length && this.eventType.userId) {
const eventTypeUser = await this.getUserById(this.eventType.userId);
if (!eventTypeUser) {
throw new Error("buildUsersFromINnerClass.eventTypeUser.notFound");
}
users.push(eventTypeUser);
}
this.users = users;
}
public buildAttendeesList() {
// Language Function was set on builder init
this.attendeesList = [
...(this.calendarEvent.attendees as unknown as PersonAttendeeCommonFields[]),
...this.teamMembers,
];
}
private async getUserById(userId: number) {
let resultUser: User | null;
try {
resultUser = await prisma.user.findUnique({
rejectOnNotFound: true,
where: {
id: userId,
},
...userSelect,
});
} catch (error) {
throw new Error("getUsersById.users.notFound");
}
return resultUser;
}
private async getEventFromEventId(eventTypeId: number) {
let resultEventType;
try {
resultEventType = await prisma.eventType.findUnique({
rejectOnNotFound: true,
where: {
id: eventTypeId,
},
select: {
id: true,
users: userSelect,
team: {
select: {
id: true,
name: true,
slug: true,
},
},
slug: true,
teamId: true,
title: true,
length: true,
eventName: true,
schedulingType: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
periodDays: true,
periodCountCalendarDays: true,
requiresConfirmation: true,
userId: true,
price: true,
currency: true,
metadata: true,
destinationCalendar: true,
hideCalendarNotes: true,
},
});
} catch (error) {
throw new Error("Error while getting eventType");
}
return resultEventType;
}
public async buildLuckyUsers() {
if (!this.eventType && this.users && this.users.length) {
throw new Error("exec buildUsersFromInnerClass before calling this function");
}
// @TODO: user?.username gets flagged as null somehow, maybe a filter before map?
const filterUsernames = this.users.filter((user) => user && typeof user.username === "string");
const userUsernames = filterUsernames.map((user) => user.username) as string[]; // @TODO: hack
const users = await prisma.user.findMany({
where: {
username: { in: userUsernames },
eventTypes: {
some: {
id: this.eventType.id,
},
},
},
select: {
id: true,
username: true,
locale: true,
},
});
const userNamesWithBookingCounts = await Promise.all(
users.map(async (user) => ({
username: user.username,
bookingCount: await prisma.booking.count({
where: {
user: {
id: user.id,
},
startTime: {
gt: new Date(),
},
eventTypeId: this.eventType.id,
},
}),
}))
);
const luckyUsers = this.getLuckyUsers(this.users, userNamesWithBookingCounts);
this.users = luckyUsers;
}
private getLuckyUsers(
users: User[],
bookingCounts: {
username: string | null;
bookingCount: number;
}[]
) {
if (!bookingCounts.length) users.slice(0, 1);
const [firstMostAvailableUser] = bookingCounts.sort((a, b) => (a.bookingCount > b.bookingCount ? 1 : -1));
const luckyUser = users.find((user) => user.username === firstMostAvailableUser?.username);
return luckyUser ? [luckyUser] : users;
}
public async buildTeamMembers() {
this.teamMembers = await this.getTeamMembers();
}
private async getTeamMembers() {
// Users[0] its organizer so we are omitting with slice(1)
const teamMemberPromises = this.users.slice(1).map(async function (user) {
return {
id: user.id,
username: user.username,
email: user.email || "", // @NOTE: Should we change this "" to teamMemberId?
name: user.name || "",
timeZone: user.timeZone,
language: {
translate: await getTranslation(user.locale ?? "en", "common"),
locale: user.locale ?? "en",
},
locale: user.locale,
} as PersonAttendeeCommonFields;
});
return await Promise.all(teamMemberPromises);
}
public buildUIDCalendarEvent() {
if (this.users && this.users.length > 0) {
throw new Error("call buildUsers before calling this function");
}
const [mainOrganizer] = this.users;
const seed = `${mainOrganizer.username}:${dayjs(this.calendarEvent.startTime)
.utc()
.format()}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
this.calendarEvent.uid = uid;
}
public setLocation(location: CalendarEventClass["location"]) {
this.calendarEvent.location = location;
}
public setUId(uid: CalendarEventClass["uid"]) {
this.calendarEvent.uid = uid;
}
public setDestinationCalendar(destinationCalendar: CalendarEventClass["destinationCalendar"]) {
this.calendarEvent.destinationCalendar = destinationCalendar;
}
public setHideCalendarNotes(hideCalendarNotes: CalendarEventClass["hideCalendarNotes"]) {
this.calendarEvent.hideCalendarNotes = hideCalendarNotes;
}
public setDescription(description: CalendarEventClass["description"]) {
this.calendarEvent.description = description;
}
public setCancellationReason(cancellationReason: CalendarEventClass["cancellationReason"]) {
this.calendarEvent.cancellationReason = cancellationReason;
}
public buildRescheduleLink(originalBookingUId: string) {
if (!this.eventType) {
throw new Error("Run buildEventObjectFromInnerClass before this function");
}
const isTeam = !!this.eventType.teamId;
const queryParams = new URLSearchParams();
queryParams.set("rescheduleUid", `${originalBookingUId}`);
const rescheduleLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/${
isTeam ? `/team/${this.eventType.team?.slug}` : this.users[0].username
}/${this.eventType.slug}?${queryParams.toString()}`;
this.rescheduleLink = rescheduleLink;
}
}

View file

@ -0,0 +1,31 @@
import { AdditionInformation, CalendarEvent, ConferenceData, Person } from "@calcom/types/Calendar";
import { DestinationCalendar } from ".prisma/client";
class CalendarEventClass implements CalendarEvent {
type!: string;
title!: string;
startTime!: string;
endTime!: string;
organizer!: Person;
attendees!: Person[];
description?: string | null;
team?: { name: string; members: string[] };
location?: string | null;
conferenceData?: ConferenceData;
additionInformation?: AdditionInformation;
uid?: string | null;
videoCallData?: any;
paymentInfo?: any;
destinationCalendar?: DestinationCalendar | null;
cancellationReason?: string | null;
rejectionReason?: string | null;
hideCalendarNotes?: boolean;
constructor(initProps?: CalendarEvent) {
// If more parameters are given we update this
Object.assign(this, initProps);
}
}
export { CalendarEventClass };

View file

@ -0,0 +1,35 @@
import { Booking } from "@prisma/client";
import { CalendarEventBuilder } from "./builder";
export class CalendarEventDirector {
private builder!: CalendarEventBuilder;
private existingBooking!: Partial<Booking>;
private cancellationReason!: string;
public setBuilder(builder: CalendarEventBuilder): void {
this.builder = builder;
}
public setExistingBooking(booking: Booking) {
this.existingBooking = booking;
}
public setCancellationReason(reason: string) {
this.cancellationReason = reason;
}
public async buildForRescheduleEmail(): Promise<void> {
if (this.existingBooking && this.existingBooking.eventTypeId && this.existingBooking.uid) {
await this.builder.buildEventObjectFromInnerClass(this.existingBooking.eventTypeId);
await this.builder.buildUsersFromInnerClass();
this.builder.buildAttendeesList();
this.builder.setLocation(this.existingBooking.location);
this.builder.setUId(this.existingBooking.uid);
this.builder.setCancellationReason(this.cancellationReason);
this.builder.buildRescheduleLink(this.existingBooking.uid);
} else {
throw new Error("buildForRescheduleEmail.missing.params.required");
}
}
}

View file

@ -3,6 +3,6 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": "." "baseUrl": "."
}, },
"include": ["."], "include": [".", "../types/*.d.ts"],
"exclude": ["dist", "build", "node_modules"] "exclude": ["dist", "build", "node_modules"]
} }

View file

@ -1,5 +1,7 @@
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { bookingReferenceMiddleware } from "./middleware";
declare global { declare global {
var prisma: PrismaClient | undefined; var prisma: PrismaClient | undefined;
} }
@ -13,5 +15,7 @@ export const prisma =
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
globalThis.prisma = prisma; globalThis.prisma = prisma;
} }
// If any changed on middleware server restart is required
bookingReferenceMiddleware(prisma);
export default prisma; export default prisma;

View file

@ -0,0 +1,51 @@
import { PrismaClient } from "@prisma/client";
async function middleware(prisma: PrismaClient) {
/***********************************/
/* SOFT DELETE MIDDLEWARE */
/***********************************/
prisma.$use(async (params, next) => {
// Check incoming query type
if (params.model === "BookingReference") {
if (params.action === "delete") {
// Delete queries
// Change action to an update
params.action = "update";
params.args["data"] = { deleted: true };
}
if (params.action === "deleteMany") {
console.log("deletingMany");
// Delete many queries
params.action = "updateMany";
if (params.args.data !== undefined) {
params.args.data["deleted"] = true;
} else {
params.args["data"] = { deleted: true };
}
}
if (params.action === "findUnique") {
// Change to findFirst - you cannot filter
// by anything except ID / unique with findUnique
params.action = "findFirst";
// Add 'deleted' filter
// ID filter maintained
params.args.where["deleted"] = null;
}
if (params.action === "findMany" || params.action === "findFirst") {
// Find many queries
if (params.args.where !== undefined) {
if (params.args.where.deleted === undefined) {
// Exclude deleted records if they have not been explicitly requested
params.args.where["deleted"] = null;
}
} else {
params.args["where"] = { deleted: null };
}
}
}
return next(params);
});
}
export default middleware;

View file

@ -0,0 +1 @@
export { default as bookingReferenceMiddleware } from "./bookingReference";

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "fromReschedule" TEXT,
ADD COLUMN "rescheduled" BOOLEAN;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "BookingReference" ADD COLUMN "deleted" BOOLEAN;

View file

@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "Payment" DROP CONSTRAINT "Payment_bookingId_fkey";
-- AddForeignKey
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -204,8 +204,9 @@ model BookingReference {
meetingId String? meetingId String?
meetingPassword String? meetingPassword String?
meetingUrl String? meetingUrl String?
booking Booking? @relation(fields: [bookingId], references: [id]) booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
bookingId Int? bookingId Int?
deleted Boolean?
} }
model Attendee { model Attendee {
@ -260,6 +261,8 @@ model Booking {
rejectionReason String? rejectionReason String?
dynamicEventSlugRef String? dynamicEventSlugRef String?
dynamicGroupSlugRef String? dynamicGroupSlugRef String?
rescheduled Boolean?
fromReschedule String?
} }
model Schedule { model Schedule {
@ -342,7 +345,7 @@ model Payment {
uid String @unique uid String @unique
type PaymentType type PaymentType
bookingId Int bookingId Int
booking Booking? @relation(fields: [bookingId], references: [id]) booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
amount Int amount Int
fee Int fee Int
currency String currency String

View file

@ -12,6 +12,8 @@ export type Person = {
email: string; email: string;
timeZone: string; timeZone: string;
language: { translate: TFunction; locale: string }; language: { translate: TFunction; locale: string };
username?: string;
id?: string;
}; };
export type EventBusyDate = Record<"start" | "end", Date | string>; export type EventBusyDate = Record<"start" | "end", Date | string>;
@ -70,11 +72,14 @@ export interface AdditionInformation {
hangoutLink?: string; hangoutLink?: string;
} }
// If modifying this interface, probably should update builders/calendarEvent files
export interface CalendarEvent { export interface CalendarEvent {
type: string; type: string;
title: string; title: string;
startTime: string; startTime: string;
endTime: string; endTime: string;
organizer: Person;
attendees: Person[];
additionalNotes?: string | null; additionalNotes?: string | null;
description?: string | null; description?: string | null;
team?: { team?: {
@ -82,8 +87,6 @@ export interface CalendarEvent {
members: string[]; members: string[];
}; };
location?: string | null; location?: string | null;
organizer: Person;
attendees: Person[];
conferenceData?: ConferenceData; conferenceData?: ConferenceData;
additionInformation?: AdditionInformation; additionInformation?: AdditionInformation;
uid?: string | null; uid?: string | null;