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/**
jobs:
test:
timeout-minutes: 15
timeout-minutes: 20
name: Testing ${{ matrix.node }} and ${{ matrix.os }}
strategy:
matrix:

View file

@ -190,8 +190,10 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
### 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
# In a terminal. Just run:
# In a terminal just run:
yarn test-e2e
# To open last HTML report run:

View file

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

View file

@ -21,6 +21,7 @@ type AvailableTimesProps = {
afterBufferTime: number;
eventTypeId: number;
eventLength: number;
recurringCount: number | undefined;
eventTypeSlug: string;
slotInterval: number | null;
date: Dayjs;
@ -37,6 +38,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
eventTypeSlug,
slotInterval,
minimumBookingNotice,
recurringCount,
timeFormat,
users,
schedulingType,
@ -90,6 +92,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
date: slot.time.format(),
type: eventTypeId,
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 { PaperAirplaneIcon } from "@heroicons/react/outline";
import { RefreshIcon } from "@heroicons/react/solid";
import { BookingStatus } from "@prisma/client";
import dayjs from "dayjs";
import { useState } from "react";
import { useMutation } from "react-query";
import { Frequency as RRuleFrequency } from "rrule";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button";
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
import { Tooltip } from "@calcom/ui/Tooltip";
import { TextArea } from "@calcom/ui/form/fields";
import { HttpError } from "@lib/core/http/error";
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 TableActions, { ActionType } from "@components/ui/TableActions";
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
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
const query = useMeQuery();
const user = query.data;
@ -30,14 +41,22 @@ function BookingListItem(booking: BookingItem) {
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
const mutation = useMutation(
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", {
method: "PATCH",
body: JSON.stringify({
id: booking.id,
confirmed: confirm,
language: i18n.language,
reason: rejectionReason,
}),
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
},
@ -58,14 +77,20 @@ function BookingListItem(booking: BookingItem) {
const pendingActions: ActionType[] = [
{
id: "reject",
label: t("reject"),
label:
booking.listingStatus === "upcoming" && booking.recurringEventId !== null
? t("reject_all")
: t("reject"),
onClick: () => setRejectionDialogIsOpen(true),
icon: BanIcon,
disabled: mutation.isLoading,
},
{
id: "confirm",
label: t("confirm"),
label:
booking.listingStatus === "upcoming" && booking.recurringEventId !== null
? t("confirm_all")
: t("confirm"),
onClick: () => mutation.mutate(true),
icon: CheckIcon,
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 [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 (
<>
<RescheduleDialog
@ -154,12 +192,40 @@ function BookingListItem(booking: BookingItem) {
</Dialog>
<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 text-gray-500">
{dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
</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 className={"flex-1 py-4 ltr:pl-4 rtl:pr-4" + (booking.rejected ? " line-through" : "")}>
<div className="sm:hidden">

View file

@ -8,6 +8,7 @@ import {
CreditCardIcon,
GlobeIcon,
InformationCircleIcon,
RefreshIcon,
} from "@heroicons/react/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useContracts } from "contexts/contractsContext";
@ -17,6 +18,7 @@ import utc from "dayjs/plugin/utc";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { FormattedNumber, IntlProvider } from "react-intl";
import { Frequency as RRuleFrequency } from "rrule";
import {
useEmbedStyles,
@ -27,11 +29,11 @@ import {
useEmbedNonStylesConfig,
} from "@calcom/embed-core";
import classNames from "@calcom/lib/classNames";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
import { BASE_URL, WEBAPP_URL } from "@lib/config/constants";
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
import useTheme from "@lib/hooks/useTheme";
import { isBrandingHidden } from "@lib/isBrandingHidden";
@ -101,6 +103,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
}
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
const [recurringEventCount, setRecurringEventCount] = useState(eventType.recurringEvent?.count);
const telemetry = useTelemetry();
@ -142,6 +145,15 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
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 (
<>
<Theme />
@ -158,9 +170,8 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
className={classNames(
shouldAlignCentrally ? "mx-auto" : "",
isEmbed
? classNames(selectedDate ? "max-w-5xl" : "max-w-3xl")
: "transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24 " +
(selectedDate ? "max-w-5xl" : "max-w-3xl")
? classNames(maxWidth)
: classNames("transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24", maxWidth)
)}>
{isReady && (
<div
@ -168,7 +179,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
className={classNames(
isBackgroundTransparent ? "" : "bg-white dark:bg-gray-800 sm:dark:border-gray-600",
"border-bookinglightest rounded-md md:border",
isEmbed ? "mx-auto" : selectedDate ? "max-w-5xl" : "max-w-3xl"
isEmbed ? "mx-auto" : maxWidth
)}>
{/* mobile: details */}
<div className="block p-4 sm:p-8 md:hidden">
@ -243,7 +254,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
<div
className={
"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
border="border-2 dark:border-gray-800 border-white"
@ -267,15 +278,42 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
{eventType.title}
</h1>
{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" />
{eventType.description}
</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" />
{eventType.length} {t("minutes")}
</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 && (
<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" />
@ -302,7 +340,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
{booking?.startTime && rescheduleUid && (
<div>
<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">
{t("former_time")}
</p>
@ -340,6 +378,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
eventTypeSlug={eventType.slug}
slotInterval={eventType.slotInterval}
eventLength={eventType.length}
recurringCount={recurringEventCount}
date={selectedDate}
users={eventType.users}
schedulingType={eventType.schedulingType ?? null}

View file

@ -4,6 +4,7 @@ import {
CreditCardIcon,
ExclamationIcon,
InformationCircleIcon,
RefreshIcon,
} from "@heroicons/react/solid";
import { zodResolver } from "@hookform/resolvers/zod";
import { EventTypeCustomInputType } from "@prisma/client";
@ -18,6 +19,8 @@ import { Controller, useForm, useWatch } from "react-hook-form";
import { FormattedNumber, IntlProvider } from "react-intl";
import { ReactMultiEmail } from "react-multi-email";
import { useMutation } from "react-query";
import { Frequency as RRuleFrequency } from "rrule";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import {
@ -31,7 +34,9 @@ import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { createPaymentLink } from "@calcom/stripe/client";
import { RecurringEvent } from "@calcom/types/Calendar";
import { Button } from "@calcom/ui/Button";
import { Tooltip } from "@calcom/ui/Tooltip";
import { EmailInput, Form } from "@calcom/ui/form/fields";
import { asStringOrNull } from "@lib/asStringOrNull";
@ -40,9 +45,11 @@ import { ensureArray } from "@lib/ensureArray";
import useTheme from "@lib/hooks/useTheme";
import { LocationType } from "@lib/location";
import createBooking from "@lib/mutations/bookings/create-booking";
import { 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 { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { BookingCreateBody } from "@lib/types/booking";
import CustomBranding from "@components/CustomBranding";
import AvatarGroup from "@components/ui/AvatarGroup";
@ -76,6 +83,7 @@ const BookingPage = ({
booking,
profile,
isDynamicGroupBooking,
recurringEventCount,
locationLabels,
hasHashedBookingLink,
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 { isReady, Theme } = useTheme(profile.theme);
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) => {
telemetry.withJitsu((jitsu) =>
jitsu.track(
@ -265,7 +318,7 @@ const BookingPage = ({
{}
);
let web3Details;
let web3Details: Record<"userWallet" | "userSignature", string> | undefined;
if (eventTypeDetail.metadata.smartContractAddress) {
web3Details = {
// @ts-ignore
@ -274,28 +327,59 @@ const BookingPage = ({
};
}
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,
});
if (recurringDates.length) {
// Identify set of bookings to one intance of recurring event to support batch changes
const recurringEventId = uuidv4();
const recurringBookings = recurringDates.map((recurringDate) => ({
...booking,
web3Details,
start: dayjs(recurringDate).format(),
end: dayjs(recurringDate).add(eventType.length, "minute").format(),
eventTypeId: eventType.id,
eventTypeSlug: eventType.slug,
recurringEventId,
// Added to track down the number of actual occurrences selected by the user
recurringCount: recurringDates.length,
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,
}));
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;
@ -375,10 +459,40 @@ const BookingPage = ({
</IntlProvider>
</p>
)}
<p className="text-bookinghighlight mb-4">
<CalendarIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
{parseDate(date, i18n)}
</p>
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
<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()}`),
})} ${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 && (
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
{t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress}

View file

@ -77,7 +77,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
/>
<DialogFooter>
<DialogClose>
<DialogClose asChild>
<Button color="secondary">{t("cancel")}</Button>
</DialogClose>
<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 { Prisma } from "@prisma/client";
import React from "react";
import React, { useMemo } from "react";
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 { useLocale } from "@lib/hooks/useLocale";
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
select: {
@ -14,6 +16,7 @@ const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
price: true,
currency: true,
schedulingType: true,
recurringEvent: true,
description: true,
},
});
@ -28,6 +31,11 @@ export type EventTypeDescriptionProps = {
export const EventTypeDescription = ({ eventType, className }: EventTypeDescriptionProps) => {
const { t } = useLocale();
const recurringEvent: RecurringEvent = useMemo(
() => (eventType.recurringEvent as RecurringEvent) || [],
[eventType.recurringEvent]
);
return (
<>
<div className={classNames("text-neutral-500 dark:text-white", className)}>
@ -54,6 +62,12 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
{t("1_on_1")}
</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 && (
<li className="flex whitespace-nowrap">
<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,
DropdownMenuTrigger,
} from "@calcom/ui/Dropdown";
import { Tooltip } from "@calcom/ui/Tooltip";
import TeamAvailabilityModal from "@ee/components/team/availability/TeamAvailabilityModal";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import useCurrentUserId from "@lib/hooks/useCurrentUserId";
import { inferQueryOutput, trpc } from "@lib/trpc";
import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Avatar from "@components/ui/Avatar";
import ModalContainer from "@components/ui/ModalContainer";

View file

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

View file

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

View file

@ -3,12 +3,12 @@ import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
import classNames from "@calcom/lib/classNames";
import Button from "@calcom/ui/Button";
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
import { Tooltip } from "@calcom/ui/Tooltip";
import { useLocale } from "@lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@lib/trpc";
import { ListItem } from "@components/List";
import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
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 { DialogFooter } from "@calcom/ui/Dialog";
import Switch from "@calcom/ui/Switch";
import { Tooltip } from "@calcom/ui/Tooltip";
import { Form, TextField } from "@calcom/ui/form/fields";
import { trpc } from "@lib/trpc";
import { Tooltip } from "@components/Tooltip";
import { DatePicker } from "@components/ui/form/DatePicker";
import { TApiKeys } from "./ApiKeyListItem";

View file

@ -7,11 +7,11 @@ import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button";
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
import { Tooltip } from "@calcom/ui/Tooltip";
import { inferQueryOutput, trpc } from "@lib/trpc";
import { ListItem } from "@components/List";
import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
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 prisma from "@calcom/prisma";
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 { HttpError as HttpCode } from "@lib/core/http/error";
@ -49,6 +49,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
confirmed: true,
attendees: true,
location: true,
eventTypeId: true,
userId: true,
id: true,
uid: true,
@ -70,6 +71,23 @@ async function handlePaymentSuccess(event: Stripe.Event) {
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;
if (!user) throw new Error("No user found");
@ -137,7 +155,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
await prisma.$transaction([paymentUpdate, bookingUpdate]);
await sendScheduledEmails({ ...evt });
await sendScheduledEmails({ ...evt }, eventType.recurringEvent);
throw new HttpCode({
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 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 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>[] = [];
emailsToSend.push(
...calEvent.attendees.map((attendee) => {
return new Promise((resolve, reject) => {
try {
const scheduledEmail = new AttendeeScheduledEmail(calEvent, attendee);
const scheduledEmail = new AttendeeScheduledEmail(calEvent, attendee, recurringEvent);
resolve(scheduledEmail.sendEmail());
} catch (e) {
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
@ -36,7 +37,7 @@ export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
emailsToSend.push(
new Promise((resolve, reject) => {
try {
const scheduledEmail = new OrganizerScheduledEmail(calEvent);
const scheduledEmail = new OrganizerScheduledEmail(calEvent, recurringEvent);
resolve(scheduledEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerScheduledEmail.sendEmail failed", e));
@ -47,14 +48,14 @@ export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
await Promise.all(emailsToSend);
};
export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
export const sendRescheduledEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(
...calEvent.attendees.map((attendee) => {
return new Promise((resolve, reject) => {
try {
const scheduledEmail = new AttendeeRescheduledEmail(calEvent, attendee);
const scheduledEmail = new AttendeeRescheduledEmail(calEvent, attendee, recurringEvent);
resolve(scheduledEmail.sendEmail());
} catch (e) {
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
@ -66,7 +67,7 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
emailsToSend.push(
new Promise((resolve, reject) => {
try {
const scheduledEmail = new OrganizerRescheduledEmail(calEvent);
const scheduledEmail = new OrganizerRescheduledEmail(calEvent, recurringEvent);
resolve(scheduledEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerScheduledEmail.sendEmail failed", e));
@ -77,10 +78,13 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
await Promise.all(emailsToSend);
};
export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => {
export const sendOrganizerRequestEmail = async (
calEvent: CalendarEvent,
recurringEvent: RecurringEvent = {}
) => {
await new Promise((resolve, reject) => {
try {
const organizerRequestEmail = new OrganizerRequestEmail(calEvent);
const organizerRequestEmail = new OrganizerRequestEmail(calEvent, recurringEvent);
resolve(organizerRequestEmail.sendEmail());
} catch (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) => {
try {
const attendeeRequestEmail = new AttendeeRequestEmail(calEvent, attendee);
const attendeeRequestEmail = new AttendeeRequestEmail(calEvent, attendee, recurringEvent);
resolve(attendeeRequestEmail.sendEmail());
} catch (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>[] = [];
emailsToSend.push(
...calEvent.attendees.map((attendee) => {
return new Promise((resolve, reject) => {
try {
const declinedEmail = new AttendeeDeclinedEmail(calEvent, attendee);
const declinedEmail = new AttendeeDeclinedEmail(calEvent, attendee, recurringEvent);
resolve(declinedEmail.sendEmail());
} catch (e) {
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
@ -118,14 +126,14 @@ export const sendDeclinedEmails = async (calEvent: CalendarEvent) => {
await Promise.all(emailsToSend);
};
export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
export const sendCancelledEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(
...calEvent.attendees.map((attendee) => {
return new Promise((resolve, reject) => {
try {
const scheduledEmail = new AttendeeCancelledEmail(calEvent, attendee);
const scheduledEmail = new AttendeeCancelledEmail(calEvent, attendee, recurringEvent);
resolve(scheduledEmail.sendEmail());
} catch (e) {
reject(console.error("AttendeeCancelledEmail.sendEmail failed", e));
@ -137,7 +145,7 @@ export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
emailsToSend.push(
new Promise((resolve, reject) => {
try {
const scheduledEmail = new OrganizerCancelledEmail(calEvent);
const scheduledEmail = new OrganizerCancelledEmail(calEvent, recurringEvent);
resolve(scheduledEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerCancelledEmail.sendEmail failed", e));
@ -148,10 +156,13 @@ export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
await Promise.all(emailsToSend);
};
export const sendOrganizerRequestReminderEmail = async (calEvent: CalendarEvent) => {
export const sendOrganizerRequestReminderEmail = async (
calEvent: CalendarEvent,
recurringEvent: RecurringEvent = {}
) => {
await new Promise((resolve, reject) => {
try {
const organizerRequestReminderEmail = new OrganizerRequestReminderEmail(calEvent);
const organizerRequestReminderEmail = new OrganizerRequestReminderEmail(calEvent, recurringEvent);
resolve(organizerRequestReminderEmail.sendEmail());
} catch (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>[] = [];
emailsToSend.push(
...calEvent.attendees.map((attendee) => {
return new Promise((resolve, reject) => {
try {
const paymentEmail = new AttendeeAwaitingPaymentEmail(calEvent, attendee);
const paymentEmail = new AttendeeAwaitingPaymentEmail(calEvent, attendee, recurringEvent);
resolve(paymentEmail.sendEmail());
} catch (e) {
reject(console.error("AttendeeAwaitingPaymentEmail.sendEmail failed", e));
@ -178,10 +192,13 @@ export const sendAwaitingPaymentEmail = async (calEvent: CalendarEvent) => {
await Promise.all(emailsToSend);
};
export const sendOrganizerPaymentRefundFailedEmail = async (calEvent: CalendarEvent) => {
export const sendOrganizerPaymentRefundFailedEmail = async (
calEvent: CalendarEvent,
recurringEvent: RecurringEvent = {}
) => {
await new Promise((resolve, reject) => {
try {
const paymentRefundFailedEmail = new OrganizerPaymentRefundFailedEmail(calEvent);
const paymentRefundFailedEmail = new OrganizerPaymentRefundFailedEmail(calEvent, recurringEvent);
resolve(paymentRefundFailedEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerPaymentRefundFailedEmail.sendEmail failed", e));
@ -213,14 +230,19 @@ export const sendTeamInviteEmail = async (teamInviteEvent: TeamInvite) => {
export const sendRequestRescheduleEmail = async (
calEvent: CalendarEvent,
metadata: { rescheduleLink: string }
metadata: { rescheduleLink: string },
recurringEvent: RecurringEvent = {}
) => {
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(
new Promise((resolve, reject) => {
try {
const requestRescheduleEmail = new AttendeeRequestRescheduledEmail(calEvent, metadata);
const requestRescheduleEmail = new AttendeeRequestRescheduledEmail(
calEvent,
metadata,
recurringEvent
);
resolve(requestRescheduleEmail.sendEmail());
} catch (e) {
reject(console.error("AttendeeRequestRescheduledEmail.sendEmail failed", e));
@ -231,7 +253,11 @@ export const sendRequestRescheduleEmail = async (
emailsToSend.push(
new Promise((resolve, reject) => {
try {
const requestRescheduleEmail = new OrganizerRequestRescheduleEmail(calEvent, metadata);
const requestRescheduleEmail = new OrganizerRequestRescheduleEmail(
calEvent,
metadata,
recurringEvent
);
resolve(requestRescheduleEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerRequestRescheduledEmail.sendEmail failed", e));

View file

@ -42,7 +42,9 @@ export default class AttendeeDeclinedEmail extends AttendeeScheduledEmail {
protected getTextBody(): string {
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.getWhat()}
${this.getWhen()}
@ -75,7 +77,9 @@ ${this.getRejectionReason()}
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("xCircle")}
${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")
)}
${emailSchedulingBodyDivider()}

View file

@ -87,10 +87,17 @@ ${this.getAdditionalNotes()}
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("calendarCircle")}
${emailScheduledBodyHeaderContent(
this.calEvent.organizer.language.translate("booking_submitted"),
this.calEvent.organizer.language.translate("user_needs_to_confirm_or_reject_booking", {
user: this.calEvent.organizer.name,
})
this.calEvent.organizer.language.translate(
this.recurringEvent?.count ? "booking_submitted_recurring" : "booking_submitted"
),
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()}
<!--[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 { getCancelLink } from "@calcom/lib/CalEventParser";
import { CalendarEvent } from "@calcom/types/Calendar";
import { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
import {
emailHead,
@ -24,8 +24,8 @@ dayjs.extend(toArray);
export default class AttendeeRequestRescheduledEmail extends OrganizerScheduledEmail {
private metadata: { rescheduleLink: string };
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
super(calEvent);
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }, recurringEvent: RecurringEvent) {
super(calEvent, recurringEvent);
this.metadata = metadata;
}
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("emailed_you_and_any_other_attendees")}
${this.getWhat()}
${this.getWhen()}
${this.getWhen()}
${this.getLocation()}
${this.getDescription()}
${this.getAdditionalNotes()}

View file

@ -4,13 +4,15 @@ import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { createEvent, DateArray } from "ics";
import { DatasetJsonLdProps } from "next-seo";
import nodemailer from "nodemailer";
import rrule from "rrule";
import { getAppName } from "@calcom/app-store/utils";
import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { serverConfig } from "@calcom/lib/serverConfig";
import type { Person, CalendarEvent } from "@calcom/types/Calendar";
import type { Person, CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
import {
emailHead,
@ -29,10 +31,12 @@ dayjs.extend(toArray);
export default class AttendeeScheduledEmail {
calEvent: CalendarEvent;
attendee: Person;
recurringEvent: RecurringEvent;
constructor(calEvent: CalendarEvent, attendee: Person) {
constructor(calEvent: CalendarEvent, attendee: Person, recurringEvent: RecurringEvent) {
this.calEvent = calEvent;
this.attendee = attendee;
this.recurringEvent = recurringEvent;
}
public sendEmail() {
@ -53,6 +57,11 @@ export default class AttendeeScheduledEmail {
}
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({
start: dayjs(this.calEvent.startTime)
.utc()
@ -72,6 +81,7 @@ export default class AttendeeScheduledEmail {
name: attendee.name,
email: attendee.email,
})),
...{ recurrenceRule },
status: "CONFIRMED",
});
if (icsEvent.error) {
@ -125,7 +135,9 @@ export default class AttendeeScheduledEmail {
}
protected getTextBody(): string {
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")}
${getRichDescription(this.calEvent)}
@ -157,7 +169,11 @@ ${getRichDescription(this.calEvent)}
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("checkCircle")}
${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")
)}
${emailSchedulingBodyDivider()}
@ -250,12 +266,30 @@ ${getRichDescription(this.calEvent)}
</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 {
return `
<p style="height: 6px"></p>
<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;">
${this.recurringEvent?.count ? `${this.calEvent.attendees[0].language.translate("starting")} ` : ""}
${this.calEvent.attendees[0].language.translate(
this.getInviteeStart().format("dddd").toLowerCase()
)}, ${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;">
${emailSchedulingBodyHeader("calendarCircle")}
${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")
)}
${emailSchedulingBodyDivider()}

View file

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

View file

@ -5,12 +5,13 @@ import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { createEvent, DateArray, Person } from "ics";
import nodemailer from "nodemailer";
import rrule from "rrule";
import { getAppName } from "@calcom/app-store/utils";
import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { serverConfig } from "@calcom/lib/serverConfig";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
import {
emailHead,
@ -28,9 +29,11 @@ dayjs.extend(toArray);
export default class OrganizerScheduledEmail {
calEvent: CalendarEvent;
recurringEvent: RecurringEvent;
constructor(calEvent: CalendarEvent) {
constructor(calEvent: CalendarEvent, recurringEvent: RecurringEvent) {
this.calEvent = calEvent;
this.recurringEvent = recurringEvent;
}
public sendEmail() {
@ -51,6 +54,11 @@ export default class OrganizerScheduledEmail {
}
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({
start: dayjs(this.calEvent.startTime)
.utc()
@ -66,6 +74,7 @@ export default class OrganizerScheduledEmail {
description: this.getTextBody(),
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
...{ recurrenceRule },
attendees: this.calEvent.attendees.map((attendee: Person) => ({
name: attendee.name,
email: attendee.email,
@ -121,7 +130,9 @@ export default class OrganizerScheduledEmail {
protected getTextBody(): string {
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")}
${getRichDescription(this.calEvent)}
@ -153,7 +164,9 @@ ${getRichDescription(this.calEvent)}
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("checkCircle")}
${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")
)}
${emailSchedulingBodyDivider()}
@ -240,12 +253,30 @@ ${getRichDescription(this.calEvent)}
</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 {
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.organizer.language.translate("when")}</p>
<p style="color: #494949;">${this.calEvent.organizer.language.translate("when")}${
this.recurringEvent?.count ? this.getRecurringWhen() : ""
}</p>
<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.getOrganizerStart().format("dddd").toLowerCase()
)}, ${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 { 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 { parseZone } from "./parseZone";
export const parseDate = (date: string | null | Dayjs, i18n: I18n) => {
if (!date) return "No date";
const processDate = (date: string | null | Dayjs, i18n: I18n) => {
const parsedZone = parseZone(date);
if (!parsedZone?.isValid()) return "Invalid date";
const formattedTime = parsedZone?.format(detectBrowserTimeFormat);
return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" });
};
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,
slug: true,
schedulingType: true,
recurringEvent: true,
price: true,
currency: true,
users: {

View file

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

View file

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

View file

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

View file

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

View file

@ -12,8 +12,9 @@ import {
getUsernameList,
} from "@calcom/lib/defaultEvents";
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 prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -69,6 +70,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const usernameList = getUsernameList(asStringOrThrow(context.query.user as string));
const eventTypeSlug = context.query.slug as string;
const recurringEventCountQuery = asStringOrNull(context.query.count);
const users = await prisma.user.findMany({
where: {
username: {
@ -111,6 +113,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
periodDays: true,
periodStartDate: true,
periodEndDate: true,
recurringEvent: true,
metadata: true,
periodCountCalendarDays: true,
price: true,
@ -150,6 +153,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const eventType = {
...eventTypeRaw,
metadata: (eventTypeRaw.metadata || {}) as JSONObject,
recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
isWeb3Active:
web3Credentials && web3Credentials.key
? (((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");
// 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 {
props: {
away: user.away,
@ -211,6 +224,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
profile,
eventType: eventTypeObject,
booking,
recurringEventCount,
trpcState: ssr.dehydrate(),
isDynamicGroupBooking,
hasHashedBookingLink: false,

View file

@ -1,9 +1,10 @@
import { Prisma, User, Booking, SchedulingType, BookingStatus } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import rrule from "rrule";
import EventManager from "@calcom/core/EventManager";
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 { refund } from "@ee/lib/stripe/server";
@ -94,12 +95,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
confirmed: true,
attendees: true,
eventTypeId: true,
eventType: {
select: {
recurringEvent: true,
},
},
location: true,
userId: true,
id: true,
uid: true,
payment: true,
destinationCalendar: true,
recurringEventId: true,
},
});
@ -147,6 +154,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
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) {
const eventManager = new EventManager(currentUser);
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;
}
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) {
log.error(error);
}
}
// @NOTE: be careful with this as if any error occurs before this booking doesn't get confirmed
// Should perform update on booking (confirm) -> then trigger the rest handlers
await prisma.booking.update({
where: {
id: bookingId,
},
data: {
confirmed: true,
references: {
create: scheduleResult.referencesToCreate,
if (req.body.recurringEventId) {
// The booking to confirm is a recurring event and comes from /booking/upcoming, proceeding to mark all related
// bookings as confirmed. Prisma updateMany does not support relations, so doing this in two steps for now.
const unconfirmedRecurringBookings = await prisma.booking.findMany({
where: {
recurringEventId: req.body.recurringEventId,
confirmed: false,
},
},
});
});
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();
} else {
await refund(booking, evt);
const rejectionReason = asStringOrNull(req.body.reason) || "";
evt.rejectionReason = rejectionReason;
await prisma.booking.update({
where: {
id: bookingId,
},
data: {
rejected: true,
status: BookingStatus.REJECTED,
rejectionReason: rejectionReason,
},
});
if (req.body.recurringEventId) {
// The booking to reject is a recurring event and comes from /booking/upcoming, proceeding to mark all related
// bookings as rejected. Prisma updateMany does not support relations, so doing this in two steps for now.
const unconfirmedRecurringBookings = await prisma.booking.findMany({
where: {
recurringEventId: req.body.recurringEventId,
confirmed: false,
},
});
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();
}

View file

@ -1,11 +1,4 @@
import {
BookingStatus,
Credential,
Payment,
Prisma,
SchedulingType,
WebhookTriggerEvents,
} from "@prisma/client";
import { BookingStatus, Credential, Prisma, SchedulingType, WebhookTriggerEvents } from "@prisma/client";
import async from "async";
import dayjs from "dayjs";
import dayjsBusinessTime from "dayjs-business-time";
@ -13,18 +6,24 @@ import isBetween from "dayjs/plugin/isBetween";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import type { NextApiRequest, NextApiResponse } from "next";
import rrule from "rrule";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
import EventManager from "@calcom/core/EventManager";
import { getBusyVideoTimes } from "@calcom/core/videoClient";
import { getDefaultEvent, getUsernameList, getGroupName } from "@calcom/lib/defaultEvents";
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import logger from "@calcom/lib/logger";
import notEmpty from "@calcom/lib/notEmpty";
import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime";
import type { AdditionInformation, CalendarEvent, EventBusyDate, Person } from "@calcom/types/Calendar";
import type {
AdditionInformation,
CalendarEvent,
EventBusyDate,
RecurringEvent,
} from "@calcom/types/Calendar";
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
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);
}
function isAvailable(busyTimes: BufferedBusyTimes, time: string, length: number): boolean {
function isAvailable(busyTimes: BufferedBusyTimes, time: dayjs.ConfigType, length: number): boolean {
// Check for conflicts
let t = true;
@ -190,7 +189,7 @@ const getUserNameWithBookingCounts = async (eventTypeId: number, selectedUserNam
};
const getEventTypesFromDB = async (eventTypeId: number) => {
return await prisma.eventType.findUnique({
const eventType = await prisma.eventType.findUnique({
rejectOnNotFound: true,
where: {
id: eventTypeId,
@ -220,14 +219,22 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
metadata: true,
destinationCalendar: true,
hideCalendarNotes: true,
recurringEvent: true,
},
});
return {
...eventType,
recurringEvent: (eventType.recurringEvent || undefined) as RecurringEvent,
};
};
type User = Prisma.UserGetPayload<typeof userSelect>;
type ExtendedBookingCreateBody = BookingCreateBody & { noEmail?: boolean; recurringCount?: number };
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
const dynamicUserList = Array.isArray(reqBody.user)
@ -382,6 +389,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}; // 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
const rescheduleUid = reqBody.rescheduleUid;
async function getOriginalRescheduledBooking(uid: string) {
@ -481,6 +494,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
: undefined,
};
if (reqBody.recurringEventId) {
newBookingData.recurringEventId = reqBody.recurringEventId;
}
if (originalRescheduledBooking) {
newBookingData["paid"] = originalRescheduledBooking.paid;
newBookingData["fromReschedule"] = originalRescheduledBooking.uid;
@ -573,7 +589,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
let isAvailableToBeBooked = true;
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 {
log.debug({
message: "Unable set isAvailableToBeBooked. Using true. ",
@ -674,11 +701,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
}
await sendRescheduledEmails({
...evt,
additionInformation: metadata,
additionalNotes, // Resets back to the addtionalNote input and not the overriden value
});
if (noEmail !== true) {
await sendRescheduledEmails(
{
...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,
// Create a booking
@ -708,17 +740,29 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints;
}
await sendScheduledEmails({
...evt,
additionInformation: metadata,
additionalNotes,
});
if (noEmail !== true) {
await sendScheduledEmails(
{
...evt,
additionInformation: metadata,
additionalNotes,
},
reqBody.recurringEventId ? (eventType.recurringEvent as RecurringEvent) : {}
);
}
}
}
if (eventType.requiresConfirmation && !rescheduleUid) {
await sendOrganizerRequestEmail({ ...evt, additionalNotes });
await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]);
if (eventType.requiresConfirmation && !rescheduleUid && noEmail !== true) {
await sendOrganizerRequestEmail(
{ ...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) {

View file

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

View file

@ -8,7 +8,7 @@ import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
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 EmptyScreen from "@components/EmptyScreen";
@ -17,6 +17,8 @@ import BookingListItem from "@components/booking/BookingListItem";
import SkeletonLoader from "@components/booking/SkeletonLoader";
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
type BookingOutput = inferQueryOutput<"viewer.bookings">["bookings"][0];
type BookingPage = inferQueryOutput<"viewer.bookings">;
export default function Bookings() {
const router = useRouter();
@ -26,6 +28,7 @@ export default function Bookings() {
const descriptionByStatus: Record<BookingListingStatus, string> = {
upcoming: t("upcoming_bookings"),
recurring: t("recurring_bookings"),
past: t("past_bookings"),
cancelled: t("cancelled_bookings"),
};
@ -44,6 +47,18 @@ export default function Bookings() {
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 (
<Shell
heading={t("bookings")}
@ -66,7 +81,12 @@ export default function Bookings() {
{query.data.pages.map((page, index) => (
<Fragment key={index}>
{page.bookings.map((booking) => (
<BookingListItem key={booking.id} {...booking} />
<BookingListItem
key={booking.id}
listingStatus={status}
{...defineRecurrentCount(booking, page)}
{...booking}
/>
))}
</Fragment>
))}

View file

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

View file

@ -6,8 +6,9 @@ import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
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 { inferSSRProps } from "@lib/types/inferSSRProps";
@ -28,6 +29,7 @@ export default function Book(props: HashLinkPageProps) {
export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const link = asStringOrThrow(context.query.link as string);
const recurringEventCountQuery = asStringOrNull(context.query.count);
const slug = context.query.slug as string;
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
@ -41,6 +43,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
periodType: true,
periodDays: true,
periodStartDate: true,
recurringEvent: true,
periodEndDate: true,
metadata: true,
periodCountCalendarDays: true,
@ -122,6 +125,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const eventType = {
...eventTypeRaw,
metadata: (eventTypeRaw.metadata || {}) as JSONObject,
recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
isWeb3Active:
web3Credentials && web3Credentials.key
? (((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");
// 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 {
props: {
locationLabels: getLocationLabels(t),
@ -155,6 +168,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
eventType: eventTypeObject,
booking: null,
trpcState: ssr.dehydrate(),
recurringEventCount,
isDynamicGroupBooking: false,
hasHashedBookingLink: true,
hashedLink: link,

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import { CheckIcon } from "@heroicons/react/outline";
import { ArrowLeftIcon, ClockIcon, XIcon } from "@heroicons/react/solid";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import classNames from "classnames";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
@ -11,6 +12,7 @@ import { useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react";
import rrule from "rrule";
import { SpaceBookingSuccessPage } from "@calcom/app-store/spacebooking/components";
import {
@ -21,6 +23,7 @@ import {
} from "@calcom/embed-core";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RecurringEvent } from "@calcom/types/Calendar";
import Button from "@calcom/ui/Button";
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 router = useRouter();
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);
}
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 title = t(
`booking_${needsConfirmation ? "submitted" : "confirmed"}${props.recurringBookings ? "_recurring" : ""}`
);
return (
(isReady && (
<>
@ -220,10 +241,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
className={isEmbed ? "" : "h-screen bg-neutral-100 dark:bg-neutral-900"}
data-testid="success-page">
<Theme />
<HeadSeo
title={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
description={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
/>
<HeadSeo title={title} description={title} />
<CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} />
<main className={classNames(shouldAlignCentrally ? "mx-auto" : "", isEmbed ? "" : "max-w-3xl")}>
<div className={classNames("overflow-y-auto", isEmbed ? "" : "z-50 ")}>
@ -263,29 +281,29 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
<h3
className="text-2xl font-semibold leading-6 text-neutral-900 dark:text-white"
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>
<div className="mt-3">
<p className="text-sm text-neutral-600 dark:text-gray-300">
{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>
<p className="text-sm text-neutral-600 dark:text-gray-300">{getTitle()}</p>
</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="font-medium">{t("what")}</div>
<div className="col-span-2 mb-6">{eventName}</div>
<div className="font-medium">{t("when")}</div>
<div className="col-span-2">
{date.format("dddd, DD MMMM YYYY")}
<br />
{date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "}
<span className="text-bookinglight">
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
</span>
<RecurringBookings
isReschedule={reschedule === "true"}
eventType={props.eventType}
recurringBookings={props.recurringBookings}
date={date}
is24h={is24h}
/>
</div>
{location && (
<>
@ -322,6 +340,10 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
}` +
(typeof location === "string"
? "&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">
@ -447,21 +469,15 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
{props.userHasSpaceBooking && (
<SpaceBookingSuccessPage
open={props.userHasSpaceBooking}
what={`
what={`
${needsConfirmation ? t("submitted") : `${t("meeting_is_scheduled")}.`}
${
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}`}
${getTitle()} ${t("what")}: ${eventName}`}
where={`${t("where")}: ${
location ? (location?.startsWith("http") ? { location } : location) : "Far far a way galaxy"
}`}
when={`${t("when")}: ${date.format("dddd, DD MMMM YYYY")} ${date.format(
is24h ? "H:mm" : "h:mma"
)} - ${props.eventType.length} mins (${
when={`${t("when")}: ${props.recurringBookings ? t("starting") : ""} ${date.format(
"dddd, DD MMMM YYYY"
)} ${date.format(is24h ? "H:mm" : "h:mma")} - ${props.eventType.length} mins (${
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) => {
return await prisma.eventType.findUnique({
where: {
@ -483,6 +564,7 @@ const getEventTypesFromDB = async (typeId: number) => {
description: true,
length: true,
eventName: true,
recurringEvent: true,
requiresConfirmation: true,
userId: true,
successRedirectUrl: true,
@ -513,6 +595,7 @@ const getEventTypesFromDB = async (typeId: number) => {
export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const typeId = parseInt(asStringOrNull(context.query.type) ?? "");
const recurringEventIdQuery = asStringOrNull(context.query.recur);
const typeSlug = asStringOrNull(context.query.eventSlug) ?? "15min";
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 {
notFound: true,
};
@ -532,11 +615,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
let spaceBookingAvailable = 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({
where: {
type: "spacebooking_other",
userId: eventType.users[0].id,
userId: eventTypeRaw.users[0].id,
},
});
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
const user = await prisma.user.findUnique({
where: {
id: eventType.userId,
id: eventTypeRaw.userId,
},
select: {
id: true,
@ -563,17 +646,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
});
if (user) {
eventType.users.push(user);
eventTypeRaw.users.push(user as any);
}
}
if (!eventType.users.length) {
if (!eventTypeRaw.users.length) {
return {
notFound: true,
};
}
// if (!typeId) eventType["eventName"] = getDynamicEventName(users, typeSlug);
const eventType = {
...eventTypeRaw,
recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
};
const profile = {
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,
};
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 {
props: {
hideBranding: eventType.team ? eventType.team.hideBranding : isBrandingHidden(eventType.users[0]),
profile,
eventType,
recurringBookings: recurringBookings ? recurringBookings.map((obj) => obj.startTime.toString()) : null,
trpcState: ssr.dehydrate(),
dynamicEventName,
userHasSpaceBooking,

View file

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

View file

@ -3,8 +3,9 @@ import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
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 prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -21,13 +22,14 @@ export default function TeamBookingPage(props: TeamBookingPageProps) {
export async function getServerSideProps(context: GetServerSidePropsContext) {
const eventTypeId = parseInt(asStringOrThrow(context.query.type));
const recurringEventCountQuery = asStringOrNull(context.query.count);
if (typeof eventTypeId !== "number" || eventTypeId % 1 !== 0) {
return {
notFound: true,
} as const;
}
const eventType = await prisma.eventType.findUnique({
const eventTypeRaw = await prisma.eventType.findUnique({
where: {
id: eventTypeId,
},
@ -44,6 +46,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
periodStartDate: true,
periodEndDate: true,
periodCountCalendarDays: true,
recurringEvent: true,
disableGuests: true,
price: 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) => {
return {
@ -83,6 +91,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
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 {
props: {
locationLabels: getLocationLabels(t),
@ -96,6 +113,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
eventName: null,
},
eventType: eventTypeObject,
recurringEventCount,
booking,
isDynamicGroupBooking: false,
hasHashedBookingLink: false,

View file

@ -49,6 +49,45 @@ test.describe("Event Types tests", () => {
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 }) => {
const firstTitle = await page.locator("[data-testid=event-type-title-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_cancelled_subject": "Cancelled: {{eventType}} with {{name}} at {{date}}",
"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",
"organizer": "Organizer",
"need_to_reschedule_or_cancel": "Need to reschedule or cancel?",
@ -23,6 +24,7 @@
"rejection_confirmation": "Reject the booking",
"manage_this_event": "Manage this event",
"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}}'.",
"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}}'",
@ -57,6 +59,7 @@
"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.",
"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_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.",
@ -79,6 +82,7 @@
"manage_my_bookings": "Manage my bookings",
"need_to_make_a_change": "Need to make a change?",
"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_timezone": "Invitee Time Zone",
"event_type": "Event Type",
@ -128,6 +132,7 @@
"ping_test": "Ping test",
"add_to_homescreen": "Add this app to your home screen for faster access and improved experience.",
"upcoming": "Upcoming",
"recurring": "Recurring",
"past": "Past",
"choose_a_file": "Choose a file...",
"upload_image": "Upload image",
@ -232,13 +237,20 @@
"add_to_calendar": "Add to calendar",
"other": "Other",
"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.",
"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_recurring": "{{user}} still needs to confirm or reject each booking of the recurring meeting.",
"meeting_is_scheduled": "This meeting is scheduled",
"meeting_is_scheduled_recurring": "The recurring events are scheduled",
"submitted": "Your booking has been submitted",
"submitted_recurring": "Your recurring meeting 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_recurring": "Your recurring meeting has been confirmed",
"enter_new_password": "Enter the new password you'd like for your account.",
"reset_password": "Reset Password",
"change_your_password": "Change your password",
@ -282,6 +294,7 @@
"bookings": "Bookings",
"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.",
"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.",
"cancelled_bookings": "Your cancelled bookings will show up here.",
"on": "on",
@ -432,6 +445,7 @@
"edit_role": "Edit Role",
"edit_team": "Edit team",
"reject": "Reject",
"reject_all": "Reject all",
"accept": "Accept",
"leave": "Leave",
"profile": "Profile",
@ -460,6 +474,7 @@
"cancel_event": "Cancel this event",
"continue": "Continue",
"confirm": "Confirm",
"confirm_all": "Confirm all",
"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.",
"remove_member_confirmation_message": "Are you sure you want to remove this member from the team?",
@ -526,6 +541,18 @@
"language": "Language",
"timezone": "Timezone",
"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",
"brand_color": "Brand Color",
"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.",
"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.",
"recurring_event": "Recurring Event",
"recurring_event_description": "People can subscribe for recurring events",
"starting": "Starting",
"disable_guests": "Disable Guests",
"disable_guests_description": "Disable adding additional guests while booking.",
"hashed_link": "Generate hashed URL",

View file

@ -6,6 +6,7 @@ import { z } from "zod";
import getApps from "@calcom/app-store/utils";
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
import { RecurringEvent } from "@calcom/types/Calendar";
import { checkRegularUsername } from "@lib/core/checkRegularUsername";
import jackson from "@lib/jackson";
@ -127,6 +128,7 @@ const loggedInViewerRouter = createProtectedRouter()
description: true,
length: true,
schedulingType: true,
recurringEvent: true,
slug: true,
hidden: true,
price: true,
@ -298,7 +300,7 @@ const loggedInViewerRouter = createProtectedRouter()
})
.query("bookings", {
input: z.object({
status: z.enum(["upcoming", "past", "cancelled"]),
status: z.enum(["upcoming", "recurring", "past", "cancelled"]),
limit: z.number().min(1).max(100).nullish(),
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 bookingListingFilters: Record<typeof bookingListingByStatus, Prisma.BookingWhereInput[]> = {
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() },
AND: [
{ NOT: { recurringEventId: { equals: null } } },
{ NOT: { status: { equals: BookingStatus.CANCELLED } } },
{ NOT: { status: { equals: BookingStatus.REJECTED } } },
],
@ -342,11 +365,22 @@ const loggedInViewerRouter = createProtectedRouter()
Prisma.BookingOrderByWithAggregationInput
> = {
upcoming: { startTime: "asc" },
recurring: { startTime: "asc" },
past: { 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 orderBy = bookingListingOrderby[bookingListingByStatus];
const distinct = bookingListingDistinct[bookingListingByStatus];
const bookingsQuery = await prisma.booking.findMany({
where: {
@ -373,10 +407,12 @@ const loggedInViewerRouter = createProtectedRouter()
rejected: true,
id: true,
startTime: true,
recurringEventId: true,
endTime: true,
eventType: {
select: {
price: true,
recurringEvent: true,
team: {
select: {
name: true,
@ -394,13 +430,23 @@ const loggedInViewerRouter = createProtectedRouter()
rescheduled: true,
},
orderBy,
distinct,
take: take + 1,
skip,
});
const groupedRecurringBookings = await prisma.booking.groupBy({
by: [Prisma.BookingScalarFieldEnum.recurringEventId],
_count: true,
});
const bookings = bookingsQuery.map((booking) => {
return {
...booking,
eventType: {
...booking.eventType,
recurringEvent: ((booking.eventType && booking.eventType.recurringEvent) || {}) as RecurringEvent,
},
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
};
@ -416,6 +462,7 @@ const loggedInViewerRouter = createProtectedRouter()
return {
bookings,
groupedRecurringBookings,
nextCursor,
};
},

View file

@ -7,6 +7,7 @@ import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
import { _DestinationCalendarModel, _EventTypeCustomInputModel, _EventTypeModel } from "@calcom/prisma/zod";
import { stringOrNumber } from "@calcom/prisma/zod-utils";
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
import { RecurringEvent } from "@calcom/types/Calendar";
import { createProtectedRouter } from "@server/createRouter";
import { viewerRouter } from "@server/routers/viewer";
@ -254,6 +255,7 @@ export const eventTypesRouter = createProtectedRouter()
locations,
destinationCalendar,
customInputs,
recurringEvent,
users,
id,
hashedLink,
@ -266,6 +268,17 @@ export const eventTypesRouter = createProtectedRouter()
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) {
/** We connect or create a destination calendar to the event type instead of the user */
await viewerRouter.createCaller(ctx).mutation("setDestinationCalendar", {

View file

@ -9,9 +9,9 @@
--brand-text-color-dark-mode: #292929;
}
/*
/*
* 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="email"]:focus,

View file

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

View file

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

View file

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

View file

@ -55,6 +55,7 @@ const commons = {
},
isWeb3Active: false,
hideCalendarNotes: false,
recurringEvent: {},
destinationCalendar: null,
team: null,
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?
periodCountCalendarDays Boolean?
requiresConfirmation Boolean @default(false)
/// @zod.custom(imports.recurringEvent)
recurringEvent Json?
disableGuests Boolean @default(false)
hideCalendarNotes Boolean @default(false)
minimumBookingNotice Int @default(120)
@ -278,6 +280,7 @@ model Booking {
dynamicGroupSlugRef String?
rescheduled Boolean?
fromReschedule String?
recurringEventId String?
}
model Schedule {

View file

@ -277,6 +277,111 @@ async function main() {
length: 60,
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 { 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 stringToDate = z.string().transform((a) => new Date(a));
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 { Time } from "ical.js";
import type { TFunction } from "next-i18next";
import type { Frequency as RRuleFrequency } from "rrule";
import type { Event } from "./Event";
import type { Ensure } from "./utils";
@ -72,6 +73,15 @@ export interface AdditionInformation {
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
export interface CalendarEvent {
type: string;
@ -96,6 +106,7 @@ export interface CalendarEvent {
cancellationReason?: string | null;
rejectionReason?: string | null;
hideCalendarNotes?: boolean;
recurrence?: string;
}
export interface EntryPoint {

View file

@ -23,7 +23,7 @@ export function Tooltip({
onOpenChange={onOpenChange}>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<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"
align="center"
{...props}>

1311
yarn.lock

File diff suppressed because it is too large Load diff