From aa166190e9eb6a9380de625b64e5e95240ea23bd Mon Sep 17 00:00:00 2001 From: Leo Giovanetti Date: Tue, 19 Jul 2022 20:29:52 -0300 Subject: [PATCH] Hotfix: Cancelling recurring events follow-up (#3454) * Other fixes and applying feedback * Adds defaultResponder to handle zod errors Co-authored-by: zomars --- .../components/booking/BookingListItem.tsx | 2 + apps/web/pages/api/cancel.ts | 46 ++++++++++--------- apps/web/pages/cancel/[uid].tsx | 23 +++++++--- apps/web/pages/cancel/success.tsx | 2 +- 4 files changed, 45 insertions(+), 28 deletions(-) diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 06ccdefc91..e562feb378 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -126,6 +126,8 @@ function BookingListItem(booking: BookingItemProps) { booking.listingStatus === "recurring" && booking.recurringEventId !== null ? t("cancel_all_remaining") : t("cancel"), + /* When cancelling we need to let the UI and the API know if the intention is to + cancel all remaining bookings or just that booking instance. */ href: `/cancel/${booking.uid}${ booking.listingStatus === "recurring" && booking.recurringEventId !== null ? "?allRemainingBookings=true" diff --git a/apps/web/pages/api/cancel.ts b/apps/web/pages/api/cancel.ts index 2f606d9da4..582d2d5f8e 100644 --- a/apps/web/pages/api/cancel.ts +++ b/apps/web/pages/api/cancel.ts @@ -1,13 +1,14 @@ import { BookingStatus, Credential, - WebhookTriggerEvents, Prisma, PrismaPromise, + WebhookTriggerEvents, WorkflowMethods, } from "@prisma/client"; import async from "async"; import { NextApiRequest, NextApiResponse } from "next"; +import z from "zod"; import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter"; @@ -15,6 +16,8 @@ import { deleteMeeting } from "@calcom/core/videoClient"; import dayjs from "@calcom/dayjs"; import { sendCancelledEmails } from "@calcom/emails"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; import prisma, { bookingMinimalSelect } from "@calcom/prisma"; import type { CalendarEvent } from "@calcom/types/Calendar"; import { refund } from "@ee/lib/stripe/server"; @@ -22,23 +25,21 @@ import { deleteScheduledEmailReminder } from "@ee/lib/workflows/reminders/emailR import { sendCancelledReminders } from "@ee/lib/workflows/reminders/reminderScheduler"; import { deleteScheduledSMSReminder } from "@ee/lib/workflows/reminders/smsReminderManager"; -import { asStringOrNull } from "@lib/asStringOrNull"; import { getSession } from "@lib/auth"; import sendPayload from "@lib/webhooks/sendPayload"; import getWebhooks from "@lib/webhooks/subscriptions"; import { getTranslation } from "@server/lib/i18n"; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - // just bail if it not a DELETE - if (req.method !== "DELETE" && req.method !== "POST") { - return res.status(405).end(); - } +const bodySchema = z.object({ + uid: z.string(), + allRemainingBookings: z.boolean().optional(), + cancellationReason: z.string().optional(), +}); - const uid = asStringOrNull(req.body.uid) || ""; - const allRemainingBookings = asStringOrNull(req.body.allRemainingBookings) || ""; - const cancellationReason = asStringOrNull(req.body.reason) || ""; - const session = await getSession({ req: req }); +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { uid, allRemainingBookings, cancellationReason } = bodySchema.parse(req.body); + const session = await getSession({ req }); const bookingToDelete = await prisma.booking.findUnique({ where: { @@ -93,15 +94,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); if (!bookingToDelete || !bookingToDelete.user) { - return res.status(404).end(); + throw new HttpError({ statusCode: 404, message: "Booking not found" }); } if ((!session || session.user?.id !== bookingToDelete.user?.id) && bookingToDelete.startTime < new Date()) { - return res.status(403).json({ message: "Cannot cancel past events" }); + throw new HttpError({ statusCode: 403, message: "Cannot cancel past events" }); } if (!bookingToDelete.userId) { - return res.status(404).json({ message: "User not found" }); + throw new HttpError({ statusCode: 404, message: "User not found" }); } const organizer = await prisma.user.findFirst({ @@ -148,10 +149,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) attendees: attendeesList, uid: bookingToDelete?.uid, /* Include recurringEvent information only when cancelling all bookings */ - recurringEvent: - allRemainingBookings === "true" - ? parseRecurringEvent(bookingToDelete.eventType?.recurringEvent) - : undefined, + recurringEvent: allRemainingBookings + ? parseRecurringEvent(bookingToDelete.eventType?.recurringEvent) + : undefined, location: bookingToDelete?.location, destinationCalendar: bookingToDelete?.destinationCalendar || bookingToDelete?.user.destinationCalendar, cancellationReason: cancellationReason, @@ -174,13 +174,12 @@ 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 && allRemainingBookings === "true") { + if (bookingToDelete.eventType?.recurringEvent && bookingToDelete.recurringEventId && allRemainingBookings) { const recurringEventId = bookingToDelete.recurringEventId; - const where = recurringEventId === null ? { uid } : { recurringEventId }; // Proceed to mark as cancelled all remaining recurring events instances (greater than or equal to right now) await prisma.booking.updateMany({ where: { - ...where, + recurringEventId, startTime: { gte: new Date(), }, @@ -341,3 +340,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) res.status(204).end(); } + +export default defaultHandler({ + DELETE: Promise.resolve({ default: defaultResponder(handler) }), + POST: Promise.resolve({ default: defaultResponder(handler) }), +}); diff --git a/apps/web/pages/cancel/[uid].tsx b/apps/web/pages/cancel/[uid].tsx index d860367d30..b3040c46a4 100644 --- a/apps/web/pages/cancel/[uid].tsx +++ b/apps/web/pages/cancel/[uid].tsx @@ -3,6 +3,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/r import { GetServerSidePropsContext } from "next"; import { useRouter } from "next/router"; import { useState } from "react"; +import z from "zod"; import dayjs from "@calcom/dayjs"; import classNames from "@calcom/lib/classNames"; @@ -13,7 +14,6 @@ import prisma, { bookingMinimalSelect } from "@calcom/prisma"; import { Button } from "@calcom/ui/Button"; import { TextField } from "@calcom/ui/form/fields"; -import { asStringOrNull, asStringOrUndefined } from "@lib/asStringOrNull"; import { getSession } from "@lib/auth"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; import { detectBrowserTimeFormat } from "@lib/timeFormat"; @@ -24,11 +24,19 @@ import { HeadSeo } from "@components/seo/head-seo"; import { ssrInit } from "@server/lib/ssr"; +const querySchema = z.object({ + uid: z.string(), + allRemainingBookings: z + .string() + .optional() + .transform((val) => (val ? JSON.parse(val) : false)), +}); + export default function Type(props: inferSSRProps) { const { t } = useLocale(); // Get router variables const router = useRouter(); - const { uid, allRemainingBookings } = router.query; + const { uid, allRemainingBookings } = querySchema.parse(router.query); const [loading, setLoading] = useState(false); const [error, setError] = useState(props.booking ? null : t("booking_already_cancelled")); const [cancellationReason, setCancellationReason] = useState(""); @@ -85,7 +93,7 @@ export default function Type(props: inferSSRProps) { ? props.cancellationAllowed ? t("reschedule_instead") : t("event_is_in_the_past") - : allRemainingBookings === "true" + : allRemainingBookings ? t("cancelling_all_recurring") : t("cancelling_event_recurring")}

@@ -229,10 +237,10 @@ 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 { allRemainingBookings, uid } = querySchema.parse(context.query); const booking = await prisma.booking.findUnique({ where: { - uid: asStringOrUndefined(context.query.uid), + uid, }, select: { ...bookingMinimalSelect, @@ -278,10 +286,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => }); let recurringInstances = null; - if (booking.eventType?.recurringEvent && allRemainingBookings === "true") { + if (booking.eventType?.recurringEvent && allRemainingBookings) { recurringInstances = await prisma.booking.findMany({ where: { recurringEventId: booking.recurringEventId, + startTime: { + gte: new Date(), + }, NOT: [{ status: "CANCELLED" }, { status: "REJECTED" }], }, select: { diff --git a/apps/web/pages/cancel/success.tsx b/apps/web/pages/cancel/success.tsx index 2cb78d6550..8d7d74dca6 100644 --- a/apps/web/pages/cancel/success.tsx +++ b/apps/web/pages/cancel/success.tsx @@ -64,7 +64,7 @@ export default function CancelSuccess() { {!loading && session?.user && (