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:
parent
6bb4b2e938
commit
3c6ac395cc
2
apps/api
2
apps/api
|
@ -1 +1 @@
|
|||
Subproject commit a1dcfa59bc43d3f71af62ae438f96a667e807913
|
||||
Subproject commit 378cbf8f3a67ea7877296f1da02edb2b6e3efbce
|
|
@ -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) {
|
|||
"{booking.description}"
|
||||
</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>
|
||||
</>
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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" : ""
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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" },
|
||||
};
|
||||
});
|
||||
};
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -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%">
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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" });
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
|
@ -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";
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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");
|
||||
|
||||
|
|
|
@ -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 };
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -393,6 +393,7 @@ const loggedInViewerRouter = createProtectedRouter()
|
|||
id: true,
|
||||
},
|
||||
},
|
||||
rescheduled: true,
|
||||
},
|
||||
orderBy,
|
||||
take: take + 1,
|
||||
|
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 };
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,6 +3,6 @@
|
|||
"compilerOptions": {
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["."],
|
||||
"include": [".", "../types/*.d.ts"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -0,0 +1 @@
|
|||
export { default as bookingReferenceMiddleware } from "./bookingReference";
|
|
@ -0,0 +1,3 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Booking" ADD COLUMN "fromReschedule" TEXT,
|
||||
ADD COLUMN "rescheduled" BOOLEAN;
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "BookingReference" ADD COLUMN "deleted" BOOLEAN;
|
|
@ -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;
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue
Block a user