diff --git a/apps/api/lib/utils/extractUserIdsFromQuery.ts b/apps/api/lib/utils/extractUserIdsFromQuery.ts new file mode 100644 index 0000000000..2cb69377c1 --- /dev/null +++ b/apps/api/lib/utils/extractUserIdsFromQuery.ts @@ -0,0 +1,14 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; + +import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId"; + +export function extractUserIdsFromQuery({ isAdmin, query }: NextApiRequest) { + /** Guard: Only admins can query other users */ + if (!isAdmin) { + throw new HttpError({ statusCode: 401, message: "ADMIN required" }); + } + const { userId: userIdOrUserIds } = schemaQuerySingleOrMultipleUserIds.parse(query); + return Array.isArray(userIdOrUserIds) ? userIdOrUserIds : [userIdOrUserIds]; +} diff --git a/apps/api/lib/validations/destination-calendar.ts b/apps/api/lib/validations/destination-calendar.ts index 7f90cd0002..15d1d8672c 100644 --- a/apps/api/lib/validations/destination-calendar.ts +++ b/apps/api/lib/validations/destination-calendar.ts @@ -14,9 +14,9 @@ const schemaDestinationCalendarCreateParams = z .object({ integration: z.string(), externalId: z.string(), - eventTypeId: z.number(), - bookingId: z.number(), - userId: z.number(), + eventTypeId: z.number().optional(), + bookingId: z.number().optional(), + userId: z.number().optional(), }) .strict(); diff --git a/apps/api/pages/api/destination-calendars/[id].ts b/apps/api/pages/api/destination-calendars/[id].ts deleted file mode 100644 index 1f521c40d7..0000000000 --- a/apps/api/pages/api/destination-calendars/[id].ts +++ /dev/null @@ -1,240 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -import { withMiddleware } from "~/lib/helpers/withMiddleware"; -import type { DestinationCalendarResponse } from "~/lib/types"; -import { - schemaDestinationCalendarEditBodyParams, - schemaDestinationCalendarReadPublic, -} from "~/lib/validations/destination-calendar"; -import { - schemaQueryIdParseInt, - withValidQueryIdTransformParseInt, -} from "~/lib/validations/shared/queryIdTransformParseInt"; - -export async function destionationCalendarById( - { method, query, body, userId, prisma }: NextApiRequest, - res: NextApiResponse -) { - const safeQuery = schemaQueryIdParseInt.safeParse(query); - const safeBody = schemaDestinationCalendarEditBodyParams.safeParse(body); - if (!safeQuery.success) { - res.status(400).json({ message: "Your query was invalid" }); - return; - } - const data = await prisma.destinationCalendar.findMany({ where: { userId } }); - const userDestinationCalendars = data.map((destinationCalendar) => destinationCalendar.id); - // FIXME: Should we also check ownership of bokingId and eventTypeId to avoid users cross-pollinating other users calendars. - // On a related note, moving from sequential integer IDs to UUIDs would be a good idea. and maybe help avoid having this problem. - if (userDestinationCalendars.includes(safeQuery.data.id)) res.status(401).json({ message: "Unauthorized" }); - else { - switch (method) { - /** - * @swagger - * /destination-calendars/{id}: - * get: - * summary: Find a destination calendar - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the destination calendar to get - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - destination-calendars - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: DestinationCalendar was not found - * patch: - * summary: Edit an existing destination calendar - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the destination calendar to edit - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * requestBody: - * description: Create a new booking related to one of your event-types - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * integration: - * type: string - * description: 'The integration' - * externalId: - * type: string - * description: 'The external ID of the integration' - * eventTypeId: - * type: integer - * description: 'The ID of the eventType it is associated with' - * bookingId: - * type: integer - * description: 'The booking ID it is associated with' - * tags: - * - destination-calendars - * responses: - * 201: - * description: OK, destinationCalendar edited successfuly - * 400: - * description: Bad request. DestinationCalendar body is invalid. - * 401: - * description: Authorization information is missing or invalid. - * delete: - * summary: Remove an existing destination calendar - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the destination calendar to delete - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - destination-calendars - * responses: - * 201: - * description: OK, destinationCalendar removed successfuly - * 400: - * description: Bad request. DestinationCalendar id is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ - case "GET": - await prisma.destinationCalendar - .findUnique({ where: { id: safeQuery.data.id } }) - .then((data) => schemaDestinationCalendarReadPublic.parse(data)) - .then((destination_calendar) => res.status(200).json({ destination_calendar })) - .catch((error: Error) => - res.status(404).json({ - message: `DestinationCalendar with id: ${safeQuery.data.id} not found`, - error, - }) - ); - break; - /** - * @swagger - * /destination-calendars/{id}: - * patch: - * summary: Edit an existing destination calendar - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the destination calendar to edit - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - destination-calendars - * responses: - * 201: - * description: OK, destinationCalendar edited successfuly - * 400: - * description: Bad request. DestinationCalendar body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ - case "PATCH": - if (!safeBody.success) { - { - res.status(400).json({ message: "Invalid request body" }); - return; - } - } - await prisma.destinationCalendar - .update({ where: { id: safeQuery.data.id }, data: safeBody.data }) - .then((data) => schemaDestinationCalendarReadPublic.parse(data)) - .then((destination_calendar) => res.status(200).json({ destination_calendar })) - .catch((error: Error) => - res.status(404).json({ - message: `DestinationCalendar with id: ${safeQuery.data.id} not found`, - error, - }) - ); - break; - /** - * @swagger - * /destination-calendars/{id}: - * delete: - * summary: Remove an existing destination calendar - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the destination calendar to delete - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - destination-calendars - * responses: - * 201: - * description: OK, destinationCalendar removed successfuly - * 400: - * description: Bad request. DestinationCalendar id is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ - case "DELETE": - await prisma.destinationCalendar - .delete({ - where: { id: safeQuery.data.id }, - }) - .then(() => - res.status(200).json({ - message: `DestinationCalendar with id: ${safeQuery.data.id} deleted`, - }) - ) - .catch((error: Error) => - res.status(404).json({ - message: `DestinationCalendar 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")( - withValidQueryIdTransformParseInt(destionationCalendarById) -); diff --git a/apps/api/pages/api/destination-calendars/[id]/_auth-middleware.ts b/apps/api/pages/api/destination-calendars/[id]/_auth-middleware.ts new file mode 100644 index 0000000000..03ab72c797 --- /dev/null +++ b/apps/api/pages/api/destination-calendars/[id]/_auth-middleware.ts @@ -0,0 +1,32 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; + +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +async function authMiddleware(req: NextApiRequest) { + const { userId, isAdmin, prisma } = req; + const { id } = schemaQueryIdParseInt.parse(req.query); + if (isAdmin) return; + const userEventTypes = await prisma.eventType.findMany({ + where: { userId }, + select: { id: true }, + }); + + const userEventTypeIds = userEventTypes.map((eventType) => eventType.id); + + const destinationCalendar = await prisma.destinationCalendar.findFirst({ + where: { + AND: [ + { id }, + { + OR: [{ userId }, { eventTypeId: { in: userEventTypeIds } }], + }, + ], + }, + }); + if (!destinationCalendar) + throw new HttpError({ statusCode: 404, message: "Destination calendar not found" }); +} + +export default authMiddleware; diff --git a/apps/api/pages/api/destination-calendars/[id]/_delete.ts b/apps/api/pages/api/destination-calendars/[id]/_delete.ts new file mode 100644 index 0000000000..05ba70551a --- /dev/null +++ b/apps/api/pages/api/destination-calendars/[id]/_delete.ts @@ -0,0 +1,42 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; + +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /destination-calendars/{id}: + * delete: + * summary: Remove an existing destination calendar + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the destination calendar to delete + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - destination-calendars + * responses: + * 200: + * description: OK, destinationCalendar removed successfully + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Destination calendar not found + */ +export async function deleteHandler(req: NextApiRequest) { + const { prisma, query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + await prisma.destinationCalendar.delete({ where: { id } }); + return { message: `OK, Destination Calendar removed successfully` }; +} + +export default defaultResponder(deleteHandler); diff --git a/apps/api/pages/api/destination-calendars/[id]/_get.ts b/apps/api/pages/api/destination-calendars/[id]/_get.ts new file mode 100644 index 0000000000..febb3fc59f --- /dev/null +++ b/apps/api/pages/api/destination-calendars/[id]/_get.ts @@ -0,0 +1,47 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; + +import { schemaDestinationCalendarReadPublic } from "~/lib/validations/destination-calendar"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /destination-calendars/{id}: + * get: + * summary: Find a destination calendar + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the destination calendar to get + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - destination-calendars + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Destination calendar not found + */ +export async function getHandler(req: NextApiRequest) { + const { prisma, query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + + const destinationCalendar = await prisma.destinationCalendar.findUnique({ + where: { id }, + }); + + return { destinationCalendar: schemaDestinationCalendarReadPublic.parse({ ...destinationCalendar }) }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/destination-calendars/[id]/_patch.ts b/apps/api/pages/api/destination-calendars/[id]/_patch.ts new file mode 100644 index 0000000000..7b5735f22c --- /dev/null +++ b/apps/api/pages/api/destination-calendars/[id]/_patch.ts @@ -0,0 +1,71 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; + +import { + schemaDestinationCalendarEditBodyParams, + schemaDestinationCalendarReadPublic, +} from "~/lib/validations/destination-calendar"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /destination-calendars/{id}: + * patch: + * summary: Edit an existing destination calendar + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the destination calendar to edit + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * requestBody: + * description: Create a new booking related to one of your event-types + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * integration: + * type: string + * description: 'The integration' + * externalId: + * type: string + * description: 'The external ID of the integration' + * eventTypeId: + * type: integer + * description: 'The ID of the eventType it is associated with' + * bookingId: + * type: integer + * description: 'The booking ID it is associated with' + * tags: + * - destination-calendars + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Destination calendar not found + */ +export async function patchHandler(req: NextApiRequest) { + const { prisma, query, body } = req; + const { id } = schemaQueryIdParseInt.parse(query); + const parsedBody = schemaDestinationCalendarEditBodyParams.parse(body); + + const destinationCalendar = await prisma.destinationCalendar.update({ + where: { id }, + data: parsedBody, + }); + return { destinationCalendar: schemaDestinationCalendarReadPublic.parse(destinationCalendar) }; +} + +export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/destination-calendars/[id]/index.ts b/apps/api/pages/api/destination-calendars/[id]/index.ts new file mode 100644 index 0000000000..727ad02843 --- /dev/null +++ b/apps/api/pages/api/destination-calendars/[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()( + 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/apps/api/pages/api/destination-calendars/_get.ts b/apps/api/pages/api/destination-calendars/_get.ts new file mode 100644 index 0000000000..f78a8cd8ab --- /dev/null +++ b/apps/api/pages/api/destination-calendars/_get.ts @@ -0,0 +1,58 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; + +import { extractUserIdsFromQuery } from "~/lib/utils/extractUserIdsFromQuery"; +import { schemaDestinationCalendarReadPublic } from "~/lib/validations/destination-calendar"; + +/** + * @swagger + * /destination-calendars: + * get: + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * summary: Find all destination calendars + * tags: + * - destination-calendars + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: No destination calendars were found + */ +async function getHandler(req: NextApiRequest) { + const { userId, prisma } = req; + const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId]; + + const userEventTypes = await prisma.eventType.findMany({ + where: { userId: { in: userIds } }, + select: { id: true }, + }); + + const userEventTypeIds = userEventTypes.map((eventType) => eventType.id); + + const allDestinationCalendars = await prisma.destinationCalendar.findMany({ + where: { + OR: [{ userId: { in: userIds } }, { eventTypeId: { in: userEventTypeIds } }], + }, + }); + + if (allDestinationCalendars.length === 0) + new HttpError({ statusCode: 404, message: "No destination calendars were found" }); + + return { + destinationCalendars: allDestinationCalendars.map((destinationCalendar) => + schemaDestinationCalendarReadPublic.parse(destinationCalendar) + ), + }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/destination-calendars/_post.ts b/apps/api/pages/api/destination-calendars/_post.ts new file mode 100644 index 0000000000..a6160fc6bc --- /dev/null +++ b/apps/api/pages/api/destination-calendars/_post.ts @@ -0,0 +1,99 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; + +import { + schemaDestinationCalendarReadPublic, + schemaDestinationCalendarCreateBodyParams, +} from "~/lib/validations/destination-calendar"; + +/** + * @swagger + * /destination-calendars: + * post: + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * summary: Creates a new destination calendar + * requestBody: + * description: Create a new destination calendar for your events + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - integration + * - externalId + * properties: + * integration: + * type: string + * description: 'The integration' + * externalId: + * type: string + * description: 'The external ID of the integration' + * eventTypeId: + * type: integer + * description: 'The ID of the eventType it is associated with' + * bookingId: + * type: integer + * description: 'The booking ID it is associated with' + * tags: + * - destination-calendars + * responses: + * 201: + * description: OK, destination calendar created + * 400: + * description: Bad request. DestinationCalendar body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +async function postHandler(req: NextApiRequest) { + const { userId, isAdmin, prisma, body } = req; + + const parsedBody = schemaDestinationCalendarCreateBodyParams.parse(body); + + await checkPermissions(req, userId); + + if (!parsedBody.eventTypeId) { + parsedBody.userId = userId; + } + + if (isAdmin) { + parsedBody.userId = parsedBody.userId || userId; + } + + const destination_calendar = await prisma.destinationCalendar.create({ data: { ...parsedBody } }); + + return { + destinationCalendar: schemaDestinationCalendarReadPublic.parse(destination_calendar), + message: "Destination calendar created successfully", + }; +} + +async function checkPermissions(req: NextApiRequest, userId: number) { + const { isAdmin } = req; + const body = schemaDestinationCalendarCreateBodyParams.parse(req.body); + + /* Non-admin users can only create destination calendars for themselves */ + if (!isAdmin && body.userId) + throw new HttpError({ + statusCode: 401, + message: "ADMIN required for `userId`", + }); + /* Admin users are required to pass in a userId */ + if (isAdmin && !body.userId) throw new HttpError({ statusCode: 400, message: "`userId` required" }); + /* User should only be able to create for their own destination calendars*/ + if (!isAdmin && body.eventTypeId) { + const ownsEventType = await req.prisma.eventType.findFirst({ where: { id: body.eventTypeId, userId } }); + if (!ownsEventType) throw new HttpError({ statusCode: 401, message: "Unauthorized" }); + } + // TODO:: Add support for team event types with validation +} + +export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/destination-calendars/index.ts b/apps/api/pages/api/destination-calendars/index.ts index c1330d49fa..2a15abfa5b 100644 --- a/apps/api/pages/api/destination-calendars/index.ts +++ b/apps/api/pages/api/destination-calendars/index.ts @@ -1,114 +1,10 @@ -import type { NextApiRequest, NextApiResponse } from "next"; +import { defaultHandler } from "@calcom/lib/server"; import { withMiddleware } from "~/lib/helpers/withMiddleware"; -import type { DestinationCalendarResponse, DestinationCalendarsResponse } from "~/lib/types"; -import { - schemaDestinationCalendarCreateBodyParams, - schemaDestinationCalendarReadPublic, -} from "~/lib/validations/destination-calendar"; -async function createOrlistAllDestinationCalendars( - { method, body, userId, prisma }: NextApiRequest, - res: NextApiResponse -) { - if (method === "GET") { - /** - * @swagger - * /destination-calendars: - * get: - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * summary: Find all destination calendars - * tags: - * - destination-calendars - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: No destination calendars were found - */ - const data = await prisma.destinationCalendar.findMany({ where: { userId } }); - const destination_calendars = data.map((destinationCalendar) => - schemaDestinationCalendarReadPublic.parse(destinationCalendar) - ); - if (data) res.status(200).json({ destination_calendars }); - else - (error: Error) => - res.status(404).json({ - message: "No DestinationCalendars were found", - error, - }); - } else if (method === "POST") { - /** - * @swagger - * /destination-calendars: - * post: - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * summary: Creates a new destination calendar - * requestBody: - * description: Create a new destination calendar for your events - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - integration - * - externalId - * properties: - * integration: - * type: string - * description: 'The integration' - * externalId: - * type: string - * description: 'The external ID of the integration' - * eventTypeId: - * type: integer - * description: 'The ID of the eventType it is associated with' - * bookingId: - * type: integer - * description: 'The booking ID it is associated with' - * tags: - * - destination-calendars - * responses: - * 201: - * description: OK, destination calendar created - * 400: - * description: Bad request. DestinationCalendar body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ - const safe = schemaDestinationCalendarCreateBodyParams.safeParse(body); - if (!safe.success) { - res.status(400).json({ message: "Invalid request body" }); - return; - } - - const data = await prisma.destinationCalendar.create({ data: { ...safe.data, userId } }); - const destination_calendar = schemaDestinationCalendarReadPublic.parse(data); - - if (destination_calendar) - res.status(201).json({ destination_calendar, message: "DestinationCalendar created successfully" }); - else - (error: Error) => - res.status(400).json({ - message: "Could not create new destinationCalendar", - error, - }); - } else res.status(405).json({ message: `Method ${method} not allowed` }); -} - -export default withMiddleware("HTTP_GET_OR_POST")(createOrlistAllDestinationCalendars); +export default withMiddleware()( + defaultHandler({ + GET: import("./_get"), + POST: import("./_post"), + }) +);