From 63660a37f99e6c4c97c60e14152c5da966bda39e Mon Sep 17 00:00:00 2001 From: Leo Giovanetti Date: Tue, 19 Jul 2022 16:06:23 -0300 Subject: [PATCH] Cancelling recurring vs single event (#3448) Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .../components/booking/BookingListItem.tsx | 27 ++++++++-------- apps/web/lib/parseDate.ts | 13 ++++++++ apps/web/pages/api/cancel.ts | 18 ++++++++--- apps/web/pages/bookings/[status].tsx | 31 ++++++++++--------- apps/web/pages/cancel/[uid].tsx | 21 ++++++++----- apps/web/public/static/locales/en/common.json | 2 ++ apps/web/server/routers/viewer.tsx | 6 ---- 7 files changed, 73 insertions(+), 45 deletions(-) diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 225bd1612b..06ccdefc91 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -14,7 +14,6 @@ import { useState } from "react"; import { useMutation } from "react-query"; import dayjs from "@calcom/dayjs"; -import { parseRecurringEvent } from "@calcom/lib"; import classNames from "@calcom/lib/classNames"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import showToast from "@calcom/lib/notification"; @@ -28,7 +27,7 @@ import { HttpError } from "@lib/core/http/error"; import useMeQuery from "@lib/hooks/useMeQuery"; import { linkValueToString } from "@lib/linkValueToString"; import { LocationType } from "@lib/location"; -import { parseRecurringDates } from "@lib/parseDate"; +import { extractRecurringDates } from "@lib/parseDate"; import { inferQueryInput, inferQueryOutput, trpc } from "@lib/trpc"; import { EditLocationDialog } from "@components/dialog/EditLocationDialog"; @@ -41,7 +40,7 @@ type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number]; type BookingItemProps = BookingItem & { listingStatus: BookingListingStatus; - recurringCount?: number; + recurringBookings?: BookingItem[]; }; function BookingListItem(booking: BookingItemProps) { @@ -127,7 +126,11 @@ function BookingListItem(booking: BookingItemProps) { booking.listingStatus === "recurring" && booking.recurringEventId !== null ? t("cancel_all_remaining") : t("cancel"), - href: `/cancel/${booking.uid}`, + href: `/cancel/${booking.uid}${ + booking.listingStatus === "recurring" && booking.recurringEventId !== null + ? "?allRemainingBookings=true" + : "" + }`, icon: XIcon, }, { @@ -202,14 +205,10 @@ function BookingListItem(booking: BookingItemProps) { let recurringStrings: string[] = []; let recurringDates: Date[] = []; const today = new Date(); - if (booking.recurringCount && booking.eventType.recurringEvent?.freq !== undefined) { - [recurringStrings, recurringDates] = parseRecurringDates( - { - startDate: booking.startTime, - timeZone: user?.timeZone, - recurringEvent: parseRecurringEvent(booking.eventType.recurringEvent), - recurringCount: booking.recurringCount, - }, + if (booking.recurringBookings && booking.eventType.recurringEvent?.freq !== undefined) { + [recurringStrings, recurringDates] = extractRecurringDates( + booking.recurringBookings, + user?.timeZone, i18n ); if (booking.status === BookingStatus.PENDING) { @@ -308,7 +307,7 @@ function BookingListItem(booking: BookingItemProps) { {dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
- {booking.recurringCount && + {booking.recurringBookings && booking.eventType?.recurringEvent?.freq && (booking.listingStatus === "recurring" || booking.listingStatus === "cancelled") && (
@@ -327,7 +326,7 @@ function BookingListItem(booking: BookingItemProps) { : getEveryFreqFor({ t, recurringEvent: booking.eventType.recurringEvent, - recurringCount: booking.recurringCount, + recurringCount: booking.recurringBookings.length, })}

diff --git a/apps/web/lib/parseDate.ts b/apps/web/lib/parseDate.ts index 838e6f7957..1b07301e9c 100644 --- a/apps/web/lib/parseDate.ts +++ b/apps/web/lib/parseDate.ts @@ -5,6 +5,7 @@ import dayjs, { Dayjs } from "@calcom/dayjs"; import { RecurringEvent } from "@calcom/types/Calendar"; import { detectBrowserTimeFormat } from "@lib/timeFormat"; +import { inferQueryOutput } from "@lib/trpc"; import { parseZone } from "./parseZone"; @@ -46,3 +47,15 @@ export const parseRecurringDates = ( }); return [dateStrings, rule.all()]; }; + +type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number]; + +export const extractRecurringDates = ( + bookings: BookingItem[], + timeZone: string | undefined, + i18n: I18n +): [string[], Date[]] => { + const dateStrings = bookings.map((booking) => processDate(dayjs(booking.startTime).tz(timeZone), i18n)); + const allDates = dateStrings.map((dateString) => dayjs(dateString).tz(timeZone).toDate()); + return [dateStrings, allDates]; +}; diff --git a/apps/web/pages/api/cancel.ts b/apps/web/pages/api/cancel.ts index 3884cd9632..79fdeefe9d 100644 --- a/apps/web/pages/api/cancel.ts +++ b/apps/web/pages/api/cancel.ts @@ -36,6 +36,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } const uid = asStringOrNull(req.body.uid) || ""; + const allRemainingBookings = asStringOrNull(req.body.allRemainingBookings) || ""; const cancellationReason = asStringOrNull(req.body.reason) || ""; const session = await getSession({ req: req }); @@ -145,7 +146,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, attendees: attendeesList, uid: bookingToDelete?.uid, - recurringEvent: parseRecurringEvent(bookingToDelete.eventType?.recurringEvent), + /* Include recurringEvent information only when cancelling all bookings */ + recurringEvent: + allRemainingBookings === "true" + ? parseRecurringEvent(bookingToDelete.eventType?.recurringEvent) + : undefined, location: bookingToDelete?.location, destinationCalendar: bookingToDelete?.destinationCalendar || bookingToDelete?.user.destinationCalendar, cancellationReason: cancellationReason, @@ -168,12 +173,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // by cancelling first, and blocking whilst doing so; we can ensure a cancel // action always succeeds even if subsequent integrations fail cancellation. - if (bookingToDelete.eventType?.recurringEvent) { + if (bookingToDelete.eventType?.recurringEvent && allRemainingBookings === "true") { const recurringEventId = bookingToDelete.recurringEventId; const where = recurringEventId === null ? { uid } : { recurringEventId }; - // Proceed to mark as cancelled all recurring event instances + // Proceed to mark as cancelled all remaining recurring events instances (greater than or equal to right now) await prisma.booking.updateMany({ - where, + where: { + ...where, + startTime: { + gte: new Date(), + }, + }, data: { status: BookingStatus.CANCELLED, cancellationReason: cancellationReason, diff --git a/apps/web/pages/bookings/[status].tsx b/apps/web/pages/bookings/[status].tsx index d5205f2ba9..944fa9be14 100644 --- a/apps/web/pages/bookings/[status].tsx +++ b/apps/web/pages/bookings/[status].tsx @@ -18,7 +18,6 @@ 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(); @@ -47,27 +46,31 @@ 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; + // Get all recurring events of the series with the same recurringEventId + const defineRecurrentBookings = ( + booking: BookingOutput, + groupedBookings: Record + ) => { + let recurringBookings = 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 + recurringBookings = groupedBookings[booking.recurringEventId]; } - return { recurringCount }; + return { recurringBookings }; }; - const shownBookings: Record = {}; + const shownBookings: Record = {}; const filterBookings = (booking: BookingOutput) => { if (status === "recurring" || status === "cancelled") { if (!booking.recurringEventId) { return true; } - if (shownBookings[booking.recurringEventId]) { + if ( + shownBookings[booking.recurringEventId] !== undefined && + shownBookings[booking.recurringEventId].length > 0 + ) { + shownBookings[booking.recurringEventId].push(booking); return false; } - shownBookings[booking.recurringEventId] = true; + shownBookings[booking.recurringEventId] = [booking]; } return true; }; @@ -89,11 +92,11 @@ export default function Bookings() { {query.data.pages.map((page, index) => ( - {page.bookings.filter(filterBookings).map((booking) => ( + {page.bookings.filter(filterBookings).map((booking: BookingOutput) => ( ))} diff --git a/apps/web/pages/cancel/[uid].tsx b/apps/web/pages/cancel/[uid].tsx index 7bd4d30d86..d860367d30 100644 --- a/apps/web/pages/cancel/[uid].tsx +++ b/apps/web/pages/cancel/[uid].tsx @@ -13,7 +13,7 @@ import prisma, { bookingMinimalSelect } from "@calcom/prisma"; import { Button } from "@calcom/ui/Button"; import { TextField } from "@calcom/ui/form/fields"; -import { asStringOrUndefined } from "@lib/asStringOrNull"; +import { asStringOrNull, asStringOrUndefined } from "@lib/asStringOrNull"; import { getSession } from "@lib/auth"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; import { detectBrowserTimeFormat } from "@lib/timeFormat"; @@ -28,7 +28,7 @@ export default function Type(props: inferSSRProps) { const { t } = useLocale(); // Get router variables const router = useRouter(); - const { uid } = router.query; + const { uid, allRemainingBookings } = router.query; const [loading, setLoading] = useState(false); const [error, setError] = useState(props.booking ? null : t("booking_already_cancelled")); const [cancellationReason, setCancellationReason] = useState(""); @@ -81,9 +81,13 @@ export default function Type(props: inferSSRProps) {

