diff --git a/lib/utils/eventName.ts b/lib/utils/eventName.ts new file mode 100644 index 0000000000..5d7865fe94 --- /dev/null +++ b/lib/utils/eventName.ts @@ -0,0 +1,62 @@ +import { TFunction } from "next-i18next"; + +type EventNameObjectType = { + attendeeName: string; + eventType: string; + eventName?: string | null; + host: string; + location?: string; + t: TFunction; +}; + +export function getEventName(eventNameObj: EventNameObjectType, forAttendeeView = false) { + if (!eventNameObj.eventName) + return eventNameObj.t("event_between_users", { + eventName: eventNameObj.eventType, + host: eventNameObj.host, + attendeeName: eventNameObj.attendeeName, + }); + + let eventName = eventNameObj.eventName; + let locationString = ""; + + if (eventNameObj.eventName.includes("{LOCATION}")) { + switch (eventNameObj.location) { + case "inPerson": + locationString = "In Person"; + break; + case "userPhone": + case "phone": + locationString = "Phone"; + break; + case "integrations:daily": + locationString = "Cal Video"; + break; + case "integrations:zoom": + locationString = "Zoom"; + break; + case "integrations:huddle01": + locationString = "Huddle01"; + break; + case "integrations:tandem": + locationString = "Tandem"; + break; + case "integrations:office365_video": + locationString = "MS Teams"; + break; + case "integrations:jitsi": + locationString = "Jitsi"; + break; + } + eventName = eventName.replace("{LOCATION}", locationString); + } + + return ( + eventName + // Need this for compatibility with older event names + .replace("{USER}", eventNameObj.attendeeName) + .replace("{ATTENDEE}", eventNameObj.attendeeName) + .replace("{HOST}", eventNameObj.host) + .replace("{HOST/ATTENDEE}", forAttendeeView ? eventNameObj.host : eventNameObj.attendeeName) + ); +} diff --git a/lib/utils/sendPayload.ts b/lib/utils/sendPayload.ts new file mode 100644 index 0000000000..a6234a1d37 --- /dev/null +++ b/lib/utils/sendPayload.ts @@ -0,0 +1,78 @@ +import { Webhook } from "@prisma/client"; +import { compile } from "handlebars"; + +// import type { CalendarEvent } from "@calcom/types/Calendar"; Add this to make it strict, change data: any to CalendarEvent type + +type ContentType = "application/json" | "application/x-www-form-urlencoded"; + +function applyTemplate(template: string, data: any, contentType: ContentType) { + const compiled = compile(template)(data); + if (contentType === "application/json") { + return JSON.stringify(jsonParse(compiled)); + } + return compiled; +} + +function jsonParse(jsonString: string) { + try { + return JSON.parse(jsonString); + } catch (e) { + // don't do anything. + console.error(`error jsonParsing in sendPayload`); + } + return false; +} + +const sendPayload = async ( + triggerEvent: string, + createdAt: string, + webhook: Pick, + data: any & { + metadata?: { [key: string]: string }; + rescheduleUid?: string; + bookingId?: number; + } +) => { + const { subscriberUrl, appId, payloadTemplate: template } = webhook; + if (!subscriberUrl || !data) { + throw new Error("Missing required elements to send webhook payload."); + } + + const contentType = + !template || jsonParse(template) ? "application/json" : "application/x-www-form-urlencoded"; + + data.description = data.description || data.additionalNotes; + + let body; + + /* Zapier id is hardcoded in the DB, we send the raw data for this case */ + if (appId === "zapier") { + body = JSON.stringify(data); + } else if (template) { + body = applyTemplate(template, data, contentType); + } else { + body = JSON.stringify({ + triggerEvent: triggerEvent, + createdAt: createdAt, + payload: data, + }); + } + + const response = await fetch(subscriberUrl, { + method: "POST", + headers: { + "Content-Type": contentType, + }, + body, + }); + + const text = await response.text(); + + return { + ok: response.ok, + status: response.status, + message: text, + }; +}; + +export default sendPayload; diff --git a/lib/utils/webhookSubscriptions.ts b/lib/utils/webhookSubscriptions.ts new file mode 100644 index 0000000000..bdf9d95576 --- /dev/null +++ b/lib/utils/webhookSubscriptions.ts @@ -0,0 +1,42 @@ +import { WebhookTriggerEvents } from "@prisma/client"; + +import prisma from "@calcom/prisma"; + +export type GetSubscriberOptions = { + userId: number; + eventTypeId: number; + triggerEvent: WebhookTriggerEvents; +}; + +const getWebhooks = async (options: GetSubscriberOptions) => { + const { userId, eventTypeId } = options; + const allWebhooks = await prisma.webhook.findMany({ + where: { + OR: [ + { + userId, + }, + { + eventTypeId, + }, + ], + AND: { + eventTriggers: { + has: options.triggerEvent, + }, + active: { + equals: true, + }, + }, + }, + select: { + subscriberUrl: true, + payloadTemplate: true, + appId: true, + }, + }); + + return allWebhooks; +}; + +export default getWebhooks; diff --git a/lib/validations/booking.ts b/lib/validations/booking.ts index 67b4f1b35a..b61d6913fd 100644 --- a/lib/validations/booking.ts +++ b/lib/validations/booking.ts @@ -13,7 +13,6 @@ const schemaBookingBaseBodyParams = Booking.pick({ const schemaBookingCreateParams = z .object({ - uid: z.string(), eventTypeId: z.number(), title: z.string(), startTime: z.date().or(z.string()), diff --git a/lib/validations/user.ts b/lib/validations/user.ts index 528bc2e292..2385224f5a 100644 --- a/lib/validations/user.ts +++ b/lib/validations/user.ts @@ -93,7 +93,7 @@ const schemaUserEditParams = z.object({ .optional() .nullable(), locale: z.nativeEnum(locales).optional().nullable(), - metadata: jsonSchema, + metadata: jsonSchema.optional(), }); // @note: These are the values that are editable via PATCH method on the user Model, diff --git a/pages/api/bookings/index.ts b/pages/api/bookings/index.ts index 40f24283ee..ab85b339cd 100644 --- a/pages/api/bookings/index.ts +++ b/pages/api/bookings/index.ts @@ -1,10 +1,15 @@ +import { WebhookTriggerEvents } from "@prisma/client"; import type { NextApiRequest, NextApiResponse } from "next"; +import { v4 as uuidv4 } from "uuid"; import prisma from "@calcom/prisma"; import { withMiddleware } from "@lib/helpers/withMiddleware"; import { BookingResponse, BookingsResponse } from "@lib/types"; +import sendPayload from "@lib/utils/sendPayload"; +import getWebhooks from "@lib/utils/webhookSubscriptions"; import { schemaBookingCreateBodyParams, schemaBookingReadPublic } from "@lib/validations/booking"; +import { schemaEventTypeReadPublic } from "@lib/validations/event-type"; async function createOrlistAllBookings( { method, body, userId }: NextApiRequest, @@ -30,6 +35,7 @@ async function createOrlistAllBookings( */ const data = await prisma.booking.findMany({ where: { userId } }); const bookings = data.map((booking) => schemaBookingReadPublic.parse(booking)); + console.log(`Bookings requested by ${userId}`); if (bookings) res.status(200).json({ bookings }); else (error: Error) => @@ -78,11 +84,67 @@ async function createOrlistAllBookings( return; } safe.data.userId = userId; - const data = await prisma.booking.create({ data: { ...safe.data } }); + const data = await prisma.booking.create({ data: { uid: uuidv4(), ...safe.data } }); const booking = schemaBookingReadPublic.parse(data); - if (booking) res.status(201).json({ booking, message: "Booking created successfully" }); - else + if (booking) { + const eventType = await prisma.eventType + .findUnique({ where: { id: booking.eventTypeId as number } }) + .then((data) => schemaEventTypeReadPublic.parse(data)) + .catch((e: Error) => { + console.error(`Event type with ID: ${booking.eventTypeId} not found`, e); + }); + console.log(`eventType: ${eventType}`); + const evt = { + type: eventType?.title || booking.title, + title: booking.title, + description: "", + additionalNotes: "", + customInputs: {}, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + organizer: { + name: "", + email: "", + timeZone: "", + language: { + locale: "en", + }, + }, + attendees: [], + location: "", + destinationCalendar: null, + hideCalendar: false, + uid: booking.uid, + metadata: {}, + }; + console.log(`evt: ${evt}`); + + // Send Webhook call if hooked to BOOKING_CREATED + const triggerEvent = WebhookTriggerEvents.BOOKING_CREATED; + console.log(`Trigger Event: ${triggerEvent}`); + const subscriberOptions = { + userId, + eventTypeId: booking.eventTypeId as number, + triggerEvent, + }; + console.log(`subscriberOptions: ${subscriberOptions}`); + + const subscribers = await getWebhooks(subscriberOptions); + console.log(`subscribers: ${subscribers}`); + const bookingId = booking?.id; + const promises = subscribers.map((sub) => + sendPayload(triggerEvent, new Date().toISOString(), sub, { + ...evt, + bookingId, + }).catch((e) => { + console.error(`Error executing webhook for event: ${triggerEvent}, URL: ${sub.subscriberUrl}`, e); + }) + ); + await Promise.all(promises); + console.log("All promises resolved! About to send the response"); + res.status(201).json({ booking, message: "Booking created successfully" }); + } else (error: Error) => { console.log(error); res.status(400).json({ diff --git a/pages/api/hooks/[id].ts b/pages/api/hooks/[id].ts index 32a81e7b12..8f81fc62d1 100644 --- a/pages/api/hooks/[id].ts +++ b/pages/api/hooks/[id].ts @@ -95,6 +95,26 @@ export async function WebhookById( return; } } + if (safeBody.data.eventTypeId) { + const team = await prisma.team.findFirst({ + where: { + eventTypes: { + some: { + id: safeBody.data.eventTypeId, + }, + }, + }, + include: { + members: true, + }, + }); + + // Team should be available and the user should be a member of the team + if (!team?.members.some((membership) => membership.userId === userId)) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + } await prisma.webhook .update({ where: { id: safeQuery.data.id }, data: safeBody.data }) .then((data) => schemaWebhookReadPublic.parse(data)) diff --git a/pages/api/hooks/index.ts b/pages/api/hooks/index.ts index 422a30e419..ec85ab13df 100644 --- a/pages/api/hooks/index.ts +++ b/pages/api/hooks/index.ts @@ -61,6 +61,26 @@ async function createOrlistAllWebhooks( res.status(400).json({ message: "Invalid request body" }); return; } + if (safe.data.eventTypeId) { + const team = await prisma.team.findFirst({ + where: { + eventTypes: { + some: { + id: safe.data.eventTypeId, + }, + }, + }, + include: { + members: true, + }, + }); + + // Team should be available and the user should be a member of the team + if (!team?.members.some((membership) => membership.userId === userId)) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + } const data = await prisma.webhook.create({ data: { id: uuidv4(), ...safe.data, userId } }); if (data) res.status(201).json({ webhook: data, message: "Webhook created successfully" }); else