Recurring Events (#2562)

* Init dev

* UI changes for recurring event + prisma

* Revisiting schema + changes WIP

* UI done, BE WIP

* Feature completion

* Unused query param removed

* Invalid comment removed

* Removed unused translation

* Update apps/web/public/static/locales/en/common.json

Thanks!

Co-authored-by: Peer Richelsen <peeroke@gmail.com>

* Success page changes

* More progress

* Email text tweaks + test + seed

* Tweaking emails + Cal Apps support WIP

* No app integration for now
Final email and pages tweaks to avoid recurring info showed

* Missing comment for clarity

* Yet again, comment

* Last minute fix

* Missing tooltip for upcoming bookings

* Fixing seed

* Fixing import

* Increasing timeout for e2e

* Fixing any

* Apply suggestions from code review

Co-authored-by: Omar López <zomars@me.com>

* Update apps/web/pages/d/[link]/book.tsx

Co-authored-by: Omar López <zomars@me.com>

* Code improvements

* More code improvements

* Reverting back number input arrows

* Update BookingPage.tsx

* Update BookingPage.tsx

* Adds fallback for sendOrganizerPaymentRefundFailedEmail

* Type overkill

* Type fixes

* Type fixes

* Nitpicks

* Update success.tsx

* Update success.tsx

* Update success.tsx

* Fixing types

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Leo Giovanetti 2022-05-05 18:16:25 -03:00 committed by GitHub
parent 26e46ff06c
commit 1a79e0624c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 1416 additions and 1475 deletions

View file

@ -7,7 +7,7 @@ on:
- public/static/locales/** - public/static/locales/**
jobs: jobs:
test: test:
timeout-minutes: 15 timeout-minutes: 20
name: Testing ${{ matrix.node }} and ${{ matrix.os }} name: Testing ${{ matrix.node }} and ${{ matrix.os }}
strategy: strategy:
matrix: matrix:

View file

@ -190,8 +190,10 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
### E2E-Testing ### E2E-Testing
Be sure to set the environment variable `NEXTAUTH_URL` to the correct value. If you are running locally, as the documentation within `.env.example` mentions, the value should be `http://localhost:3000`.
```sh ```sh
# In a terminal. Just run: # In a terminal just run:
yarn test-e2e yarn test-e2e
# To open last HTML report run: # To open last HTML report run:

View file

@ -11,6 +11,10 @@ export default function BookingsShell({ children }: { children: React.ReactNode
name: t("upcoming"), name: t("upcoming"),
href: "/bookings/upcoming", href: "/bookings/upcoming",
}, },
{
name: t("recurring"),
href: "/bookings/recurring",
},
{ {
name: t("past"), name: t("past"),
href: "/bookings/past", href: "/bookings/past",

View file

@ -21,6 +21,7 @@ type AvailableTimesProps = {
afterBufferTime: number; afterBufferTime: number;
eventTypeId: number; eventTypeId: number;
eventLength: number; eventLength: number;
recurringCount: number | undefined;
eventTypeSlug: string; eventTypeSlug: string;
slotInterval: number | null; slotInterval: number | null;
date: Dayjs; date: Dayjs;
@ -37,6 +38,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
eventTypeSlug, eventTypeSlug,
slotInterval, slotInterval,
minimumBookingNotice, minimumBookingNotice,
recurringCount,
timeFormat, timeFormat,
users, users,
schedulingType, schedulingType,
@ -90,6 +92,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
date: slot.time.format(), date: slot.time.format(),
type: eventTypeId, type: eventTypeId,
slug: eventTypeSlug, slug: eventTypeSlug,
/** Treat as recurring only when a count exist and it's not a rescheduling workflow */
count: recurringCount && !rescheduleUid ? recurringCount : undefined,
}, },
}; };

View file

