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 { PaperAirplaneIcon } from "@heroicons/react/outline";
import { BookingStatus } from "@prisma/client";
import dayjs from "dayjs";
import { useState } from "react";
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 { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
import { TextArea } from "@calcom/ui/form/fields";
import { HttpError } from "@lib/core/http/error";
import { useLocale } from "@lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@lib/trpc";
import { useMeQuery } from "@components/Shell";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
import TableActions, { ActionType } from "@components/ui/TableActions";
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
@ -80,15 +83,42 @@ function BookingListItem(booking: BookingItem) {
{
id: "reschedule",
label: t("reschedule"),
href: `/reschedule/${booking.uid}`,
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 (
<>
<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}>
<DialogContent>
<DialogHeader title={t("rejection_reason_title")} />
@ -146,7 +176,10 @@ function BookingListItem(booking: BookingItem) {
</div>
<div
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.title}
{!!booking?.eventType?.price && !booking.paid && (
@ -161,11 +194,17 @@ function BookingListItem(booking: BookingItem) {
&quot;{booking.description}&quot;
</div>
)}
{booking.attendees.length !== 0 && (
<div className="text-sm text-gray-900 hover:text-blue-500">
<a href={"mailto:" + booking.attendees[0].email}>{booking.attendees[0].email}</a>
</div>
)}
{isCancelled && booking.rescheduled && (
<div className="mt-2 inline-block text-left text-sm md:hidden">
<RequestSentMessage />
</div>
)}
</td>
<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}
{isCancelled && booking.rescheduled && (
<div className="hidden h-full items-center md:flex">
<RequestSentMessage />
</div>
)}
</td>
</tr>
</>

View File

@ -1,11 +1,13 @@
// Get router variables
import {
ArrowLeftIcon,
CalendarIcon,
ChevronDownIcon,
ChevronUpIcon,
ClockIcon,
CreditCardIcon,
GlobeIcon,
InformationCircleIcon,
} from "@heroicons/react/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
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 classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
import { BASE_URL } from "@lib/config/constants";
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import { parseDate } from "@lib/parseDate";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { detectBrowserTimeFormat } from "@lib/timeFormat";
@ -45,12 +48,12 @@ dayjs.extend(customParseFormat);
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 isEmbed = useIsEmbed();
const { rescheduleUid } = router.query;
const { isReady, Theme } = useTheme(profile.theme);
const { t } = useLocale();
const { t, i18n } = useLocale();
const { contracts } = useContracts();
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
let isBackgroundTransparent = useIsBackgroundTransparent();
@ -179,15 +182,21 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
/>
<div className="mt-4 sm:-mt-2">
<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?.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>
<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")}
</div>
{eventType.price > 0 && (
<div>
<CreditCardIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
<div className="text-gray-600 dark:text-white">
<CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 dark:text-gray-400" />
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
@ -200,7 +209,6 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
</div>
</div>
</div>
<p className="mt-3 text-gray-600 dark:text-gray-200">{eventType.description}</p>
</div>
<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}
truncateAfter={3}
/>
<h2 className="dark:text-bookinglight mt-3 font-medium text-gray-500">{profile.name}</h2>
<h1 className="font-cal text-bookingdark mb-4 text-3xl font-semibold dark:text-white">
<h2 className="mt-3 font-medium text-gray-500 dark:text-gray-300">{profile.name}</h2>
<h1 className="font-cal mb-4 text-xl font-semibold text-gray-900 dark:text-white">
{eventType.title}
</h1>
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
<ClockIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
{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 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")}
</p>
{eventType.price > 0 && (
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
<CreditCardIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
<p className="mb-1 -ml-2 px-2 py-1 text-gray-600 dark:text-white">
<CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
@ -248,8 +262,6 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
)}
<TimezoneDropdown />
<p className="mt-3 mb-8 text-gray-600 dark:text-gray-200">{eventType.description}</p>
{previousPage === `${BASE_URL}/${profile.slug}` && (
<div className="flex h-full flex-col justify-end">
<ArrowLeftIcon
@ -259,6 +271,17 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
<p className="sr-only">Go Back</p>
</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>
<DatePicker
date={selectedDate}
@ -305,8 +328,8 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
function TimezoneDropdown() {
return (
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
<Collapsible.Trigger className="min-w-32 text-bookinglight mb-1 -ml-2 px-2 py-1 text-left">
<GlobeIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
<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-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
{timeZone()}
{isTimeOptionsOpen ? (
<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 { useContracts } from "contexts/contractsContext";
import dayjs from "dayjs";
@ -12,7 +18,7 @@ import { FormattedNumber, IntlProvider } from "react-intl";
import { ReactMultiEmail } from "react-multi-email";
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 { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
@ -26,10 +32,9 @@ import { ensureArray } from "@lib/ensureArray";
import useTheme from "@lib/hooks/useTheme";
import { LocationType } from "@lib/location";
import createBooking from "@lib/mutations/bookings/create-booking";
import { parseZone } from "@lib/parseZone";
import { parseDate } from "@lib/parseDate";
import slugify from "@lib/slugify";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { detectBrowserTimeFormat } from "@lib/timeFormat";
import CustomBranding from "@components/CustomBranding";
import AvatarGroup from "@components/ui/AvatarGroup";
@ -147,6 +152,10 @@ const BookingPage = ({
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
const loggedInIsOwner = eventType?.users[0]?.name === session?.user?.name;
const guestListEmails = !isDynamicGroupBooking
? booking?.attendees.slice(1).map((attendee) => attendee.email)
: [];
const defaultValues = () => {
if (!rescheduleUid) {
return {
@ -173,7 +182,8 @@ const BookingPage = ({
return {
name: primaryAttendee.name || "",
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) => {
telemetry.withJitsu((jitsu) =>
jitsu.track(
@ -273,6 +275,8 @@ const BookingPage = ({
});
};
const disableInput = !!rescheduleUid;
return (
<div>
<Theme />
@ -322,16 +326,22 @@ const BookingPage = ({
<h2 className="font-cal text-bookinglight mt-2 font-medium dark:text-gray-300">
{profile.name}
</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}
</h1>
<p className="text-bookinglight mb-2">
<ClockIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
{eventType?.description && (
<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")}
</p>
{eventType.price > 0 && (
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
<CreditCardIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1 dark:text-white">
<CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
@ -342,15 +352,25 @@ const BookingPage = ({
</p>
)}
<p className="text-bookinghighlight mb-4">
<CalendarIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
{parseDate(date)}
<CalendarIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
{parseDate(date, i18n)}
</p>
{eventTypeDetail.isWeb3Active && eventType.metadata.smartContractAddress && (
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
{t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress}
</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 className="sm:w-1/2 sm:pl-8 sm:pr-4">
<Form form={bookingForm} handleSubmit={bookEvent}>
@ -365,8 +385,12 @@ const BookingPage = ({
name="name"
id="name"
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")}
disabled={disableInput}
/>
</div>
</div>
@ -380,9 +404,13 @@ const BookingPage = ({
<EmailInput
{...bookingForm.register("email")}
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"
type="search" // Disables annoying 1password intrusive popup (non-optimal, I know I know...)
disabled={disableInput}
/>
</div>
</div>
@ -399,6 +427,7 @@ const BookingPage = ({
{...bookingForm.register("locationType", { required: true })}
value={location.type}
defaultChecked={selectedLocation === location.type}
disabled={disableInput}
/>
<span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-gray-500">
{locationLabels[location.type]}
@ -421,6 +450,7 @@ const BookingPage = ({
placeholder={t("enter_phone_number")}
id="phone"
required
disabled={disableInput}
/>
</div>
</div>
@ -443,8 +473,12 @@ const BookingPage = ({
})}
id={"custom_" + input.id}
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}
disabled={disableInput}
/>
)}
{input.type === EventTypeCustomInputType.TEXT && (
@ -456,6 +490,7 @@ const BookingPage = ({
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"
placeholder={input.placeholder}
disabled={disableInput}
/>
)}
{input.type === EventTypeCustomInputType.NUMBER && (
@ -507,32 +542,49 @@ const BookingPage = ({
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
{t("guests")}
</label>
<Controller
control={bookingForm.control}
name="guests"
render={({ field: { onChange, value } }) => (
<ReactMultiEmail
className="relative"
placeholder="guest@example.com"
emails={value}
onChange={onChange}
getLabel={(
email: string,
index: number,
removeEmail: (index: number) => void
) => {
return (
<div data-tag key={index}>
{email}
<span data-tag-handle onClick={() => removeEmail(index)}>
×
</span>
</div>
);
}}
/>
)}
/>
{!disableInput && (
<Controller
control={bookingForm.control}
name="guests"
render={({ field: { onChange, value } }) => (
<ReactMultiEmail
className="relative"
placeholder="guest@example.com"
emails={value}
onChange={onChange}
getLabel={(
email: string,
index: number,
removeEmail: (index: number) => void
) => {
return (
<div data-tag key={index} className="cursor-pointer">
{email}
{!disableInput && (
<span data-tag-handle onClick={() => removeEmail(index)}>
×
</span>
)}
</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>
@ -546,9 +598,14 @@ const BookingPage = ({
<textarea
{...bookingForm.register("notes")}
id="notes"
name="notes"
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")}
disabled={disableInput}
/>
</div>
<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 { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button";
import Dropdown, { DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@calcom/ui/Dropdown";
@ -9,56 +8,76 @@ import { SVGComponent } from "@lib/types/SVGComponent";
export type ActionType = {
id: string;
icon: SVGComponent;
icon?: SVGComponent;
label: string;
disabled?: boolean;
color?: "primary" | "secondary";
} & ({ href?: never; onClick: () => any } | { href: string; onClick?: never });
} & ({ href?: never; onClick: () => any } | { href?: string; onClick?: never }) & {
actions?: ActionType[];
};
interface Props {
actions: ActionType[];
}
const DropdownActions = ({ actions, actionTrigger }: { actions: ActionType[]; actionTrigger?: any }) => {
return (
<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">
<DotsHorizontalIcon className="h-5 w-5 group-hover:text-gray-800" />
</DropdownMenuTrigger>
) : (
<DropdownMenuTrigger asChild>{actionTrigger}</DropdownMenuTrigger>
)}
<DropdownMenuContent portalled>
{actions.map((action) => (
<DropdownMenuItem key={action.id} className="focus-visible:outline-none">
<Button
type="button"
size="sm"
color="minimal"
className="w-full rounded-none font-normal"
href={action.href}
StartIcon={action.icon}
onClick={action.onClick}
data-testid={action.id}>
{action.label}
</Button>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</Dropdown>
);
};
const TableActions: FC<Props> = ({ actions }) => {
const { t } = useLocale();
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>
))}
{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">
<Dropdown>
<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" />
</DropdownMenuTrigger>
<DropdownMenuContent portalled>
{actions.map((action) => (
<DropdownMenuItem key={action.id}>
<Button
type="button"
size="lg"
color="minimal"
className="w-full rounded-none font-normal"
href={action.href}
StartIcon={action.icon}
onClick={action.onClick}>
{action.label}
</Button>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</Dropdown>
<DropdownActions actions={actions} />
</div>
</>
);

View File

@ -20,7 +20,8 @@ function PhoneInput<FormValues>({ control, name, ...rest }: PhoneInputProps<Form
name={name}
control={control}
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 useTheme from "@lib/hooks/useTheme";
import { LocationOptionsToString } from "@lib/locationOptions";
import { isBrowserLocale24h } from "@lib/timeFormat";
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">
<CreditCardIcon className="h-8 w-8 text-green-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3
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 && (
<>
<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>

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 AttendeeDeclinedEmail from "@lib/emails/templates/attendee-declined-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 AttendeeScheduledEmail from "@lib/emails/templates/attendee-scheduled-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 OrganizerRequestEmail from "@lib/emails/templates/organizer-request-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 OrganizerScheduledEmail from "@lib/emails/templates/organizer-scheduled-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%;">
<tbody>
<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]-->
<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%">

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

View File

@ -1,4 +1,3 @@
import { Prisma } from "@prisma/client";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
@ -15,6 +14,7 @@ import {
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { asStringOrThrow } from "@lib/asStringOrNull";
import getBooking, { GetBookingType } from "@lib/getBooking";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -102,6 +102,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
disableGuests: true,
users: {
select: {
id: true,
username: true,
name: true,
email: true,
@ -147,28 +148,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
})[0];
async function getBooking() {
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;
let booking: GetBookingType | null = null;
if (context.query.rescheduleUid) {
booking = await getBooking();
booking = await getBooking(prisma, context.query.rescheduleUid as string);
}
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.entryPoints = results[0].createdEvent?.entryPoints;
}
await sendScheduledEmails({ ...evt, additionInformation: metadata });
try {
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({
where: {
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 dayjs from "dayjs";
import dayjsBusinessTime from "dayjs-business-time";
@ -12,12 +19,12 @@ import { v5 as uuidv5 } from "uuid";
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
import EventManager from "@calcom/core/EventManager";
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 logger from "@calcom/lib/logger";
import notEmpty from "@calcom/lib/notEmpty";
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 { 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;
// 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 eventTypeId = reqBody.eventTypeId;
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,
""
);
const evt: CalendarEvent = {
type: eventType.title,
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
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() {
// @TODO: check as metadata
@ -381,6 +426,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
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
? {}
: {
@ -392,51 +443,72 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null;
const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null;
return prisma.booking.create({
const newBookingData: Prisma.BookingCreateInput = {
uid,
title: evt.title,
startTime: dayjs(evt.startTime).toDate(),
endTime: dayjs(evt.endTime).toDate(),
description: evt.additionalNotes,
confirmed: (!eventType.requiresConfirmation && !eventType.price) || !!rescheduleUid,
location: evt.location,
eventType: eventTypeRel,
attendees: {
createMany: {
data: evt.attendees.map((attendee) => {
//if attendee is team member, it should fetch their locale not booker's locale
//perhaps make email fetch request to see if his locale is stored, else
const retObj = {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
locale: attendee.language.locale,
};
return retObj;
}),
},
},
dynamicEventSlugRef,
dynamicGroupSlugRef,
user: {
connect: {
id: users[0].id,
},
},
destinationCalendar: evt.destinationCalendar
? {
connect: { id: evt.destinationCalendar.id },
}
: 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: {
uid,
title: evt.title,
startTime: dayjs(evt.startTime).toDate(),
endTime: dayjs(evt.endTime).toDate(),
description: evt.additionalNotes,
confirmed: (!eventType.requiresConfirmation && !eventType.price) || !!rescheduleUid,
location: evt.location,
eventType: eventTypeRel,
attendees: {
createMany: {
data: evt.attendees.map((attendee) => {
//if attendee is team member, it should fetch their locale not booker's locale
//perhaps make email fetch request to see if his locale is stored, else
const retObj = {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
locale: attendee.language.locale,
};
return retObj;
}),
},
},
dynamicEventSlugRef,
dynamicGroupSlugRef,
user: {
connect: {
id: users[0].id,
},
},
destinationCalendar: evt.destinationCalendar
? {
connect: { id: evt.destinationCalendar.id },
}
: undefined,
},
});
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[] = [];
@ -569,9 +641,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const credentials = await refreshCredentials(user.credentials);
const eventManager = new EventManager({ ...user, credentials });
if (rescheduleUid) {
if (originalRescheduledBooking?.uid) {
// 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
// to the default description when we are sending the emails.
evt.description = eventType.description;
@ -615,7 +687,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
results = createManager.results;
referencesToCreate = createManager.referencesToCreate;
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingCreatingMeetingFailed",
@ -641,9 +712,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await sendAttendeeRequestEmail(evt, attendeesList[0]);
}
if (typeof eventType.price === "number" && eventType.price > 0) {
if (typeof eventType.price === "number" && eventType.price > 0 && !originalRescheduledBooking?.paid) {
try {
const [firstStripeCredential] = user.credentials.filter((cred) => cred.type == "stripe_payment");
if (!booking.user) booking.user = user;
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 { Fragment } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
import { useLocale } from "@lib/hooks/useLocale";
import { inferQueryInput, trpc } from "@lib/trpc";
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"
/>
<div className="space-x-2 text-center rtl:space-x-reverse">
<Button color="secondary" onClick={() => router.push("/reschedule/" + uid)}>
{t("reschedule_this")}
<Button color="secondary" onClick={() => router.back()}>
{t("back_to_bookings")}
</Button>
<Button
data-testid="cancel"

View File

@ -1,5 +1,5 @@
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 { useRouter } from "next/router";
@ -52,7 +52,7 @@ export default function CancelSuccess() {
<div className="mt-5">
{!loading && !session?.user && <Button href={eventPage as string}>Pick another</Button>}
{!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")}
</Button>
)}

View File

@ -1,5 +1,5 @@
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 dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
@ -7,6 +7,7 @@ import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { createEvent } from "ics";
import { GetServerSidePropsContext } from "next";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/router";
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 = Array.isArray(_location) ? _location[0] : _location;
const [is24h, setIs24h] = useState(isBrowserLocale24h());
const { data: session } = useSession();
const [date, setDate] = useState(dayjs.utc(asStringOrThrow(router.query.date)));
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);
}
const userIsOwner = !!(session?.user?.id && eventType.users.find((user) => (user.id = session.user.id)));
return (
(isReady && (
<div
@ -268,8 +270,8 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
</div>
{location && (
<>
<div className="font-medium">{t("where")}</div>
<div className="col-span-2">
<div className="mt-6 font-medium">{t("where")}</div>
<div className="col-span-2 mt-6">
{location.startsWith("http") ? (
<a title="Meeting Link" href={location}>
{location}
@ -382,7 +384,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
</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">
<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>
</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>
@ -432,6 +443,7 @@ const getEventTypesFromDB = async (typeId: number) => {
successRedirectUrl: true,
users: {
select: {
id: true,
name: true,
hideBranding: true,
plan: true,
@ -478,6 +490,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
id: eventType.userId,
},
select: {
id: true,
name: true,
hideBranding: true,
plan: true,

View File

@ -5,6 +5,7 @@ import { UserPlan } from "@calcom/prisma/client";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
import getBooking, { GetBookingType } from "@lib/getBooking";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -20,6 +21,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const slugParam = asStringOrNull(context.query.slug);
const typeParam = asStringOrNull(context.query.type);
const dateParam = asStringOrNull(context.query.date);
const rescheduleUid = asStringOrNull(context.query.rescheduleUid);
if (!slugParam || !typeParam) {
throw new Error(`File is not named [idOrSlug]/[user]`);
@ -110,6 +112,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
eventTypeObject.availability = [];
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBooking(prisma, rescheduleUid);
}
return {
props: {
// Team is always pro
@ -127,6 +134,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
eventType: eventTypeObject,
workingHours,
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 { asStringOrThrow } from "@lib/asStringOrNull";
import getBooking, { GetBookingType } from "@lib/getBooking";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -56,6 +57,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
users: {
select: {
id: true,
avatar: true,
name: true,
},
@ -74,28 +76,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
})[0];
async function getBooking() {
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;
let booking: GetBookingType | null = null;
if (context.query.rescheduleUid) {
booking = await getBooking();
booking = await getBooking(prisma, context.query.rescheduleUid as string);
}
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.locator('[data-testid="reschedule"]').click();
await page.locator('[data-testid="edit"]').click();
await page.waitForNavigation({
url: (url) => {
const bookingId = url.searchParams.get("rescheduleUid");

View File

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

View File

@ -13,6 +13,7 @@ test.describe.serial("Stripe integration", () => {
test.afterAll(() => {
teardown.deleteAllPaymentsByEmail("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");

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";
export const deleteAllBookingsByEmail = async (email: string) =>
export const deleteAllBookingsByEmail = async (
email: string,
whereConditional: Prisma.BookingWhereInput = {}
) =>
prisma.booking.deleteMany({
where: {
user: {
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",
"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",
"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}}",
"ics_event_title": "{{eventType}} with {{name}}",
"new_event_subject": "New event: {{attendeeName}} - {{date}} - {{eventType}}",
@ -84,6 +89,7 @@
"meeting_url": "Meeting URL",
"meeting_request_rejected": "Your meeting request has been rejected",
"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}}",
"hi": "Hi",
"join_team": "Join team",
@ -411,7 +417,7 @@
"booking_confirmation": "Confirm your {{eventTypeTitle}} with {{profileName}}",
"booking_reschedule_confirmation": "Reschedule your {{eventTypeTitle}} with {{profileName}}",
"in_person_meeting": "In-person meeting",
"link_meeting":"Link meeting",
"link_meeting": "Link meeting",
"phone_call": "Phone call",
"phone_number": "Phone Number",
"enter_phone_number": "Enter phone number",
@ -720,5 +726,15 @@
"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.",
"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,
},
},
rescheduled: true,
},
orderBy,
take: take + 1,

View File

@ -13,20 +13,20 @@
* Override the default tailwindcss-forms styling (default is: 'colors.blue.600')
* @see: https://github.com/tailwindlabs/tailwindcss-forms/issues/14#issuecomment-1005376006
*/
[type='text']:focus,
[type='email']:focus,
[type='url']:focus,
[type='password']:focus,
[type='number']:focus,
[type='date']:focus,
[type='datetime-local']:focus,
[type='month']:focus,
[type='search']:focus,
[type='tel']:focus,
[type='checkbox']:focus,
[type='radio']:focus,
[type='time']:focus,
[type='week']:focus,
[type="text"]:focus,
[type="email"]:focus,
[type="url"]:focus,
[type="password"]:focus,
[type="number"]:focus,
[type="date"]:focus,
[type="datetime-local"]:focus,
[type="month"]:focus,
[type="search"]:focus,
[type="tel"]:focus,
[type="checkbox"]:focus,
[type="radio"]:focus,
[type="time"]:focus,
[type="week"]:focus,
[multiple]:focus,
textarea:focus,
select:focus {
@ -217,7 +217,7 @@ button[role="switch"][data-state="checked"] span {
}
.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] {

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

View File

@ -161,7 +161,11 @@ export default class EventManager {
*
* @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);
if (!rescheduleUid) {
@ -187,6 +191,7 @@ export default class EventManager {
},
},
destinationCalendar: true,
payment: true,
},
});
@ -210,6 +215,23 @@ export default class EventManager {
// Update all calendar events.
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.
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: {
@ -345,4 +367,16 @@ export default class EventManager {
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": {
"baseUrl": "."
},
"include": ["."],
"include": [".", "../types/*.d.ts"],
"exclude": ["dist", "build", "node_modules"]
}

View File

@ -1,5 +1,7 @@
import { PrismaClient } from "@prisma/client";
import { bookingReferenceMiddleware } from "./middleware";
declare global {
var prisma: PrismaClient | undefined;
}
@ -13,5 +15,7 @@ export const prisma =
if (process.env.NODE_ENV !== "production") {
globalThis.prisma = prisma;
}
// If any changed on middleware server restart is required
bookingReferenceMiddleware(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?
meetingPassword String?
meetingUrl String?
booking Booking? @relation(fields: [bookingId], references: [id])
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
bookingId Int?
deleted Boolean?
}
model Attendee {
@ -260,6 +261,8 @@ model Booking {
rejectionReason String?
dynamicEventSlugRef String?
dynamicGroupSlugRef String?
rescheduled Boolean?
fromReschedule String?
}
model Schedule {
@ -342,7 +345,7 @@ model Payment {
uid String @unique
type PaymentType
bookingId Int
booking Booking? @relation(fields: [bookingId], references: [id])
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
amount Int
fee Int
currency String

View File

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