From 02ac593ebfa85a3a7ba018b0c9975467cd3eef09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20L=C3=B3pez?= Date: Fri, 26 Aug 2022 15:58:08 -0600 Subject: [PATCH] Migrate legacy api endpoints to tRPC II (#3865) (#3991) * Migrate legacy api endpoints to tRPC II (#3865) * fix: delete api route /api/availability/day * fix: delete api route /api/availability/eventtype (#3573) * fix: delete api route /api/event-type (#3573) * migrate /api/book/confirm to viewer.bookings.confirm * Merge branch 'main' into pr/3865 Co-authored-by: hussamkhatib Co-authored-by: Peer Richelsen Co-authored-by: zomars * Import fixes * Import fixes Co-authored-by: mohammed hussam <52914487+hussamkhatib@users.noreply.github.com> Co-authored-by: hussamkhatib Co-authored-by: Peer Richelsen --- apps/web/calendso.yaml | 173 ------- .../components/booking/BookingListItem.tsx | 56 +-- apps/web/lib/webhooks/subscriptions.tsx | 1 + apps/web/pages/api/availability/day.ts | 33 -- apps/web/pages/api/availability/eventtype.ts | 32 -- apps/web/pages/api/book/confirm.ts | 425 ------------------ apps/web/pages/api/book/event.ts | 5 +- apps/web/pages/api/eventType.ts | 117 ----- packages/prisma/zod-utils.ts | 7 + .../trpc/server/routers/viewer/bookings.tsx | 363 ++++++++++++++- 10 files changed, 391 insertions(+), 821 deletions(-) delete mode 100644 apps/web/pages/api/availability/day.ts delete mode 100644 apps/web/pages/api/availability/eventtype.ts delete mode 100644 apps/web/pages/api/book/confirm.ts delete mode 100644 apps/web/pages/api/eventType.ts diff --git a/apps/web/calendso.yaml b/apps/web/calendso.yaml index f27bc5c1db..7cf5c66177 100644 --- a/apps/web/calendso.yaml +++ b/apps/web/calendso.yaml @@ -277,179 +277,6 @@ paths: type: string integration: type: string - /api/availability/day: - patch: - description: Updates the start and end times for a user's availability. - summary: Updates the user's start and end times - tags: - - Availability - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - message: - type: string - requestBody: - content: - application/json: - schema: - type: object - properties: - startMins: - type: string - endMins: - type: string - bufferMins: - type: string - description: "" - /api/availability/eventtype: - post: - description: Adds a new event type for the user. - summary: Adds a new event type - tags: - - Availability - requestBody: - content: - application/json: - schema: - type: object - properties: - title: - type: string - slug: - type: string - description: - type: string - length: - type: string - hidden: - type: boolean - requiresConfirmation: - type: boolean - locations: - type: array - items: {} - eventName: - type: string - customInputs: - type: array - items: {} - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - eventType: - type: object - properties: - title: - type: string - slug: - type: string - description: - type: string - length: - type: string - hidden: - type: boolean - requiresConfirmation: - type: boolean - locations: - type: array - items: {} - eventName: - type: string - customInputs: - type: array - items: {} - "500": - description: Internal Server Error - patch: - description: Updates an event type for the user. - summary: Updates an event type - tags: - - Availability - requestBody: - content: - application/json: - schema: - type: object - properties: - title: - type: string - slug: - type: string - description: - type: string - length: - type: string - hidden: - type: boolean - requiresConfirmation: - type: boolean - locations: - type: array - items: {} - eventName: - type: string - customInputs: - type: array - items: {} - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - eventType: - type: object - properties: - title: - type: string - slug: - type: string - description: - type: string - length: - type: string - hidden: - type: boolean - requiresConfirmation: - type: boolean - locations: - type: array - items: {} - eventName: - type: string - customInputs: - type: array - items: {} - "500": - description: Internal Server Error - delete: - description: Deletes an event type for the user. - summary: Deletes an event type - tags: - - Availability - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: {} - "500": - description: Internal Server Error "/api/book/event": post: description: Creates a booking in the user's calendar. diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 705eb91746..6a76256174 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -1,7 +1,6 @@ import { BookingStatus } from "@prisma/client"; import { useRouter } from "next/router"; import { useState } from "react"; -import { useMutation } from "react-query"; import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations"; import dayjs from "@calcom/dayjs"; @@ -16,7 +15,6 @@ import { Icon } from "@calcom/ui/Icon"; 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 { extractRecurringDates } from "@lib/parseDate"; @@ -42,39 +40,29 @@ function BookingListItem(booking: BookingItemProps) { const router = useRouter(); const [rejectionReason, setRejectionReason] = useState(""); 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 "Recurring" tab, to support confirming discretionally in the "Recurring" tab. - */ - if (booking.listingStatus === "recurring" && booking.recurringEventId !== null) { - body = Object.assign({}, body, { recurringEventId: booking.recurringEventId }); - } - const res = await fetch("/api/book/confirm", { - method: "PATCH", - body: JSON.stringify(body), - headers: { - "Content-Type": "application/json", - }, - }); - if (!res.ok) { - throw new HttpError({ statusCode: res.status }); - } + const mutation = trpc.useMutation(["viewer.bookings.confirm"], { + onSuccess: () => { setRejectionDialogIsOpen(false); + utils.invalidateQueries("viewer.bookings"); }, - { - async onSettled() { - await utils.invalidateQueries(["viewer.bookings"]); - }, + }); + + const bookingConfirm = async (confirm: boolean) => { + let body = { + bookingId: booking.id, + confirmed: confirm, + reason: rejectionReason, + }; + /** + * Only pass down the recurring event id when we need to confirm the entire series, which happens in + * the "Recurring" tab, to support confirming discretionally in the "Recurring" tab. + */ + if (booking.listingStatus === "recurring" && booking.recurringEventId !== null) { + body = Object.assign({}, body, { recurringEventId: booking.recurringEventId }); } - ); + mutation.mutate(body); + }; + const isUpcoming = new Date(booking.endTime) >= new Date(); const isCancelled = booking.status === BookingStatus.CANCELLED; const isConfirmed = booking.status === BookingStatus.ACCEPTED; @@ -101,7 +89,7 @@ function BookingListItem(booking: BookingItemProps) { ? t("confirm_all") : t("confirm"), onClick: () => { - mutation.mutate(true); + bookingConfirm(true); }, icon: Icon.FiCheck, disabled: mutation.isLoading, @@ -269,7 +257,7 @@ function BookingListItem(booking: BookingItemProps) { diff --git a/apps/web/lib/webhooks/subscriptions.tsx b/apps/web/lib/webhooks/subscriptions.tsx index 0145697838..09acbd1782 100644 --- a/apps/web/lib/webhooks/subscriptions.tsx +++ b/apps/web/lib/webhooks/subscriptions.tsx @@ -8,6 +8,7 @@ export type GetSubscriberOptions = { triggerEvent: WebhookTriggerEvents; }; +/** @deprecated use `packages/lib/webhooks/subscriptions.tsx` */ const getWebhooks = async (options: GetSubscriberOptions) => { const { userId, eventTypeId } = options; const allWebhooks = await prisma.webhook.findMany({ diff --git a/apps/web/pages/api/availability/day.ts b/apps/web/pages/api/availability/day.ts deleted file mode 100644 index c101047ba3..0000000000 --- a/apps/web/pages/api/availability/day.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -import { getSession } from "@lib/auth"; - -import prisma from "../../../lib/prisma"; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const session = await getSession({ req: req }); - - if (!session) { - res.status(401).json({ message: "Not authenticated" }); - return; - } - - if (req.method == "PATCH") { - const startMins = req.body.start; - const endMins = req.body.end; - const bufferMins = req.body.buffer; - - await prisma.user.update({ - where: { - id: session.user.id, - }, - data: { - startTime: startMins, - endTime: endMins, - bufferTime: bufferMins, - }, - }); - - res.status(200).json({ message: "Start and end times updated successfully" }); - } -} diff --git a/apps/web/pages/api/availability/eventtype.ts b/apps/web/pages/api/availability/eventtype.ts deleted file mode 100644 index e5721a5258..0000000000 --- a/apps/web/pages/api/availability/eventtype.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -import { createContext } from "@calcom/trpc/server/createContext"; -import { viewerRouter } from "@calcom/trpc/server/routers/viewer"; - -import { getSession } from "@lib/auth"; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const session = await getSession({ req }); - /** So we can reuse tRCP queries */ - const trpcCtx = await createContext({ req, res }); - - if (!session?.user?.id) { - res.status(401).json({ message: "Not authenticated" }); - return; - } - - if (req.method === "POST") { - const eventType = await viewerRouter.createCaller(trpcCtx).mutation("eventTypes.create", req.body); - res.status(201).json({ eventType }); - } - - if (req.method === "PATCH") { - const eventType = await viewerRouter.createCaller(trpcCtx).mutation("eventTypes.update", req.body); - res.status(201).json({ eventType }); - } - - if (req.method === "DELETE") { - await viewerRouter.createCaller(trpcCtx).mutation("eventTypes.delete", { id: req.body.id }); - res.status(200).json({ id: req.body.id, message: "Event Type deleted" }); - } -} diff --git a/apps/web/pages/api/book/confirm.ts b/apps/web/pages/api/book/confirm.ts deleted file mode 100644 index f87d17165d..0000000000 --- a/apps/web/pages/api/book/confirm.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { - Booking, - BookingStatus, - Prisma, - SchedulingType, - User, - WebhookTriggerEvents, - Workflow, - WorkflowsOnEventTypes, - WorkflowStep, -} from "@prisma/client"; -import type { NextApiRequest } from "next"; -import { z } from "zod"; - -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 { sendDeclinedEmails, sendScheduledEmails } from "@calcom/emails"; -import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; -import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; -import logger from "@calcom/lib/logger"; -import { defaultHandler, defaultResponder } from "@calcom/lib/server"; -import prisma from "@calcom/prisma"; -import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar"; - -import { getSession } from "@lib/auth"; -import { HttpError } from "@lib/core/http/error"; -import getSubscribers from "@lib/webhooks/subscriptions"; - -import { getTranslation } from "@server/lib/i18n"; - -const authorized = async ( - currentUser: Pick, - booking: Pick -) => { - // if the organizer - if (booking.userId === currentUser.id) { - return true; - } - const eventType = await prisma.eventType.findUnique({ - where: { - id: booking.eventTypeId || undefined, - }, - select: { - id: true, - schedulingType: true, - users: true, - }, - }); - if ( - eventType?.schedulingType === SchedulingType.COLLECTIVE && - eventType.users.find((user) => user.id === currentUser.id) - ) { - return true; - } - return false; -}; - -const log = logger.getChildLogger({ prefix: ["[api] book:user"] }); - -const bookingConfirmPatchBodySchema = z.object({ - confirmed: z.boolean(), - id: z.number(), - recurringEventId: z.string().optional(), - reason: z.string().optional(), -}); - -async function patchHandler(req: NextApiRequest) { - const session = await getSession({ req }); - if (!session?.user?.id) { - throw new HttpError({ statusCode: 401, message: "Not authenticated" }); - } - - const { - id: bookingId, - recurringEventId, - reason: rejectionReason, - confirmed, - } = bookingConfirmPatchBodySchema.parse(req.body); - - const currentUser = await prisma.user.findFirst({ - rejectOnNotFound() { - throw new HttpError({ statusCode: 404, message: "User not found" }); - }, - where: { - id: session.user.id, - }, - select: { - id: true, - credentials: { - orderBy: { id: "desc" as Prisma.SortOrder }, - }, - timeZone: true, - email: true, - name: true, - username: true, - destinationCalendar: true, - locale: true, - }, - }); - - const tOrganizer = await getTranslation(currentUser.locale ?? "en", "common"); - - const booking = await prisma.booking.findFirst({ - where: { - id: bookingId, - }, - rejectOnNotFound() { - throw new HttpError({ statusCode: 404, message: "Booking not found" }); - }, - select: { - title: true, - description: true, - customInputs: true, - startTime: true, - endTime: true, - attendees: true, - eventTypeId: true, - eventType: { - select: { - id: true, - recurringEvent: true, - requiresConfirmation: true, - workflows: { - include: { - workflow: { - include: { - steps: true, - }, - }, - }, - }, - }, - }, - location: true, - userId: true, - id: true, - uid: true, - payment: true, - destinationCalendar: true, - paid: true, - recurringEventId: true, - status: true, - smsReminderNumber: true, - scheduledJobs: true, - }, - }); - - if (!(await authorized(currentUser, booking))) { - throw new HttpError({ statusCode: 401, message: "UNAUTHORIZED" }); - } - - const isConfirmed = booking.status === BookingStatus.ACCEPTED; - if (isConfirmed) { - throw new HttpError({ statusCode: 400, message: "booking already confirmed" }); - } - - /** When a booking that requires payment its being confirmed but doesn't have any payment, - * we shouldn’t save it on DestinationCalendars - */ - if (booking.payment.length > 0 && !booking.paid) { - await prisma.booking.update({ - where: { - id: bookingId, - }, - data: { - status: BookingStatus.ACCEPTED, - }, - }); - - req.statusCode = 204; - return { message: "Booking confirmed" }; - } - - const attendeesListPromises = booking.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 evt: CalendarEvent = { - type: booking.title, - title: booking.title, - description: booking.description, - customInputs: isPrismaObjOrUndefined(booking.customInputs), - startTime: booking.startTime.toISOString(), - endTime: booking.endTime.toISOString(), - organizer: { - email: currentUser.email, - name: currentUser.name || "Unnamed", - timeZone: currentUser.timeZone, - language: { translate: tOrganizer, locale: currentUser.locale ?? "en" }, - }, - attendees: attendeesList, - location: booking.location ?? "", - uid: booking.uid, - destinationCalendar: booking?.destinationCalendar || currentUser.destinationCalendar, - requiresConfirmation: booking?.eventType?.requiresConfirmation ?? false, - eventTypeId: booking.eventType?.id, - }; - - const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent); - if (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; - // count changed, parsing again to get the new value in - evt.recurringEvent = parseRecurringEvent(recurringEvent); - } - - if (confirmed) { - const eventManager = new EventManager(currentUser); - const scheduleResult = await eventManager.create(evt); - - const results = scheduleResult.results; - - if (results.length > 0 && results.every((res) => !res.success)) { - const error = { - errorCode: "BookingCreatingMeetingFailed", - message: "Booking failed", - }; - - log.error(`Booking ${currentUser.username} failed`, error, results); - } else { - const metadata: AdditionalInformation = {}; - - if (results.length) { - // TODO: Handle created event metadata more elegantly - metadata.hangoutLink = results[0].createdEvent?.hangoutLink; - metadata.conferenceData = results[0].createdEvent?.conferenceData; - metadata.entryPoints = results[0].createdEvent?.entryPoints; - } - try { - await sendScheduledEmails({ ...evt, additionalInformation: metadata }); - } catch (error) { - log.error(error); - } - } - let updatedBookings: { - scheduledJobs: string[]; - id: number; - startTime: Date; - endTime: Date; - uid: string; - smsReminderNumber: string | null; - eventType: { - workflows: (WorkflowsOnEventTypes & { - workflow: Workflow & { - steps: WorkflowStep[]; - }; - })[]; - } | null; - }[] = []; - - if (recurringEventId) { - // The booking to confirm is a recurring event and comes from /booking/recurring, 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, - status: BookingStatus.PENDING, - }, - }); - - const updateBookingsPromise = unconfirmedRecurringBookings.map((recurringBooking) => { - return prisma.booking.update({ - where: { - id: recurringBooking.id, - }, - data: { - status: BookingStatus.ACCEPTED, - references: { - create: scheduleResult.referencesToCreate, - }, - }, - select: { - eventType: { - select: { - workflows: { - include: { - workflow: { - include: { - steps: true, - }, - }, - }, - }, - }, - }, - uid: true, - startTime: true, - endTime: true, - smsReminderNumber: true, - id: true, - scheduledJobs: true, - }, - }); - }); - const updatedBookingsResult = await Promise.all(updateBookingsPromise); - updatedBookings = updatedBookings.concat(updatedBookingsResult); - } 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 - const updatedBooking = await prisma.booking.update({ - where: { - id: bookingId, - }, - data: { - status: BookingStatus.ACCEPTED, - references: { - create: scheduleResult.referencesToCreate, - }, - }, - select: { - eventType: { - select: { - workflows: { - include: { - workflow: { - include: { - steps: true, - }, - }, - }, - }, - }, - }, - uid: true, - startTime: true, - endTime: true, - smsReminderNumber: true, - id: true, - scheduledJobs: true, - }, - }); - updatedBookings.push(updatedBooking); - } - - //Workflows - set reminders for confirmed events - for (const updatedBooking of updatedBookings) { - if (updatedBooking.eventType?.workflows) { - const evtOfBooking = evt; - evtOfBooking.startTime = updatedBooking.startTime.toISOString(); - evtOfBooking.endTime = updatedBooking.endTime.toISOString(); - evtOfBooking.uid = updatedBooking.uid; - - await scheduleWorkflowReminders( - updatedBooking.eventType.workflows, - updatedBooking.smsReminderNumber, - evtOfBooking, - false - ); - } - } - - // schedule job for zapier trigger 'when meeting ends' - const subscriberOptionsMeetingEnded = { - userId: booking.userId || 0, - eventTypeId: booking.eventTypeId || 0, - triggerEvent: WebhookTriggerEvents.MEETING_ENDED, - }; - - const subscribersMeetingEnded = await getSubscribers(subscriberOptionsMeetingEnded); - - subscribersMeetingEnded.forEach((subscriber) => { - updatedBookings.forEach((booking) => { - scheduleTrigger(booking, subscriber.subscriberUrl, subscriber); - }); - }); - } else { - evt.rejectionReason = rejectionReason; - if (recurringEventId) { - // The booking to reject is a recurring event and comes from /booking/upcoming, proceeding to mark all related - // bookings as rejected. - await prisma.booking.updateMany({ - where: { - recurringEventId, - status: BookingStatus.PENDING, - }, - data: { - status: BookingStatus.REJECTED, - rejectionReason, - }, - }); - } else { - await refund(booking, evt); // No payment integration for recurring events for v1 - await prisma.booking.update({ - where: { - id: bookingId, - }, - data: { - status: BookingStatus.REJECTED, - rejectionReason, - }, - }); - } - - await sendDeclinedEmails(evt); - } - - req.statusCode = 204; - return { message: "Booking " + confirmed ? "confirmed" : "rejected" }; -} - -export type BookConfirmPatchResponse = Awaited>; - -export default defaultHandler({ - // To prevent too much git diff until moved to another file - PATCH: Promise.resolve({ default: defaultResponder(patchHandler) }), -}); diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index 7658396c26..63120a90b3 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -26,9 +26,9 @@ import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defa import { getErrorFromUnknown } from "@calcom/lib/errors"; import isOutOfBounds, { BookingDateInPastError } from "@calcom/lib/isOutOfBounds"; import logger from "@calcom/lib/logger"; -import { getLuckyUser } from "@calcom/lib/server"; -import { defaultResponder } from "@calcom/lib/server"; +import { defaultResponder, getLuckyUser } from "@calcom/lib/server"; import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager"; +import getSubscribers from "@calcom/lib/webhooks/subscriptions"; import prisma, { userSelect } from "@calcom/prisma"; import { extendedBookingCreateBody } from "@calcom/prisma/zod-utils"; import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime"; @@ -38,7 +38,6 @@ import type { EventResult, PartialReference } from "@calcom/types/EventManager"; import { getSession } from "@lib/auth"; import { HttpError } from "@lib/core/http/error"; import sendPayload from "@lib/webhooks/sendPayload"; -import getSubscribers from "@lib/webhooks/subscriptions"; import { getTranslation } from "@server/lib/i18n"; diff --git a/apps/web/pages/api/eventType.ts b/apps/web/pages/api/eventType.ts deleted file mode 100644 index 6b8f8f2e42..0000000000 --- a/apps/web/pages/api/eventType.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Prisma } from "@prisma/client"; -import type { NextApiRequest, NextApiResponse } from "next"; -import { getSession } from "next-auth/react"; - -import prisma from "@calcom/prisma"; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const session = await getSession({ req: req }); - - if (!session?.user?.id) { - res.status(401).json({ message: "Not authenticated" }); - return; - } - - const eventTypeSelect = Prisma.validator()({ - id: true, - title: true, - description: true, - length: true, - schedulingType: true, - slug: true, - hidden: true, - price: true, - currency: true, - metadata: true, - users: { - select: { - id: true, - avatar: true, - name: true, - }, - }, - }); - - const user = await prisma.user.findUnique({ - rejectOnNotFound: true, - where: { - id: session.user.id, - }, - select: { - id: true, - username: true, - name: true, - startTime: true, - endTime: true, - bufferTime: true, - avatar: true, - completedOnboarding: true, - createdDate: true, - plan: true, - teams: { - where: { - accepted: true, - }, - select: { - role: true, - team: { - select: { - id: true, - name: true, - slug: true, - logo: true, - members: { - select: { - userId: true, - }, - }, - eventTypes: { - select: eventTypeSelect, - }, - }, - }, - }, - }, - eventTypes: { - where: { - team: null, - }, - select: eventTypeSelect, - }, - }, - }); - - // backwards compatibility, TMP: - const typesRaw = await prisma.eventType.findMany({ - where: { - userId: session.user.id, - }, - select: eventTypeSelect, - }); - - type EventTypeGroup = { - teamId?: number | null; - profile?: { - slug: typeof user["username"]; - name: typeof user["name"]; - image: typeof user["avatar"]; - }; - metadata: { - membershipCount: number; - readOnly: boolean; - }; - eventTypes: (typeof user.eventTypes[number] & { $disabled?: boolean })[]; - }; - - const eventTypesHashMap = user.eventTypes.concat(typesRaw).reduce((hashMap, newItem) => { - const oldItem = hashMap[newItem.id] || {}; - hashMap[newItem.id] = { ...oldItem, ...newItem }; - return hashMap; - }, {} as Record); - const mergedEventTypes = Object.values(eventTypesHashMap).map((et, index) => ({ - ...et, - $disabled: user?.plan === "FREE" && index > 0, - })); - - return res.status(200).json({ eventTypes: mergedEventTypes }); -} diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 57a30083fa..887aac44d2 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -89,6 +89,13 @@ export const bookingCreateBodySchema = z.object({ export type BookingCreateBody = z.input; +export const bookingConfirmPatchBodySchema = z.object({ + bookingId: z.number(), + confirmed: z.boolean(), + recurringEventId: z.string().optional(), + reason: z.string().optional(), +}); + export const extendedBookingCreateBody = bookingCreateBodySchema.merge( z.object({ noEmail: z.boolean().optional(), diff --git a/packages/trpc/server/routers/viewer/bookings.tsx b/packages/trpc/server/routers/viewer/bookings.tsx index 9ebdcf7c3d..01d0af6298 100644 --- a/packages/trpc/server/routers/viewer/bookings.tsx +++ b/packages/trpc/server/routers/viewer/bookings.tsx @@ -1,13 +1,26 @@ -import { SchedulingType } from "@prisma/client"; +import { + BookingStatus, + Prisma, + SchedulingType, + WebhookTriggerEvents, + Workflow, + WorkflowsOnEventTypes, + WorkflowStep, +} from "@prisma/client"; import { z } from "zod"; 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 dayjs from "@calcom/dayjs"; -import { sendLocationChangeEmails } from "@calcom/emails"; -import { parseRecurringEvent } from "@calcom/lib"; +import { sendDeclinedEmails, sendLocationChangeEmails, sendScheduledEmails } from "@calcom/emails"; +import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; +import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; import logger from "@calcom/lib/logger"; -import { getTranslation } from "@calcom/lib/server/i18n"; +import { getTranslation } from "@calcom/lib/server"; +import getSubscribers from "@calcom/lib/webhooks/subscriptions"; +import { bookingConfirmPatchBodySchema } from "@calcom/prisma/zod-utils"; import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar"; import { TRPCError } from "@trpc/server"; @@ -19,6 +32,8 @@ const commonBookingSchema = z.object({ bookingId: z.number(), }); +const log = logger.getChildLogger({ prefix: ["[api] book:user"] }); + export const bookingsRouter = createProtectedRouter() .middleware(async ({ ctx, rawInput, next }) => { // Endpoints that just read the logged in user's data - like 'list' don't necessary have any input @@ -169,4 +184,344 @@ export const bookingsRouter = createProtectedRouter() } return { message: "Location updated" }; }, + }) + .mutation("confirm", { + input: bookingConfirmPatchBodySchema, + async resolve({ ctx, input }) { + const { user, prisma } = ctx; + const { bookingId, recurringEventId, reason: rejectionReason, confirmed } = input; + + const tOrganizer = await getTranslation(user.locale ?? "en", "common"); + + const booking = await prisma.booking.findFirst({ + where: { + id: bookingId, + }, + rejectOnNotFound() { + throw new TRPCError({ code: "NOT_FOUND", message: "Booking not found" }); + }, + // should trpc handle this error ? + select: { + title: true, + description: true, + customInputs: true, + startTime: true, + endTime: true, + attendees: true, + eventTypeId: true, + eventType: { + select: { + id: true, + recurringEvent: true, + requiresConfirmation: true, + workflows: { + include: { + workflow: { + include: { + steps: true, + }, + }, + }, + }, + }, + }, + location: true, + userId: true, + id: true, + uid: true, + payment: true, + destinationCalendar: true, + paid: true, + recurringEventId: true, + status: true, + smsReminderNumber: true, + scheduledJobs: true, + }, + }); + const authorized = async () => { + // if the organizer + if (booking.userId === user.id) { + return true; + } + const eventType = await prisma.eventType.findUnique({ + where: { + id: booking.eventTypeId || undefined, + }, + select: { + id: true, + schedulingType: true, + users: true, + }, + }); + if ( + eventType?.schedulingType === SchedulingType.COLLECTIVE && + eventType.users.find((user) => user.id === user.id) + ) { + return true; + } + return false; + }; + + if (!(await authorized())) throw new TRPCError({ code: "UNAUTHORIZED", message: "UNAUTHORIZED" }); + + const isConfirmed = booking.status === BookingStatus.ACCEPTED; + if (isConfirmed) throw new TRPCError({ code: "BAD_REQUEST", message: "Booking already confirmed" }); + + /** When a booking that requires payment its being confirmed but doesn't have any payment, + * we shouldn’t save it on DestinationCalendars + */ + if (booking.payment.length > 0 && !booking.paid) { + await prisma.booking.update({ + where: { + id: bookingId, + }, + data: { + status: BookingStatus.ACCEPTED, + }, + }); + + return { message: "Booking confirmed" }; + } + const attendeesListPromises = booking.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 evt: CalendarEvent = { + type: booking.title, + title: booking.title, + description: booking.description, + customInputs: isPrismaObjOrUndefined(booking.customInputs), + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + organizer: { + email: user.email, + name: user.name || "Unnamed", + timeZone: user.timeZone, + language: { translate: tOrganizer, locale: user.locale ?? "en" }, + }, + attendees: attendeesList, + location: booking.location ?? "", + uid: booking.uid, + destinationCalendar: booking?.destinationCalendar || user.destinationCalendar, + requiresConfirmation: booking?.eventType?.requiresConfirmation ?? false, + eventTypeId: booking.eventType?.id, + }; + + const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent); + if (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; + // count changed, parsing again to get the new value in + evt.recurringEvent = parseRecurringEvent(recurringEvent); + } + + if (confirmed) { + const eventManager = new EventManager(user); + const scheduleResult = await eventManager.create(evt); + + const results = scheduleResult.results; + + if (results.length > 0 && results.every((res) => !res.success)) { + const error = { + errorCode: "BookingCreatingMeetingFailed", + message: "Booking failed", + }; + + log.error(`Booking ${user.username} failed`, error, results); + } else { + const metadata: AdditionalInformation = {}; + + if (results.length) { + // TODO: Handle created event metadata more elegantly + metadata.hangoutLink = results[0].createdEvent?.hangoutLink; + metadata.conferenceData = results[0].createdEvent?.conferenceData; + metadata.entryPoints = results[0].createdEvent?.entryPoints; + } + try { + await sendScheduledEmails({ ...evt, additionalInformation: metadata }); + } catch (error) { + log.error(error); + } + } + let updatedBookings: { + scheduledJobs: string[]; + id: number; + startTime: Date; + endTime: Date; + uid: string; + smsReminderNumber: string | null; + eventType: { + workflows: (WorkflowsOnEventTypes & { + workflow: Workflow & { + steps: WorkflowStep[]; + }; + })[]; + } | null; + }[] = []; + + if (recurringEventId) { + // The booking to confirm is a recurring event and comes from /booking/recurring, 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, + status: BookingStatus.PENDING, + }, + }); + + const updateBookingsPromise = unconfirmedRecurringBookings.map((recurringBooking) => { + return prisma.booking.update({ + where: { + id: recurringBooking.id, + }, + data: { + status: BookingStatus.ACCEPTED, + references: { + create: scheduleResult.referencesToCreate, + }, + }, + select: { + eventType: { + select: { + workflows: { + include: { + workflow: { + include: { + steps: true, + }, + }, + }, + }, + }, + }, + uid: true, + startTime: true, + endTime: true, + smsReminderNumber: true, + id: true, + scheduledJobs: true, + }, + }); + }); + const updatedBookingsResult = await Promise.all(updateBookingsPromise); + updatedBookings = updatedBookings.concat(updatedBookingsResult); + } 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 + const updatedBooking = await prisma.booking.update({ + where: { + id: bookingId, + }, + data: { + status: BookingStatus.ACCEPTED, + references: { + create: scheduleResult.referencesToCreate, + }, + }, + select: { + eventType: { + select: { + workflows: { + include: { + workflow: { + include: { + steps: true, + }, + }, + }, + }, + }, + }, + uid: true, + startTime: true, + endTime: true, + smsReminderNumber: true, + id: true, + scheduledJobs: true, + }, + }); + updatedBookings.push(updatedBooking); + } + + //Workflows - set reminders for confirmed events + for (const updatedBooking of updatedBookings) { + if (updatedBooking.eventType?.workflows) { + const evtOfBooking = evt; + evtOfBooking.startTime = updatedBooking.startTime.toISOString(); + evtOfBooking.endTime = updatedBooking.endTime.toISOString(); + evtOfBooking.uid = updatedBooking.uid; + + await scheduleWorkflowReminders( + updatedBooking.eventType.workflows, + updatedBooking.smsReminderNumber, + evtOfBooking, + false + ); + } + } + + // schedule job for zapier trigger 'when meeting ends' + const subscriberOptionsMeetingEnded = { + userId: booking.userId || 0, + eventTypeId: booking.eventTypeId || 0, + triggerEvent: WebhookTriggerEvents.MEETING_ENDED, + }; + + const subscribersMeetingEnded = await getSubscribers(subscriberOptionsMeetingEnded); + + subscribersMeetingEnded.forEach((subscriber) => { + updatedBookings.forEach((booking) => { + scheduleTrigger(booking, subscriber.subscriberUrl, subscriber); + }); + }); + } else { + evt.rejectionReason = rejectionReason; + if (recurringEventId) { + // The booking to reject is a recurring event and comes from /booking/upcoming, proceeding to mark all related + // bookings as rejected. + await prisma.booking.updateMany({ + where: { + recurringEventId, + status: BookingStatus.PENDING, + }, + data: { + status: BookingStatus.REJECTED, + rejectionReason, + }, + }); + } else { + await refund(booking, evt); // No payment integration for recurring events for v1 + await prisma.booking.update({ + where: { + id: bookingId, + }, + data: { + status: BookingStatus.REJECTED, + rejectionReason, + }, + }); + } + + await sendDeclinedEmails(evt); + } + return { message: "Booking " + confirmed ? "confirmed" : "rejected" }; + }, });