diff --git a/apps/web/components/dialog/RescheduleDialog.tsx b/apps/web/components/dialog/RescheduleDialog.tsx index 57bbf47f50..741da6ce62 100644 --- a/apps/web/components/dialog/RescheduleDialog.tsx +++ b/apps/web/components/dialog/RescheduleDialog.tsx @@ -1,5 +1,3 @@ -import { useMutation } from "@tanstack/react-query"; -import { RescheduleResponse } from "pages/api/book/request-reschedule"; import React, { useState, Dispatch, SetStateAction } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -10,8 +8,6 @@ import { Icon } from "@calcom/ui/Icon"; import { TextArea } from "@calcom/ui/form/fields"; import Button from "@calcom/ui/v2/core/Button"; -import * as fetchWrapper from "@lib/core/http/fetch-wrapper"; - interface IRescheduleDialog { isOpenDialog: boolean; setIsOpenDialog: Dispatch>; @@ -23,35 +19,18 @@ export const RescheduleDialog = (props: IRescheduleDialog) => { const utils = trpc.useContext(); const { isOpenDialog, setIsOpenDialog, bookingUId: bookingId } = props; const [rescheduleReason, setRescheduleReason] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const rescheduleApi = useMutation( - async () => { - setIsLoading(true); - try { - const result = await fetchWrapper.post< - { bookingId: string; rescheduleReason: string }, - RescheduleResponse - >("/api/book/request-reschedule", { - bookingId, - rescheduleReason, - }); - if (result) { - showToast(t("reschedule_request_sent"), "success"); - setIsOpenDialog(false); - } - } catch (error) { - showToast(t("unexpected_error_try_again"), "error"); - // @TODO: notify sentry - } - setIsLoading(false); + const { mutate: rescheduleApi, isLoading } = trpc.useMutation("viewer.bookings.requestReschedule", { + async onSuccess() { + showToast(t("reschedule_request_sent"), "success"); + setIsOpenDialog(false); + await utils.invalidateQueries(["viewer.bookings"]); }, - { - async onSettled() { - await utils.invalidateQueries(["viewer.bookings"]); - }, - } - ); + onError() { + showToast(t("unexpected_error_try_again"), "error"); + // @TODO: notify sentry + }, + }); return ( @@ -84,7 +63,10 @@ export const RescheduleDialog = (props: IRescheduleDialog) => { data-testid="send_request" disabled={isLoading} onClick={() => { - rescheduleApi.mutate(); + rescheduleApi({ + bookingId, + rescheduleReason, + }); }}> {t("send_reschedule_request")} diff --git a/apps/web/pages/api/book/request-reschedule.ts b/apps/web/pages/api/book/request-reschedule.ts deleted file mode 100644 index 9f28f77789..0000000000 --- a/apps/web/pages/api/book/request-reschedule.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { - Attendee, - Booking, - BookingReference, - BookingStatus, - EventType, - User, - WebhookTriggerEvents, -} from "@prisma/client"; -import type { NextApiRequest, NextApiResponse } from "next"; -import { getSession } from "next-auth/react"; -import type { TFunction } from "next-i18next"; -import { z, ZodError } from "zod"; - -import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; -import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder"; -import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director"; -import { deleteMeeting } from "@calcom/core/videoClient"; -import dayjs from "@calcom/dayjs"; -import { sendRequestRescheduleEmail } from "@calcom/emails"; -import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; -import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; -import { isPrismaObjOrUndefined } from "@calcom/lib"; -import { getTranslation } from "@calcom/lib/server/i18n"; -import prisma from "@calcom/prisma"; -import type { CalendarEvent, Person } from "@calcom/types/Calendar"; - -export type RescheduleResponse = Booking & { - attendees: Attendee[]; -}; -export type PersonAttendeeCommonFields = Pick< - User, - "id" | "email" | "name" | "locale" | "timeZone" | "username" ->; - -const rescheduleSchema = z.object({ - bookingId: z.string(), - rescheduleReason: z.string().optional(), -}); - -const findUserDataByUserId = async (userId: number) => { - return await prisma.user.findUniqueOrThrow({ - where: { - id: userId, - }, - select: { - id: true, - name: true, - username: true, - email: true, - timeZone: true, - locale: true, - credentials: true, - destinationCalendar: true, - teams: true, - }, - }); -}; - -const handler = async ( - req: NextApiRequest, - res: NextApiResponse -): Promise => { - const session = await getSession({ req }); - const { - bookingId, - rescheduleReason: cancellationReason, - }: { bookingId: string; rescheduleReason: string; cancellationReason: string } = req.body; - let user: Awaited>; - try { - if (session?.user?.id) { - user = await findUserDataByUserId(session?.user.id); - } else { - return res.status(501).end(); - } - - const bookingToReschedule = await prisma.booking.findFirstOrThrow({ - select: { - id: true, - uid: true, - userId: true, - title: true, - description: true, - startTime: true, - endTime: true, - eventTypeId: true, - eventType: true, - location: true, - attendees: true, - references: true, - customInputs: true, - dynamicEventSlugRef: true, - dynamicGroupSlugRef: true, - destinationCalendar: true, - }, - where: { - uid: bookingId, - NOT: { - status: { - in: [BookingStatus.CANCELLED, BookingStatus.REJECTED], - }, - }, - }, - }); - - if (!bookingToReschedule.userId) { - throw new Error("Booking to reschedule doesn't have an owner."); - } - if (!bookingToReschedule.eventType) { - throw new Error("EventType not found for current booking."); - } - - const bookingBelongsToTeam = !!bookingToReschedule.eventType?.teamId; - if (bookingBelongsToTeam && bookingToReschedule.eventType?.teamId) { - const userTeamIds = user.teams.map((item) => item.teamId); - if (userTeamIds.indexOf(bookingToReschedule?.eventType?.teamId) === -1) { - throw new Error("User isn't a member on the team"); - } - } - if (!bookingBelongsToTeam && bookingToReschedule.userId !== user.id) { - throw new Error("User isn't owner of the current booking"); - } - - if (bookingToReschedule && user) { - let event: Partial = {}; - if (bookingToReschedule.eventTypeId) { - event = await prisma.eventType.findFirstOrThrow({ - select: { - title: true, - users: true, - schedulingType: true, - recurringEvent: true, - }, - where: { - id: bookingToReschedule.eventTypeId, - }, - }); - } - await prisma.booking.update({ - where: { - id: bookingToReschedule.id, - }, - data: { - rescheduled: true, - cancellationReason, - status: BookingStatus.CANCELLED, - updatedAt: dayjs().toISOString(), - }, - }); - - const [mainAttendee] = bookingToReschedule.attendees; - // @NOTE: Should we assume attendees language? - const tAttendees = await getTranslation(mainAttendee.locale ?? "en", "common"); - const usersToPeopleType = ( - users: PersonAttendeeCommonFields[], - selectedLanguage: TFunction - ): Person[] => { - return users?.map((user) => { - return { - email: user.email || "", - name: user.name || "", - username: user?.username || "", - language: { translate: selectedLanguage, locale: user.locale || "en" }, - timeZone: user?.timeZone, - }; - }); - }; - - const userTranslation = await getTranslation(user.locale ?? "en", "common"); - const [userAsPeopleType] = usersToPeopleType([user], userTranslation); - - const builder = new CalendarEventBuilder(); - builder.init({ - title: bookingToReschedule.title, - type: event && event.title ? event.title : bookingToReschedule.title, - startTime: bookingToReschedule.startTime.toISOString(), - endTime: bookingToReschedule.endTime.toISOString(), - attendees: usersToPeopleType( - // username field doesn't exists on attendee but could be in the future - bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[], - tAttendees - ), - organizer: userAsPeopleType, - }); - - const director = new CalendarEventDirector(); - director.setBuilder(builder); - director.setExistingBooking(bookingToReschedule); - director.setCancellationReason(cancellationReason); - if (event) { - await director.buildForRescheduleEmail(); - } else { - await director.buildWithoutEventTypeForRescheduleEmail(); - } - - // Handling calendar and videos cancellation - // This can set previous time as available, until virtual calendar is done - const credentialsMap = new Map(); - user.credentials.forEach((credential) => { - credentialsMap.set(credential.type, credential); - }); - const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter( - (ref) => !!credentialsMap.get(ref.type) - ); - bookingRefsFiltered.forEach((bookingRef) => { - if (bookingRef.uid) { - if (bookingRef.type.endsWith("_calendar")) { - const calendar = getCalendar(credentialsMap.get(bookingRef.type)); - - return calendar?.deleteEvent( - bookingRef.uid, - builder.calendarEvent, - bookingRef.externalCalendarId - ); - } else if (bookingRef.type.endsWith("_video")) { - return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid); - } - } - }); - - // Send emails - await sendRequestRescheduleEmail(builder.calendarEvent, { - rescheduleLink: builder.rescheduleLink, - }); - - const evt: CalendarEvent = { - title: bookingToReschedule?.title, - type: event && event.title ? event.title : bookingToReschedule.title, - description: bookingToReschedule?.description || "", - customInputs: isPrismaObjOrUndefined(bookingToReschedule.customInputs), - startTime: bookingToReschedule?.startTime ? dayjs(bookingToReschedule.startTime).format() : "", - endTime: bookingToReschedule?.endTime ? dayjs(bookingToReschedule.endTime).format() : "", - organizer: userAsPeopleType, - attendees: usersToPeopleType( - // username field doesn't exists on attendee but could be in the future - bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[], - tAttendees - ), - uid: bookingToReschedule?.uid, - location: bookingToReschedule?.location, - destinationCalendar: - bookingToReschedule?.destinationCalendar || bookingToReschedule?.destinationCalendar, - cancellationReason: `Please reschedule. ${cancellationReason}`, // TODO::Add i18-next for this - }; - - // Send webhook - const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED"; - // Send Webhook call if hooked to BOOKING.CANCELLED - const subscriberOptions = { - userId: bookingToReschedule.userId, - eventTypeId: (bookingToReschedule.eventTypeId as number) || 0, - triggerEvent: eventTrigger, - }; - const webhooks = await getWebhooks(subscriberOptions); - const promises = webhooks.map((webhook) => - sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, evt).catch((e) => { - console.error( - `Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, - e - ); - }) - ); - await Promise.all(promises); - } - - return res.status(200).json(bookingToReschedule); - } catch (error) { - if (error instanceof Error) { - throw new Error("Error.request.reschedule " + error?.message); - } - } -}; - -function validate( - handler: (req: NextApiRequest, res: NextApiResponse) => Promise -) { - return async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method === "POST") { - try { - rescheduleSchema.parse(req.body); - } catch (error) { - if (error instanceof ZodError && error?.name === "ZodError") { - return res.status(400).json(error?.issues); - } - return res.status(402).end(); - } - } else { - return res.status(405).end(); - } - await handler(req, res); - }; -} - -export default validate(handler); diff --git a/packages/trpc/server/routers/viewer/bookings.tsx b/packages/trpc/server/routers/viewer/bookings.tsx index be03f1e7f9..f1d7b90e4d 100644 --- a/packages/trpc/server/routers/viewer/bookings.tsx +++ b/packages/trpc/server/routers/viewer/bookings.tsx @@ -1,32 +1,51 @@ import { + BookingReference, BookingStatus, + EventType, Prisma, SchedulingType, + User, WebhookTriggerEvents, Workflow, WorkflowsOnEventTypes, WorkflowStep, } from "@prisma/client"; +import type { TFunction } from "next-i18next"; import { z } from "zod"; +import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { DailyLocationType } from "@calcom/app-store/locations"; import { refund } from "@calcom/app-store/stripepayment/lib/server"; import { scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler"; 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"; import dayjs from "@calcom/dayjs"; -import { sendDeclinedEmails, sendLocationChangeEmails, sendScheduledEmails } from "@calcom/emails"; +import { + sendDeclinedEmails, + sendLocationChangeEmails, + sendRequestRescheduleEmail, + sendScheduledEmails, +} from "@calcom/emails"; import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; +import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; import logger from "@calcom/lib/logger"; import { getTranslation } from "@calcom/lib/server"; import { bookingConfirmPatchBodySchema } from "@calcom/prisma/zod-utils"; -import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar"; +import type { AdditionalInformation, CalendarEvent, Person } from "@calcom/types/Calendar"; import { TRPCError } from "@trpc/server"; import { createProtectedRouter } from "../../createRouter"; +export type PersonAttendeeCommonFields = Pick< + User, + "id" | "email" | "name" | "locale" | "timeZone" | "username" +>; + // Common data for all endpoints under webhook const commonBookingSchema = z.object({ bookingId: z.number(), @@ -35,6 +54,216 @@ const commonBookingSchema = z.object({ const log = logger.getChildLogger({ prefix: ["[api] book:user"] }); export const bookingsRouter = createProtectedRouter() + .mutation("requestReschedule", { + input: z.object({ + bookingId: z.string(), + rescheduleReason: z.string().optional(), + }), + async resolve({ ctx, input }) { + const { user, prisma } = ctx; + const { bookingId, rescheduleReason: cancellationReason } = input; + + const bookingToReschedule = await prisma.booking.findFirstOrThrow({ + select: { + id: true, + uid: true, + userId: true, + title: true, + description: true, + startTime: true, + endTime: true, + eventTypeId: true, + eventType: true, + location: true, + attendees: true, + references: true, + customInputs: true, + dynamicEventSlugRef: true, + dynamicGroupSlugRef: true, + destinationCalendar: true, + }, + where: { + uid: bookingId, + NOT: { + status: { + in: [BookingStatus.CANCELLED, BookingStatus.REJECTED], + }, + }, + }, + }); + + if (!bookingToReschedule.userId) { + throw new TRPCError({ code: "FORBIDDEN", message: "Booking to reschedule doesn't have an owner" }); + } + + if (!bookingToReschedule.eventType) { + throw new TRPCError({ code: "FORBIDDEN", message: "EventType not found for current booking." }); + } + + const bookingBelongsToTeam = !!bookingToReschedule.eventType?.teamId; + + const userTeams = await prisma.user.findUniqueOrThrow({ + where: { + id: user.id, + }, + select: { + teams: true, + }, + }); + + if (bookingBelongsToTeam && bookingToReschedule.eventType?.teamId) { + const userTeamIds = userTeams.teams.map((item) => item.teamId); + if (userTeamIds.indexOf(bookingToReschedule?.eventType?.teamId) === -1) { + throw new TRPCError({ code: "FORBIDDEN", message: "User isn't a member on the team" }); + } + } + if (!bookingBelongsToTeam && bookingToReschedule.userId !== user.id) { + throw new TRPCError({ code: "FORBIDDEN", message: "User isn't owner of the current booking" }); + } + + if (bookingToReschedule) { + let event: Partial = {}; + if (bookingToReschedule.eventTypeId) { + event = await prisma.eventType.findFirstOrThrow({ + select: { + title: true, + users: true, + schedulingType: true, + recurringEvent: true, + }, + where: { + id: bookingToReschedule.eventTypeId, + }, + }); + } + await prisma.booking.update({ + where: { + id: bookingToReschedule.id, + }, + data: { + rescheduled: true, + cancellationReason, + status: BookingStatus.CANCELLED, + updatedAt: dayjs().toISOString(), + }, + }); + + const [mainAttendee] = bookingToReschedule.attendees; + // @NOTE: Should we assume attendees language? + const tAttendees = await getTranslation(mainAttendee.locale ?? "en", "common"); + const usersToPeopleType = ( + users: PersonAttendeeCommonFields[], + selectedLanguage: TFunction + ): Person[] => { + return users?.map((user) => { + return { + email: user.email || "", + name: user.name || "", + username: user?.username || "", + language: { translate: selectedLanguage, locale: user.locale || "en" }, + timeZone: user?.timeZone, + }; + }); + }; + + const userTranslation = await getTranslation(user.locale ?? "en", "common"); + const [userAsPeopleType] = usersToPeopleType([user], userTranslation); + + const builder = new CalendarEventBuilder(); + builder.init({ + title: bookingToReschedule.title, + type: event && event.title ? event.title : bookingToReschedule.title, + startTime: bookingToReschedule.startTime.toISOString(), + endTime: bookingToReschedule.endTime.toISOString(), + attendees: usersToPeopleType( + // username field doesn't exists on attendee but could be in the future + bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[], + tAttendees + ), + organizer: userAsPeopleType, + }); + + const director = new CalendarEventDirector(); + director.setBuilder(builder); + director.setExistingBooking(bookingToReschedule); + cancellationReason && director.setCancellationReason(cancellationReason); + if (event) { + await director.buildForRescheduleEmail(); + } else { + await director.buildWithoutEventTypeForRescheduleEmail(); + } + + // Handling calendar and videos cancellation + // This can set previous time as available, until virtual calendar is done + const credentialsMap = new Map(); + user.credentials.forEach((credential) => { + credentialsMap.set(credential.type, credential); + }); + const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter( + (ref) => !!credentialsMap.get(ref.type) + ); + bookingRefsFiltered.forEach((bookingRef) => { + if (bookingRef.uid) { + if (bookingRef.type.endsWith("_calendar")) { + const calendar = getCalendar(credentialsMap.get(bookingRef.type)); + + return calendar?.deleteEvent( + bookingRef.uid, + builder.calendarEvent, + bookingRef.externalCalendarId + ); + } else if (bookingRef.type.endsWith("_video")) { + return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid); + } + } + }); + + // Send emails + await sendRequestRescheduleEmail(builder.calendarEvent, { + rescheduleLink: builder.rescheduleLink, + }); + + const evt: CalendarEvent = { + title: bookingToReschedule?.title, + type: event && event.title ? event.title : bookingToReschedule.title, + description: bookingToReschedule?.description || "", + customInputs: isPrismaObjOrUndefined(bookingToReschedule.customInputs), + startTime: bookingToReschedule?.startTime ? dayjs(bookingToReschedule.startTime).format() : "", + endTime: bookingToReschedule?.endTime ? dayjs(bookingToReschedule.endTime).format() : "", + organizer: userAsPeopleType, + attendees: usersToPeopleType( + // username field doesn't exists on attendee but could be in the future + bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[], + tAttendees + ), + uid: bookingToReschedule?.uid, + location: bookingToReschedule?.location, + destinationCalendar: + bookingToReschedule?.destinationCalendar || bookingToReschedule?.destinationCalendar, + cancellationReason: `Please reschedule. ${cancellationReason}`, // TODO::Add i18-next for this + }; + + // Send webhook + const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED"; + // Send Webhook call if hooked to BOOKING.CANCELLED + const subscriberOptions = { + userId: bookingToReschedule.userId, + eventTypeId: (bookingToReschedule.eventTypeId as number) || 0, + triggerEvent: eventTrigger, + }; + const webhooks = await getWebhooks(subscriberOptions); + const promises = webhooks.map((webhook) => + sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, evt).catch((e) => { + console.error( + `Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, + e + ); + }) + ); + await Promise.all(promises); + } + }, + }) .middleware(async ({ ctx, rawInput, next }) => { // Endpoints that just read the logged in user's data - like 'list' don't necessary have any input if (!rawInput) return next({ ctx: { ...ctx, booking: null } });