diff --git a/lib/helpers/verifyApiKey.ts b/lib/helpers/verifyApiKey.ts index 38fa6b44c3..c45f3d3c3f 100644 --- a/lib/helpers/verifyApiKey.ts +++ b/lib/helpers/verifyApiKey.ts @@ -11,7 +11,6 @@ declare module "next" { userId: number; method: string; query: { [key: string]: string | string[] }; - body: any; } } 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/isAdmin.ts b/lib/utils/isAdmin.ts new file mode 100644 index 0000000000..64af37367f --- /dev/null +++ b/lib/utils/isAdmin.ts @@ -0,0 +1,8 @@ +import { UserPermissionRole } from "@prisma/client"; + +import prisma from "@calcom/prisma"; + +export const isAdminGuard = async (userId: number) => { + const user = await prisma.user.findUnique({ where: { id: userId } }); + return user?.role === UserPermissionRole.ADMIN; +}; 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/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 d78d8e23b2..528bc2e292 100644 --- a/lib/validations/user.ts +++ b/lib/validations/user.ts @@ -85,19 +85,45 @@ const schemaUserEditParams = z.object({ bufferTime: z.number().min(0).max(86400).optional(), startTime: z.number().min(0).max(86400).optional(), endTime: z.number().min(0).max(86400).optional(), - theme: z.nativeEnum(theme).optional(), + theme: z.nativeEnum(theme).optional().nullable(), timeFormat: z.nativeEnum(timeFormat).optional(), defaultScheduleId: z .number() .refine((id: number) => id > 0) - .optional(), - locale: z.nativeEnum(locales).optional(), + .optional() + .nullable(), + locale: z.nativeEnum(locales).optional().nullable(), metadata: jsonSchema, }); +// @note: These are the values that are editable via PATCH method on the user Model, +// merging both BaseBodyParams with RequiredParams, and omiting whatever we want at the end. + +const schemaUserCreateParams = z.object({ + email: z.string().email(), + weekStart: z.nativeEnum(weekdays).optional(), + brandColor: z.string().min(4).max(9).regex(/^#/).optional(), + darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(), + timeZone: timeZone.optional(), + bufferTime: z.number().min(0).max(86400).optional(), + startTime: z.number().min(0).max(86400).optional(), + endTime: z.number().min(0).max(86400).optional(), + theme: z.nativeEnum(theme).optional().nullable(), + timeFormat: z.nativeEnum(timeFormat).optional(), + defaultScheduleId: z + .number() + .refine((id: number) => id > 0) + .optional() + .nullable(), + locale: z.nativeEnum(locales).optional(), + metadata: jsonSchema, + createdDate: z.string().or(z.date()).optional(), +}); + // @note: These are the values that are editable via PATCH method on the user Model, // merging both BaseBodyParams with RequiredParams, and omiting whatever we want at the end. export const schemaUserEditBodyParams = schemaUserBaseBodyParams.merge(schemaUserEditParams).omit({}); +export const schemaUserCreateBodyParams = schemaUserBaseBodyParams.merge(schemaUserCreateParams).omit({}); // @note: These are the values that are always returned when reading a user export const schemaUserReadPublic = User.pick({ @@ -124,4 +150,4 @@ export const schemaUserReadPublic = User.pick({ createdDate: true, verified: true, invitedTo: true, -}); +}).merge(schemaUserEditBodyParams); diff --git a/lib/validations/webhook.ts b/lib/validations/webhook.ts index ee42052204..f5de69c45a 100644 --- a/lib/validations/webhook.ts +++ b/lib/validations/webhook.ts @@ -26,7 +26,6 @@ const schemaWebhookBaseBodyParams = Webhook.pick({ export const schemaWebhookCreateParams = z .object({ - id: z.string(), subscriberUrl: z.string().url(), eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array(), active: z.boolean(), @@ -52,6 +51,7 @@ export const schemaWebhookReadPublic = Webhook.pick({ eventTypeId: true, payloadTemplate: true, eventTriggers: true, + /** @todo: find out how to properly add back and validate those. */ // eventType: true, // app: true, appId: true, diff --git a/package.json b/package.json index 16c51d9d6b..57da05e5e6 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "next-validations": "^0.2.0", "typescript": "^4.6.4", "tzdata": "^1.0.30", + "uuid": "^8.3.2", "zod": "^3.16.0" } } diff --git a/pages/api/bookings/index.ts b/pages/api/bookings/index.ts index 40f24283ee..af20e851fe 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, @@ -78,11 +83,62 @@ 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) { + 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((e: Error) => { + console.error(`Event type with ID: ${booking.eventTypeId} not found`, e); + }); + 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: {}, + }; + + // 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({ diff --git a/pages/api/hooks/[id].ts b/pages/api/hooks/[id].ts index dcf82aaa2d..8f81fc62d1 100644 --- a/pages/api/hooks/[id].ts +++ b/pages/api/hooks/[id].ts @@ -51,7 +51,7 @@ export async function WebhookById( case "GET": await prisma.webhook .findUnique({ where: { id: safeQuery.data.id } }) - // .then((data) => schemaWebhookReadPublic.parse(data)) + .then((data) => schemaWebhookReadPublic.parse(data)) .then((webhook) => res.status(200).json({ webhook })) .catch((error: Error) => res.status(404).json({ @@ -95,9 +95,29 @@ 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)) + .then((data) => schemaWebhookReadPublic.parse(data)) .then((webhook) => res.status(200).json({ webhook })) .catch((error: Error) => res.status(404).json({ diff --git a/pages/api/hooks/index.ts b/pages/api/hooks/index.ts index 05b6e90d57..ec85ab13df 100644 --- a/pages/api/hooks/index.ts +++ b/pages/api/hooks/index.ts @@ -1,4 +1,5 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import { v4 as uuidv4 } from "uuid"; import prisma from "@calcom/prisma"; @@ -60,7 +61,27 @@ async function createOrlistAllWebhooks( res.status(400).json({ message: "Invalid request body" }); return; } - const data = await prisma.webhook.create({ data: { ...safe.data, userId } }); + 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 (error: Error) => diff --git a/pages/api/users/[id].ts b/pages/api/users/[id].ts index 7539d513b8..65b864c652 100644 --- a/pages/api/users/[id].ts +++ b/pages/api/users/[id].ts @@ -4,6 +4,7 @@ import prisma from "@calcom/prisma"; import { withMiddleware } from "@lib/helpers/withMiddleware"; import type { UserResponse } from "@lib/types"; +import { isAdminGuard } from "@lib/utils/isAdmin"; import { schemaQueryIdParseInt, withValidQueryIdTransformParseInt, @@ -20,8 +21,11 @@ export async function userById( res.status(400).json({ message: "Your query was invalid" }); return; } - if (safeQuery.data.id !== userId) res.status(401).json({ message: "Unauthorized" }); - else { + const isAdmin = await isAdminGuard(userId); + // Here we only check for ownership of the user if the user is not admin, otherwise we let ADMIN's edit any user + if (!isAdmin) { + if (safeQuery.data.id !== userId) res.status(401).json({ message: "Unauthorized" }); + } else { switch (method) { case "GET": /** diff --git a/pages/api/users/index.ts b/pages/api/users/index.ts index 9f80a421d2..3839ae21b6 100644 --- a/pages/api/users/index.ts +++ b/pages/api/users/index.ts @@ -4,7 +4,8 @@ import prisma from "@calcom/prisma"; import { withMiddleware } from "@lib/helpers/withMiddleware"; import { UserResponse, UsersResponse } from "@lib/types"; -import { schemaUserReadPublic } from "@lib/validations/user"; +import { isAdminGuard } from "@lib/utils/isAdmin"; +import { schemaUserReadPublic, schemaUserCreateBodyParams } from "@lib/validations/user"; /** * @swagger @@ -26,34 +27,34 @@ async function getAllorCreateUser( { userId, method, body }: NextApiRequest, res: NextApiResponse ) { + const isAdmin = await isAdminGuard(userId); if (method === "GET") { - const data = await prisma.user.findMany({ - where: { - id: userId, - }, - }); - const users = data.map((user) => schemaUserReadPublic.parse(user)); - if (users) res.status(200).json({ users }); - else - (error: Error) => - res.status(404).json({ - message: "No Users were found", - error, - }); + if (!isAdmin) { + // If user is not ADMIN, return only his data. + const data = await prisma.user.findMany({ where: { id: userId } }); + const users = data.map((user) => schemaUserReadPublic.parse(user)); + if (users) res.status(200).json({ users }); + } else { + // If user is admin, return all users. + const data = await prisma.user.findMany({}); + const users = data.map((user) => schemaUserReadPublic.parse(user)); + if (users) res.status(200).json({ users }); + } + } else if (method === "POST") { + // If user is not ADMIN, return unauthorized. + if (!isAdmin) res.status(401).json({ message: "You are not authorized" }); + else { + const safeBody = schemaUserCreateBodyParams.safeParse(body); + if (!safeBody.success) { + res.status(400).json({ message: "Your body was invalid" }); + return; + } + const user = await prisma.user.create({ + data: safeBody.data, + }); + res.status(201).json({ user }); + } } - // else if (method === "POST") { - // const isAdmin = await prisma.user - // .findUnique({ where: { id: userId } }) - // .then((user) => user?.role === "ADMIN"); - // if (!isAdmin) res.status(401).json({ message: "You are not authorized" }); - // else { - // const user = await prisma.user.create({ - // data: schemaUserReadPublic.parse(body), - // }); - // res.status(201).json({ user }); - // } - // } } -// No POST endpoint for users for now as a regular user you're expected to signup. export default withMiddleware("HTTP_GET_OR_POST")(getAllorCreateUser);