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..f40c0ad743 --- /dev/null +++ b/lib/utils/sendPayload.ts @@ -0,0 +1,77 @@ +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. + } + 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/pages/api/bookings/index.ts b/pages/api/bookings/index.ts index 40f24283ee..90f9cf7f22 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 { getTranslation } from "@calcom/lib/server/i18n"; 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, @@ -81,8 +86,64 @@ async function createOrlistAllBookings( const data = await prisma.booking.create({ data: { ...safe.data } }); const booking = schemaBookingReadPublic.parse(data); - if (booking) res.status(201).json({ booking, message: "Booking created successfully" }); - else + if (booking) { + res.status(201).json({ booking, message: "Booking created successfully" }); + // Create Calendar Event for webhook payload + const eventType = await prisma.eventType + .findUnique({ where: { id: booking.eventTypeId as number } }) + .then((data) => schemaEventTypeReadPublic.parse(data)) + .catch((error: Error) => + res.status(404).json({ + message: `EventType with id: ${booking.eventTypeId} not found`, + error, + }) + ); + const fallbackTfunction = await getTranslation("en", "common"); + 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: { + translate: fallbackTfunction, + locale: "en", + }, + }, + attendees: [], + location: "", + destinationCalendar: null, + hideCalendar: false, + uid: booking.uid, + metadata: {}, + }; + + // Send Webhook call if hooked to BOOKING_CREATED + const triggerEvent = WebhookTriggerEvents.BOOKING_CREATED; + const subscriberOptions = { + userId, + eventTypeId: booking.eventTypeId as number, + triggerEvent, + }; + + const subscribers = await getWebhooks(subscriberOptions); + 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); + } else (error: Error) => { console.log(error); res.status(400).json({