diff --git a/apps/web/components/booking/CancelBooking.tsx b/apps/web/components/booking/CancelBooking.tsx index 1e1a2ab510..0dbc58ae4e 100644 --- a/apps/web/components/booking/CancelBooking.tsx +++ b/apps/web/components/booking/CancelBooking.tsx @@ -12,6 +12,7 @@ type Props = { booking: { title?: string; uid?: string; + id?: number; }; profile: { name: string | null; @@ -79,7 +80,7 @@ export default function CancelBooking(props: Props) { setLoading(true); const payload = { - uid: booking?.uid, + id: booking?.id, cancellationReason: cancellationReason, }; diff --git a/apps/web/pages/api/cancel.ts b/apps/web/pages/api/cancel.ts index b0bbd0e63c..15b47c7112 100644 --- a/apps/web/pages/api/cancel.ts +++ b/apps/web/pages/api/cancel.ts @@ -1,396 +1,14 @@ -import { - BookingStatus, - Prisma, - PrismaPromise, - WebhookTriggerEvents, - WorkflowMethods, - WorkflowReminder, -} from "@prisma/client"; -import { NextApiRequest, NextApiResponse } from "next"; -import z from "zod"; +import type { NextApiRequest } from "next"; -import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; -import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter"; -import { DailyLocationType } from "@calcom/app-store/locations"; -import { refund } from "@calcom/app-store/stripepayment/lib/server"; -import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler"; -import { deleteMeeting } from "@calcom/core/videoClient"; -import dayjs from "@calcom/dayjs"; -import { sendCancelledEmails } from "@calcom/emails"; -import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager"; -import { sendCancelledReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; -import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager"; -import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; -import sendPayload, { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload"; -import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; +import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking"; import { getSession } from "@calcom/lib/auth"; -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 { defaultResponder, defaultHandler } from "@calcom/lib/server"; -import { getTranslation } from "@server/lib/i18n"; - -const bodySchema = z.object({ - uid: z.string(), - allRemainingBookings: z.boolean().optional(), - cancellationReason: z.string().optional(), -}); - -async function handler(req: NextApiRequest, res: NextApiResponse) { - const { uid, allRemainingBookings, cancellationReason } = bodySchema.parse(req.body); +async function handler(req: NextApiRequest & { userId?: number }) { const session = await getSession({ req }); - - const bookingToDelete = await prisma.booking.findUnique({ - where: { - uid, - }, - select: { - ...bookingMinimalSelect, - recurringEventId: true, - userId: true, - user: { - select: { - id: true, - credentials: true, - email: true, - timeZone: true, - name: true, - destinationCalendar: true, - }, - }, - location: true, - references: { - select: { - uid: true, - type: true, - externalCalendarId: true, - credentialId: true, - }, - }, - payment: true, - paid: true, - eventType: { - select: { - recurringEvent: true, - title: true, - description: true, - requiresConfirmation: true, - price: true, - currency: true, - length: true, - workflows: { - include: { - workflow: { - include: { - steps: true, - }, - }, - }, - }, - }, - }, - uid: true, - eventTypeId: true, - destinationCalendar: true, - smsReminderNumber: true, - workflowReminders: true, - scheduledJobs: true, - }, - }); - - if (!bookingToDelete || !bookingToDelete.user) { - throw new HttpError({ statusCode: 404, message: "Booking not found" }); - } - - if ((!session || session.user?.id !== bookingToDelete.user?.id) && bookingToDelete.startTime < new Date()) { - throw new HttpError({ statusCode: 403, message: "Cannot cancel past events" }); - } - - if (!bookingToDelete.userId) { - throw new HttpError({ statusCode: 404, message: "User not found" }); - } - - const organizer = await prisma.user.findFirstOrThrow({ - where: { - id: bookingToDelete.userId, - }, - select: { - name: true, - email: true, - timeZone: true, - locale: true, - }, - }); - - const attendeesListPromises = bookingToDelete.attendees.map(async (attendee) => { - return { - name: attendee.name, - email: attendee.email, - timeZone: attendee.timeZone, - language: { - translate: await getTranslation(attendee.locale ?? "en", "common"), - locale: attendee.locale ?? "en", - }, - }; - }); - - const attendeesList = await Promise.all(attendeesListPromises); - const tOrganizer = await getTranslation(organizer.locale ?? "en", "common"); - - const evt: CalendarEvent = { - title: bookingToDelete?.title, - type: (bookingToDelete?.eventType?.title as string) || bookingToDelete?.title, - description: bookingToDelete?.description || "", - customInputs: isPrismaObjOrUndefined(bookingToDelete.customInputs), - startTime: bookingToDelete?.startTime ? dayjs(bookingToDelete.startTime).format() : "", - endTime: bookingToDelete?.endTime ? dayjs(bookingToDelete.endTime).format() : "", - organizer: { - email: organizer.email, - name: organizer.name ?? "Nameless", - timeZone: organizer.timeZone, - language: { translate: tOrganizer, locale: organizer.locale ?? "en" }, - }, - attendees: attendeesList, - uid: bookingToDelete?.uid, - /* Include recurringEvent information only when cancelling all bookings */ - recurringEvent: allRemainingBookings - ? parseRecurringEvent(bookingToDelete.eventType?.recurringEvent) - : undefined, - location: bookingToDelete?.location, - destinationCalendar: bookingToDelete?.destinationCalendar || bookingToDelete?.user.destinationCalendar, - cancellationReason: cancellationReason, - }; - // Hook up the webhook logic here - const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED"; - // Send Webhook call if hooked to BOOKING.CANCELLED - const subscriberOptions = { - userId: bookingToDelete.userId, - eventTypeId: (bookingToDelete.eventTypeId as number) || 0, - triggerEvent: eventTrigger, - }; - - const eventTypeInfo: EventTypeInfo = { - eventTitle: bookingToDelete?.eventType?.title || null, - eventDescription: bookingToDelete?.eventType?.description || null, - requiresConfirmation: bookingToDelete?.eventType?.requiresConfirmation || null, - price: bookingToDelete?.eventType?.price || null, - currency: bookingToDelete?.eventType?.currency || null, - length: bookingToDelete?.eventType?.length || null, - }; - - const webhooks = await getWebhooks(subscriberOptions); - const promises = webhooks.map((webhook) => - sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, { - ...evt, - ...eventTypeInfo, - status: "CANCELLED", - }).catch((e) => { - console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, e); - }) - ); - await Promise.all(promises); - - //Workflows - schedule reminders - if (bookingToDelete.eventType?.workflows) { - await sendCancelledReminders( - bookingToDelete.eventType?.workflows, - bookingToDelete.smsReminderNumber, - evt - ); - } - - let updatedBookings: { - uid: string; - workflowReminders: WorkflowReminder[]; - scheduledJobs: string[]; - }[] = []; - - // 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 && bookingToDelete.recurringEventId && allRemainingBookings) { - const recurringEventId = bookingToDelete.recurringEventId; - // Proceed to mark as cancelled all remaining recurring events instances (greater than or equal to right now) - await prisma.booking.updateMany({ - where: { - recurringEventId, - startTime: { - gte: new Date(), - }, - }, - data: { - status: BookingStatus.CANCELLED, - cancellationReason: cancellationReason, - }, - }); - const allUpdatedBookings = await prisma.booking.findMany({ - where: { - recurringEventId: bookingToDelete.recurringEventId, - startTime: { - gte: new Date(), - }, - }, - select: { - workflowReminders: true, - uid: true, - scheduledJobs: true, - }, - }); - updatedBookings = updatedBookings.concat(allUpdatedBookings); - } else { - const updatedBooking = await prisma.booking.update({ - where: { - uid, - }, - data: { - status: BookingStatus.CANCELLED, - cancellationReason: cancellationReason, - }, - select: { - workflowReminders: true, - uid: true, - scheduledJobs: true, - }, - }); - updatedBookings.push(updatedBooking); - } - - /** TODO: Remove this without breaking functionality */ - if (bookingToDelete.location === DailyLocationType) { - bookingToDelete.user.credentials.push(FAKE_DAILY_CREDENTIAL); - } - - const apiDeletes = []; - - const bookingCalendarReference = bookingToDelete.references.find((reference) => - reference.type.includes("_calendar") - ); - - if (bookingCalendarReference) { - const { credentialId, uid, externalCalendarId } = bookingCalendarReference; - // If the booking calendar reference contains a credentialId - if (credentialId) { - // Find the correct calendar credential under user credentials - const calendarCredential = bookingToDelete.user.credentials.find( - (credential) => credential.id === credentialId - ); - if (calendarCredential) { - const calendar = getCalendar(calendarCredential); - apiDeletes.push(calendar?.deleteEvent(uid, evt, externalCalendarId)); - } - // For bookings made before the refactor we go through the old behaviour of running through each calendar credential - } else { - bookingToDelete.user.credentials - .filter((credential) => credential.type.endsWith("_calendar")) - .forEach((credential) => { - const calendar = getCalendar(credential); - apiDeletes.push(calendar?.deleteEvent(uid, evt, externalCalendarId)); - }); - } - } - - const bookingVideoReference = bookingToDelete.references.find((reference) => - reference.type.includes("_video") - ); - - // If the video reference has a credentialId find the specific credential - if (bookingVideoReference && bookingVideoReference.credentialId) { - const { credentialId, uid } = bookingVideoReference; - if (credentialId) { - const videoCredential = bookingToDelete.user.credentials.find( - (credential) => credential.id === credentialId - ); - - if (videoCredential) { - apiDeletes.push(deleteMeeting(videoCredential, uid)); - } - } - // For bookings made before this refactor we go through the old behaviour of running through each video credential - } else { - bookingToDelete.user.credentials - .filter((credential) => credential.type.endsWith("_video")) - .forEach((credential) => { - apiDeletes.push(deleteMeeting(credential, uid)); - }); - } - - // Avoiding taking care of recurrence for now as Payments are not supported with Recurring Events at the moment - if (bookingToDelete && bookingToDelete.paid) { - const evt: CalendarEvent = { - type: bookingToDelete?.eventType?.title as string, - title: bookingToDelete.title, - description: bookingToDelete.description ?? "", - customInputs: isPrismaObjOrUndefined(bookingToDelete.customInputs), - startTime: bookingToDelete.startTime.toISOString(), - endTime: bookingToDelete.endTime.toISOString(), - organizer: { - email: bookingToDelete.user?.email ?? "dev@calendso.com", - name: bookingToDelete.user?.name ?? "no user", - timeZone: bookingToDelete.user?.timeZone ?? "", - language: { translate: tOrganizer, locale: organizer.locale ?? "en" }, - }, - attendees: attendeesList, - location: bookingToDelete.location ?? "", - uid: bookingToDelete.uid ?? "", - destinationCalendar: bookingToDelete?.destinationCalendar || bookingToDelete?.user.destinationCalendar, - }; - await refund(bookingToDelete, evt); - await prisma.booking.update({ - where: { - id: bookingToDelete.id, - }, - data: { - status: BookingStatus.REJECTED, - }, - }); - - // We skip the deletion of the event, because that would also delete the payment reference, which we should keep - await apiDeletes; - return res.status(200).json({ message: "Booking successfully deleted." }); - } - - const attendeeDeletes = prisma.attendee.deleteMany({ - where: { - bookingId: bookingToDelete.id, - }, - }); - - const bookingReferenceDeletes = prisma.bookingReference.deleteMany({ - where: { - bookingId: bookingToDelete.id, - }, - }); - - // delete scheduled jobs of cancelled bookings - updatedBookings.forEach((booking) => { - cancelScheduledJobs(booking); - }); - - //Workflows - delete all reminders for bookings - const remindersToDelete: PrismaPromise[] = []; - updatedBookings.forEach((booking) => { - booking.workflowReminders.forEach((reminder) => { - if (reminder.scheduled && reminder.referenceId) { - if (reminder.method === WorkflowMethods.EMAIL) { - deleteScheduledEmailReminder(reminder.referenceId); - } else if (reminder.method === WorkflowMethods.SMS) { - deleteScheduledSMSReminder(reminder.referenceId); - } - } - const reminderToDelete = prisma.workflowReminder.deleteMany({ - where: { - id: reminder.id, - }, - }); - remindersToDelete.push(reminderToDelete); - }); - }); - - await Promise.all([apiDeletes, attendeeDeletes, bookingReferenceDeletes].concat(remindersToDelete)); - - await sendCancelledEmails(evt); - - res.status(204).end(); + /* To mimic API behavior */ + req.userId = session?.user?.id; + return await handleCancelBooking(req); } export default defaultHandler({ diff --git a/apps/web/pages/success.tsx b/apps/web/pages/success.tsx index f4c6bb7110..0f1af44a7a 100644 --- a/apps/web/pages/success.tsx +++ b/apps/web/pages/success.tsx @@ -460,7 +460,7 @@ export default function Success(props: SuccessProps) { ) : ( { + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { + translate: await getTranslation(attendee.locale ?? "en", "common"), + locale: attendee.locale ?? "en", + }, + }; + }); + + const attendeesList = await Promise.all(attendeesListPromises); + const tOrganizer = await getTranslation(organizer.locale ?? "en", "common"); + + const evt: CalendarEvent = { + title: bookingToDelete?.title, + type: (bookingToDelete?.eventType?.title as string) || bookingToDelete?.title, + description: bookingToDelete?.description || "", + customInputs: isPrismaObjOrUndefined(bookingToDelete.customInputs), + startTime: bookingToDelete?.startTime ? dayjs(bookingToDelete.startTime).format() : "", + endTime: bookingToDelete?.endTime ? dayjs(bookingToDelete.endTime).format() : "", + organizer: { + email: organizer.email, + name: organizer.name ?? "Nameless", + timeZone: organizer.timeZone, + language: { translate: tOrganizer, locale: organizer.locale ?? "en" }, + }, + attendees: attendeesList, + uid: bookingToDelete?.uid, + /* Include recurringEvent information only when cancelling all bookings */ + recurringEvent: allRemainingBookings + ? parseRecurringEvent(bookingToDelete.eventType?.recurringEvent) + : undefined, + location: bookingToDelete?.location, + destinationCalendar: bookingToDelete?.destinationCalendar || bookingToDelete?.user.destinationCalendar, + cancellationReason: cancellationReason, + }; + // Hook up the webhook logic here + const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED"; + // Send Webhook call if hooked to BOOKING.CANCELLED + const subscriberOptions = { + userId: bookingToDelete.userId, + eventTypeId: (bookingToDelete.eventTypeId as number) || 0, + triggerEvent: eventTrigger, + }; + + const eventTypeInfo: EventTypeInfo = { + eventTitle: bookingToDelete?.eventType?.title || null, + eventDescription: bookingToDelete?.eventType?.description || null, + requiresConfirmation: bookingToDelete?.eventType?.requiresConfirmation || null, + price: bookingToDelete?.eventType?.price || null, + currency: bookingToDelete?.eventType?.currency || null, + length: bookingToDelete?.eventType?.length || null, + }; + + const webhooks = await getWebhooks(subscriberOptions); + const promises = webhooks.map((webhook) => + sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, { + ...evt, + ...eventTypeInfo, + status: "CANCELLED", + }).catch((e) => { + console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, e); + }) + ); + await Promise.all(promises); + + //Workflows - schedule reminders + if (bookingToDelete.eventType?.workflows) { + await sendCancelledReminders( + bookingToDelete.eventType?.workflows, + bookingToDelete.smsReminderNumber, + evt + ); + } + + let updatedBookings: { + uid: string; + workflowReminders: WorkflowReminder[]; + scheduledJobs: string[]; + }[] = []; + + // 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 && bookingToDelete.recurringEventId && allRemainingBookings) { + const recurringEventId = bookingToDelete.recurringEventId; + // Proceed to mark as cancelled all remaining recurring events instances (greater than or equal to right now) + await prisma.booking.updateMany({ + where: { + recurringEventId, + startTime: { + gte: new Date(), + }, + }, + data: { + status: BookingStatus.CANCELLED, + cancellationReason: cancellationReason, + }, + }); + const allUpdatedBookings = await prisma.booking.findMany({ + where: { + recurringEventId: bookingToDelete.recurringEventId, + startTime: { + gte: new Date(), + }, + }, + select: { + workflowReminders: true, + uid: true, + scheduledJobs: true, + }, + }); + updatedBookings = updatedBookings.concat(allUpdatedBookings); + } else { + const updatedBooking = await prisma.booking.update({ + where: { + id, + uid, + }, + data: { + status: BookingStatus.CANCELLED, + cancellationReason: cancellationReason, + }, + select: { + workflowReminders: true, + uid: true, + scheduledJobs: true, + }, + }); + updatedBookings.push(updatedBooking); + } + + /** TODO: Remove this without breaking functionality */ + if (bookingToDelete.location === DailyLocationType) { + bookingToDelete.user.credentials.push(FAKE_DAILY_CREDENTIAL); + } + + const apiDeletes = []; + + const bookingCalendarReference = bookingToDelete.references.find((reference) => + reference.type.includes("_calendar") + ); + + if (bookingCalendarReference) { + const { credentialId, uid, externalCalendarId } = bookingCalendarReference; + // If the booking calendar reference contains a credentialId + if (credentialId) { + // Find the correct calendar credential under user credentials + const calendarCredential = bookingToDelete.user.credentials.find( + (credential) => credential.id === credentialId + ); + if (calendarCredential) { + const calendar = getCalendar(calendarCredential); + apiDeletes.push(calendar?.deleteEvent(uid, evt, externalCalendarId)); + } + // For bookings made before the refactor we go through the old behaviour of running through each calendar credential + } else { + bookingToDelete.user.credentials + .filter((credential) => credential.type.endsWith("_calendar")) + .forEach((credential) => { + const calendar = getCalendar(credential); + apiDeletes.push(calendar?.deleteEvent(uid, evt, externalCalendarId)); + }); + } + } + + const bookingVideoReference = bookingToDelete.references.find((reference) => + reference.type.includes("_video") + ); + + // If the video reference has a credentialId find the specific credential + if (bookingVideoReference && bookingVideoReference.credentialId) { + const { credentialId, uid } = bookingVideoReference; + if (credentialId) { + const videoCredential = bookingToDelete.user.credentials.find( + (credential) => credential.id === credentialId + ); + + if (videoCredential) { + apiDeletes.push(deleteMeeting(videoCredential, uid)); + } + } + // For bookings made before this refactor we go through the old behaviour of running through each video credential + } else { + bookingToDelete.user.credentials + .filter((credential) => credential.type.endsWith("_video")) + .forEach((credential) => { + apiDeletes.push(deleteMeeting(credential, bookingToDelete.uid)); + }); + } + + // Avoiding taking care of recurrence for now as Payments are not supported with Recurring Events at the moment + if (bookingToDelete && bookingToDelete.paid) { + const evt: CalendarEvent = { + type: bookingToDelete?.eventType?.title as string, + title: bookingToDelete.title, + description: bookingToDelete.description ?? "", + customInputs: isPrismaObjOrUndefined(bookingToDelete.customInputs), + startTime: bookingToDelete.startTime.toISOString(), + endTime: bookingToDelete.endTime.toISOString(), + organizer: { + email: bookingToDelete.user?.email ?? "dev@calendso.com", + name: bookingToDelete.user?.name ?? "no user", + timeZone: bookingToDelete.user?.timeZone ?? "", + language: { translate: tOrganizer, locale: organizer.locale ?? "en" }, + }, + attendees: attendeesList, + location: bookingToDelete.location ?? "", + uid: bookingToDelete.uid ?? "", + destinationCalendar: bookingToDelete?.destinationCalendar || bookingToDelete?.user.destinationCalendar, + }; + await refund(bookingToDelete, evt); + await prisma.booking.update({ + where: { + id: bookingToDelete.id, + }, + data: { + status: BookingStatus.REJECTED, + }, + }); + + // We skip the deletion of the event, because that would also delete the payment reference, which we should keep + await apiDeletes; + req.statusCode = 200; + return { message: "Booking successfully cancelled." }; + } + + const attendeeDeletes = prisma.attendee.deleteMany({ + where: { + bookingId: bookingToDelete.id, + }, + }); + + const bookingReferenceDeletes = prisma.bookingReference.deleteMany({ + where: { + bookingId: bookingToDelete.id, + }, + }); + + // delete scheduled jobs of cancelled bookings + updatedBookings.forEach((booking) => { + cancelScheduledJobs(booking); + }); + + //Workflows - delete all reminders for bookings + const remindersToDelete: PrismaPromise[] = []; + updatedBookings.forEach((booking) => { + booking.workflowReminders.forEach((reminder) => { + if (reminder.scheduled && reminder.referenceId) { + if (reminder.method === WorkflowMethods.EMAIL) { + deleteScheduledEmailReminder(reminder.referenceId); + } else if (reminder.method === WorkflowMethods.SMS) { + deleteScheduledSMSReminder(reminder.referenceId); + } + } + const reminderToDelete = prisma.workflowReminder.deleteMany({ + where: { + id: reminder.id, + }, + }); + remindersToDelete.push(reminderToDelete); + }); + }); + + await Promise.all([apiDeletes, attendeeDeletes, bookingReferenceDeletes].concat(remindersToDelete)); + + await sendCancelledEmails(evt); + + req.statusCode = 200; + return { message: "Booking successfully cancelled." }; +} + +export default handler; diff --git a/packages/prisma/middleware/bookingReference.ts b/packages/prisma/middleware/bookingReference.ts index e72e2c7b9e..8141e999a0 100644 --- a/packages/prisma/middleware/bookingReference.ts +++ b/packages/prisma/middleware/bookingReference.ts @@ -15,7 +15,6 @@ function middleware(prisma: PrismaClient) { params.args["data"] = { deleted: true }; } if (params.action === "deleteMany") { - console.log("deletingMany"); // Delete many queries params.action = "updateMany"; if (params.args.data !== undefined) { diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index cb9400450e..3444ce490b 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -160,6 +160,13 @@ export const extendedBookingCreateBody = bookingCreateBodySchema.merge( }) ); +export const schemaBookingCancelParams = z.object({ + id: z.number().optional(), + uid: z.string().optional(), + allRemainingBookings: z.boolean().optional(), + cancellationReason: z.string().optional(), +}); + export const vitalSettingsUpdateSchema = z.object({ connected: z.boolean().optional(), selectedParam: z.string().optional(),