- {props.cancellationAllowed && !props.booking?.eventType.recurringEvent - ? t("reschedule_instead") - : t("event_is_in_the_past")} + {!props.booking?.eventType.recurringEvent + ? props.cancellationAllowed + ? t("reschedule_instead") + : t("event_is_in_the_past") + : allRemainingBookings === "true" + ? t("cancelling_all_recurring") + : t("cancelling_event_recurring")}

@@ -175,6 +179,7 @@ export default function Type(props: inferSSRProps) { const payload = { uid: uid, reason: cancellationReason, + allRemainingBookings: !!props.recurringInstances, }; telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters()); @@ -193,7 +198,7 @@ export default function Type(props: inferSSRProps) { props.booking.title }&eventPage=${props.profile.slug}&team=${ props.booking.eventType?.team ? 1 : 0 - }&recurring=${!!props.booking.eventType?.recurringEvent}` + }&recurring=${!!props.recurringInstances}` ); } else { setLoading(false); @@ -224,6 +229,7 @@ export default function Type(props: inferSSRProps) { export const getServerSideProps = async (context: GetServerSidePropsContext) => { const ssr = await ssrInit(context); const session = await getSession(context); + const allRemainingBookings = asStringOrNull(context.query.allRemainingBookings) || ""; const booking = await prisma.booking.findUnique({ where: { uid: asStringOrUndefined(context.query.uid), @@ -272,10 +278,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => }); let recurringInstances = null; - if (booking.eventType?.recurringEvent) { + if (booking.eventType?.recurringEvent && allRemainingBookings === "true") { recurringInstances = await prisma.booking.findMany({ where: { recurringEventId: booking.recurringEventId, + NOT: [{ status: "CANCELLED" }, { status: "REJECTED" }], }, select: { startTime: true, diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 07dff34b51..990879319a 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -286,6 +286,8 @@ "cannot_cancel_booking": "You cannot cancel this booking", "reschedule_instead": "Instead, you could also reschedule it.", "event_is_in_the_past": "The event is in the past", + "cancelling_event_recurring": "The event is one instance of a recurring event.", + "cancelling_all_recurring": "These are all remaining instances in the recurring event.", "error_with_status_code_occured": "An error with status code {{status}} occurred.", "booking_already_cancelled": "This booking was already cancelled", "go_back_home": "Go back home", diff --git a/apps/web/server/routers/viewer.tsx b/apps/web/server/routers/viewer.tsx index 4dde3ca090..60f9799e47 100644 --- a/apps/web/server/routers/viewer.tsx +++ b/apps/web/server/routers/viewer.tsx @@ -428,11 +428,6 @@ const loggedInViewerRouter = createProtectedRouter() skip, }); - const groupedRecurringBookings = await prisma.booking.groupBy({ - by: [Prisma.BookingScalarFieldEnum.recurringEventId], - _count: true, - }); - let bookings = bookingsQuery.map((booking) => { return { ...booking, @@ -473,7 +468,6 @@ const loggedInViewerRouter = createProtectedRouter() return { bookings, - groupedRecurringBookings, nextCursor, }; },