diff --git a/lib/validations/shared/queryIdString.ts b/lib/validations/shared/queryIdString.ts index 718e26123f..97bdfa4b15 100644 --- a/lib/validations/shared/queryIdString.ts +++ b/lib/validations/shared/queryIdString.ts @@ -5,6 +5,7 @@ import { baseApiParams } from "./baseApiParams"; // Extracted out as utility function so can be reused // at different endpoints that require this validation. +/** Used for UUID style id queries */ export const schemaQueryIdAsString = baseApiParams .extend({ id: z.string(), diff --git a/lib/validations/webhook.ts b/lib/validations/webhook.ts index f5de69c45a..91d8560195 100644 --- a/lib/validations/webhook.ts +++ b/lib/validations/webhook.ts @@ -1,49 +1,40 @@ import { z } from "zod"; +import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants"; import { _WebhookModel as Webhook } from "@calcom/prisma/zod"; -export const WebhookTriggerEvents = { - BOOKING_CREATED: "BOOKING_CREATED", - BOOKING_RESCHEDULED: "BOOKING_RESCHEDULED", - BOOKING_CANCELLED: "BOOKING_CANCELLED", -}; - -export const WEBHOOK_TRIGGER_EVENTS = [ - WebhookTriggerEvents.BOOKING_CANCELLED, - WebhookTriggerEvents.BOOKING_CREATED, - WebhookTriggerEvents.BOOKING_RESCHEDULED, -] as ["BOOKING_CANCELLED", "BOOKING_CREATED", "BOOKING_RESCHEDULED"]; - const schemaWebhookBaseBodyParams = Webhook.pick({ - id: true, userId: true, eventTypeId: true, eventTriggers: true, active: true, subscriberUrl: true, payloadTemplate: true, -}).partial(); +}); export const schemaWebhookCreateParams = z .object({ - subscriberUrl: z.string().url(), - eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array(), - active: z.boolean(), + // subscriberUrl: z.string().url(), + // eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array(), + // active: z.boolean(), payloadTemplate: z.string().optional().nullable(), eventTypeId: z.number().optional(), - appId: z.string().optional().nullable(), + userId: z.number().optional(), + // API shouldn't mess with Apps webhooks yet (ie. Zapier) + // appId: z.string().optional().nullable(), }) .strict(); export const schemaWebhookCreateBodyParams = schemaWebhookBaseBodyParams.merge(schemaWebhookCreateParams); -export const schemaWebhookEditBodyParams = schemaWebhookBaseBodyParams.merge( - z.object({ - payloadTemplate: z.string().optional(), - eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(), - subscriberUrl: z.string().optional(), - }) -); +export const schemaWebhookEditBodyParams = schemaWebhookBaseBodyParams + .merge( + z.object({ + eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(), + }) + ) + .partial() + .strict(); export const schemaWebhookReadPublic = Webhook.pick({ id: true, @@ -51,8 +42,14 @@ export const schemaWebhookReadPublic = Webhook.pick({ eventTypeId: true, payloadTemplate: true, eventTriggers: true, + // FIXME: We have some invalid urls saved in the DB + // subscriberUrl: true, /** @todo: find out how to properly add back and validate those. */ // eventType: true, // app: true, appId: true, -}); +}).merge( + z.object({ + subscriberUrl: z.string(), + }) +); diff --git a/next.config.js b/next.config.js index 1031c0a949..7cbb49652e 100644 --- a/next.config.js +++ b/next.config.js @@ -30,6 +30,11 @@ module.exports = withAxiom( source: "/api/v:version/:rest*", destination: "/api/:rest*?version=:version", }, + // Keeps backwards compatibility with old webhook URLs + { + source: "/api/hooks/:rest*", + destination: "/api/webhooks/:rest*", + }, ], fallback: [ // These rewrites are checked after both pages/public files diff --git a/pages/api/hooks/[id].ts b/pages/api/hooks/[id].ts deleted file mode 100644 index 4c0e51ab69..0000000000 --- a/pages/api/hooks/[id].ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -import { withMiddleware } from "@lib/helpers/withMiddleware"; -import type { WebhookResponse } from "@lib/types"; -import { schemaQueryIdAsString } from "@lib/validations/shared/queryIdString"; -import { schemaWebhookEditBodyParams, schemaWebhookReadPublic } from "@lib/validations/webhook"; - -export async function WebhookById( - { method, query, body, userId, prisma }: NextApiRequest, - res: NextApiResponse -) { - const safeQuery = schemaQueryIdAsString.safeParse(query); - if (!safeQuery.success) { - res.status(400).json({ message: "Your query was invalid" }); - return; - } - const data = await prisma.webhook.findMany({ where: { userId } }); - const userWebhooks = data.map((webhook) => webhook.id); - if (!userWebhooks.includes(safeQuery.data.id)) res.status(401).json({ message: "Unauthorized" }); - else { - switch (method) { - /** - * @swagger - * /hooks/{id}: - * get: - * summary: Find a webhook - * operationId: getWebhookById - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: Numeric ID of the webhook to get - * security: - * - ApiKeyAuth: [] - * tags: - * - hooks - * externalDocs: - * url: https://docs.cal.com/hooks - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: Webhook was not found - */ - case "GET": - await prisma.webhook - .findUnique({ where: { id: safeQuery.data.id } }) - .then((data) => schemaWebhookReadPublic.parse(data)) - .then((webhook) => res.status(200).json({ webhook })) - .catch((error: Error) => - res.status(404).json({ - message: `Webhook with id: ${safeQuery.data.id} not found`, - error, - }) - ); - break; - /** - * @swagger - * /hooks/{id}: - * patch: - * summary: Edit an existing webhook - * operationId: editWebhookById - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: Numeric ID of the webhook to edit - * security: - * - ApiKeyAuth: [] - * tags: - * - hooks - * externalDocs: - * url: https://docs.cal.com/hooks - * responses: - * 201: - * description: OK, webhook edited successfuly - * 400: - * description: Bad request. Webhook body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ - case "PATCH": - const safeBody = schemaWebhookEditBodyParams.safeParse(body); - if (!safeBody.success) { - { - res.status(400).json({ message: "Invalid request body" }); - 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((webhook) => res.status(200).json({ webhook })) - .catch((error: Error) => - res.status(404).json({ - message: `Webhook with id: ${safeQuery.data.id} not found`, - error, - }) - ); - break; - /** - * @swagger - * /hooks/{id}: - * delete: - * summary: Remove an existing hook - * operationId: removeWebhookById - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: Numeric ID of the hooks to delete - * security: - * - ApiKeyAuth: [] - * tags: - * - hooks - * externalDocs: - * url: https://docs.cal.com/hooks - * responses: - * 201: - * description: OK, hook removed successfuly - * 400: - * description: Bad request. hook id is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ - case "DELETE": - await prisma.webhook - .delete({ where: { id: safeQuery.data.id } }) - .then(() => - res.status(200).json({ - message: `Webhook with id: ${safeQuery.data.id} deleted`, - }) - ) - .catch((error: Error) => - res.status(404).json({ - message: `Webhook with id: ${safeQuery.data.id} not found`, - error, - }) - ); - break; - - default: - res.status(405).json({ message: "Method not allowed" }); - break; - } - } -} - -export default withMiddleware("HTTP_GET_DELETE_PATCH")(WebhookById); diff --git a/pages/api/hooks/index.ts b/pages/api/hooks/index.ts deleted file mode 100644 index 0bdb7470bf..0000000000 --- a/pages/api/hooks/index.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { v4 as uuidv4 } from "uuid"; - -import { withMiddleware } from "@lib/helpers/withMiddleware"; -import { WebhookResponse, WebhooksResponse } from "@lib/types"; -import { schemaWebhookCreateBodyParams } from "@lib/validations/webhook"; - -async function createOrlistAllWebhooks( - { method, body, userId, prisma }: NextApiRequest, - res: NextApiResponse -) { - if (method === "GET") { - /** - * @swagger - * /hooks: - * get: - * summary: Find all webhooks - * operationId: listWebhooks - * tags: - * - hooks - * externalDocs: - * url: https://docs.cal.com/webhooks - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: No webhooks were found - */ - const webhooks = await prisma.webhook - .findMany({ where: { userId } }) - .catch((error) => console.log(error)); - if (!webhooks) { - console.log(); - res.status(404).json({ message: "No webhooks were found" }); - } else res.status(200).json({ webhooks }); - } else if (method === "POST") { - /** - * @swagger - * /hooks: - * post: - * summary: Creates a new webhook - * operationId: addWebhook - * tags: - * - webhooks - * externalDocs: - * url: https://docs.cal.com/webhooks - * responses: - * 201: - * description: OK, webhook created - * 400: - * description: Bad request. webhook body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ - const safe = schemaWebhookCreateBodyParams.safeParse(body); - if (!safe.success) { - 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 - (error: Error) => - res.status(400).json({ - message: "Could not create new webhook", - error, - }); - } else res.status(405).json({ message: `Method ${method} not allowed` }); -} - -export default withMiddleware("HTTP_GET_OR_POST")(createOrlistAllWebhooks); diff --git a/pages/api/webhooks/[id]/_auth-middleware.ts b/pages/api/webhooks/[id]/_auth-middleware.ts new file mode 100644 index 0000000000..ef45e638ed --- /dev/null +++ b/pages/api/webhooks/[id]/_auth-middleware.ts @@ -0,0 +1,19 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; + +import { schemaQueryIdAsString } from "@lib/validations/shared/queryIdString"; + +async function authMiddleware(req: NextApiRequest) { + const { userId, isAdmin, prisma } = req; + const { id } = schemaQueryIdAsString.parse(req.query); + // Admins can just skip this check + if (isAdmin) return; + // Check if the current user can access the webhook + const webhook = await prisma.webhook.findFirst({ + where: { id, appId: null, OR: [{ userId }, { eventType: { team: { members: { some: { userId } } } } }] }, + }); + if (!webhook) throw new HttpError({ statusCode: 403, message: "Forbidden" }); +} + +export default authMiddleware; diff --git a/pages/api/webhooks/[id]/_delete.ts b/pages/api/webhooks/[id]/_delete.ts new file mode 100644 index 0000000000..cb0998bd94 --- /dev/null +++ b/pages/api/webhooks/[id]/_delete.ts @@ -0,0 +1,41 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; + +import { schemaQueryIdAsString } from "@lib/validations/shared/queryIdString"; + +/** + * @swagger + * /webhooks/{id}: + * delete: + * summary: Remove an existing hook + * operationId: removeWebhookById + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: Numeric ID of the hooks to delete + * security: + * - ApiKeyAuth: [] + * tags: + * - hooks + * externalDocs: + * url: https://docs.cal.com/hooks + * responses: + * 201: + * description: OK, hook removed successfully + * 400: + * description: Bad request. hook id is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function deleteHandler(req: NextApiRequest) { + const { prisma, query } = req; + const { id } = schemaQueryIdAsString.parse(query); + await prisma.webhook.delete({ where: { id } }); + return { message: `Schedule with id: ${id} deleted successfully` }; +} + +export default defaultResponder(deleteHandler); diff --git a/pages/api/webhooks/[id]/_get.ts b/pages/api/webhooks/[id]/_get.ts new file mode 100644 index 0000000000..3b9639c2c6 --- /dev/null +++ b/pages/api/webhooks/[id]/_get.ts @@ -0,0 +1,42 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; + +import { schemaQueryIdAsString } from "@lib/validations/shared/queryIdString"; +import { schemaWebhookReadPublic } from "@lib/validations/webhook"; + +/** + * @swagger + * /webhooks/{id}: + * get: + * summary: Find a webhook + * operationId: getWebhookById + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: Numeric ID of the webhook to get + * security: + * - ApiKeyAuth: [] + * tags: + * - hooks + * externalDocs: + * url: https://docs.cal.com/hooks + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Webhook was not found + */ +export async function getHandler(req: NextApiRequest) { + const { prisma, query } = req; + const { id } = schemaQueryIdAsString.parse(query); + const data = await prisma.webhook.findUniqueOrThrow({ where: { id } }); + return { webhook: schemaWebhookReadPublic.parse(data) }; +} + +export default defaultResponder(getHandler); diff --git a/pages/api/webhooks/[id]/_patch.ts b/pages/api/webhooks/[id]/_patch.ts new file mode 100644 index 0000000000..dbcc8c8306 --- /dev/null +++ b/pages/api/webhooks/[id]/_patch.ts @@ -0,0 +1,62 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; + +import { schemaQueryIdAsString } from "@lib/validations/shared/queryIdString"; +import { schemaWebhookEditBodyParams, schemaWebhookReadPublic } from "@lib/validations/webhook"; + +/** + * @swagger + * /webhooks/{id}: + * patch: + * summary: Edit an existing webhook + * operationId: editWebhookById + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: Numeric ID of the webhook to edit + * security: + * - ApiKeyAuth: [] + * tags: + * - hooks + * externalDocs: + * url: https://docs.cal.com/hooks + * responses: + * 201: + * description: OK, webhook edited successfully + * 400: + * description: Bad request. Webhook body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function patchHandler(req: NextApiRequest) { + const { prisma, query, userId, isAdmin } = req; + const { id } = schemaQueryIdAsString.parse(query); + const { eventTypeId, userId: bodyUserId, ...data } = schemaWebhookEditBodyParams.parse(req.body); + const args: Prisma.WebhookUpdateArgs = { where: { id }, data }; + + if (eventTypeId) { + const where: Prisma.EventTypeWhereInput = { id: eventTypeId }; + if (!isAdmin) where.userId = userId; + await prisma.eventType.findFirstOrThrow({ where }); + args.data.eventTypeId = eventTypeId; + } + + if (!isAdmin && bodyUserId) throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` }); + + if (isAdmin && bodyUserId) { + const where: Prisma.UserWhereInput = { id: userId }; + await prisma.user.findFirstOrThrow({ where }); + args.data.userId = userId; + } + + const result = await prisma.webhook.update(args); + return { webhook: schemaWebhookReadPublic.parse(result) }; +} + +export default defaultResponder(patchHandler); diff --git a/pages/api/webhooks/[id]/index.ts b/pages/api/webhooks/[id]/index.ts new file mode 100644 index 0000000000..cbff13f006 --- /dev/null +++ b/pages/api/webhooks/[id]/index.ts @@ -0,0 +1,18 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; + +import { withMiddleware } from "@lib/helpers/withMiddleware"; + +import authMiddleware from "./_auth-middleware"; + +export default withMiddleware("HTTP_GET_DELETE_PATCH")( + defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => { + await authMiddleware(req); + return defaultHandler({ + GET: import("./_get"), + PATCH: import("./_patch"), + DELETE: import("./_delete"), + })(req, res); + }) +); diff --git a/pages/api/webhooks/_get.ts b/pages/api/webhooks/_get.ts new file mode 100644 index 0000000000..fbf4665561 --- /dev/null +++ b/pages/api/webhooks/_get.ts @@ -0,0 +1,47 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; + +import { schemaQuerySingleOrMultipleUserIds } from "@lib/validations/shared/queryUserId"; +import { schemaWebhookReadPublic } from "@lib/validations/webhook"; + +/** + * @swagger + * /webhooks: + * get: + * summary: Find all webhooks + * operationId: listWebhooks + * tags: + * - hooks + * externalDocs: + * url: https://docs.cal.com/webhooks + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: No webhooks were found + */ +async function getHandler(req: NextApiRequest) { + const { userId, isAdmin, prisma } = req; + const args: Prisma.WebhookFindManyArgs = isAdmin + ? {} + : { where: { OR: [{ eventType: { userId } }, { userId }] } }; + + /** Only admins can query other users */ + if (!isAdmin && req.query.userId) throw new HttpError({ statusCode: 403, message: "ADMIN required" }); + if (isAdmin && req.query.userId) { + const query = schemaQuerySingleOrMultipleUserIds.parse(req.query); + const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId]; + args.where = { OR: [{ eventType: { userId: { in: userIds } } }, { userId: { in: userIds } }] }; + if (Array.isArray(query.userId)) args.orderBy = { userId: "asc", eventType: { userId: "asc" } }; + } + + const data = await prisma.webhook.findMany(args); + return { webhooks: data.map((v) => schemaWebhookReadPublic.parse(v)) }; +} + +export default defaultResponder(getHandler); diff --git a/pages/api/webhooks/_post.ts b/pages/api/webhooks/_post.ts new file mode 100644 index 0000000000..dd7f97dd87 --- /dev/null +++ b/pages/api/webhooks/_post.ts @@ -0,0 +1,59 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; +import { v4 as uuidv4 } from "uuid"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; + +import { schemaWebhookCreateBodyParams, schemaWebhookReadPublic } from "@lib/validations/webhook"; + +/** + * @swagger + * /hooks: + * post: + * summary: Creates a new webhook + * operationId: addWebhook + * tags: + * - webhooks + * externalDocs: + * url: https://docs.cal.com/webhooks + * responses: + * 201: + * description: OK, webhook created + * 400: + * description: Bad request. webhook body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +async function postHandler(req: NextApiRequest) { + const { userId, isAdmin, prisma } = req; + const { eventTypeId, userId: bodyUserId, ...body } = schemaWebhookCreateBodyParams.parse(req.body); + const args: Prisma.WebhookCreateArgs = { data: { id: uuidv4(), ...body } }; + + // If no event type, we assume is for the current user. If admin we run more checks below... + if (!eventTypeId) args.data.userId = userId; + + if (eventTypeId) { + const where: Prisma.EventTypeWhereInput = { id: eventTypeId }; + if (!isAdmin) where.userId = userId; + await prisma.eventType.findFirstOrThrow({ where }); + args.data.eventTypeId = eventTypeId; + } + + if (!isAdmin && bodyUserId) throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` }); + + if (isAdmin && bodyUserId) { + const where: Prisma.UserWhereInput = { id: userId }; + await prisma.user.findFirstOrThrow({ where }); + args.data.userId = userId; + } + + const data = await prisma.webhook.create(args); + + return { + webhook: schemaWebhookReadPublic.parse(data), + message: "Webhook created successfully", + }; +} + +export default defaultResponder(postHandler); diff --git a/pages/api/webhooks/index.ts b/pages/api/webhooks/index.ts new file mode 100644 index 0000000000..c07846423f --- /dev/null +++ b/pages/api/webhooks/index.ts @@ -0,0 +1,10 @@ +import { defaultHandler } from "@calcom/lib/server"; + +import { withMiddleware } from "@lib/helpers/withMiddleware"; + +export default withMiddleware("HTTP_GET_OR_POST")( + defaultHandler({ + GET: import("./_get"), + POST: import("./_post"), + }) +);