From b5849ca30fde97a986cb697220b47712c24834e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20L=C3=B3pez?= Date: Fri, 24 Feb 2023 20:57:49 -0700 Subject: [PATCH] Zomars/cal 884 paid events not sending the link (#7318) * WIP * Sends correct emails for paid bookings * Update PaymentService.ts * Update webhook.ts * Update webhook.ts --- packages/core/EventManager.ts | 8 +- .../bookings/lib/handleConfirmation.ts | 241 ++++++++++++++++++ packages/features/ee/payments/api/webhook.ts | 41 +-- .../trpc/server/routers/viewer/bookings.tsx | 240 +---------------- 4 files changed, 277 insertions(+), 253 deletions(-) create mode 100644 packages/features/bookings/lib/handleConfirmation.ts diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index 97f95b201b..74ede6f7a0 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -1,7 +1,7 @@ -import { DestinationCalendar } from "@prisma/client"; +import type { DestinationCalendar } from "@prisma/client"; import merge from "lodash/merge"; import { v5 as uuidv5 } from "uuid"; -import { z } from "zod"; +import type { z } from "zod"; import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter"; import { getEventLocationTypeFromApp } from "@calcom/app-store/locations"; @@ -10,7 +10,7 @@ import getApps from "@calcom/app-store/utils"; import prisma from "@calcom/prisma"; import { createdEventSchema } from "@calcom/prisma/zod-utils"; import type { AdditionalInformation, CalendarEvent, NewCalendarEventType } from "@calcom/types/Calendar"; -import { CredentialPayload, CredentialWithAppName } from "@calcom/types/Credential"; +import type { CredentialPayload, CredentialWithAppName } from "@calcom/types/Credential"; import type { Event } from "@calcom/types/Event"; import type { CreateUpdateResult, @@ -59,7 +59,7 @@ export const processLocation = (event: CalendarEvent): CalendarEvent => { return event; }; -type EventManagerUser = { +export type EventManagerUser = { credentials: CredentialPayload[]; destinationCalendar: DestinationCalendar | null; }; diff --git a/packages/features/bookings/lib/handleConfirmation.ts b/packages/features/bookings/lib/handleConfirmation.ts new file mode 100644 index 0000000000..7816484393 --- /dev/null +++ b/packages/features/bookings/lib/handleConfirmation.ts @@ -0,0 +1,241 @@ +import type { PrismaClient, Workflow, WorkflowsOnEventTypes, WorkflowStep } from "@prisma/client"; +import { BookingStatus, WebhookTriggerEvents } from "@prisma/client"; + +import { scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler"; +import type { EventManagerUser } from "@calcom/core/EventManager"; +import EventManager from "@calcom/core/EventManager"; +import { sendScheduledEmails } from "@calcom/emails"; +import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; +import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; +import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload"; +import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; +import logger from "@calcom/lib/logger"; +import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar"; + +const log = logger.getChildLogger({ prefix: ["[handleConfirmation] book:user"] }); + +export async function handleConfirmation(args: { + user: EventManagerUser & { username: string | null }; + evt: CalendarEvent; + recurringEventId?: string; + prisma: PrismaClient; + bookingId: number; + booking: { + eventType: { + currency: string; + description: string | null; + id: number; + length: number; + price: number; + requiresConfirmation: boolean; + title: string; + } | null; + eventTypeId: number | null; + smsReminderNumber: string | null; + userId: number | null; + }; + paid?: boolean; +}) { + const { user, evt, recurringEventId, prisma, bookingId, booking, paid } = args; + 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) => + prisma.booking.update({ + where: { + id: recurringBooking.id, + }, + data: { + status: BookingStatus.ACCEPTED, + references: { + create: scheduleResult.referencesToCreate, + }, + paid, + }, + 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 + try { + for (let index = 0; index < updatedBookings.length; index++) { + if (updatedBookings[index].eventType?.workflows) { + const evtOfBooking = evt; + evtOfBooking.startTime = updatedBookings[index].startTime.toISOString(); + evtOfBooking.endTime = updatedBookings[index].endTime.toISOString(); + evtOfBooking.uid = updatedBookings[index].uid; + + const isFirstBooking = index === 0; + + await scheduleWorkflowReminders( + updatedBookings[index]?.eventType?.workflows || [], + updatedBookings[index].smsReminderNumber, + evtOfBooking, + false, + false, + isFirstBooking + ); + } + } + } catch (error) { + // Silently fail + console.error(error); + } + + try { + // schedule job for zapier trigger 'when meeting ends' + const subscribersBookingCreated = await getWebhooks({ + userId: booking.userId || 0, + eventTypeId: booking.eventTypeId || 0, + triggerEvent: WebhookTriggerEvents.BOOKING_CREATED, + }); + const subscribersMeetingEnded = await getWebhooks({ + userId: booking.userId || 0, + eventTypeId: booking.eventTypeId || 0, + triggerEvent: WebhookTriggerEvents.MEETING_ENDED, + }); + + subscribersMeetingEnded.forEach((subscriber) => { + updatedBookings.forEach((booking) => { + scheduleTrigger(booking, subscriber.subscriberUrl, subscriber); + }); + }); + + const eventTypeInfo: EventTypeInfo = { + eventTitle: booking.eventType?.title, + eventDescription: booking.eventType?.description, + requiresConfirmation: booking.eventType?.requiresConfirmation || null, + price: booking.eventType?.price, + currency: booking.eventType?.currency, + length: booking.eventType?.length, + }; + + const promises = subscribersBookingCreated.map((sub) => + sendPayload(sub.secret, WebhookTriggerEvents.BOOKING_CREATED, new Date().toISOString(), sub, { + ...evt, + ...eventTypeInfo, + bookingId, + eventTypeId: booking.eventType?.id, + status: "ACCEPTED", + smsReminderNumber: booking.smsReminderNumber || undefined, + }).catch((e) => { + console.error( + `Error executing webhook for event: ${WebhookTriggerEvents.BOOKING_CREATED}, URL: ${sub.subscriberUrl}`, + e + ); + }) + ); + await Promise.all(promises); + } catch (error) { + // Silently fail + console.error(error); + } +} diff --git a/packages/features/ee/payments/api/webhook.ts b/packages/features/ee/payments/api/webhook.ts index f8bbecb4e2..386b7bb883 100644 --- a/packages/features/ee/payments/api/webhook.ts +++ b/packages/features/ee/payments/api/webhook.ts @@ -1,11 +1,13 @@ -import { BookingStatus, Prisma } from "@prisma/client"; +import { BookingStatus } from "@prisma/client"; +import type { Prisma } from "@prisma/client"; import { buffer } from "micro"; import type { NextApiRequest, NextApiResponse } from "next"; -import Stripe from "stripe"; +import type Stripe from "stripe"; import stripe from "@calcom/app-store/stripepayment/lib/server"; import EventManager from "@calcom/core/EventManager"; import { sendScheduledEmails } from "@calcom/emails"; +import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; import { IS_PRODUCTION } from "@calcom/lib/constants"; import { getErrorFromUnknown } from "@calcom/lib/errors"; @@ -20,6 +22,18 @@ export const config = { }, }; +async function getEventType(id: number) { + return prisma.eventType.findUnique({ + where: { + id, + }, + select: { + recurringEvent: true, + requiresConfirmation: true, + }, + }); +} + async function handlePaymentSuccess(event: Stripe.Event) { const paymentIntent = event.data.object as Stripe.PaymentIntent; const payment = await prisma.payment.findFirst({ @@ -42,6 +56,8 @@ async function handlePaymentSuccess(event: Stripe.Event) { }, select: { ...bookingMinimalSelect, + eventType: true, + smsReminderNumber: true, location: true, eventTypeId: true, userId: true, @@ -52,6 +68,7 @@ async function handlePaymentSuccess(event: Stripe.Event) { user: { select: { id: true, + username: true, credentials: true, timeZone: true, email: true, @@ -65,20 +82,10 @@ async function handlePaymentSuccess(event: Stripe.Event) { if (!booking) throw new HttpCode({ statusCode: 204, message: "No booking found" }); - const eventTypeSelect = Prisma.validator()({ - recurringEvent: true, - requiresConfirmation: true, - }); - const eventTypeData = Prisma.validator()({ select: eventTypeSelect }); - type EventTypeRaw = Prisma.EventTypeGetPayload; + type EventTypeRaw = Awaited>; let eventTypeRaw: EventTypeRaw | null = null; if (booking.eventTypeId) { - eventTypeRaw = await prisma.eventType.findUnique({ - where: { - id: booking.eventTypeId, - }, - select: eventTypeSelect, - }); + eventTypeRaw = await getEventType(booking.eventTypeId); } const { user } = booking; @@ -155,7 +162,11 @@ async function handlePaymentSuccess(event: Stripe.Event) { await prisma.$transaction([paymentUpdate, bookingUpdate]); - await sendScheduledEmails({ ...evt }); + if (!isConfirmed && !eventTypeRaw?.requiresConfirmation) { + await handleConfirmation({ user, evt, prisma, bookingId: booking.id, booking, paid: true }); + } else { + await sendScheduledEmails({ ...evt }); + } throw new HttpCode({ statusCode: 200, diff --git a/packages/trpc/server/routers/viewer/bookings.tsx b/packages/trpc/server/routers/viewer/bookings.tsx index 0d58bbbf81..48387f7eb7 100644 --- a/packages/trpc/server/routers/viewer/bookings.tsx +++ b/packages/trpc/server/routers/viewer/bookings.tsx @@ -1,26 +1,11 @@ -import type { - BookingReference, - EventType, - User, - Workflow, - WorkflowsOnEventTypes, - WorkflowStep, -} from "@prisma/client"; -import { - BookingStatus, - MembershipRole, - Prisma, - SchedulingType, - WebhookTriggerEvents, - WorkflowMethods, -} from "@prisma/client"; +import type { BookingReference, EventType, User, WebhookTriggerEvents } from "@prisma/client"; +import { BookingStatus, MembershipRole, Prisma, SchedulingType, WorkflowMethods } from "@prisma/client"; import type { TFunction } from "next-i18next"; import { z } from "zod"; import appStore from "@calcom/app-store"; import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { DailyLocationType } from "@calcom/app-store/locations"; -import { scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler"; import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler"; import EventManager from "@calcom/core/EventManager"; import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder"; @@ -29,15 +14,9 @@ import { deleteMeeting } from "@calcom/core/videoClient"; import dayjs from "@calcom/dayjs"; import { deleteScheduledEmailReminder } from "@calcom/ee/workflows/lib/reminders/emailReminderManager"; import { deleteScheduledSMSReminder } from "@calcom/ee/workflows/lib/reminders/smsReminderManager"; -import { - sendDeclinedEmails, - sendLocationChangeEmails, - sendRequestRescheduleEmail, - sendScheduledEmails, -} from "@calcom/emails"; -import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; +import { sendDeclinedEmails, sendLocationChangeEmails, sendRequestRescheduleEmail } from "@calcom/emails"; +import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; -import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload"; import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; import logger from "@calcom/lib/logger"; @@ -48,7 +27,7 @@ import type { AdditionalInformation, CalendarEvent, Person } from "@calcom/types import { TRPCError } from "@trpc/server"; -import { router, authedProcedure } from "../../trpc"; +import { authedProcedure, router } from "../../trpc"; export type PersonAttendeeCommonFields = Pick< User, @@ -60,8 +39,6 @@ const commonBookingSchema = z.object({ bookingId: z.number(), }); -const log = logger.getChildLogger({ prefix: ["[api] book:user"] }); - const bookingsProcedure = authedProcedure.input(commonBookingSchema).use(async ({ ctx, input, next }) => { // Endpoints that just read the logged in user's data - like 'list' don't necessary have any input const { bookingId } = input; @@ -875,212 +852,7 @@ export const bookingsRouter = router({ } 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 - try { - for (let index = 0; index < updatedBookings.length; index++) { - if (updatedBookings[index].eventType?.workflows) { - const evtOfBooking = evt; - evtOfBooking.startTime = updatedBookings[index].startTime.toISOString(); - evtOfBooking.endTime = updatedBookings[index].endTime.toISOString(); - evtOfBooking.uid = updatedBookings[index].uid; - - const isFirstBooking = index === 0; - - await scheduleWorkflowReminders( - updatedBookings[index]?.eventType?.workflows || [], - updatedBookings[index].smsReminderNumber, - evtOfBooking, - false, - false, - isFirstBooking - ); - } - } - } catch (error) { - // Silently fail - console.error(error); - } - - try { - // schedule job for zapier trigger 'when meeting ends' - const subscriberOptionsMeetingEnded = { - userId: booking.userId || 0, - eventTypeId: booking.eventTypeId || 0, - triggerEvent: WebhookTriggerEvents.MEETING_ENDED, - }; - - const subscriberOptionsBookingCreated = { - userId: booking.userId || 0, - eventTypeId: booking.eventTypeId || 0, - triggerEvent: WebhookTriggerEvents.BOOKING_CREATED, - }; - - const subscribersBookingCreated = await getWebhooks(subscriberOptionsBookingCreated); - - const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded); - - subscribersMeetingEnded.forEach((subscriber) => { - updatedBookings.forEach((booking) => { - scheduleTrigger(booking, subscriber.subscriberUrl, subscriber); - }); - }); - - const eventTypeInfo: EventTypeInfo = { - eventTitle: booking.eventType?.title, - eventDescription: booking.eventType?.description, - requiresConfirmation: booking.eventType?.requiresConfirmation || null, - price: booking.eventType?.price, - currency: booking.eventType?.currency, - length: booking.eventType?.length, - }; - - const promises = subscribersBookingCreated.map((sub) => - sendPayload(sub.secret, WebhookTriggerEvents.BOOKING_CREATED, new Date().toISOString(), sub, { - ...evt, - ...eventTypeInfo, - bookingId, - eventTypeId: booking.eventType?.id, - status: "ACCEPTED", - smsReminderNumber: booking.smsReminderNumber || undefined, - }).catch((e) => { - console.error( - `Error executing webhook for event: ${WebhookTriggerEvents.BOOKING_CREATED}, URL: ${sub.subscriberUrl}`, - e - ); - }) - ); - await Promise.all(promises); - } catch (error) { - // Silently fail - console.error(error); - } + await handleConfirmation({ user, evt, recurringEventId, prisma, bookingId, booking }); } else { evt.rejectionReason = rejectionReason; if (recurringEventId) {