@ -1,26 +1,37 @@
import { BanIcon, CheckIcon, ClockIcon, XIcon, PencilAltIcon } from "@heroicons/react/outline"; import { BanIcon, CheckIcon, ClockIcon, XIcon, PencilAltIcon } from "@heroicons/react/outline";
import { PaperAirplaneIcon } from "@heroicons/react/outline"; import { PaperAirplaneIcon } from "@heroicons/react/outline";
import { RefreshIcon } from "@heroicons/react/solid";
import { BookingStatus } from "@prisma/client"; import { BookingStatus } from "@prisma/client";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useState } from "react"; import { useState } from "react";
import { useMutation } from "react-query"; import { useMutation } from "react-query";
import { Frequency as RRuleFrequency } from "rrule";
import classNames from "@calcom/lib/classNames"; import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button"; import Button from "@calcom/ui/Button";
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog"; import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
import { Tooltip } from "@calcom/ui/Tooltip";
import { TextArea } from "@calcom/ui/form/fields"; import { TextArea } from "@calcom/ui/form/fields";
import { HttpError } from "@lib/core/http/error"; import { HttpError } from "@lib/core/http/error";
import useMeQuery from "@lib/hooks/useMeQuery"; import useMeQuery from "@lib/hooks/useMeQuery";
import { inferQueryOutput, trpc } from "@lib/trpc"; import { parseRecurringDates } from "@lib/parseDate";
import { inferQueryOutput, trpc, inferQueryInput } from "@lib/trpc";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog"; import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
import TableActions, { ActionType } from "@components/ui/TableActions"; import TableActions, { ActionType } from "@components/ui/TableActions";
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number]; type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
function BookingListItem(booking: BookingItem) { type BookingItemProps = BookingItem & {
listingStatus: BookingListingStatus;
recurringCount?: number;
};
function BookingListItem(booking: BookingItemProps) {
// Get user so we can determine 12/24 hour format preferences // Get user so we can determine 12/24 hour format preferences
const query = useMeQuery(); const query = useMeQuery();
const user = query.data; const user = query.data;
@ -30,14 +41,22 @@ function BookingListItem(booking: BookingItem) {
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false); const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
const mutation = useMutation( const mutation = useMutation(
async (confirm: boolean) => { async (confirm: boolean) => {
let body = {
id: booking.id,
confirmed: confirm,
language: i18n.language,
reason: rejectionReason,
};
/**
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
* the "Upcoming" tab, to support confirming discretionally in the "Recurring" tab.
*/
if (booking.listingStatus === "upcoming" && booking.recurringEventId !== null) {
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
}
const res = await fetch("/api/book/confirm", { const res = await fetch("/api/book/confirm", {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ body: JSON.stringify(body),
id: booking.id,
confirmed: confirm,
language: i18n.language,
reason: rejectionReason,
}),
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
@ -58,14 +77,20 @@ function BookingListItem(booking: BookingItem) {
const pendingActions: ActionType[] = [ const pendingActions: ActionType[] = [
{ {
id: "reject", id: "reject",
label: t("reject"), label:
booking.listingStatus === "upcoming" && booking.recurringEventId !== null
? t("reject_all")
: t("reject"),
onClick: () => setRejectionDialogIsOpen(true), onClick: () => setRejectionDialogIsOpen(true),
icon: BanIcon, icon: BanIcon,
disabled: mutation.isLoading, disabled: mutation.isLoading,
}, },
{ {
id: "confirm", id: "confirm",
label: t("confirm"), label:
booking.listingStatus === "upcoming" && booking.recurringEventId !== null
? t("confirm_all")
: t("confirm"),
onClick: () => mutation.mutate(true), onClick: () => mutation.mutate(true),
icon: CheckIcon, icon: CheckIcon,
disabled: mutation.isLoading, disabled: mutation.isLoading,
@ -112,6 +137,19 @@ function BookingListItem(booking: BookingItem) {
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY"); const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false); const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
// Calculate the booking date(s)
let recurringStrings: string[] = [];
if (booking.recurringCount && booking.eventType.recurringEvent?.freq !== null) {
[recurringStrings] = parseRecurringDates(
{
startDate: booking.startTime,
recurringEvent: booking.eventType.recurringEvent,
recurringCount: booking.recurringCount,
},
i18n
);
}
return ( return (
<> <>
<RescheduleDialog <RescheduleDialog
@ -154,12 +192,40 @@ function BookingListItem(booking: BookingItem) {
</Dialog> </Dialog>
<tr className="flex"> <tr className="flex">
<td className="hidden whitespace-nowrap py-4 align-top ltr:pl-6 rtl:pr-6 sm:table-cell"> <td className="hidden whitespace-nowrap py-4 align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:w-56">
<div className="text-sm leading-6 text-gray-900">{startTime}</div> <div className="text-sm leading-6 text-gray-900">{startTime}</div>
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
{dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "} {dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} {dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
</div> </div>
<div className="text-sm text-gray-400">
{booking.recurringCount &&
booking.eventType?.recurringEvent?.freq &&
booking.listingStatus === "upcoming" && (
<div className="underline decoration-gray-400 decoration-dashed underline-offset-2">
<div className="flex">
<Tooltip
content={recurringStrings.map((aDate, key) => (
<p key={key}>{aDate}</p>
))}>
<p className="text-gray-600 dark:text-white">
<RefreshIcon className="mr-1 -mt-1 inline-block h-4 w-4 text-gray-400" />
{`${t("every_for_freq", {
freq: t(
`${RRuleFrequency[booking.eventType.recurringEvent.freq]
.toString()
.toLowerCase()}`
),
})} ${booking.recurringCount} ${t(
`${RRuleFrequency[booking.eventType.recurringEvent.freq].toString().toLowerCase()}`,
{ count: booking.recurringCount }
)}`}
</p>
</Tooltip>
</div>
</div>
)}
</div>
</td> </td>
<td className={"flex-1 py-4 ltr:pl-4 rtl:pr-4" + (booking.rejected ? " line-through" : "")}> <td className={"flex-1 py-4 ltr:pl-4 rtl:pr-4" + (booking.rejected ? " line-through" : "")}>
<div className="sm:hidden"> <div className="sm:hidden">

View file

@ -8,6 +8,7 @@ import {
CreditCardIcon, CreditCardIcon,
GlobeIcon, GlobeIcon,
InformationCircleIcon, InformationCircleIcon,
RefreshIcon,
} from "@heroicons/react/solid"; } from "@heroicons/react/solid";
import * as Collapsible from "@radix-ui/react-collapsible"; import * as Collapsible from "@radix-ui/react-collapsible";
import { useContracts } from "contexts/contractsContext"; import { useContracts } from "contexts/contractsContext";
@ -17,6 +18,7 @@ import utc from "dayjs/plugin/utc";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { FormattedNumber, IntlProvider } from "react-intl"; import { FormattedNumber, IntlProvider } from "react-intl";
import { Frequency as RRuleFrequency } from "rrule";
import { import {
useEmbedStyles, useEmbedStyles,
@ -27,11 +29,11 @@ import {
useEmbedNonStylesConfig, useEmbedNonStylesConfig,
} from "@calcom/embed-core"; } from "@calcom/embed-core";
import classNames from "@calcom/lib/classNames"; import classNames from "@calcom/lib/classNames";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { asStringOrNull } from "@lib/asStringOrNull"; import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock"; import { timeZone } from "@lib/clock";
import { BASE_URL, WEBAPP_URL } from "@lib/config/constants";
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally"; import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
import useTheme from "@lib/hooks/useTheme"; import useTheme from "@lib/hooks/useTheme";
import { isBrandingHidden } from "@lib/isBrandingHidden"; import { isBrandingHidden } from "@lib/isBrandingHidden";
@ -101,6 +103,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
} }
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat); const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
const [recurringEventCount, setRecurringEventCount] = useState(eventType.recurringEvent?.count);
const telemetry = useTelemetry(); const telemetry = useTelemetry();
@ -142,6 +145,15 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
setTimeFormat(is24hClock ? "HH:mm" : "h:mma"); setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
}; };
// Recurring event sidebar requires more space
const maxWidth = selectedDate
? recurringEventCount
? "max-w-6xl"
: "max-w-5xl"
: recurringEventCount
? "max-w-4xl"
: "max-w-3xl";
return ( return (
<> <>
<Theme /> <Theme />
@ -158,9 +170,8 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
className={classNames( className={classNames(
shouldAlignCentrally ? "mx-auto" : "", shouldAlignCentrally ? "mx-auto" : "",
isEmbed isEmbed
? classNames(selectedDate ? "max-w-5xl" : "max-w-3xl") ? classNames(maxWidth)
: "transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24 " + : classNames("transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24", maxWidth)
(selectedDate ? "max-w-5xl" : "max-w-3xl")
)}> )}>
{isReady && ( {isReady && (
<div <div
@ -168,7 +179,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
className={classNames( className={classNames(
isBackgroundTransparent ? "" : "bg-white dark:bg-gray-800 sm:dark:border-gray-600", isBackgroundTransparent ? "" : "bg-white dark:bg-gray-800 sm:dark:border-gray-600",
"border-bookinglightest rounded-md md:border", "border-bookinglightest rounded-md md:border",
isEmbed ? "mx-auto" : selectedDate ? "max-w-5xl" : "max-w-3xl" isEmbed ? "mx-auto" : maxWidth
)}> )}>
{/* mobile: details */} {/* mobile: details */}
<div className="block p-4 sm:p-8 md:hidden"> <div className="block p-4 sm:p-8 md:hidden">
@ -243,7 +254,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
<div <div
className={ className={
"hidden pr-8 sm:border-r sm:dark:border-gray-700 md:flex md:flex-col " + "hidden pr-8 sm:border-r sm:dark:border-gray-700 md:flex md:flex-col " +
(selectedDate ? "sm:w-1/3" : "sm:w-1/2") (selectedDate ? "sm:w-1/3" : recurringEventCount ? "sm:w-2/3" : "sm:w-1/2")
}> }>
<AvatarGroup <AvatarGroup
border="border-2 dark:border-gray-800 border-white" border="border-2 dark:border-gray-800 border-white"
@ -267,15 +278,42 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
{eventType.title} {eventType.title}
</h1> </h1>
{eventType?.description && ( {eventType?.description && (
<p className="text-bookinglight mb-2 dark:text-white"> <p className="text-bookinglight mb-3 dark:text-white">
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" /> <InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
{eventType.description} {eventType.description}
</p> </p>
)} )}
<p className="text-bookinglight mb-2 dark:text-white"> <p className="text-bookinglight mb-3 dark:text-white">
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" /> <ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
{eventType.length} {t("minutes")} {eventType.length} {t("minutes")}
</p> </p>
{!rescheduleUid && eventType.recurringEvent?.count && eventType.recurringEvent?.freq && (
<div className="mb-3 text-gray-600 dark:text-white">
<RefreshIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
<p className="mb-1 -ml-2 inline px-2 py-1">
{t("every_for_freq", {
freq: t(
`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`
),
})}
</p>
<input
type="number"
min="1"
max={eventType.recurringEvent.count}
className="w-16 rounded-sm border-gray-300 bg-white text-gray-600 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600 dark:text-white sm:text-sm"
defaultValue={eventType.recurringEvent.count}
onChange={(event) => {
setRecurringEventCount(parseInt(event?.target.value));
}}
/>
<p className="inline text-gray-600 dark:text-white">
{t(`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`, {
count: recurringEventCount,
})}
</p>
</div>
)}
{eventType.price > 0 && ( {eventType.price > 0 && (
<p className="mb-1 -ml-2 px-2 py-1 text-gray-600 dark:text-white"> <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" /> <CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
@ -302,7 +340,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
{booking?.startTime && rescheduleUid && ( {booking?.startTime && rescheduleUid && (
<div> <div>
<p <p
className="mt-4 mb-2 text-gray-600 dark:text-white" className="mt-4 mb-3 text-gray-600 dark:text-white"
data-testid="former_time_p_desktop"> data-testid="former_time_p_desktop">
{t("former_time")} {t("former_time")}
</p> </p>
@ -340,6 +378,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
eventTypeSlug={eventType.slug} eventTypeSlug={eventType.slug}
slotInterval={eventType.slotInterval} slotInterval={eventType.slotInterval}
eventLength={eventType.length} eventLength={eventType.length}
recurringCount={recurringEventCount}
date={selectedDate} date={selectedDate}
users={eventType.users} users={eventType.users}
schedulingType={eventType.schedulingType ?? null} schedulingType={eventType.schedulingType ?? null}

View file

@ -4,6 +4,7 @@ import {
CreditCardIcon, CreditCardIcon,
ExclamationIcon, ExclamationIcon,
InformationCircleIcon, InformationCircleIcon,
RefreshIcon,
} from "@heroicons/react/solid"; } from "@heroicons/react/solid";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { EventTypeCustomInputType } from "@prisma/client"; import { EventTypeCustomInputType } from "@prisma/client";
@ -18,6 +19,8 @@ import { Controller, useForm, useWatch } from "react-hook-form";
import { FormattedNumber, IntlProvider } from "react-intl"; import { FormattedNumber, IntlProvider } from "react-intl";
import { ReactMultiEmail } from "react-multi-email"; import { ReactMultiEmail } from "react-multi-email";
import { useMutation } from "react-query"; import { useMutation } from "react-query";
import { Frequency as RRuleFrequency } from "rrule";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod"; import { z } from "zod";
import { import {
@ -31,7 +34,9 @@ import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error"; import { HttpError } from "@calcom/lib/http-error";
import { createPaymentLink } from "@calcom/stripe/client"; import { createPaymentLink } from "@calcom/stripe/client";
import { RecurringEvent } from "@calcom/types/Calendar";
import { Button } from "@calcom/ui/Button"; import { Button } from "@calcom/ui/Button";
import { Tooltip } from "@calcom/ui/Tooltip";
import { EmailInput, Form } from "@calcom/ui/form/fields"; import { EmailInput, Form } from "@calcom/ui/form/fields";
import { asStringOrNull } from "@lib/asStringOrNull"; import { asStringOrNull } from "@lib/asStringOrNull";
@ -40,9 +45,11 @@ import { ensureArray } from "@lib/ensureArray";
import useTheme from "@lib/hooks/useTheme"; import useTheme from "@lib/hooks/useTheme";
import { LocationType } from "@lib/location"; import { LocationType } from "@lib/location";
import createBooking from "@lib/mutations/bookings/create-booking"; import createBooking from "@lib/mutations/bookings/create-booking";
import { parseDate } from "@lib/parseDate"; import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
import { parseDate, parseRecurringDates } from "@lib/parseDate";
import slugify from "@lib/slugify"; import slugify from "@lib/slugify";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { BookingCreateBody } from "@lib/types/booking";
import CustomBranding from "@components/CustomBranding"; import CustomBranding from "@components/CustomBranding";
import AvatarGroup from "@components/ui/AvatarGroup"; import AvatarGroup from "@components/ui/AvatarGroup";
@ -76,6 +83,7 @@ const BookingPage = ({
booking, booking,
profile, profile,
isDynamicGroupBooking, isDynamicGroupBooking,
recurringEventCount,
locationLabels, locationLabels,
hasHashedBookingLink, hasHashedBookingLink,
hashedLink, hashedLink,
@ -140,6 +148,37 @@ const BookingPage = ({
}, },
}); });
const recurringMutation = useMutation(createRecurringBooking, {
onSuccess: async (responseData = []) => {
const { attendees = [], recurringEventId } = responseData[0] || {};
const location = (function humanReadableLocation(location) {
if (!location) {
return;
}
if (location.includes("integration")) {
return t("web_conferencing_details_to_follow");
}
return location;
})(responseData[0].location);
return router.push({
pathname: "/success",
query: {
date,
type: eventType.id,
eventSlug: eventType.slug,
recur: recurringEventId,
user: profile.slug,
reschedule: !!rescheduleUid,
name: attendees[0].name,
email: attendees[0].email,
location,
eventName: profile.eventName || "",
},
});
},
});
const rescheduleUid = router.query.rescheduleUid as string; const rescheduleUid = router.query.rescheduleUid as string;
const { isReady, Theme } = useTheme(profile.theme); const { isReady, Theme } = useTheme(profile.theme);
const date = asStringOrNull(router.query.date); const date = asStringOrNull(router.query.date);
@ -243,6 +282,20 @@ const BookingPage = ({
} }
}; };
// Calculate the booking date(s)
let recurringStrings: string[] = [],
recurringDates: Date[] = [];
if (eventType.recurringEvent?.freq && recurringEventCount !== null) {
[recurringStrings, recurringDates] = parseRecurringDates(
{
startDate: date,
recurringEvent: eventType.recurringEvent,
recurringCount: parseInt(recurringEventCount.toString()),
},
i18n
);
}
const bookEvent = (booking: BookingFormValues) => { const bookEvent = (booking: BookingFormValues) => {
telemetry.withJitsu((jitsu) => telemetry.withJitsu((jitsu) =>
jitsu.track( jitsu.track(
@ -265,7 +318,7 @@ const BookingPage = ({
{} {}
); );
let web3Details; let web3Details: Record<"userWallet" | "userSignature", string> | undefined;
if (eventTypeDetail.metadata.smartContractAddress) { if (eventTypeDetail.metadata.smartContractAddress) {
web3Details = { web3Details = {
// @ts-ignore // @ts-ignore
@ -274,28 +327,59 @@ const BookingPage = ({
}; };
} }
mutation.mutate({ if (recurringDates.length) {
...booking, // Identify set of bookings to one intance of recurring event to support batch changes
web3Details, const recurringEventId = uuidv4();
start: dayjs(date).format(), const recurringBookings = recurringDates.map((recurringDate) => ({
end: dayjs(date).add(eventType.length, "minute").format(), ...booking,
eventTypeId: eventType.id, web3Details,
eventTypeSlug: eventType.slug, start: dayjs(recurringDate).format(),
timeZone: timeZone(), end: dayjs(recurringDate).add(eventType.length, "minute").format(),
language: i18n.language, eventTypeId: eventType.id,
rescheduleUid, eventTypeSlug: eventType.slug,
user: router.query.user, recurringEventId,
location: getLocationValue( // Added to track down the number of actual occurrences selected by the user
booking.locationType ? booking : { ...booking, locationType: selectedLocation } recurringCount: recurringDates.length,
), timeZone: timeZone(),
metadata, language: i18n.language,
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({ rescheduleUid,
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label, user: router.query.user,
value: booking.customInputs![inputId], location: getLocationValue(
})), booking.locationType ? booking : { ...booking, locationType: selectedLocation }
hasHashedBookingLink, ),
hashedLink, metadata,
}); customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
value: booking.customInputs![inputId],
})),
hasHashedBookingLink,
hashedLink,
}));
recurringMutation.mutate(recurringBookings);
} else {
mutation.mutate({
...booking,
web3Details,
start: dayjs(date).format(),
end: dayjs(date).add(eventType.length, "minute").format(),
eventTypeId: eventType.id,
eventTypeSlug: eventType.slug,
timeZone: timeZone(),
language: i18n.language,
rescheduleUid,
user: router.query.user,
location: getLocationValue(
booking.locationType ? booking : { ...booking, locationType: selectedLocation }
),
metadata,
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
value: booking.customInputs![inputId],
})),
hasHashedBookingLink,
hashedLink,
});
}
}; };
const disableInput = !!rescheduleUid; const disableInput = !!rescheduleUid;
@ -375,10 +459,40 @@ const BookingPage = ({
</IntlProvider> </IntlProvider>
</p> </p>
)} )}
<p className="text-bookinghighlight mb-4"> {!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
<CalendarIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" /> <div className="mb-3 text-gray-600 dark:text-white">
{parseDate(date, i18n)} <RefreshIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
</p> <p className="mb-1 -ml-2 inline px-2 py-1">
{`${t("every_for_freq", {
freq: t(`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`),
})} ${recurringEventCount} ${t(
`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`,
{ count: parseInt(recurringEventCount.toString()) }
)}`}
</p>
</div>
)}
<div className="text-bookinghighlight mb-4 flex">
<CalendarIcon className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
<div className="-mt-1">
{(rescheduleUid || !eventType.recurringEvent.freq) && parseDate(date, i18n)}
{!rescheduleUid &&
eventType.recurringEvent.freq &&
recurringStrings.slice(0, 5).map((aDate, key) => <p key={key}>{aDate}</p>)}
{!rescheduleUid && eventType.recurringEvent.freq && recurringStrings.length > 5 && (
<div className="flex">
<Tooltip
content={recurringStrings.slice(5).map((aDate, key) => (
<p key={key}>{aDate}</p>
))}>
<p className="text-gray-600 dark:text-white">
{t("plus_more", { count: recurringStrings.length - 5 })}
</p>
</Tooltip>
</div>
)}
</div>
</div>
{eventTypeDetail.isWeb3Active && eventType.metadata.smartContractAddress && ( {eventTypeDetail.isWeb3Active && eventType.metadata.smartContractAddress && (
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1"> <p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
{t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress} {t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress}

View file

@ -77,7 +77,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
/> />
<DialogFooter> <DialogFooter>
<DialogClose> <DialogClose asChild>
<Button color="secondary">{t("cancel")}</Button> <Button color="secondary">{t("cancel")}</Button>
</DialogClose> </DialogClose>
<Button <Button

View file

@ -1,11 +1,13 @@
import { ClockIcon, CreditCardIcon, UserIcon, UsersIcon } from "@heroicons/react/solid"; import { ClockIcon, CreditCardIcon, RefreshIcon, UserIcon, UsersIcon } from "@heroicons/react/solid";
import { SchedulingType } from "@prisma/client"; import { SchedulingType } from "@prisma/client";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import React from "react"; import React, { useMemo } from "react";
import { FormattedNumber, IntlProvider } from "react-intl"; import { FormattedNumber, IntlProvider } from "react-intl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RecurringEvent } from "@calcom/types/Calendar";
import classNames from "@lib/classNames"; import classNames from "@lib/classNames";
import { useLocale } from "@lib/hooks/useLocale";
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({ const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
select: { select: {
@ -14,6 +16,7 @@ const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
price: true, price: true,
currency: true, currency: true,
schedulingType: true, schedulingType: true,
recurringEvent: true,
description: true, description: true,
}, },
}); });
@ -28,6 +31,11 @@ export type EventTypeDescriptionProps = {
export const EventTypeDescription = ({ eventType, className }: EventTypeDescriptionProps) => { export const EventTypeDescription = ({ eventType, className }: EventTypeDescriptionProps) => {
const { t } = useLocale(); const { t } = useLocale();
const recurringEvent: RecurringEvent = useMemo(
() => (eventType.recurringEvent as RecurringEvent) || [],
[eventType.recurringEvent]
);
return ( return (
<> <>
<div className={classNames("text-neutral-500 dark:text-white", className)}> <div className={classNames("text-neutral-500 dark:text-white", className)}>
@ -54,6 +62,12 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
{t("1_on_1")} {t("1_on_1")}
</li> </li>
)} )}
{recurringEvent?.count && recurringEvent.count > 0 && (
<li className="flex whitespace-nowrap">
<RefreshIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
{t("repeats_up_to", { count: recurringEvent.count })}
</li>
)}
{eventType.price > 0 && ( {eventType.price > 0 && (
<li className="flex whitespace-nowrap"> <li className="flex whitespace-nowrap">
<CreditCardIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" /> <CreditCardIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />

View file

@ -0,0 +1,132 @@
import { Collapsible, CollapsibleContent } from "@radix-ui/react-collapsible";
import React, { useState } from "react";
import { UseFormReturn } from "react-hook-form";
import { Frequency as RRuleFrequency } from "rrule";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RecurringEvent } from "@calcom/types/Calendar";
import Select from "@components/ui/form/Select";
type RecurringEventControllerProps = { recurringEvent: RecurringEvent; formMethods: UseFormReturn<any, any> };
export default function RecurringEventController({
recurringEvent,
formMethods,
}: RecurringEventControllerProps) {
const { t } = useLocale();
const [recurringEventDefined, setRecurringEventDefined] = useState(recurringEvent?.count !== undefined);
const [recurringEventInterval, setRecurringEventInterval] = useState(recurringEvent?.interval || 1);
const [recurringEventFrequency, setRecurringEventFrequency] = useState(
recurringEvent?.freq || RRuleFrequency.WEEKLY
);
const [recurringEventCount, setRecurringEventCount] = useState(recurringEvent?.count || 12);
/* Just yearly-0, monthly-1 and weekly-2 */
const recurringEventFreqOptions = Object.entries(RRuleFrequency)
.filter(([key, value]) => isNaN(Number(key)) && Number(value) < 3)
.map(([key, value]) => ({
label: t(`${key.toString().toLowerCase()}`, { count: recurringEventInterval }),
value: value.toString(),
}));
return (
<div className="block items-start sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor="recurringEvent" className="flex text-sm font-medium text-neutral-700">
{t("recurring_event")}
</label>
</div>
<div className="w-full">
<div className="relative flex items-start">
<div className="flex h-5 items-center">
<input
onChange={(event) => {
setRecurringEventDefined(event?.target.checked);
if (!event?.target.checked) {
formMethods.setValue("recurringEvent", {});
} else {
formMethods.setValue(
"recurringEvent",
recurringEventDefined
? recurringEvent
: {
interval: 1,
count: 12,
freq: RRuleFrequency.WEEKLY,
}
);
}
recurringEvent = formMethods.getValues("recurringEvent");
}}
type="checkbox"
className="text-primary-600 h-4 w-4 rounded border-gray-300"
defaultChecked={recurringEventDefined}
data-testid="recurring-event-check"
/>
</div>
<div className="text-sm ltr:ml-3 rtl:mr-3">
<p className="text-neutral-900">{t("recurring_event_description")}</p>
</div>
</div>
<Collapsible
open={recurringEventDefined}
data-testid="recurring-event-collapsible"
onOpenChange={() => setRecurringEventDefined(!recurringEventDefined)}>
<CollapsibleContent className="mt-4 text-sm">
<div className="flex items-center">
<p className="mr-2 text-neutral-900">{t("repeats_every")}</p>
<input
type="number"
min="1"
max="20"
className="block w-16 rounded-sm border-gray-300 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
defaultValue={recurringEvent?.interval || 1}
onChange={(event) => {
setRecurringEventInterval(parseInt(event?.target.value));
recurringEvent.interval = parseInt(event?.target.value);
formMethods.setValue("recurringEvent", recurringEvent);
}}
/>
<Select
options={recurringEventFreqOptions}
value={recurringEventFreqOptions[recurringEventFrequency]}
isSearchable={false}
className="w-18 block min-w-0 rounded-sm sm:text-sm"
onChange={(e) => {
if (e?.value) {
setRecurringEventFrequency(parseInt(e?.value));
recurringEvent.freq = parseInt(e?.value);
formMethods.setValue("recurringEvent", recurringEvent);
}
}}
/>
</div>
<div className="mt-4 flex items-center">
<p className="mr-2 text-neutral-900">{t("max")}</p>
<input
type="number"
min="1"
max="20"
className="block w-16 rounded-sm border-gray-300 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
defaultValue={recurringEvent?.count || 12}
onChange={(event) => {
setRecurringEventCount(parseInt(event?.target.value));
recurringEvent.count = parseInt(event?.target.value);
formMethods.setValue("recurringEvent", recurringEvent);
}}
/>
<p className="mr-2 text-neutral-900">
{t(`${RRuleFrequency[recurringEventFrequency].toString().toLowerCase()}`, {
count: recurringEventCount,
})}
</p>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
);
}

View file

@ -14,13 +14,13 @@ import Dropdown, {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@calcom/ui/Dropdown"; } from "@calcom/ui/Dropdown";
import { Tooltip } from "@calcom/ui/Tooltip";
import TeamAvailabilityModal from "@ee/components/team/availability/TeamAvailabilityModal"; import TeamAvailabilityModal from "@ee/components/team/availability/TeamAvailabilityModal";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar"; import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import useCurrentUserId from "@lib/hooks/useCurrentUserId"; import useCurrentUserId from "@lib/hooks/useCurrentUserId";
import { inferQueryOutput, trpc } from "@lib/trpc"; import { inferQueryOutput, trpc } from "@lib/trpc";
import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Avatar from "@components/ui/Avatar"; import Avatar from "@components/ui/Avatar";
import ModalContainer from "@components/ui/ModalContainer"; import ModalContainer from "@components/ui/ModalContainer";

View file

@ -19,12 +19,12 @@ import Dropdown, {
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuSeparator, DropdownMenuSeparator,
} from "@calcom/ui/Dropdown"; } from "@calcom/ui/Dropdown";
import { Tooltip } from "@calcom/ui/Tooltip";
import classNames from "@lib/classNames"; import classNames from "@lib/classNames";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar"; import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { trpc, inferQueryOutput } from "@lib/trpc"; import { trpc, inferQueryOutput } from "@lib/trpc";
import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Avatar from "@components/ui/Avatar"; import Avatar from "@components/ui/Avatar";

View file

@ -1,6 +1,6 @@
import { InformationCircleIcon } from "@heroicons/react/solid"; import { InformationCircleIcon } from "@heroicons/react/solid";
import { Tooltip } from "@components/Tooltip"; import { Tooltip } from "@calcom/ui/Tooltip";
export default function InfoBadge({ content }: { content: string }) { export default function InfoBadge({ content }: { content: string }) {
return ( return (

View file

@ -3,12 +3,12 @@ import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
import classNames from "@calcom/lib/classNames"; import classNames from "@calcom/lib/classNames";
import Button from "@calcom/ui/Button"; import Button from "@calcom/ui/Button";
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog"; import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
import { Tooltip } from "@calcom/ui/Tooltip";
import { useLocale } from "@lib/hooks/useLocale"; import { useLocale } from "@lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@lib/trpc"; import { inferQueryOutput, trpc } from "@lib/trpc";
import { ListItem } from "@components/List"; import { ListItem } from "@components/List";
import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
export type TWebhook = inferQueryOutput<"viewer.webhook.list">[number]; export type TWebhook = inferQueryOutput<"viewer.webhook.list">[number];

View file

@ -8,11 +8,11 @@ import showToast from "@calcom/lib/notification";
import Button from "@calcom/ui/Button"; import Button from "@calcom/ui/Button";
import { DialogFooter } from "@calcom/ui/Dialog"; import { DialogFooter } from "@calcom/ui/Dialog";
import Switch from "@calcom/ui/Switch"; import Switch from "@calcom/ui/Switch";
import { Tooltip } from "@calcom/ui/Tooltip";
import { Form, TextField } from "@calcom/ui/form/fields"; import { Form, TextField } from "@calcom/ui/form/fields";
import { trpc } from "@lib/trpc"; import { trpc } from "@lib/trpc";
import { Tooltip } from "@components/Tooltip";
import { DatePicker } from "@components/ui/form/DatePicker"; import { DatePicker } from "@components/ui/form/DatePicker";
import { TApiKeys } from "./ApiKeyListItem"; import { TApiKeys } from "./ApiKeyListItem";

View file

@ -7,11 +7,11 @@ import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button"; import Button from "@calcom/ui/Button";
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog"; import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
import { Tooltip } from "@calcom/ui/Tooltip";
import { inferQueryOutput, trpc } from "@lib/trpc"; import { inferQueryOutput, trpc } from "@lib/trpc";
import { ListItem } from "@components/List"; import { ListItem } from "@components/List";
import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Badge from "@components/ui/Badge"; import Badge from "@components/ui/Badge";

View file

@ -7,7 +7,7 @@ import EventManager from "@calcom/core/EventManager";
import { getErrorFromUnknown } from "@calcom/lib/errors"; import { getErrorFromUnknown } from "@calcom/lib/errors";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import stripe from "@calcom/stripe/server"; import stripe from "@calcom/stripe/server";
import { CalendarEvent } from "@calcom/types/Calendar"; import { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
import { IS_PRODUCTION } from "@lib/config/constants"; import { IS_PRODUCTION } from "@lib/config/constants";
import { HttpError as HttpCode } from "@lib/core/http/error"; import { HttpError as HttpCode } from "@lib/core/http/error";
@ -49,6 +49,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
confirmed: true, confirmed: true,
attendees: true, attendees: true,
location: true, location: true,
eventTypeId: true,
userId: true, userId: true,
id: true, id: true,
uid: true, uid: true,
@ -70,6 +71,23 @@ async function handlePaymentSuccess(event: Stripe.Event) {
if (!booking) throw new Error("No booking found"); if (!booking) throw new Error("No booking found");
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({ recurringEvent: true });
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({ select: eventTypeSelect });
type EventTypeRaw = Prisma.EventTypeGetPayload<typeof eventTypeData>;
let eventTypeRaw: EventTypeRaw | null = null;
if (booking.eventTypeId) {
eventTypeRaw = await prisma.eventType.findUnique({
where: {
id: booking.eventTypeId,
},
select: eventTypeSelect,
});
}
const eventType = {
recurringEvent: (eventTypeRaw?.recurringEvent || {}) as RecurringEvent,
};
const { user } = booking; const { user } = booking;
if (!user) throw new Error("No user found"); if (!user) throw new Error("No user found");
@ -137,7 +155,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
await prisma.$transaction([paymentUpdate, bookingUpdate]); await prisma.$transaction([paymentUpdate, bookingUpdate]);
await sendScheduledEmails({ ...evt }); await sendScheduledEmails({ ...evt }, eventType.recurringEvent);
throw new HttpCode({ throw new HttpCode({
statusCode: 200, statusCode: 200,

View file

@ -1,4 +1,5 @@
import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import { recurringEvent } from "@calcom/prisma/zod-utils";
import type { CalendarEvent, Person, RecurringEvent } from "@calcom/types/Calendar";
import AttendeeAwaitingPaymentEmail from "@lib/emails/templates/attendee-awaiting-payment-email"; import AttendeeAwaitingPaymentEmail from "@lib/emails/templates/attendee-awaiting-payment-email";
import AttendeeCancelledEmail from "@lib/emails/templates/attendee-cancelled-email"; import AttendeeCancelledEmail from "@lib/emails/templates/attendee-cancelled-email";
@ -17,14 +18,14 @@ import OrganizerRescheduledEmail from "@lib/emails/templates/organizer-reschedul
import OrganizerScheduledEmail from "@lib/emails/templates/organizer-scheduled-email"; import OrganizerScheduledEmail from "@lib/emails/templates/organizer-scheduled-email";
import TeamInviteEmail, { TeamInvite } from "@lib/emails/templates/team-invite-email"; import TeamInviteEmail, { TeamInvite } from "@lib/emails/templates/team-invite-email";
export const sendScheduledEmails = async (calEvent: CalendarEvent) => { export const sendScheduledEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
const emailsToSend: Promise<unknown>[] = []; const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push( emailsToSend.push(
...calEvent.attendees.map((attendee) => { ...calEvent.attendees.map((attendee) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const scheduledEmail = new AttendeeScheduledEmail(calEvent, attendee); const scheduledEmail = new AttendeeScheduledEmail(calEvent, attendee, recurringEvent);
resolve(scheduledEmail.sendEmail()); resolve(scheduledEmail.sendEmail());
} catch (e) { } catch (e) {
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e)); reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
@ -36,7 +37,7 @@ export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
emailsToSend.push( emailsToSend.push(
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
try { try {
const scheduledEmail = new OrganizerScheduledEmail(calEvent); const scheduledEmail = new OrganizerScheduledEmail(calEvent, recurringEvent);
resolve(scheduledEmail.sendEmail()); resolve(scheduledEmail.sendEmail());
} catch (e) { } catch (e) {
reject(console.error("OrganizerScheduledEmail.sendEmail failed", e)); reject(console.error("OrganizerScheduledEmail.sendEmail failed", e));
@ -47,14 +48,14 @@ export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
await Promise.all(emailsToSend); await Promise.all(emailsToSend);
}; };
export const sendRescheduledEmails = async (calEvent: CalendarEvent) => { export const sendRescheduledEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
const emailsToSend: Promise<unknown>[] = []; const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push( emailsToSend.push(
...calEvent.attendees.map((attendee) => { ...calEvent.attendees.map((attendee) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const scheduledEmail = new AttendeeRescheduledEmail(calEvent, attendee); const scheduledEmail = new AttendeeRescheduledEmail(calEvent, attendee, recurringEvent);
resolve(scheduledEmail.sendEmail()); resolve(scheduledEmail.sendEmail());
} catch (e) { } catch (e) {
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e)); reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
@ -66,7 +67,7 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
emailsToSend.push( emailsToSend.push(
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
try { try {
const scheduledEmail = new OrganizerRescheduledEmail(calEvent); const scheduledEmail = new OrganizerRescheduledEmail(calEvent, recurringEvent);
resolve(scheduledEmail.sendEmail()); resolve(scheduledEmail.sendEmail());
} catch (e) { } catch (e) {
reject(console.error("OrganizerScheduledEmail.sendEmail failed", e)); reject(console.error("OrganizerScheduledEmail.sendEmail failed", e));
@ -77,10 +78,13 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
await Promise.all(emailsToSend); await Promise.all(emailsToSend);
}; };
export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => { export const sendOrganizerRequestEmail = async (
calEvent: CalendarEvent,
recurringEvent: RecurringEvent = {}
) => {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
try { try {
const organizerRequestEmail = new OrganizerRequestEmail(calEvent); const organizerRequestEmail = new OrganizerRequestEmail(calEvent, recurringEvent);
resolve(organizerRequestEmail.sendEmail()); resolve(organizerRequestEmail.sendEmail());
} catch (e) { } catch (e) {
reject(console.error("OrganizerRequestEmail.sendEmail failed", e)); reject(console.error("OrganizerRequestEmail.sendEmail failed", e));
@ -88,10 +92,14 @@ export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => {
}); });
}; };
export const sendAttendeeRequestEmail = async (calEvent: CalendarEvent, attendee: Person) => { export const sendAttendeeRequestEmail = async (
calEvent: CalendarEvent,
attendee: Person,
recurringEvent: RecurringEvent = {}
) => {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
try { try {
const attendeeRequestEmail = new AttendeeRequestEmail(calEvent, attendee); const attendeeRequestEmail = new AttendeeRequestEmail(calEvent, attendee, recurringEvent);
resolve(attendeeRequestEmail.sendEmail()); resolve(attendeeRequestEmail.sendEmail());
} catch (e) { } catch (e) {
reject(console.error("AttendRequestEmail.sendEmail failed", e)); reject(console.error("AttendRequestEmail.sendEmail failed", e));
@ -99,14 +107,14 @@ export const sendAttendeeRequestEmail = async (calEvent: CalendarEvent, attendee
}); });
}; };
export const sendDeclinedEmails = async (calEvent: CalendarEvent) => { export const sendDeclinedEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
const emailsToSend: Promise<unknown>[] = []; const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push( emailsToSend.push(
...calEvent.attendees.map((attendee) => { ...calEvent.attendees.map((attendee) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const declinedEmail = new AttendeeDeclinedEmail(calEvent, attendee); const declinedEmail = new AttendeeDeclinedEmail(calEvent, attendee, recurringEvent);
resolve(declinedEmail.sendEmail()); resolve(declinedEmail.sendEmail());
} catch (e) { } catch (e) {
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e)); reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
@ -118,14 +126,14 @@ export const sendDeclinedEmails = async (calEvent: CalendarEvent) => {
await Promise.all(emailsToSend); await Promise.all(emailsToSend);
}; };
export const sendCancelledEmails = async (calEvent: CalendarEvent) => { export const sendCancelledEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
const emailsToSend: Promise<unknown>[] = []; const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push( emailsToSend.push(
...calEvent.attendees.map((attendee) => { ...calEvent.attendees.map((attendee) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const scheduledEmail = new AttendeeCancelledEmail(calEvent, attendee); const scheduledEmail = new AttendeeCancelledEmail(calEvent, attendee, recurringEvent);
resolve(scheduledEmail.sendEmail()); resolve(scheduledEmail.sendEmail());
} catch (e) { } catch (e) {
reject(console.error("AttendeeCancelledEmail.sendEmail failed", e)); reject(console.error("AttendeeCancelledEmail.sendEmail failed", e));
@ -137,7 +145,7 @@ export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
emailsToSend.push( emailsToSend.push(
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
try { try {
const scheduledEmail = new OrganizerCancelledEmail(calEvent); const scheduledEmail = new OrganizerCancelledEmail(calEvent, recurringEvent);
resolve(scheduledEmail.sendEmail()); resolve(scheduledEmail.sendEmail());
} catch (e) { } catch (e) {
reject(console.error("OrganizerCancelledEmail.sendEmail failed", e)); reject(console.error("OrganizerCancelledEmail.sendEmail failed", e));
@ -148,10 +156,13 @@ export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
await Promise.all(emailsToSend); await Promise.all(emailsToSend);
}; };
export const sendOrganizerRequestReminderEmail = async (calEvent: CalendarEvent) => { export const sendOrganizerRequestReminderEmail = async (
calEvent: CalendarEvent,
recurringEvent: RecurringEvent = {}
) => {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
try { try {
const organizerRequestReminderEmail = new OrganizerRequestReminderEmail(calEvent); const organizerRequestReminderEmail = new OrganizerRequestReminderEmail(calEvent, recurringEvent);
resolve(organizerRequestReminderEmail.sendEmail()); resolve(organizerRequestReminderEmail.sendEmail());
} catch (e) { } catch (e) {
reject(console.error("OrganizerRequestReminderEmail.sendEmail failed", e)); reject(console.error("OrganizerRequestReminderEmail.sendEmail failed", e));
@ -159,14 +170,17 @@ export const sendOrganizerRequestReminderEmail = async (calEvent: CalendarEvent)
}); });
}; };
export const sendAwaitingPaymentEmail = async (calEvent: CalendarEvent) => { export const sendAwaitingPaymentEmail = async (
calEvent: CalendarEvent,
recurringEvent: RecurringEvent = {}
) => {
const emailsToSend: Promise<unknown>[] = []; const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push( emailsToSend.push(
...calEvent.attendees.map((attendee) => { ...calEvent.attendees.map((attendee) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const paymentEmail = new AttendeeAwaitingPaymentEmail(calEvent, attendee); const paymentEmail = new AttendeeAwaitingPaymentEmail(calEvent, attendee, recurringEvent);
resolve(paymentEmail.sendEmail()); resolve(paymentEmail.sendEmail());
} catch (e) { } catch (e) {
reject(console.error("AttendeeAwaitingPaymentEmail.sendEmail failed", e)); reject(console.error("AttendeeAwaitingPaymentEmail.sendEmail failed", e));
@ -178,10 +192,13 @@ export const sendAwaitingPaymentEmail = async (calEvent: CalendarEvent) => {
await Promise.all(emailsToSend); await Promise.all(emailsToSend);
}; };
export const sendOrganizerPaymentRefundFailedEmail = async (calEvent: CalendarEvent) => { export const sendOrganizerPaymentRefundFailedEmail = async (
calEvent: CalendarEvent,
recurringEvent: RecurringEvent = {}
) => {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
try { try {
const paymentRefundFailedEmail = new OrganizerPaymentRefundFailedEmail(calEvent); const paymentRefundFailedEmail = new OrganizerPaymentRefundFailedEmail(calEvent, recurringEvent);
resolve(paymentRefundFailedEmail.sendEmail()); resolve(paymentRefundFailedEmail.sendEmail());
} catch (e) { } catch (e) {
reject(console.error("OrganizerPaymentRefundFailedEmail.sendEmail failed", e)); reject(console.error("OrganizerPaymentRefundFailedEmail.sendEmail failed", e));
@ -213,14 +230,19 @@ export const sendTeamInviteEmail = async (teamInviteEvent: TeamInvite) => {
export const sendRequestRescheduleEmail = async ( export const sendRequestRescheduleEmail = async (
calEvent: CalendarEvent, calEvent: CalendarEvent,
metadata: { rescheduleLink: string } metadata: { rescheduleLink: string },
recurringEvent: RecurringEvent = {}
) => { ) => {
const emailsToSend: Promise<unknown>[] = []; const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push( emailsToSend.push(
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
try { try {
const requestRescheduleEmail = new AttendeeRequestRescheduledEmail(calEvent, metadata); const requestRescheduleEmail = new AttendeeRequestRescheduledEmail(
calEvent,
metadata,
recurringEvent
);
resolve(requestRescheduleEmail.sendEmail()); resolve(requestRescheduleEmail.sendEmail());
} catch (e) { } catch (e) {
reject(console.error("AttendeeRequestRescheduledEmail.sendEmail failed", e)); reject(console.error("AttendeeRequestRescheduledEmail.sendEmail failed", e));
@ -231,7 +253,11 @@ export const sendRequestRescheduleEmail = async (
emailsToSend.push( emailsToSend.push(
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
try { try {
const requestRescheduleEmail = new OrganizerRequestRescheduleEmail(calEvent, metadata); const requestRescheduleEmail = new OrganizerRequestRescheduleEmail(
calEvent,
metadata,
recurringEvent
);
resolve(requestRescheduleEmail.sendEmail()); resolve(requestRescheduleEmail.sendEmail());
} catch (e) { } catch (e) {
reject(console.error("OrganizerRequestRescheduledEmail.sendEmail failed", e)); reject(console.error("OrganizerRequestRescheduledEmail.sendEmail failed", e));

View file

@ -42,7 +42,9 @@ export default class AttendeeDeclinedEmail extends AttendeeScheduledEmail {
protected getTextBody(): string { protected getTextBody(): string {
return ` return `
${this.attendee.language.translate("event_request_declined")} ${this.attendee.language.translate(
this.recurringEvent?.count ? "event_request_declined_recurring" : "event_request_declined"
)}
${this.attendee.language.translate("emailed_you_and_any_other_attendees")} ${this.attendee.language.translate("emailed_you_and_any_other_attendees")}
${this.getWhat()} ${this.getWhat()}
${this.getWhen()} ${this.getWhen()}
@ -75,7 +77,9 @@ ${this.getRejectionReason()}
<div style="background-color:#F5F5F5;"> <div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("xCircle")} ${emailSchedulingBodyHeader("xCircle")}
${emailScheduledBodyHeaderContent( ${emailScheduledBodyHeaderContent(
this.attendee.language.translate("event_request_declined"), this.attendee.language.translate(
this.recurringEvent?.count ? "event_request_declined_recurring" : "event_request_declined"
),
this.attendee.language.translate("emailed_you_and_any_other_attendees") this.attendee.language.translate("emailed_you_and_any_other_attendees")
)} )}
${emailSchedulingBodyDivider()} ${emailSchedulingBodyDivider()}

View file

@ -87,10 +87,17 @@ ${this.getAdditionalNotes()}
<div style="background-color:#F5F5F5;"> <div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("calendarCircle")} ${emailSchedulingBodyHeader("calendarCircle")}
${emailScheduledBodyHeaderContent( ${emailScheduledBodyHeaderContent(
this.calEvent.organizer.language.translate("booking_submitted"), this.calEvent.organizer.language.translate(
this.calEvent.organizer.language.translate("user_needs_to_confirm_or_reject_booking", { this.recurringEvent?.count ? "booking_submitted_recurring" : "booking_submitted"
user: this.calEvent.organizer.name, ),
}) this.calEvent.organizer.language.translate(
this.recurringEvent.count
? "user_needs_to_confirm_or_reject_booking_recurring"
: "user_needs_to_confirm_or_reject_booking",
{
user: this.calEvent.organizer.name,
}
)
)} )}
${emailSchedulingBodyDivider()} ${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]--> <!--[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]-->

View file

@ -6,7 +6,7 @@ import utc from "dayjs/plugin/utc";
import { createEvent, DateArray, Person } from "ics"; import { createEvent, DateArray, Person } from "ics";
import { getCancelLink } from "@calcom/lib/CalEventParser"; import { getCancelLink } from "@calcom/lib/CalEventParser";
import { CalendarEvent } from "@calcom/types/Calendar"; import { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
import { import {
emailHead, emailHead,
@ -24,8 +24,8 @@ dayjs.extend(toArray);
export default class AttendeeRequestRescheduledEmail extends OrganizerScheduledEmail { export default class AttendeeRequestRescheduledEmail extends OrganizerScheduledEmail {
private metadata: { rescheduleLink: string }; private metadata: { rescheduleLink: string };
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) { constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }, recurringEvent: RecurringEvent) {
super(calEvent); super(calEvent, recurringEvent);
this.metadata = metadata; this.metadata = metadata;
} }
protected getNodeMailerPayload(): Record<string, unknown> { protected getNodeMailerPayload(): Record<string, unknown> {

View file

@ -53,7 +53,7 @@ export default class AttendeeRescheduledEmail extends AttendeeScheduledEmail {
${this.attendee.language.translate("event_has_been_rescheduled")} ${this.attendee.language.translate("event_has_been_rescheduled")}
${this.attendee.language.translate("emailed_you_and_any_other_attendees")} ${this.attendee.language.translate("emailed_you_and_any_other_attendees")}
${this.getWhat()} ${this.getWhat()}
${this.getWhen()} ${this.getWhen()}
${this.getLocation()} ${this.getLocation()}
${this.getDescription()} ${this.getDescription()}
${this.getAdditionalNotes()} ${this.getAdditionalNotes()}

View file

@ -4,13 +4,15 @@ import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray"; import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import { createEvent, DateArray } from "ics"; import { createEvent, DateArray } from "ics";
import { DatasetJsonLdProps } from "next-seo";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import rrule from "rrule";
import { getAppName } from "@calcom/app-store/utils"; import { getAppName } from "@calcom/app-store/utils";
import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser"; import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser";
import { getErrorFromUnknown } from "@calcom/lib/errors"; import { getErrorFromUnknown } from "@calcom/lib/errors";
import { serverConfig } from "@calcom/lib/serverConfig"; import { serverConfig } from "@calcom/lib/serverConfig";
import type { Person, CalendarEvent } from "@calcom/types/Calendar"; import type { Person, CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
import { import {
emailHead, emailHead,
@ -29,10 +31,12 @@ dayjs.extend(toArray);
export default class AttendeeScheduledEmail { export default class AttendeeScheduledEmail {
calEvent: CalendarEvent; calEvent: CalendarEvent;
attendee: Person; attendee: Person;
recurringEvent: RecurringEvent;
constructor(calEvent: CalendarEvent, attendee: Person) { constructor(calEvent: CalendarEvent, attendee: Person, recurringEvent: RecurringEvent) {
this.calEvent = calEvent; this.calEvent = calEvent;
this.attendee = attendee; this.attendee = attendee;
this.recurringEvent = recurringEvent;
} }
public sendEmail() { public sendEmail() {
@ -53,6 +57,11 @@ export default class AttendeeScheduledEmail {
} }
protected getiCalEventAsString(): string | undefined { protected getiCalEventAsString(): string | undefined {
// Taking care of recurrence rule beforehand
let recurrenceRule: string | undefined = undefined;
if (this.recurringEvent?.count) {
recurrenceRule = new rrule(this.recurringEvent).toString();
}
const icsEvent = createEvent({ const icsEvent = createEvent({
start: dayjs(this.calEvent.startTime) start: dayjs(this.calEvent.startTime)
.utc() .utc()
@ -72,6 +81,7 @@ export default class AttendeeScheduledEmail {
name: attendee.name, name: attendee.name,
email: attendee.email, email: attendee.email,
})), })),
...{ recurrenceRule },
status: "CONFIRMED", status: "CONFIRMED",
}); });
if (icsEvent.error) { if (icsEvent.error) {
@ -125,7 +135,9 @@ export default class AttendeeScheduledEmail {
} }
protected getTextBody(): string { protected getTextBody(): string {
return ` return `
${this.calEvent.attendees[0].language.translate("your_event_has_been_scheduled")} ${this.calEvent.attendees[0].language.translate(
this.recurringEvent?.count ? "your_event_has_been_scheduled_recurring" : "your_event_has_been_scheduled"
)}
${this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")} ${this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")}
${getRichDescription(this.calEvent)} ${getRichDescription(this.calEvent)}
@ -157,7 +169,11 @@ ${getRichDescription(this.calEvent)}
<div style="background-color:#F5F5F5;"> <div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("checkCircle")} ${emailSchedulingBodyHeader("checkCircle")}
${emailScheduledBodyHeaderContent( ${emailScheduledBodyHeaderContent(
this.calEvent.attendees[0].language.translate("your_event_has_been_scheduled"), this.calEvent.attendees[0].language.translate(
this.recurringEvent?.count
? "your_event_has_been_scheduled_recurring"
: "your_event_has_been_scheduled"
),
this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees") this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")
)} )}
${emailSchedulingBodyDivider()} ${emailSchedulingBodyDivider()}
@ -250,12 +266,30 @@ ${getRichDescription(this.calEvent)}
</div>`; </div>`;
} }
protected getRecurringWhen(): string {
if (this.recurringEvent?.freq) {
return ` - ${this.calEvent.attendees[0].language.translate("every_for_freq", {
freq: this.calEvent.attendees[0].language.translate(
`${rrule.FREQUENCIES[this.recurringEvent.freq].toString().toLowerCase()}`
),
})} ${this.recurringEvent.count} ${this.calEvent.attendees[0].language.translate(
`${rrule.FREQUENCIES[this.recurringEvent.freq].toString().toLowerCase()}`,
{ count: this.recurringEvent.count }
)}`;
} else {
return "";
}
}
protected getWhen(): string { protected getWhen(): string {
return ` return `
<p style="height: 6px"></p> <p style="height: 6px"></p>
<div style="line-height: 6px;"> <div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.attendees[0].language.translate("when")}</p> <p style="color: #494949;">${this.calEvent.attendees[0].language.translate("when")}${
this.recurringEvent?.count ? this.getRecurringWhen() : ""
}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;"> <p style="color: #494949; font-weight: 400; line-height: 24px;">
${this.recurringEvent?.count ? `${this.calEvent.attendees[0].language.translate("starting")} ` : ""}
${this.calEvent.attendees[0].language.translate( ${this.calEvent.attendees[0].language.translate(
this.getInviteeStart().format("dddd").toLowerCase() this.getInviteeStart().format("dddd").toLowerCase()
)}, ${this.calEvent.attendees[0].language.translate( )}, ${this.calEvent.attendees[0].language.translate(

View file

@ -86,7 +86,9 @@ ${process.env.NEXT_PUBLIC_WEBAPP_URL} + "/bookings/upcoming"
<div style="background-color:#F5F5F5;"> <div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("calendarCircle")} ${emailSchedulingBodyHeader("calendarCircle")}
${emailScheduledBodyHeaderContent( ${emailScheduledBodyHeaderContent(
this.calEvent.organizer.language.translate("event_awaiting_approval"), this.calEvent.organizer.language.translate(
this.recurringEvent?.count ? "event_awaiting_approval_recurring" : "event_awaiting_approval"
),
this.calEvent.organizer.language.translate("someone_requested_an_event") this.calEvent.organizer.language.translate("someone_requested_an_event")
)} )}
${emailSchedulingBodyDivider()} ${emailSchedulingBodyDivider()}

View file

@ -6,7 +6,7 @@ import utc from "dayjs/plugin/utc";
import { createEvent, DateArray, Person } from "ics"; import { createEvent, DateArray, Person } from "ics";
import { getCancelLink } from "@calcom/lib/CalEventParser"; import { getCancelLink } from "@calcom/lib/CalEventParser";
import { CalendarEvent } from "@calcom/types/Calendar"; import { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
import { import {
emailHead, emailHead,
@ -24,8 +24,8 @@ dayjs.extend(toArray);
export default class OrganizerRequestRescheduledEmail extends OrganizerScheduledEmail { export default class OrganizerRequestRescheduledEmail extends OrganizerScheduledEmail {
private metadata: { rescheduleLink: string }; private metadata: { rescheduleLink: string };
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) { constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }, recurringEvent: RecurringEvent) {
super(calEvent); super(calEvent, recurringEvent);
this.metadata = metadata; this.metadata = metadata;
} }
protected getNodeMailerPayload(): Record<string, unknown> { protected getNodeMailerPayload(): Record<string, unknown> {

View file

@ -5,12 +5,13 @@ import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import { createEvent, DateArray, Person } from "ics"; import { createEvent, DateArray, Person } from "ics";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import rrule from "rrule";
import { getAppName } from "@calcom/app-store/utils"; import { getAppName } from "@calcom/app-store/utils";
import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser"; import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser";
import { getErrorFromUnknown } from "@calcom/lib/errors"; import { getErrorFromUnknown } from "@calcom/lib/errors";
import { serverConfig } from "@calcom/lib/serverConfig"; import { serverConfig } from "@calcom/lib/serverConfig";
import type { CalendarEvent } from "@calcom/types/Calendar"; import type { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
import { import {
emailHead, emailHead,
@ -28,9 +29,11 @@ dayjs.extend(toArray);
export default class OrganizerScheduledEmail { export default class OrganizerScheduledEmail {
calEvent: CalendarEvent; calEvent: CalendarEvent;
recurringEvent: RecurringEvent;
constructor(calEvent: CalendarEvent) { constructor(calEvent: CalendarEvent, recurringEvent: RecurringEvent) {
this.calEvent = calEvent; this.calEvent = calEvent;
this.recurringEvent = recurringEvent;
} }
public sendEmail() { public sendEmail() {
@ -51,6 +54,11 @@ export default class OrganizerScheduledEmail {
} }
protected getiCalEventAsString(): string | undefined { protected getiCalEventAsString(): string | undefined {
// Taking care of recurrence rule beforehand
let recurrenceRule: string | undefined = undefined;
if (this.recurringEvent?.count) {
recurrenceRule = new rrule(this.recurringEvent).toString();
}
const icsEvent = createEvent({ const icsEvent = createEvent({
start: dayjs(this.calEvent.startTime) start: dayjs(this.calEvent.startTime)
.utc() .utc()
@ -66,6 +74,7 @@ export default class OrganizerScheduledEmail {
description: this.getTextBody(), description: this.getTextBody(),
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") }, duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email }, organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
...{ recurrenceRule },
attendees: this.calEvent.attendees.map((attendee: Person) => ({ attendees: this.calEvent.attendees.map((attendee: Person) => ({
name: attendee.name, name: attendee.name,
email: attendee.email, email: attendee.email,
@ -121,7 +130,9 @@ export default class OrganizerScheduledEmail {
protected getTextBody(): string { protected getTextBody(): string {
return ` return `
${this.calEvent.organizer.language.translate("new_event_scheduled")} ${this.calEvent.organizer.language.translate(
this.recurringEvent?.count ? "new_event_scheduled_recurring" : "new_event_scheduled"
)}
${this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")} ${this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")}
${getRichDescription(this.calEvent)} ${getRichDescription(this.calEvent)}
@ -153,7 +164,9 @@ ${getRichDescription(this.calEvent)}
<div style="background-color:#F5F5F5;"> <div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("checkCircle")} ${emailSchedulingBodyHeader("checkCircle")}
${emailScheduledBodyHeaderContent( ${emailScheduledBodyHeaderContent(
this.calEvent.organizer.language.translate("new_event_scheduled"), this.calEvent.organizer.language.translate(
this.recurringEvent?.count ? "new_event_scheduled_recurring" : "new_event_scheduled"
),
this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees") this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")
)} )}
${emailSchedulingBodyDivider()} ${emailSchedulingBodyDivider()}
@ -240,12 +253,30 @@ ${getRichDescription(this.calEvent)}
</div>`; </div>`;
} }
protected getRecurringWhen(): string {
if (this.recurringEvent?.freq) {
return ` - ${this.calEvent.attendees[0].language.translate("every_for_freq", {
freq: this.calEvent.attendees[0].language.translate(
`${rrule.FREQUENCIES[this.recurringEvent.freq].toString().toLowerCase()}`
),
})} ${this.recurringEvent.count} ${this.calEvent.attendees[0].language.translate(
`${rrule.FREQUENCIES[this.recurringEvent.freq].toString().toLowerCase()}`,
{ count: this.recurringEvent.count }
)}`;
} else {
return "";
}
}
protected getWhen(): string { protected getWhen(): string {
return ` return `
<p style="height: 6px"></p> <p style="height: 6px"></p>
<div style="line-height: 6px;"> <div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.organizer.language.translate("when")}</p> <p style="color: #494949;">${this.calEvent.organizer.language.translate("when")}${
this.recurringEvent?.count ? this.getRecurringWhen() : ""
}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;"> <p style="color: #494949; font-weight: 400; line-height: 24px;">
${this.recurringEvent?.count ? `${this.calEvent.attendees[0].language.translate("starting")} ` : ""}
${this.calEvent.organizer.language.translate( ${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("dddd").toLowerCase() this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.organizer.language.translate( )}, ${this.calEvent.organizer.language.translate(

View file

@ -0,0 +1,22 @@
import * as fetch from "@lib/core/http/fetch-wrapper";
import { BookingCreateBody, BookingResponse } from "@lib/types/booking";
type ExtendedBookingCreateBody = BookingCreateBody & { noEmail?: boolean; recurringCount?: number };
const createRecurringBooking = async (data: ExtendedBookingCreateBody[]) => {
return Promise.all(
data.map((booking, key) => {
// We only want to send the first occurrence of the meeting at the moment, not all at once
if (key === 0) {
return fetch.post<ExtendedBookingCreateBody, BookingResponse>("/api/book/event", booking);
} else {
return fetch.post<ExtendedBookingCreateBody, BookingResponse>("/api/book/event", {
...booking,
noEmail: true,
});
}
})
);
};
export default createRecurringBooking;

View file

@ -1,14 +1,42 @@
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import { I18n } from "next-i18next"; import { I18n } from "next-i18next";
import { RRule } from "rrule";
import { recurringEvent } from "@calcom/prisma/zod-utils";
import { RecurringEvent } from "@calcom/types/Calendar";
import { detectBrowserTimeFormat } from "@lib/timeFormat"; import { detectBrowserTimeFormat } from "@lib/timeFormat";
import { parseZone } from "./parseZone"; import { parseZone } from "./parseZone";
export const parseDate = (date: string | null | Dayjs, i18n: I18n) => { const processDate = (date: string | null | Dayjs, i18n: I18n) => {
if (!date) return "No date";
const parsedZone = parseZone(date); const parsedZone = parseZone(date);
if (!parsedZone?.isValid()) return "Invalid date"; if (!parsedZone?.isValid()) return "Invalid date";
const formattedTime = parsedZone?.format(detectBrowserTimeFormat); const formattedTime = parsedZone?.format(detectBrowserTimeFormat);
return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" }); return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" });
}; };
export const parseDate = (date: string | null | Dayjs, i18n: I18n) => {
if (!date) return ["No date"];
return processDate(date, i18n);
};
export const parseRecurringDates = (
{
startDate,
recurringEvent,
recurringCount,
}: { startDate: string | null | Dayjs; recurringEvent: RecurringEvent; recurringCount: number },
i18n: I18n
): [string[], Date[]] => {
const { count, ...restRecurringEvent } = recurringEvent;
const rule = new RRule({
...restRecurringEvent,
count: recurringCount,
dtstart: dayjs(startDate).toDate(),
});
const dateStrings = rule.all().map((r) => {
return processDate(dayjs(r), i18n);
});
return [dateStrings, rule.all()];
};

View file

@ -44,6 +44,7 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
length: true, length: true,
slug: true, slug: true,
schedulingType: true, schedulingType: true,
recurringEvent: true,
price: true, price: true,
currency: true, currency: true,
users: { users: {

View file

@ -19,6 +19,7 @@ export type BookingCreateBody = {
name: string; name: string;
notes?: string; notes?: string;
rescheduleUid?: string; rescheduleUid?: string;
recurringEventId?: string;
start: string; start: string;
timeZone: string; timeZone: string;
user?: string | string[]; user?: string | string[];

View file

@ -104,6 +104,7 @@
"react-use-intercom": "1.4.0", "react-use-intercom": "1.4.0",
"react-virtualized-auto-sizer": "^1.0.6", "react-virtualized-auto-sizer": "^1.0.6",
"react-window": "^1.8.6", "react-window": "^1.8.6",
"rrule": "^2.6.9",
"short-uuid": "^4.2.0", "short-uuid": "^4.2.0",
"stripe": "^8.191.0", "stripe": "^8.191.0",
"superjson": "1.8.1", "superjson": "1.8.1",

View file

@ -18,6 +18,7 @@ import defaultEvents, {
getUsernameSlugLink, getUsernameSlugLink,
} from "@calcom/lib/defaultEvents"; } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RecurringEvent } from "@calcom/types/Calendar";
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally"; import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
import useTheme from "@lib/hooks/useTheme"; import useTheme from "@lib/hooks/useTheme";
@ -272,6 +273,7 @@ const getEventTypesWithHiddenFromDB = async (userId: number, plan: UserPlan) =>
description: true, description: true,
hidden: true, hidden: true,
schedulingType: true, schedulingType: true,
recurringEvent: true,
price: true, price: true,
currency: true, currency: true,
metadata: true, metadata: true,

View file

@ -5,6 +5,7 @@ import { JSONObject } from "superjson/dist/types";
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents"; import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RecurringEvent } from "@calcom/types/Calendar";
import { asStringOrNull } from "@lib/asStringOrNull"; import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability"; import { getWorkingHours } from "@lib/availability";
@ -84,6 +85,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
periodDays: true, periodDays: true,
periodCountCalendarDays: true, periodCountCalendarDays: true,
schedulingType: true, schedulingType: true,
recurringEvent: true,
schedule: { schedule: {
select: { select: {
availability: true, availability: true,
@ -256,6 +258,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
metadata: (eventType.metadata || {}) as JSONObject, metadata: (eventType.metadata || {}) as JSONObject,
periodStartDate: eventType.periodStartDate?.toString() ?? null, periodStartDate: eventType.periodStartDate?.toString() ?? null,
periodEndDate: eventType.periodEndDate?.toString() ?? null, periodEndDate: eventType.periodEndDate?.toString() ?? null,
recurringEvent: (eventType.recurringEvent || {}) as RecurringEvent,
}); });
const schedule = eventType.schedule const schedule = eventType.schedule

View file

@ -12,8 +12,9 @@ import {
getUsernameList, getUsernameList,
} from "@calcom/lib/defaultEvents"; } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RecurringEvent } from "@calcom/types/Calendar";
import { asStringOrThrow } from "@lib/asStringOrNull"; import { asStringOrThrow, asStringOrNull } from "@lib/asStringOrNull";
import getBooking, { GetBookingType } from "@lib/getBooking"; import getBooking, { GetBookingType } from "@lib/getBooking";
import prisma from "@lib/prisma"; import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps"; import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -69,6 +70,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context); const ssr = await ssrInit(context);
const usernameList = getUsernameList(asStringOrThrow(context.query.user as string)); const usernameList = getUsernameList(asStringOrThrow(context.query.user as string));
const eventTypeSlug = context.query.slug as string; const eventTypeSlug = context.query.slug as string;
const recurringEventCountQuery = asStringOrNull(context.query.count);
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
where: { where: {
username: { username: {
@ -111,6 +113,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
periodDays: true, periodDays: true,
periodStartDate: true, periodStartDate: true,
periodEndDate: true, periodEndDate: true,
recurringEvent: true,
metadata: true, metadata: true,
periodCountCalendarDays: true, periodCountCalendarDays: true,
price: true, price: true,
@ -150,6 +153,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const eventType = { const eventType = {
...eventTypeRaw, ...eventTypeRaw,
metadata: (eventTypeRaw.metadata || {}) as JSONObject, metadata: (eventTypeRaw.metadata || {}) as JSONObject,
recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
isWeb3Active: isWeb3Active:
web3Credentials && web3Credentials.key web3Credentials && web3Credentials.key
? (((web3Credentials.key as JSONObject).isWeb3Active || false) as boolean) ? (((web3Credentials.key as JSONObject).isWeb3Active || false) as boolean)
@ -204,6 +208,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const t = await getTranslation(context.locale ?? "en", "common"); const t = await getTranslation(context.locale ?? "en", "common");
// Checking if number of recurring event ocurrances is valid against event type configuration
const recurringEventCount =
(eventType.recurringEvent?.count &&
recurringEventCountQuery &&
(parseInt(recurringEventCountQuery) <= eventType.recurringEvent.count
? recurringEventCountQuery
: eventType.recurringEvent.count)) ||
null;
return { return {
props: { props: {
away: user.away, away: user.away,
@ -211,6 +224,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
profile, profile,
eventType: eventTypeObject, eventType: eventTypeObject,
booking, booking,
recurringEventCount,
trpcState: ssr.dehydrate(), trpcState: ssr.dehydrate(),
isDynamicGroupBooking, isDynamicGroupBooking,
hasHashedBookingLink: false, hasHashedBookingLink: false,

View file

@ -1,9 +1,10 @@
import { Prisma, User, Booking, SchedulingType, BookingStatus } from "@prisma/client"; import { Prisma, User, Booking, SchedulingType, BookingStatus } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import rrule from "rrule";
import EventManager from "@calcom/core/EventManager"; import EventManager from "@calcom/core/EventManager";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import type { AdditionInformation } from "@calcom/types/Calendar"; import type { AdditionInformation, RecurringEvent } from "@calcom/types/Calendar";
import type { CalendarEvent } from "@calcom/types/Calendar"; import type { CalendarEvent } from "@calcom/types/Calendar";
import { refund } from "@ee/lib/stripe/server"; import { refund } from "@ee/lib/stripe/server";
@ -94,12 +95,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
confirmed: true, confirmed: true,
attendees: true, attendees: true,
eventTypeId: true, eventTypeId: true,
eventType: {
select: {
recurringEvent: true,
},
},
location: true, location: true,
userId: true, userId: true,
id: true, id: true,
uid: true, uid: true,
payment: true, payment: true,
destinationCalendar: true, destinationCalendar: true,
recurringEventId: true,
}, },
}); });
@ -147,6 +154,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
destinationCalendar: booking?.destinationCalendar || currentUser.destinationCalendar, destinationCalendar: booking?.destinationCalendar || currentUser.destinationCalendar,
}; };
const recurringEvent = booking.eventType?.recurringEvent as RecurringEvent;
if (req.body.recurringEventId && recurringEvent) {
const groupedRecurringBookings = await prisma.booking.groupBy({
where: {
recurringEventId: booking.recurringEventId,
},
by: [Prisma.BookingScalarFieldEnum.recurringEventId],
_count: true,
});
// Overriding the recurring event configuration count to be the actual number of events booked for
// the recurring event (equal or less than recurring event configuration count)
recurringEvent.count = groupedRecurringBookings[0]._count;
}
if (reqBody.confirmed) { if (reqBody.confirmed) {
const eventManager = new EventManager(currentUser); const eventManager = new EventManager(currentUser);
const scheduleResult = await eventManager.create(evt); const scheduleResult = await eventManager.create(evt);
@ -170,43 +192,93 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
metadata.entryPoints = results[0].createdEvent?.entryPoints; metadata.entryPoints = results[0].createdEvent?.entryPoints;
} }
try { try {
await sendScheduledEmails({ ...evt, additionInformation: metadata }); await sendScheduledEmails(
{ ...evt, additionInformation: metadata },
req.body.recurringEventId ? recurringEvent : {} // Send email with recurring event info only on recurring event context
);
} catch (error) { } catch (error) {
log.error(error); log.error(error);
} }
} }
// @NOTE: be careful with this as if any error occurs before this booking doesn't get confirmed if (req.body.recurringEventId) {
// Should perform update on booking (confirm) -> then trigger the rest handlers // The booking to confirm is a recurring event and comes from /booking/upcoming, proceeding to mark all related
await prisma.booking.update({ // bookings as confirmed. Prisma updateMany does not support relations, so doing this in two steps for now.
where: { const unconfirmedRecurringBookings = await prisma.booking.findMany({
id: bookingId, where: {
}, recurringEventId: req.body.recurringEventId,
data: { confirmed: false,
confirmed: true,
references: {
create: scheduleResult.referencesToCreate,
}, },
}, });
}); unconfirmedRecurringBookings.map(async (recurringBooking) => {
await prisma.booking.update({
where: {
id: recurringBooking.id,
},
data: {
confirmed: true,
references: {
create: scheduleResult.referencesToCreate,
},
},
});
});
} else {
// @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,
},
data: {
confirmed: true,
references: {
create: scheduleResult.referencesToCreate,
},
},
});
}
res.status(204).end(); res.status(204).end();
} else { } else {
await refund(booking, evt);
const rejectionReason = asStringOrNull(req.body.reason) || ""; const rejectionReason = asStringOrNull(req.body.reason) || "";
evt.rejectionReason = rejectionReason; evt.rejectionReason = rejectionReason;
await prisma.booking.update({ if (req.body.recurringEventId) {
where: { // The booking to reject is a recurring event and comes from /booking/upcoming, proceeding to mark all related
id: bookingId, // bookings as rejected. Prisma updateMany does not support relations, so doing this in two steps for now.
}, const unconfirmedRecurringBookings = await prisma.booking.findMany({
data: { where: {
rejected: true, recurringEventId: req.body.recurringEventId,
status: BookingStatus.REJECTED, confirmed: false,
rejectionReason: rejectionReason, },
}, });
}); unconfirmedRecurringBookings.map(async (recurringBooking) => {
await prisma.booking.update({
where: {
id: recurringBooking.id,
},
data: {
rejected: true,
status: BookingStatus.REJECTED,
rejectionReason: rejectionReason,
},
});
});
} else {
await refund(booking, evt); // No payment integration for recurring events for v1
await prisma.booking.update({
where: {
id: bookingId,
},
data: {
rejected: true,
status: BookingStatus.REJECTED,
rejectionReason: rejectionReason,
},
});
}
await sendDeclinedEmails(evt); await sendDeclinedEmails(evt, req.body.recurringEventId ? recurringEvent : {}); // Send email with recurring event info only on recurring event context
res.status(204).end(); res.status(204).end();
} }

View file

@ -1,11 +1,4 @@
import { import { BookingStatus, Credential, Prisma, SchedulingType, WebhookTriggerEvents } from "@prisma/client";
BookingStatus,
Credential,
Payment,
Prisma,
SchedulingType,
WebhookTriggerEvents,
} from "@prisma/client";
import async from "async"; import async from "async";
import dayjs from "dayjs"; import dayjs from "dayjs";
import dayjsBusinessTime from "dayjs-business-time"; import dayjsBusinessTime from "dayjs-business-time";
@ -13,18 +6,24 @@ import isBetween from "dayjs/plugin/isBetween";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import rrule from "rrule";
import short from "short-uuid"; import short from "short-uuid";
import { v5 as uuidv5 } from "uuid"; import { v5 as uuidv5 } from "uuid";
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager"; import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
import EventManager from "@calcom/core/EventManager"; import EventManager from "@calcom/core/EventManager";
import { getBusyVideoTimes } from "@calcom/core/videoClient"; import { getBusyVideoTimes } from "@calcom/core/videoClient";
import { getDefaultEvent, getUsernameList, getGroupName } from "@calcom/lib/defaultEvents"; import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
import { getErrorFromUnknown } from "@calcom/lib/errors"; import { getErrorFromUnknown } from "@calcom/lib/errors";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import notEmpty from "@calcom/lib/notEmpty"; import notEmpty from "@calcom/lib/notEmpty";
import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime"; import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime";
import type { AdditionInformation, CalendarEvent, EventBusyDate, Person } from "@calcom/types/Calendar"; import type {
AdditionInformation,
CalendarEvent,
EventBusyDate,
RecurringEvent,
} from "@calcom/types/Calendar";
import type { EventResult, PartialReference } from "@calcom/types/EventManager"; import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import { handlePayment } from "@ee/lib/stripe/server"; import { handlePayment } from "@ee/lib/stripe/server";
@ -83,7 +82,7 @@ async function refreshCredentials(credentials: Array<Credential>): Promise<Array
return await async.mapLimit(credentials, 5, refreshCredential); return await async.mapLimit(credentials, 5, refreshCredential);
} }
function isAvailable(busyTimes: BufferedBusyTimes, time: string, length: number): boolean { function isAvailable(busyTimes: BufferedBusyTimes, time: dayjs.ConfigType, length: number): boolean {
// Check for conflicts // Check for conflicts
let t = true; let t = true;
@ -190,7 +189,7 @@ const getUserNameWithBookingCounts = async (eventTypeId: number, selectedUserNam
}; };
const getEventTypesFromDB = async (eventTypeId: number) => { const getEventTypesFromDB = async (eventTypeId: number) => {
return await prisma.eventType.findUnique({ const eventType = await prisma.eventType.findUnique({
rejectOnNotFound: true, rejectOnNotFound: true,
where: { where: {
id: eventTypeId, id: eventTypeId,
@ -220,14 +219,22 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
metadata: true, metadata: true,
destinationCalendar: true, destinationCalendar: true,
hideCalendarNotes: true, hideCalendarNotes: true,
recurringEvent: true,
}, },
}); });
return {
...eventType,
recurringEvent: (eventType.recurringEvent || undefined) as RecurringEvent,
};
}; };
type User = Prisma.UserGetPayload<typeof userSelect>; type User = Prisma.UserGetPayload<typeof userSelect>;
type ExtendedBookingCreateBody = BookingCreateBody & { noEmail?: boolean; recurringCount?: number };
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const reqBody = req.body as BookingCreateBody; const { recurringCount, noEmail, ...reqBody } = req.body as ExtendedBookingCreateBody;
// handle dynamic user // handle dynamic user
const dynamicUserList = Array.isArray(reqBody.user) const dynamicUserList = Array.isArray(reqBody.user)
@ -382,6 +389,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}; // used for invitee emails }; // used for invitee emails
} }
if (reqBody.recurringEventId && eventType.recurringEvent) {
// Overriding the recurring event configuration count to be the actual number of events booked for
// the recurring event (equal or less than recurring event configuration count)
eventType.recurringEvent = Object.assign({}, eventType.recurringEvent, { count: recurringCount });
}
// Initialize EventManager with credentials // Initialize EventManager with credentials
const rescheduleUid = reqBody.rescheduleUid; const rescheduleUid = reqBody.rescheduleUid;
async function getOriginalRescheduledBooking(uid: string) { async function getOriginalRescheduledBooking(uid: string) {
@ -481,6 +494,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
: undefined, : undefined,
}; };
if (reqBody.recurringEventId) {
newBookingData.recurringEventId = reqBody.recurringEventId;
}
if (originalRescheduledBooking) { if (originalRescheduledBooking) {
newBookingData["paid"] = originalRescheduledBooking.paid; newBookingData["paid"] = originalRescheduledBooking.paid;
newBookingData["fromReschedule"] = originalRescheduledBooking.uid; newBookingData["fromReschedule"] = originalRescheduledBooking.uid;
@ -573,7 +589,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
let isAvailableToBeBooked = true; let isAvailableToBeBooked = true;
try { try {
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, reqBody.start, eventType.length); if (eventType.recurringEvent) {
const allBookingDates = new rrule({
dtstart: new Date(reqBody.start),
...eventType.recurringEvent,
}).all();
// Go through each date for the recurring event and check if each one's availability
isAvailableToBeBooked = allBookingDates
.map((aDate) => isAvailable(bufferedBusyTimes, aDate, eventType.length)) // <-- array of booleans
.reduce((acc, value) => acc && value, true); // <-- checks boolean array applying "AND" to each value and the current one, starting in true
} else {
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, reqBody.start, eventType.length);
}
} catch { } catch {
log.debug({ log.debug({
message: "Unable set isAvailableToBeBooked. Using true. ", message: "Unable set isAvailableToBeBooked. Using true. ",
@ -674,11 +701,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
} }
await sendRescheduledEmails({ if (noEmail !== true) {
...evt, await sendRescheduledEmails(
additionInformation: metadata, {
additionalNotes, // Resets back to the addtionalNote input and not the overriden value ...evt,
}); additionInformation: metadata,
additionalNotes, // Resets back to the addtionalNote input and not the overriden value
},
reqBody.recurringEventId ? (eventType.recurringEvent as RecurringEvent) : {}
);
}
} }
// If it's not a reschedule, doesn't require confirmation and there's no price, // If it's not a reschedule, doesn't require confirmation and there's no price,
// Create a booking // Create a booking
@ -708,17 +740,29 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
metadata.conferenceData = results[0].createdEvent?.conferenceData; metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints; metadata.entryPoints = results[0].createdEvent?.entryPoints;
} }
await sendScheduledEmails({ if (noEmail !== true) {
...evt, await sendScheduledEmails(
additionInformation: metadata, {
additionalNotes, ...evt,
}); additionInformation: metadata,
additionalNotes,
},
reqBody.recurringEventId ? (eventType.recurringEvent as RecurringEvent) : {}
);
}
} }
} }
if (eventType.requiresConfirmation && !rescheduleUid) { if (eventType.requiresConfirmation && !rescheduleUid && noEmail !== true) {
await sendOrganizerRequestEmail({ ...evt, additionalNotes }); await sendOrganizerRequestEmail(
await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]); { ...evt, additionalNotes },
reqBody.recurringEventId ? (eventType.recurringEvent as RecurringEvent) : {}
);
await sendAttendeeRequestEmail(
{ ...evt, additionalNotes },
attendeesList[0],
reqBody.recurringEventId ? (eventType.recurringEvent as RecurringEvent) : {}
);
} }
if (typeof eventType.price === "number" && eventType.price > 0 && !originalRescheduledBooking?.paid) { if (typeof eventType.price === "number" && eventType.price > 0 && !originalRescheduledBooking?.paid) {

View file

@ -6,7 +6,6 @@ import type { TFunction } from "next-i18next";
import { z, ZodError } from "zod"; import { z, ZodError } from "zod";
import { getCalendar } from "@calcom/core/CalendarManager"; import { getCalendar } from "@calcom/core/CalendarManager";
import EventManager from "@calcom/core/EventManager";
import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder"; import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder";
import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director"; import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director";
import { deleteMeeting } from "@calcom/core/videoClient"; import { deleteMeeting } from "@calcom/core/videoClient";
@ -100,6 +99,7 @@ const handler = async (
title: true, title: true,
users: true, users: true,
schedulingType: true, schedulingType: true,
recurringEvent: true,
}, },
rejectOnNotFound: true, rejectOnNotFound: true,
where: { where: {

View file

@ -8,7 +8,7 @@ import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button"; import Button from "@calcom/ui/Button";
import { useInViewObserver } from "@lib/hooks/useInViewObserver"; import { useInViewObserver } from "@lib/hooks/useInViewObserver";
import { inferQueryInput, trpc } from "@lib/trpc"; import { inferQueryInput, inferQueryOutput, trpc } from "@lib/trpc";
import BookingsShell from "@components/BookingsShell"; import BookingsShell from "@components/BookingsShell";
import EmptyScreen from "@components/EmptyScreen"; import EmptyScreen from "@components/EmptyScreen";
@ -17,6 +17,8 @@ import BookingListItem from "@components/booking/BookingListItem";
import SkeletonLoader from "@components/booking/SkeletonLoader"; import SkeletonLoader from "@components/booking/SkeletonLoader";
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"]; type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
type BookingOutput = inferQueryOutput<"viewer.bookings">["bookings"][0];
type BookingPage = inferQueryOutput<"viewer.bookings">;
export default function Bookings() { export default function Bookings() {
const router = useRouter(); const router = useRouter();
@ -26,6 +28,7 @@ export default function Bookings() {
const descriptionByStatus: Record<BookingListingStatus, string> = { const descriptionByStatus: Record<BookingListingStatus, string> = {
upcoming: t("upcoming_bookings"), upcoming: t("upcoming_bookings"),
recurring: t("recurring_bookings"),
past: t("past_bookings"), past: t("past_bookings"),
cancelled: t("cancelled_bookings"), cancelled: t("cancelled_bookings"),
}; };
@ -44,6 +47,18 @@ export default function Bookings() {
const isEmpty = !query.data?.pages[0]?.bookings.length; const isEmpty = !query.data?.pages[0]?.bookings.length;
// Get the recurrentCount value from the grouped recurring bookings
// created with the same recurringEventId
const defineRecurrentCount = (booking: BookingOutput, page: BookingPage) => {
let recurringCount = undefined;
if (booking.recurringEventId !== null) {
recurringCount = page.groupedRecurringBookings.filter(
(group) => group.recurringEventId === booking.recurringEventId
)[0]._count; // If found, only one object exists, just assing the needed _count value
}
return { recurringCount };
};
return ( return (
<Shell <Shell
heading={t("bookings")} heading={t("bookings")}
@ -66,7 +81,12 @@ export default function Bookings() {
{query.data.pages.map((page, index) => ( {query.data.pages.map((page, index) => (
<Fragment key={index}> <Fragment key={index}>
{page.bookings.map((booking) => ( {page.bookings.map((booking) => (
<BookingListItem key={booking.id} {...booking} /> <BookingListItem
key={booking.id}
listingStatus={status}
{...defineRecurrentCount(booking, page)}
{...booking}
/>
))} ))}
</Fragment> </Fragment>
))} ))}

View file

@ -2,6 +2,8 @@ import { Prisma } from "@prisma/client";
import { GetServerSidePropsContext } from "next"; import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types"; import { JSONObject } from "superjson/dist/types";
import { RecurringEvent } from "@calcom/types/Calendar";
import { asStringOrNull } from "@lib/asStringOrNull"; import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability"; import { getWorkingHours } from "@lib/availability";
import { GetBookingType } from "@lib/getBooking"; import { GetBookingType } from "@lib/getBooking";
@ -37,6 +39,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
periodEndDate: true, periodEndDate: true,
periodDays: true, periodDays: true,
periodCountCalendarDays: true, periodCountCalendarDays: true,
recurringEvent: true,
schedulingType: true, schedulingType: true,
userId: true, userId: true,
schedule: { schedule: {
@ -131,6 +134,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const [user] = users; const [user] = users;
const eventTypeObject = Object.assign({}, hashedLink.eventType, { const eventTypeObject = Object.assign({}, hashedLink.eventType, {
metadata: {} as JSONObject, metadata: {} as JSONObject,
recurringEvent: (eventTypeSelect.recurringEvent || {}) as RecurringEvent,
periodStartDate: hashedLink.eventType.periodStartDate?.toString() ?? null, periodStartDate: hashedLink.eventType.periodStartDate?.toString() ?? null,
periodEndDate: hashedLink.eventType.periodEndDate?.toString() ?? null, periodEndDate: hashedLink.eventType.periodEndDate?.toString() ?? null,
slug, slug,

View file

@ -6,8 +6,9 @@ import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types"; import { JSONObject } from "superjson/dist/types";
import { getLocationLabels } from "@calcom/app-store/utils"; import { getLocationLabels } from "@calcom/app-store/utils";
import { RecurringEvent } from "@calcom/types/Calendar";
import { asStringOrThrow } from "@lib/asStringOrNull"; import { asStringOrThrow, asStringOrNull } from "@lib/asStringOrNull";
import prisma from "@lib/prisma"; import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps"; import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -28,6 +29,7 @@ export default function Book(props: HashLinkPageProps) {
export async function getServerSideProps(context: GetServerSidePropsContext) { export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context); const ssr = await ssrInit(context);
const link = asStringOrThrow(context.query.link as string); const link = asStringOrThrow(context.query.link as string);
const recurringEventCountQuery = asStringOrNull(context.query.count);
const slug = context.query.slug as string; const slug = context.query.slug as string;
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({ const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
@ -41,6 +43,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
periodType: true, periodType: true,
periodDays: true, periodDays: true,
periodStartDate: true, periodStartDate: true,
recurringEvent: true,
periodEndDate: true, periodEndDate: true,
metadata: true, metadata: true,
periodCountCalendarDays: true, periodCountCalendarDays: true,
@ -122,6 +125,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const eventType = { const eventType = {
...eventTypeRaw, ...eventTypeRaw,
metadata: (eventTypeRaw.metadata || {}) as JSONObject, metadata: (eventTypeRaw.metadata || {}) as JSONObject,
recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
isWeb3Active: isWeb3Active:
web3Credentials && web3Credentials.key web3Credentials && web3Credentials.key
? (((web3Credentials.key as JSONObject).isWeb3Active || false) as boolean) ? (((web3Credentials.key as JSONObject).isWeb3Active || false) as boolean)
@ -148,6 +152,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const t = await getTranslation(context.locale ?? "en", "common"); const t = await getTranslation(context.locale ?? "en", "common");
// Checking if number of recurring event ocurrances is valid against event type configuration
const recurringEventCount =
(eventTypeObject?.recurringEvent?.count &&
recurringEventCountQuery &&
(parseInt(recurringEventCountQuery) <= eventTypeObject.recurringEvent.count
? recurringEventCountQuery
: eventType.recurringEvent.count)) ||
null;
return { return {
props: { props: {
locationLabels: getLocationLabels(t), locationLabels: getLocationLabels(t),
@ -155,6 +168,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
eventType: eventTypeObject, eventType: eventTypeObject,
booking: null, booking: null,
trpcState: ssr.dehydrate(), trpcState: ssr.dehydrate(),
recurringEventCount,
isDynamicGroupBooking: false, isDynamicGroupBooking: false,
hasHashedBookingLink: true, hasHashedBookingLink: true,
hashedLink: link, hashedLink: link,

View file

@ -34,9 +34,11 @@ import getApps, { getLocationOptions } from "@calcom/app-store/utils";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification"; import showToast from "@calcom/lib/notification";
import { StripeData } from "@calcom/stripe/server"; import { StripeData } from "@calcom/stripe/server";
import { RecurringEvent } from "@calcom/types/Calendar";
import Button from "@calcom/ui/Button"; import Button from "@calcom/ui/Button";
import { Dialog, DialogContent, DialogTrigger } from "@calcom/ui/Dialog"; import { Dialog, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
import Switch from "@calcom/ui/Switch"; import Switch from "@calcom/ui/Switch";
import { Tooltip } from "@calcom/ui/Tooltip";
import { Form } from "@calcom/ui/form/fields"; import { Form } from "@calcom/ui/form/fields";
import { QueryCell } from "@lib/QueryCell"; import { QueryCell } from "@lib/QueryCell";
@ -55,9 +57,9 @@ import DestinationCalendarSelector from "@components/DestinationCalendarSelector
import { EmbedButton, EmbedDialog } from "@components/Embed"; import { EmbedButton, EmbedDialog } from "@components/Embed";
import Loader from "@components/Loader"; import Loader from "@components/Loader";
import Shell from "@components/Shell"; import Shell from "@components/Shell";
import { Tooltip } from "@components/Tooltip";
import { UpgradeToProDialog } from "@components/UpgradeToProDialog"; import { UpgradeToProDialog } from "@components/UpgradeToProDialog";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import RecurringEventController from "@components/eventtype/RecurringEventController";
import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm"; import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm";
import Badge from "@components/ui/Badge"; import Badge from "@components/ui/Badge";
import InfoBadge from "@components/ui/InfoBadge"; import InfoBadge from "@components/ui/InfoBadge";
@ -65,7 +67,7 @@ import CheckboxField from "@components/ui/form/CheckboxField";
import CheckedSelect from "@components/ui/form/CheckedSelect"; import CheckedSelect from "@components/ui/form/CheckedSelect";
import { DateRangePicker } from "@components/ui/form/DateRangePicker"; import { DateRangePicker } from "@components/ui/form/DateRangePicker";
import MinutesField from "@components/ui/form/MinutesField"; import MinutesField from "@components/ui/form/MinutesField";
import Select, { SelectProps } from "@components/ui/form/Select"; import Select from "@components/ui/form/Select";
import * as RadioArea from "@components/ui/form/radio-area"; import * as RadioArea from "@components/ui/form/radio-area";
import WebhookListContainer from "@components/webhook/WebhookListContainer"; import WebhookListContainer from "@components/webhook/WebhookListContainer";
@ -272,8 +274,12 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
PERIOD_TYPES.find((s) => s.type === eventType.periodType) || PERIOD_TYPES.find((s) => s.type === eventType.periodType) ||
PERIOD_TYPES.find((s) => s.type === "UNLIMITED"); PERIOD_TYPES.find((s) => s.type === "UNLIMITED");
const [requirePayment, setRequirePayment] = useState(eventType.price > 0);
const [advancedSettingsVisible, setAdvancedSettingsVisible] = useState(false); const [advancedSettingsVisible, setAdvancedSettingsVisible] = useState(false);
const [requirePayment, setRequirePayment] = useState(
eventType.price > 0 && eventType.recurringEvent?.count !== undefined
);
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink); const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
useEffect(() => { useEffect(() => {
@ -483,6 +489,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
description: string; description: string;
disableGuests: boolean; disableGuests: boolean;
requiresConfirmation: boolean; requiresConfirmation: boolean;
recurringEvent: RecurringEvent;
schedulingType: SchedulingType | null; schedulingType: SchedulingType | null;
price: number; price: number;
currency: string; currency: string;
@ -510,6 +517,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
}>({ }>({
defaultValues: { defaultValues: {
locations: eventType.locations || [], locations: eventType.locations || [],
recurringEvent: eventType.recurringEvent || {},
schedule: eventType.schedule?.id, schedule: eventType.schedule?.id,
periodDates: { periodDates: {
startDate: periodDates.startDate, startDate: periodDates.startDate,
@ -928,15 +936,15 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
giphyThankYouPage, giphyThankYouPage,
beforeBufferTime, beforeBufferTime,
afterBufferTime, afterBufferTime,
recurringEvent,
locations, locations,
...input ...input
} = values; } = values;
if (requirePayment) input.currency = currency;
updateMutation.mutate({ updateMutation.mutate({
...input, ...input,
locations, locations,
recurringEvent,
periodStartDate: periodDates.startDate, periodStartDate: periodDates.startDate,
periodEndDate: periodDates.endDate, periodEndDate: periodDates.endDate,
periodCountCalendarDays: periodCountCalendarDays === "1", periodCountCalendarDays: periodCountCalendarDays === "1",
@ -1334,6 +1342,11 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
)} )}
/> />
<RecurringEventController
recurringEvent={eventType.recurringEvent}
formMethods={formMethods}
/>
<Controller <Controller
name="disableGuests" name="disableGuests"
control={formMethods.control} control={formMethods.control}
@ -1641,7 +1654,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<SuccessRedirectEdit<typeof formMethods> <SuccessRedirectEdit<typeof formMethods>
formMethods={formMethods} formMethods={formMethods}
eventType={eventType}></SuccessRedirectEdit> eventType={eventType}></SuccessRedirectEdit>
{hasPaymentIntegration && ( {hasPaymentIntegration && eventType.recurringEvent?.count !== undefined && (
<> <>
<hr className="border-neutral-200" /> <hr className="border-neutral-200" />
<div className="block sm:flex"> <div className="block sm:flex">
@ -2054,6 +2067,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
periodEndDate: true, periodEndDate: true,
periodCountCalendarDays: true, periodCountCalendarDays: true,
requiresConfirmation: true, requiresConfirmation: true,
recurringEvent: true,
hideCalendarNotes: true, hideCalendarNotes: true,
disableGuests: true, disableGuests: true,
minimumBookingNotice: true, minimumBookingNotice: true,
@ -2118,6 +2132,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const { locations, metadata, ...restEventType } = rawEventType; const { locations, metadata, ...restEventType } = rawEventType;
const eventType = { const eventType = {
...restEventType, ...restEventType,
recurringEvent: (restEventType.recurringEvent || {}) as RecurringEvent,
locations: locations as unknown as Location[], locations: locations as unknown as Location[],
metadata: (metadata || {}) as JSONObject, metadata: (metadata || {}) as JSONObject,
isWeb3Active: isWeb3Active:

View file

@ -31,6 +31,7 @@ import Dropdown, {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
} from "@calcom/ui/Dropdown"; } from "@calcom/ui/Dropdown";
import { Tooltip } from "@calcom/ui/Tooltip";
import { withQuery } from "@lib/QueryCell"; import { withQuery } from "@lib/QueryCell";
import classNames from "@lib/classNames"; import classNames from "@lib/classNames";
@ -40,7 +41,6 @@ import { inferQueryOutput, trpc } from "@lib/trpc";
import { EmbedButton, EmbedDialog } from "@components/Embed"; import { EmbedButton, EmbedDialog } from "@components/Embed";
import EmptyScreen from "@components/EmptyScreen"; import EmptyScreen from "@components/EmptyScreen";
import Shell from "@components/Shell"; import Shell from "@components/Shell";
import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import CreateEventTypeButton from "@components/eventtype/CreateEventType"; import CreateEventTypeButton from "@components/eventtype/CreateEventType";
import EventTypeDescription from "@components/eventtype/EventTypeDescription"; import EventTypeDescription from "@components/eventtype/EventTypeDescription";

View file

@ -1,5 +1,6 @@
import { CheckIcon } from "@heroicons/react/outline"; import { CheckIcon } from "@heroicons/react/outline";
import { ArrowLeftIcon, ClockIcon, XIcon } from "@heroicons/react/solid"; import { ArrowLeftIcon, ClockIcon, XIcon } from "@heroicons/react/solid";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import classNames from "classnames"; import classNames from "classnames";
import dayjs from "dayjs"; import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
@ -11,6 +12,7 @@ import { useSession } from "next-auth/react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import rrule from "rrule";
import { SpaceBookingSuccessPage } from "@calcom/app-store/spacebooking/components"; import { SpaceBookingSuccessPage } from "@calcom/app-store/spacebooking/components";
import { import {
@ -21,6 +23,7 @@ import {
} from "@calcom/embed-core"; } from "@calcom/embed-core";
import { getDefaultEvent } from "@calcom/lib/defaultEvents"; import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RecurringEvent } from "@calcom/types/Calendar";
import Button from "@calcom/ui/Button"; import Button from "@calcom/ui/Button";
import { EmailInput } from "@calcom/ui/form/fields"; import { EmailInput } from "@calcom/ui/form/fields";
@ -133,7 +136,9 @@ function RedirectionToast({ url }: { url: string }) {
); );
} }
export default function Success(props: inferSSRProps<typeof getServerSideProps>) { type SuccessProps = inferSSRProps<typeof getServerSideProps>;
export default function Success(props: SuccessProps) {
const { t } = useLocale(); const { t } = useLocale();
const router = useRouter(); const router = useRouter();
const { location: _location, name, reschedule } = router.query; const { location: _location, name, reschedule } = router.query;
@ -212,7 +217,23 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
return encodeURIComponent(event.value ? event.value : false); return encodeURIComponent(event.value ? event.value : false);
} }
function getTitle(): string {
const titleSuffix = props.recurringBookings ? "_recurring" : "";
if (needsConfirmation) {
if (props.profile.name !== null) {
return t("user_needs_to_confirm_or_reject_booking" + titleSuffix, {
user: props.profile.name,
});
}
return t("needs_to_be_confirmed_or_rejected" + titleSuffix);
}
return t("emailed_you_and_attendees" + titleSuffix);
}
const userIsOwner = !!(session?.user?.id && eventType.users.find((user) => (user.id = session.user.id))); const userIsOwner = !!(session?.user?.id && eventType.users.find((user) => (user.id = session.user.id)));
const title = t(
`booking_${needsConfirmation ? "submitted" : "confirmed"}${props.recurringBookings ? "_recurring" : ""}`
);
return ( return (
(isReady && ( (isReady && (
<> <>
@ -220,10 +241,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
className={isEmbed ? "" : "h-screen bg-neutral-100 dark:bg-neutral-900"} className={isEmbed ? "" : "h-screen bg-neutral-100 dark:bg-neutral-900"}
data-testid="success-page"> data-testid="success-page">
<Theme /> <Theme />
<HeadSeo <HeadSeo title={title} description={title} />
title={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
description={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
/>
<CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} /> <CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} />
<main className={classNames(shouldAlignCentrally ? "mx-auto" : "", isEmbed ? "" : "max-w-3xl")}> <main className={classNames(shouldAlignCentrally ? "mx-auto" : "", isEmbed ? "" : "max-w-3xl")}>
<div className={classNames("overflow-y-auto", isEmbed ? "" : "z-50 ")}> <div className={classNames("overflow-y-auto", isEmbed ? "" : "z-50 ")}>
@ -263,29 +281,29 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
<h3 <h3
className="text-2xl font-semibold leading-6 text-neutral-900 dark:text-white" className="text-2xl font-semibold leading-6 text-neutral-900 dark:text-white"
id="modal-headline"> id="modal-headline">
{needsConfirmation ? t("submitted") : t("meeting_is_scheduled")} {needsConfirmation
? props.recurringBookings
? t("submitted_recurring")
: t("submitted")
: props.recurringBookings
? t("meeting_is_scheduled_recurring")
: t("meeting_is_scheduled")}
</h3> </h3>
<div className="mt-3"> <div className="mt-3">
<p className="text-sm text-neutral-600 dark:text-gray-300"> <p className="text-sm text-neutral-600 dark:text-gray-300">{getTitle()}</p>
{needsConfirmation
? props.profile.name !== null
? t("user_needs_to_confirm_or_reject_booking", { user: props.profile.name })
: t("needs_to_be_confirmed_or_rejected")
: t("emailed_you_and_attendees")}
</p>
</div> </div>
<div className="border-bookinglightest text-bookingdark mt-4 grid grid-cols-3 border-t border-b py-4 text-left dark:border-gray-900 dark:text-gray-300"> <div className="border-bookinglightest text-bookingdark mt-4 grid grid-cols-3 border-t border-b py-4 text-left dark:border-gray-900 dark:text-gray-300">
<div className="font-medium">{t("what")}</div> <div className="font-medium">{t("what")}</div>
<div className="col-span-2 mb-6">{eventName}</div> <div className="col-span-2 mb-6">{eventName}</div>
<div className="font-medium">{t("when")}</div> <div className="font-medium">{t("when")}</div>
<div className="col-span-2"> <div className="col-span-2">
{date.format("dddd, DD MMMM YYYY")} <RecurringBookings
<br /> isReschedule={reschedule === "true"}
{date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "} eventType={props.eventType}
<span className="text-bookinglight"> recurringBookings={props.recurringBookings}
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()}) date={date}
</span> is24h={is24h}
/>
</div> </div>
{location && ( {location && (
<> <>
@ -322,6 +340,10 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
}` + }` +
(typeof location === "string" (typeof location === "string"
? "&location=" + encodeURIComponent(location) ? "&location=" + encodeURIComponent(location)
: "") +
(props.eventType.recurringEvent
? "&recur=" +
encodeURIComponent(new rrule(props.eventType.recurringEvent).toString())
: "") : "")
}> }>
<a className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white"> <a className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white">
@ -447,21 +469,15 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
{props.userHasSpaceBooking && ( {props.userHasSpaceBooking && (
<SpaceBookingSuccessPage <SpaceBookingSuccessPage
open={props.userHasSpaceBooking} open={props.userHasSpaceBooking}
what={` what={`
${needsConfirmation ? t("submitted") : `${t("meeting_is_scheduled")}.`} ${needsConfirmation ? t("submitted") : `${t("meeting_is_scheduled")}.`}
${ ${getTitle()} ${t("what")}: ${eventName}`}
needsConfirmation
? props.profile.name !== null
? t("user_needs_to_confirm_or_reject_booking", { user: props.profile.name })
: t("needs_to_be_confirmed_or_rejected")
: t("emailed_you_and_attendees")
} ${t("what")}: ${eventName}`}
where={`${t("where")}: ${ where={`${t("where")}: ${
location ? (location?.startsWith("http") ? { location } : location) : "Far far a way galaxy" location ? (location?.startsWith("http") ? { location } : location) : "Far far a way galaxy"
}`} }`}
when={`${t("when")}: ${date.format("dddd, DD MMMM YYYY")} ${date.format( when={`${t("when")}: ${props.recurringBookings ? t("starting") : ""} ${date.format(
is24h ? "H:mm" : "h:mma" "dddd, DD MMMM YYYY"
)} - ${props.eventType.length} mins (${ )} ${date.format(is24h ? "H:mm" : "h:mma")} - ${props.eventType.length} mins (${
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess() localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()
})`} })`}
/> />
@ -472,6 +488,71 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
); );
} }
type RecurringBookingsProps = {
isReschedule: boolean;
eventType: SuccessProps["eventType"];
recurringBookings: SuccessProps["recurringBookings"];
date: dayjs.Dayjs;
is24h: boolean;
};
function RecurringBookings({
isReschedule = false,
eventType,
recurringBookings,
date,
is24h,
}: RecurringBookingsProps) {
const [moreEventsVisible, setMoreEventsVisible] = useState(false);
const { t } = useLocale();
return !isReschedule && recurringBookings ? (
<>
{eventType.recurringEvent?.count &&
recurringBookings.slice(0, 4).map((dateStr, idx) => (
<div key={idx} className="mb-2">
{dayjs(dateStr).format("dddd, DD MMMM YYYY")}
<br />
{dayjs(dateStr).format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "}
<span className="text-bookinglight">
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
</span>
</div>
))}
{recurringBookings.length > 4 && (
<Collapsible open={moreEventsVisible} onOpenChange={() => setMoreEventsVisible(!moreEventsVisible)}>
<CollapsibleTrigger
type="button"
className={classNames("flex w-full", moreEventsVisible ? "hidden" : "")}>
{t("plus_more", { count: recurringBookings.length - 4 })}
</CollapsibleTrigger>
<CollapsibleContent>
{eventType.recurringEvent?.count &&
recurringBookings.slice(4).map((dateStr, idx) => (
<div key={idx} className="mb-2">
{dayjs(dateStr).format("dddd, DD MMMM YYYY")}
<br />
{dayjs(dateStr).format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "}
<span className="text-bookinglight">
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
</span>
</div>
))}
</CollapsibleContent>
</Collapsible>
)}
</>
) : !eventType.recurringEvent.freq ? (
<>
{date.format("dddd, DD MMMM YYYY")}
<br />
{date.format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "}
<span className="text-bookinglight">
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
</span>
</>
) : null;
}
const getEventTypesFromDB = async (typeId: number) => { const getEventTypesFromDB = async (typeId: number) => {
return await prisma.eventType.findUnique({ return await prisma.eventType.findUnique({
where: { where: {
@ -483,6 +564,7 @@ const getEventTypesFromDB = async (typeId: number) => {
description: true, description: true,
length: true, length: true,
eventName: true, eventName: true,
recurringEvent: true,
requiresConfirmation: true, requiresConfirmation: true,
userId: true, userId: true,
successRedirectUrl: true, successRedirectUrl: true,
@ -513,6 +595,7 @@ const getEventTypesFromDB = async (typeId: number) => {
export async function getServerSideProps(context: GetServerSidePropsContext) { export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context); const ssr = await ssrInit(context);
const typeId = parseInt(asStringOrNull(context.query.type) ?? ""); const typeId = parseInt(asStringOrNull(context.query.type) ?? "");
const recurringEventIdQuery = asStringOrNull(context.query.recur);
const typeSlug = asStringOrNull(context.query.eventSlug) ?? "15min"; const typeSlug = asStringOrNull(context.query.eventSlug) ?? "15min";
const dynamicEventName = asStringOrNull(context.query.eventName) ?? ""; const dynamicEventName = asStringOrNull(context.query.eventName) ?? "";
@ -522,9 +605,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}; };
} }
const eventType = !typeId ? getDefaultEvent(typeSlug) : await getEventTypesFromDB(typeId); let eventTypeRaw = !typeId ? getDefaultEvent(typeSlug) : await getEventTypesFromDB(typeId);
if (!eventType) { if (!eventTypeRaw) {
return { return {
notFound: true, notFound: true,
}; };
@ -532,11 +615,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
let spaceBookingAvailable = false; let spaceBookingAvailable = false;
let userHasSpaceBooking = false; let userHasSpaceBooking = false;
if (eventType.users[0] && eventType.users[0].id) { if (eventTypeRaw.users[0] && eventTypeRaw.users[0].id) {
const credential = await prisma.credential.findFirst({ const credential = await prisma.credential.findFirst({
where: { where: {
type: "spacebooking_other", type: "spacebooking_other",
userId: eventType.users[0].id, userId: eventTypeRaw.users[0].id,
}, },
}); });
if (credential && credential.type === "spacebooking_other") { if (credential && credential.type === "spacebooking_other") {
@ -544,11 +627,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
} }
} }
if (!eventType.users.length && eventType.userId) { if (!eventTypeRaw.users.length && eventTypeRaw.userId) {
// TODO we should add `user User` relation on `EventType` so this extra query isn't needed // TODO we should add `user User` relation on `EventType` so this extra query isn't needed
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
id: eventType.userId, id: eventTypeRaw.userId,
}, },
select: { select: {
id: true, id: true,
@ -563,17 +646,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}, },
}); });
if (user) { if (user) {
eventType.users.push(user); eventTypeRaw.users.push(user as any);
} }
} }
if (!eventType.users.length) { if (!eventTypeRaw.users.length) {
return { return {
notFound: true, notFound: true,
}; };
} }
// if (!typeId) eventType["eventName"] = getDynamicEventName(users, typeSlug); const eventType = {
...eventTypeRaw,
recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
};
const profile = { const profile = {
name: eventType.team?.name || eventType.users[0]?.name || null, name: eventType.team?.name || eventType.users[0]?.name || null,
@ -583,11 +669,25 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor || null, darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor || null,
}; };
let recurringBookings = null;
if (recurringEventIdQuery) {
// We need to get the dates for the bookings to be able to show them in the UI
recurringBookings = await prisma.booking.findMany({
where: {
recurringEventId: recurringEventIdQuery,
},
select: {
startTime: true,
},
});
}
return { return {
props: { props: {
hideBranding: eventType.team ? eventType.team.hideBranding : isBrandingHidden(eventType.users[0]), hideBranding: eventType.team ? eventType.team.hideBranding : isBrandingHidden(eventType.users[0]),
profile, profile,
eventType, eventType,
recurringBookings: recurringBookings ? recurringBookings.map((obj) => obj.startTime.toString()) : null,
trpcState: ssr.dehydrate(), trpcState: ssr.dehydrate(),
dynamicEventName, dynamicEventName,
userHasSpaceBooking, userHasSpaceBooking,

View file

@ -2,6 +2,8 @@ import { UserPlan } from "@prisma/client";
import { GetServerSidePropsContext } from "next"; import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types"; import { JSONObject } from "superjson/dist/types";
import { RecurringEvent } from "@calcom/types/Calendar";
import { asStringOrNull } from "@lib/asStringOrNull"; import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability"; import { getWorkingHours } from "@lib/availability";
import getBooking, { GetBookingType } from "@lib/getBooking"; import getBooking, { GetBookingType } from "@lib/getBooking";
@ -68,6 +70,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
minimumBookingNotice: true, minimumBookingNotice: true,
beforeEventBuffer: true, beforeEventBuffer: true,
afterEventBuffer: true, afterEventBuffer: true,
recurringEvent: true,
price: true, price: true,
currency: true, currency: true,
timeZone: true, timeZone: true,
@ -107,6 +110,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
metadata: (eventType.metadata || {}) as JSONObject, metadata: (eventType.metadata || {}) as JSONObject,
periodStartDate: eventType.periodStartDate?.toString() ?? null, periodStartDate: eventType.periodStartDate?.toString() ?? null,
periodEndDate: eventType.periodEndDate?.toString() ?? null, periodEndDate: eventType.periodEndDate?.toString() ?? null,
recurringEvent: (eventType.recurringEvent || {}) as RecurringEvent,
}); });
eventTypeObject.availability = []; eventTypeObject.availability = [];

View file

@ -3,8 +3,9 @@ import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types"; import { JSONObject } from "superjson/dist/types";
import { getLocationLabels } from "@calcom/app-store/utils"; import { getLocationLabels } from "@calcom/app-store/utils";
import { RecurringEvent } from "@calcom/types/Calendar";
import { asStringOrThrow } from "@lib/asStringOrNull"; import { asStringOrThrow, asStringOrNull } from "@lib/asStringOrNull";
import getBooking, { GetBookingType } from "@lib/getBooking"; import getBooking, { GetBookingType } from "@lib/getBooking";
import prisma from "@lib/prisma"; import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps"; import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -21,13 +22,14 @@ export default function TeamBookingPage(props: TeamBookingPageProps) {
export async function getServerSideProps(context: GetServerSidePropsContext) { export async function getServerSideProps(context: GetServerSidePropsContext) {
const eventTypeId = parseInt(asStringOrThrow(context.query.type)); const eventTypeId = parseInt(asStringOrThrow(context.query.type));
const recurringEventCountQuery = asStringOrNull(context.query.count);
if (typeof eventTypeId !== "number" || eventTypeId % 1 !== 0) { if (typeof eventTypeId !== "number" || eventTypeId % 1 !== 0) {
return { return {
notFound: true, notFound: true,
} as const; } as const;
} }
const eventType = await prisma.eventType.findUnique({ const eventTypeRaw = await prisma.eventType.findUnique({
where: { where: {
id: eventTypeId, id: eventTypeId,
}, },
@ -44,6 +46,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
periodStartDate: true, periodStartDate: true,
periodEndDate: true, periodEndDate: true,
periodCountCalendarDays: true, periodCountCalendarDays: true,
recurringEvent: true,
disableGuests: true, disableGuests: true,
price: true, price: true,
currency: true, currency: true,
@ -65,7 +68,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}, },
}); });
if (!eventType) return { notFound: true }; if (!eventTypeRaw) return { notFound: true };
const eventType = {
...eventTypeRaw,
recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
};
const eventTypeObject = [eventType].map((e) => { const eventTypeObject = [eventType].map((e) => {
return { return {
@ -83,6 +91,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const t = await getTranslation(context.locale ?? "en", "common"); const t = await getTranslation(context.locale ?? "en", "common");
// Checking if number of recurring event ocurrances is valid against event type configuration
const recurringEventCount =
(eventType.recurringEvent?.count &&
recurringEventCountQuery &&
(parseInt(recurringEventCountQuery) <= eventType.recurringEvent.count
? recurringEventCountQuery
: eventType.recurringEvent.count)) ||
null;
return { return {
props: { props: {
locationLabels: getLocationLabels(t), locationLabels: getLocationLabels(t),
@ -96,6 +113,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
eventName: null, eventName: null,
}, },
eventType: eventTypeObject, eventType: eventTypeObject,
recurringEventCount,
booking, booking,
isDynamicGroupBooking: false, isDynamicGroupBooking: false,
hasHashedBookingLink: false, hasHashedBookingLink: false,

View file

@ -49,6 +49,45 @@ test.describe("Event Types tests", () => {
isCreated = await expect(page.locator(`text='${eventTitle}'`)).toBeVisible(); isCreated = await expect(page.locator(`text='${eventTitle}'`)).toBeVisible();
}); });
test("enabling recurring event comes with default options", async ({ page }) => {
await page.click("[data-testid=new-event-type]");
const nonce = randomString(3);
eventTitle = `my recurring event ${nonce}`;
await page.fill("[name=title]", eventTitle);
await page.fill("[name=length]", "15");
await page.click("[type=submit]");
await page.waitForNavigation({
url(url) {
return url.pathname !== "/event-types";
},
});
await page.click("[data-testid=show-advanced-settings]");
await expect(await page.locator("[data-testid=recurring-event-collapsible] > *")).not.toBeVisible();
await page.click("[data-testid=recurring-event-check]");
isCreated = await expect(
await page.locator("[data-testid=recurring-event-collapsible] > *")
).toBeVisible();
await expect(
await page
.locator("[data-testid=recurring-event-collapsible] input[type=number]")
.nth(0)
.getAttribute("value")
).toBe("1");
await expect(
await page.locator("[data-testid=recurring-event-collapsible] div[class$=singleValue]").textContent()
).toBe("week");
await expect(
await page
.locator("[data-testid=recurring-event-collapsible] input[type=number]")
.nth(1)
.getAttribute("value")
).toBe("12");
});
test("can duplicate an existing event type", async ({ page }) => { test("can duplicate an existing event type", async ({ page }) => {
const firstTitle = await page.locator("[data-testid=event-type-title-3]").innerText(); const firstTitle = await page.locator("[data-testid=event-type-title-3]").innerText();
const firstFullSlug = await page.locator("[data-testid=event-type-slug-3]").innerText(); const firstFullSlug = await page.locator("[data-testid=event-type-slug-3]").innerText();

View file

@ -12,6 +12,7 @@
"event_declined_subject": "Declined: {{eventType}} with {{name}} at {{date}}", "event_declined_subject": "Declined: {{eventType}} with {{name}} at {{date}}",
"event_cancelled_subject": "Cancelled: {{eventType}} with {{name}} at {{date}}", "event_cancelled_subject": "Cancelled: {{eventType}} with {{name}} at {{date}}",
"event_request_declined": "Your event request has been declined", "event_request_declined": "Your event request has been declined",
"event_request_declined_recurring": "Your recurring event request has been declined",
"event_request_cancelled": "Your scheduled event was cancelled", "event_request_cancelled": "Your scheduled event was cancelled",
"organizer": "Organizer", "organizer": "Organizer",
"need_to_reschedule_or_cancel": "Need to reschedule or cancel?", "need_to_reschedule_or_cancel": "Need to reschedule or cancel?",
@ -23,6 +24,7 @@
"rejection_confirmation": "Reject the booking", "rejection_confirmation": "Reject the booking",
"manage_this_event": "Manage this event", "manage_this_event": "Manage this event",
"your_event_has_been_scheduled": "Your event has been scheduled", "your_event_has_been_scheduled": "Your event has been scheduled",
"your_event_has_been_scheduled_recurring": "Your recurring event has been scheduled",
"accept_our_license": "Accept our license by changing the .env variable <1>NEXT_PUBLIC_LICENSE_CONSENT</1> to '{{agree}}'.", "accept_our_license": "Accept our license by changing the .env variable <1>NEXT_PUBLIC_LICENSE_CONSENT</1> to '{{agree}}'.",
"remove_banner_instructions": "To remove this banner, please open your .env file and change the <1>NEXT_PUBLIC_LICENSE_CONSENT</1> variable to '{{agree}}'.", "remove_banner_instructions": "To remove this banner, please open your .env file and change the <1>NEXT_PUBLIC_LICENSE_CONSENT</1> variable to '{{agree}}'.",
"error_message": "The error message was: '{{errorMessage}}'", "error_message": "The error message was: '{{errorMessage}}'",
@ -57,6 +59,7 @@
"confirm_or_reject_request": "Confirm or reject the request", "confirm_or_reject_request": "Confirm or reject the request",
"check_bookings_page_to_confirm_or_reject": "Check your bookings page to confirm or reject the booking.", "check_bookings_page_to_confirm_or_reject": "Check your bookings page to confirm or reject the booking.",
"event_awaiting_approval": "An event is waiting for your approval", "event_awaiting_approval": "An event is waiting for your approval",
"event_awaiting_approval_recurring": "A recurring event is waiting for your approval",
"someone_requested_an_event": "Someone has requested to schedule an event on your calendar.", "someone_requested_an_event": "Someone has requested to schedule an event on your calendar.",
"someone_requested_password_reset": "Someone has requested a link to change your password.", "someone_requested_password_reset": "Someone has requested a link to change your password.",
"password_reset_instructions": "If you didn't request this, you can safely ignore this email and your password will not be changed.", "password_reset_instructions": "If you didn't request this, you can safely ignore this email and your password will not be changed.",
@ -79,6 +82,7 @@
"manage_my_bookings": "Manage my bookings", "manage_my_bookings": "Manage my bookings",
"need_to_make_a_change": "Need to make a change?", "need_to_make_a_change": "Need to make a change?",
"new_event_scheduled": "A new event has been scheduled.", "new_event_scheduled": "A new event has been scheduled.",
"new_event_scheduled_recurring": "A new recurring event has been scheduled.",
"invitee_email": "Invitee Email", "invitee_email": "Invitee Email",
"invitee_timezone": "Invitee Time Zone", "invitee_timezone": "Invitee Time Zone",
"event_type": "Event Type", "event_type": "Event Type",
@ -128,6 +132,7 @@
"ping_test": "Ping test", "ping_test": "Ping test",
"add_to_homescreen": "Add this app to your home screen for faster access and improved experience.", "add_to_homescreen": "Add this app to your home screen for faster access and improved experience.",
"upcoming": "Upcoming", "upcoming": "Upcoming",
"recurring": "Recurring",
"past": "Past", "past": "Past",
"choose_a_file": "Choose a file...", "choose_a_file": "Choose a file...",
"upload_image": "Upload image", "upload_image": "Upload image",
@ -232,13 +237,20 @@
"add_to_calendar": "Add to calendar", "add_to_calendar": "Add to calendar",
"other": "Other", "other": "Other",
"emailed_you_and_attendees": "We emailed you and the other attendees a calendar invitation with all the details.", "emailed_you_and_attendees": "We emailed you and the other attendees a calendar invitation with all the details.",
"emailed_you_and_attendees_recurring": "We emailed you and the other attendees a calendar invitation for the first of these recurring events.",
"emailed_you_and_any_other_attendees": "You and any other attendees have been emailed with this information.", "emailed_you_and_any_other_attendees": "You and any other attendees have been emailed with this information.",
"needs_to_be_confirmed_or_rejected": "Your booking still needs to be confirmed or rejected.", "needs_to_be_confirmed_or_rejected": "Your booking still needs to be confirmed or rejected.",
"needs_to_be_confirmed_or_rejected_recurring": "Your recurring meeting still needs to be confirmed or rejected.",
"user_needs_to_confirm_or_reject_booking": "{{user}} still needs to confirm or reject the booking.", "user_needs_to_confirm_or_reject_booking": "{{user}} still needs to confirm or reject the booking.",
"user_needs_to_confirm_or_reject_booking_recurring": "{{user}} still needs to confirm or reject each booking of the recurring meeting.",
"meeting_is_scheduled": "This meeting is scheduled", "meeting_is_scheduled": "This meeting is scheduled",
"meeting_is_scheduled_recurring": "The recurring events are scheduled",
"submitted": "Your booking has been submitted", "submitted": "Your booking has been submitted",
"submitted_recurring": "Your recurring meeting has been submitted",
"booking_submitted": "Your booking has been submitted", "booking_submitted": "Your booking has been submitted",
"booking_submitted_recurring": "Your recurring meeting has been submitted",
"booking_confirmed": "Your booking has been confirmed", "booking_confirmed": "Your booking has been confirmed",
"booking_confirmed_recurring": "Your recurring meeting has been confirmed",
"enter_new_password": "Enter the new password you'd like for your account.", "enter_new_password": "Enter the new password you'd like for your account.",
"reset_password": "Reset Password", "reset_password": "Reset Password",
"change_your_password": "Change your password", "change_your_password": "Change your password",
@ -282,6 +294,7 @@
"bookings": "Bookings", "bookings": "Bookings",
"bookings_description": "See upcoming and past events booked through your event type links.", "bookings_description": "See upcoming and past events booked through your event type links.",
"upcoming_bookings": "As soon as someone books a time with you it will show up here.", "upcoming_bookings": "As soon as someone books a time with you it will show up here.",
"recurring_bookings": "As soon as someone books a recurring meeting with you it will show up here.",
"past_bookings": "Your past bookings will show up here.", "past_bookings": "Your past bookings will show up here.",
"cancelled_bookings": "Your cancelled bookings will show up here.", "cancelled_bookings": "Your cancelled bookings will show up here.",
"on": "on", "on": "on",
@ -432,6 +445,7 @@
"edit_role": "Edit Role", "edit_role": "Edit Role",
"edit_team": "Edit team", "edit_team": "Edit team",
"reject": "Reject", "reject": "Reject",
"reject_all": "Reject all",
"accept": "Accept", "accept": "Accept",
"leave": "Leave", "leave": "Leave",
"profile": "Profile", "profile": "Profile",
@ -460,6 +474,7 @@
"cancel_event": "Cancel this event", "cancel_event": "Cancel this event",
"continue": "Continue", "continue": "Continue",
"confirm": "Confirm", "confirm": "Confirm",
"confirm_all": "Confirm all",
"disband_team": "Disband Team", "disband_team": "Disband Team",
"disband_team_confirmation_message": "Are you sure you want to disband this team? Anyone who you've shared this team link with will no longer be able to book using it.", "disband_team_confirmation_message": "Are you sure you want to disband this team? Anyone who you've shared this team link with will no longer be able to book using it.",
"remove_member_confirmation_message": "Are you sure you want to remove this member from the team?", "remove_member_confirmation_message": "Are you sure you want to remove this member from the team?",
@ -526,6 +541,18 @@
"language": "Language", "language": "Language",
"timezone": "Timezone", "timezone": "Timezone",
"first_day_of_week": "First Day of Week", "first_day_of_week": "First Day of Week",
"repeats_up_to": "Repeats up to {{count}} time",
"repeats_up_to_plural": "Repeats up to {{count}} times",
"every_for_freq": "Every {{freq}} for",
"repeats_every": "Repeats every",
"weekly": "week",
"weekly_plural": "weeks",
"monthly": "month",
"monthly_plural": "months",
"yearly": "year",
"yearly_plural": "years",
"plus_more": "+ {{count}} more",
"max": "Max",
"single_theme": "Single Theme", "single_theme": "Single Theme",
"brand_color": "Brand Color", "brand_color": "Brand Color",
"light_brand_color": "Brand Color (Light Theme)", "light_brand_color": "Brand Color (Light Theme)",
@ -582,6 +609,9 @@
"disable_notes_description": "For privacy reasons, additional inputs and notes will be hidden in the calendar entry. They will still be sent to your email.", "disable_notes_description": "For privacy reasons, additional inputs and notes will be hidden in the calendar entry. They will still be sent to your email.",
"opt_in_booking": "Opt-in Booking", "opt_in_booking": "Opt-in Booking",
"opt_in_booking_description": "The booking needs to be manually confirmed before it is pushed to the integrations and a confirmation mail is sent.", "opt_in_booking_description": "The booking needs to be manually confirmed before it is pushed to the integrations and a confirmation mail is sent.",
"recurring_event": "Recurring Event",
"recurring_event_description": "People can subscribe for recurring events",
"starting": "Starting",
"disable_guests": "Disable Guests", "disable_guests": "Disable Guests",
"disable_guests_description": "Disable adding additional guests while booking.", "disable_guests_description": "Disable adding additional guests while booking.",
"hashed_link": "Generate hashed URL", "hashed_link": "Generate hashed URL",

View file

@ -6,6 +6,7 @@ import { z } from "zod";
import getApps from "@calcom/app-store/utils"; import getApps from "@calcom/app-store/utils";
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername"; import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
import { RecurringEvent } from "@calcom/types/Calendar";
import { checkRegularUsername } from "@lib/core/checkRegularUsername"; import { checkRegularUsername } from "@lib/core/checkRegularUsername";
import jackson from "@lib/jackson"; import jackson from "@lib/jackson";
@ -127,6 +128,7 @@ const loggedInViewerRouter = createProtectedRouter()
description: true, description: true,
length: true, length: true,
schedulingType: true, schedulingType: true,
recurringEvent: true,
slug: true, slug: true,
hidden: true, hidden: true,
price: true, price: true,
@ -298,7 +300,7 @@ const loggedInViewerRouter = createProtectedRouter()
}) })
.query("bookings", { .query("bookings", {
input: z.object({ input: z.object({
status: z.enum(["upcoming", "past", "cancelled"]), status: z.enum(["upcoming", "recurring", "past", "cancelled"]),
limit: z.number().min(1).max(100).nullish(), limit: z.number().min(1).max(100).nullish(),
cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
}), }),
@ -311,9 +313,30 @@ const loggedInViewerRouter = createProtectedRouter()
const bookingListingByStatus = input.status; const bookingListingByStatus = input.status;
const bookingListingFilters: Record<typeof bookingListingByStatus, Prisma.BookingWhereInput[]> = { const bookingListingFilters: Record<typeof bookingListingByStatus, Prisma.BookingWhereInput[]> = {
upcoming: [ upcoming: [
{
endTime: { gte: new Date() },
// These changes are needed to not show confirmed recurring events,
// as rescheduling or cancel for recurring event bookings should be
// handled separately for each occurrence
OR: [
{
AND: [{ NOT: { recurringEventId: { equals: null } } }, { confirmed: false }],
},
{
AND: [
{ recurringEventId: { equals: null } },
{ NOT: { status: { equals: BookingStatus.CANCELLED } } },
{ NOT: { status: { equals: BookingStatus.REJECTED } } },
],
},
],
},
],
recurring: [
{ {
endTime: { gte: new Date() }, endTime: { gte: new Date() },
AND: [ AND: [
{ NOT: { recurringEventId: { equals: null } } },
{ NOT: { status: { equals: BookingStatus.CANCELLED } } }, { NOT: { status: { equals: BookingStatus.CANCELLED } } },
{ NOT: { status: { equals: BookingStatus.REJECTED } } }, { NOT: { status: { equals: BookingStatus.REJECTED } } },
], ],
@ -342,11 +365,22 @@ const loggedInViewerRouter = createProtectedRouter()
Prisma.BookingOrderByWithAggregationInput Prisma.BookingOrderByWithAggregationInput
> = { > = {
upcoming: { startTime: "asc" }, upcoming: { startTime: "asc" },
recurring: { startTime: "asc" },
past: { startTime: "desc" }, past: { startTime: "desc" },
cancelled: { startTime: "desc" }, cancelled: { startTime: "desc" },
}; };
const bookingListingDistinct: Record<
typeof bookingListingByStatus,
Prisma.Enumerable<Prisma.BookingScalarFieldEnum> | undefined
> = {
upcoming: Prisma.BookingScalarFieldEnum.recurringEventId,
recurring: undefined,
past: undefined,
cancelled: undefined,
};
const passedBookingsFilter = bookingListingFilters[bookingListingByStatus]; const passedBookingsFilter = bookingListingFilters[bookingListingByStatus];
const orderBy = bookingListingOrderby[bookingListingByStatus]; const orderBy = bookingListingOrderby[bookingListingByStatus];
const distinct = bookingListingDistinct[bookingListingByStatus];
const bookingsQuery = await prisma.booking.findMany({ const bookingsQuery = await prisma.booking.findMany({
where: { where: {
@ -373,10 +407,12 @@ const loggedInViewerRouter = createProtectedRouter()
rejected: true, rejected: true,
id: true, id: true,
startTime: true, startTime: true,
recurringEventId: true,
endTime: true, endTime: true,
eventType: { eventType: {
select: { select: {
price: true, price: true,
recurringEvent: true,
team: { team: {
select: { select: {
name: true, name: true,
@ -394,13 +430,23 @@ const loggedInViewerRouter = createProtectedRouter()
rescheduled: true, rescheduled: true,
}, },
orderBy, orderBy,
distinct,
take: take + 1, take: take + 1,
skip, skip,
}); });
const groupedRecurringBookings = await prisma.booking.groupBy({
by: [Prisma.BookingScalarFieldEnum.recurringEventId],
_count: true,
});
const bookings = bookingsQuery.map((booking) => { const bookings = bookingsQuery.map((booking) => {
return { return {
...booking, ...booking,
eventType: {
...booking.eventType,
recurringEvent: ((booking.eventType && booking.eventType.recurringEvent) || {}) as RecurringEvent,
},
startTime: booking.startTime.toISOString(), startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(), endTime: booking.endTime.toISOString(),
}; };
@ -416,6 +462,7 @@ const loggedInViewerRouter = createProtectedRouter()
return { return {
bookings, bookings,
groupedRecurringBookings,
nextCursor, nextCursor,
}; };
}, },

View file

@ -7,6 +7,7 @@ import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
import { _DestinationCalendarModel, _EventTypeCustomInputModel, _EventTypeModel } from "@calcom/prisma/zod"; import { _DestinationCalendarModel, _EventTypeCustomInputModel, _EventTypeModel } from "@calcom/prisma/zod";
import { stringOrNumber } from "@calcom/prisma/zod-utils"; import { stringOrNumber } from "@calcom/prisma/zod-utils";
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype"; import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
import { RecurringEvent } from "@calcom/types/Calendar";
import { createProtectedRouter } from "@server/createRouter"; import { createProtectedRouter } from "@server/createRouter";
import { viewerRouter } from "@server/routers/viewer"; import { viewerRouter } from "@server/routers/viewer";
@ -254,6 +255,7 @@ export const eventTypesRouter = createProtectedRouter()
locations, locations,
destinationCalendar, destinationCalendar,
customInputs, customInputs,
recurringEvent,
users, users,
id, id,
hashedLink, hashedLink,
@ -266,6 +268,17 @@ export const eventTypesRouter = createProtectedRouter()
data.periodType = handlePeriodType(periodType); data.periodType = handlePeriodType(periodType);
} }
if (recurringEvent) {
data.recurringEvent = {
dstart: recurringEvent.dtstart as unknown as Prisma.InputJsonObject,
interval: recurringEvent.interval,
count: recurringEvent.count,
freq: recurringEvent.freq,
until: recurringEvent.until as unknown as Prisma.InputJsonObject,
tzid: recurringEvent.tzid,
};
}
if (destinationCalendar) { if (destinationCalendar) {
/** We connect or create a destination calendar to the event type instead of the user */ /** We connect or create a destination calendar to the event type instead of the user */
await viewerRouter.createCaller(ctx).mutation("setDestinationCalendar", { await viewerRouter.createCaller(ctx).mutation("setDestinationCalendar", {

View file

@ -9,9 +9,9 @@
--brand-text-color-dark-mode: #292929; --brand-text-color-dark-mode: #292929;
} }
/* /*
* Override the default tailwindcss-forms styling (default is: 'colors.blue.600') * Override the default tailwindcss-forms styling (default is: 'colors.blue.600')
* @see: https://github.com/tailwindlabs/tailwindcss-forms/issues/14#issuecomment-1005376006 * @see: https://github.com/tailwindlabs/tailwindcss-forms/issues/14#issuecomment-1005376006
*/ */
[type="text"]:focus, [type="text"]:focus,
[type="email"]:focus, [type="email"]:focus,

View file

@ -7,7 +7,7 @@ import { ConfirmDialog } from "./confirmDialog";
interface IWipeMyCalActionButtonProps { interface IWipeMyCalActionButtonProps {
trpc: any; trpc: any;
bookingsEmpty: boolean; bookingsEmpty: boolean;
bookingStatus: "upcoming" | "past" | "cancelled"; bookingStatus: "upcoming" | "recurring" | "past" | "cancelled";
} }
const WipeMyCalActionButton = (props: IWipeMyCalActionButtonProps) => { const WipeMyCalActionButton = (props: IWipeMyCalActionButtonProps) => {

View file

@ -6,8 +6,8 @@ import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification"; import showToast from "@calcom/lib/notification";
import { Button } from "@calcom/ui"; import { Button } from "@calcom/ui";
import { Tooltip } from "@calcom/ui/Tooltip";
import Loader from "@calcom/web/components/Loader"; import Loader from "@calcom/web/components/Loader";
import { Tooltip } from "@calcom/web/components/Tooltip";
import Icon from "./icon"; import Icon from "./icon";

View file

@ -22,6 +22,7 @@ class CalendarEventClass implements CalendarEvent {
rejectionReason?: string | null; rejectionReason?: string | null;
hideCalendarNotes?: boolean; hideCalendarNotes?: boolean;
additionalNotes?: string | null | undefined; additionalNotes?: string | null | undefined;
recurrence?: string;
constructor(initProps?: CalendarEvent) { constructor(initProps?: CalendarEvent) {
// If more parameters are given we update this // If more parameters are given we update this

View file

@ -55,6 +55,7 @@ const commons = {
}, },
isWeb3Active: false, isWeb3Active: false,
hideCalendarNotes: false, hideCalendarNotes: false,
recurringEvent: {},
destinationCalendar: null, destinationCalendar: null,
team: null, team: null,
requiresConfirmation: false, requiresConfirmation: false,

View file

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "recurringEventId" TEXT;
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "recurringEvent" JSONB;

View file

@ -59,6 +59,8 @@ model EventType {
periodDays Int? periodDays Int?
periodCountCalendarDays Boolean? periodCountCalendarDays Boolean?
requiresConfirmation Boolean @default(false) requiresConfirmation Boolean @default(false)
/// @zod.custom(imports.recurringEvent)
recurringEvent Json?
disableGuests Boolean @default(false) disableGuests Boolean @default(false)
hideCalendarNotes Boolean @default(false) hideCalendarNotes Boolean @default(false)
minimumBookingNotice Int @default(120) minimumBookingNotice Int @default(120)
@ -278,6 +280,7 @@ model Booking {
dynamicGroupSlugRef String? dynamicGroupSlugRef String?
rescheduled Boolean? rescheduled Boolean?
fromReschedule String? fromReschedule String?
recurringEventId String?
} }
model Schedule { model Schedule {

View file

@ -277,6 +277,111 @@ async function main() {
length: 60, length: 60,
locations: [{ type: "integrations:google:meet" }], locations: [{ type: "integrations:google:meet" }],
}, },
{
title: "Yoga class",
slug: "yoga-class",
length: 30,
recurringEvent: { freq: 2, count: 12, interval: 1 },
_bookings: [
{
uid: uuid(),
title: "Yoga class",
recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").toDate(),
endTime: dayjs().add(1, "day").add(30, "minutes").toDate(),
confirmed: false,
},
{
uid: uuid(),
title: "Yoga class",
recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").add(1, "week").toDate(),
endTime: dayjs().add(1, "day").add(1, "week").add(30, "minutes").toDate(),
confirmed: false,
},
{
uid: uuid(),
title: "Yoga class",
recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").add(2, "week").toDate(),
endTime: dayjs().add(1, "day").add(2, "week").add(30, "minutes").toDate(),
confirmed: false,
},
{
uid: uuid(),
title: "Yoga class",
recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").add(3, "week").toDate(),
endTime: dayjs().add(1, "day").add(3, "week").add(30, "minutes").toDate(),
confirmed: false,
},
{
uid: uuid(),
title: "Yoga class",
recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").add(4, "week").toDate(),
endTime: dayjs().add(1, "day").add(4, "week").add(30, "minutes").toDate(),
confirmed: false,
},
{
uid: uuid(),
title: "Yoga class",
recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").add(5, "week").toDate(),
endTime: dayjs().add(1, "day").add(5, "week").add(30, "minutes").toDate(),
confirmed: false,
},
],
},
{
title: "Tennis class",
slug: "tennis-class",
length: 60,
recurringEvent: { freq: 2, count: 10, interval: 2 },
requiresConfirmation: true,
_bookings: [
{
uid: uuid(),
title: "Tennis class",
recurringEventId: Buffer.from("tennis-class").toString("base64"),
startTime: dayjs().add(2, "day").toDate(),
endTime: dayjs().add(2, "day").add(60, "minutes").toDate(),
confirmed: false,
},
{
uid: uuid(),
title: "Tennis class",
recurringEventId: Buffer.from("tennis-class").toString("base64"),
startTime: dayjs().add(2, "day").add(2, "week").toDate(),
endTime: dayjs().add(2, "day").add(2, "week").add(60, "minutes").toDate(),
confirmed: false,
},
{
uid: uuid(),
title: "Tennis class",
recurringEventId: Buffer.from("tennis-class").toString("base64"),
startTime: dayjs().add(2, "day").add(4, "week").toDate(),
endTime: dayjs().add(2, "day").add(4, "week").add(60, "minutes").toDate(),
confirmed: false,
},
{
uid: uuid(),
title: "Tennis class",
recurringEventId: Buffer.from("tennis-class").toString("base64"),
startTime: dayjs().add(2, "day").add(8, "week").toDate(),
endTime: dayjs().add(2, "day").add(8, "week").add(60, "minutes").toDate(),
confirmed: false,
},
{
uid: uuid(),
title: "Tennis class",
recurringEventId: Buffer.from("tennis-class").toString("base64"),
startTime: dayjs().add(2, "day").add(10, "week").toDate(),
endTime: dayjs().add(2, "day").add(10, "week").add(60, "minutes").toDate(),
confirmed: false,
},
],
},
], ],
}); });

View file

@ -1,3 +1,4 @@
import { Frequency as RRuleFrequency } from "rrule";
import { z } from "zod"; import { z } from "zod";
import { LocationType } from "@calcom/core/location"; import { LocationType } from "@calcom/core/location";
@ -11,6 +12,16 @@ export const eventTypeLocations = z.array(
}) })
); );
// Matching RRule.Options: rrule/dist/esm/src/types.d.ts
export const recurringEvent = z.object({
dtstart: z.date().optional(),
interval: z.number().optional(),
count: z.number().optional(),
freq: z.nativeEnum(RRuleFrequency).optional(),
until: z.date().optional(),
tzid: z.string().optional(),
});
export const eventTypeSlug = z.string().transform((val) => slugify(val.trim())); export const eventTypeSlug = z.string().transform((val) => slugify(val.trim()));
export const stringToDate = z.string().transform((a) => new Date(a)); export const stringToDate = z.string().transform((a) => new Date(a));
export const stringOrNumber = z.union([z.string().transform((v) => parseInt(v, 10)), z.number().int()]); export const stringOrNumber = z.union([z.string().transform((v) => parseInt(v, 10)), z.number().int()]);

View file

@ -3,6 +3,7 @@ import type { Dayjs } from "dayjs";
import type { calendar_v3 } from "googleapis"; import type { calendar_v3 } from "googleapis";
import type { Time } from "ical.js"; import type { Time } from "ical.js";
import type { TFunction } from "next-i18next"; import type { TFunction } from "next-i18next";
import type { Frequency as RRuleFrequency } from "rrule";
import type { Event } from "./Event"; import type { Event } from "./Event";
import type { Ensure } from "./utils"; import type { Ensure } from "./utils";
@ -72,6 +73,15 @@ export interface AdditionInformation {
hangoutLink?: string; hangoutLink?: string;
} }
export interface RecurringEvent {
dtstart?: Date | undefined;
interval?: number;
count?: number;
freq?: RRuleFrequency;
until?: Date | undefined;
tzid?: string | undefined;
}
// If modifying this interface, probably should update builders/calendarEvent files // If modifying this interface, probably should update builders/calendarEvent files
export interface CalendarEvent { export interface CalendarEvent {
type: string; type: string;
@ -96,6 +106,7 @@ export interface CalendarEvent {
cancellationReason?: string | null; cancellationReason?: string | null;
rejectionReason?: string | null; rejectionReason?: string | null;
hideCalendarNotes?: boolean; hideCalendarNotes?: boolean;
recurrence?: string;
} }
export interface EntryPoint { export interface EntryPoint {

View file

@ -23,7 +23,7 @@ export function Tooltip({
onOpenChange={onOpenChange}> onOpenChange={onOpenChange}>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger> <TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Content <TooltipPrimitive.Content
className="slideInBottom -mt-2 rounded-sm bg-black px-1 py-0.5 text-xs text-white shadow-lg" className="-mt-2 rounded-sm bg-black px-1 py-0.5 text-xs text-white shadow-lg"
side="top" side="top"
align="center" align="center"
{...props}> {...props}>

1311
yarn.lock

File diff suppressed because it is too large Load diff