diff --git a/apps/api/lib/validations/destination-calendar.ts b/apps/api/lib/validations/destination-calendar.ts index 371ae5ad51..15d1d8672c 100644 --- a/apps/api/lib/validations/destination-calendar.ts +++ b/apps/api/lib/validations/destination-calendar.ts @@ -3,7 +3,6 @@ import { z } from "zod"; import { _DestinationCalendarModel as DestinationCalendar } from "@calcom/prisma/zod"; export const schemaDestinationCalendarBaseBodyParams = DestinationCalendar.pick({ - credentialId: true, integration: true, externalId: true, eventTypeId: true, @@ -15,7 +14,6 @@ const schemaDestinationCalendarCreateParams = z .object({ integration: z.string(), externalId: z.string(), - credentialId: z.number(), eventTypeId: z.number().optional(), bookingId: z.number().optional(), userId: z.number().optional(), @@ -47,5 +45,4 @@ export const schemaDestinationCalendarReadPublic = DestinationCalendar.pick({ eventTypeId: true, bookingId: true, userId: true, - credentialId: true, }); diff --git a/apps/api/pages/api/destination-calendars/[id]/_patch.ts b/apps/api/pages/api/destination-calendars/[id]/_patch.ts index 7b5735f22c..0ea5b23598 100644 --- a/apps/api/pages/api/destination-calendars/[id]/_patch.ts +++ b/apps/api/pages/api/destination-calendars/[id]/_patch.ts @@ -1,6 +1,12 @@ +import type { Prisma } from "@prisma/client"; import type { NextApiRequest } from "next"; +import type { z } from "zod"; +import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; +import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import type { PrismaClient } from "@calcom/prisma"; +import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { schemaDestinationCalendarEditBodyParams, @@ -56,16 +62,251 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform * 404: * description: Destination calendar not found */ +type DestinationCalendarType = { + userId?: number | null; + eventTypeId?: number | null; + credentialId: number | null; +}; + +type UserCredentialType = { + id: number; + appId: string | null; + type: string; + userId: number | null; + user: { + email: string; + } | null; + teamId: number | null; + key: Prisma.JsonValue; + invalid: boolean | null; +}; + export async function patchHandler(req: NextApiRequest) { - const { prisma, query, body } = req; + const { userId, isAdmin, prisma, query, body } = req; const { id } = schemaQueryIdParseInt.parse(query); const parsedBody = schemaDestinationCalendarEditBodyParams.parse(body); + const assignedUserId = isAdmin ? parsedBody.userId || userId : userId; + validateIntegrationInput(parsedBody); + const destinationCalendarObject: DestinationCalendarType = await getDestinationCalendar(id, prisma); + await validateRequestAndOwnership({ destinationCalendarObject, parsedBody, assignedUserId, prisma }); + + const userCredentials = await getUserCredentials({ + credentialId: destinationCalendarObject.credentialId, + userId: assignedUserId, + prisma, + }); + const credentialId = await verifyCredentialsAndGetId({ + parsedBody, + userCredentials, + currentCredentialId: destinationCalendarObject.credentialId, + }); + // If the user has passed eventTypeId, we need to remove userId from the update data to make sure we don't link it to user as well + if (parsedBody.eventTypeId) parsedBody.userId = undefined; const destinationCalendar = await prisma.destinationCalendar.update({ where: { id }, - data: parsedBody, + data: { ...parsedBody, credentialId }, }); return { destinationCalendar: schemaDestinationCalendarReadPublic.parse(destinationCalendar) }; } +/** + * Retrieves user credentials associated with a given credential ID and user ID and validates if the credentials belong to this user + * + * @param credentialId - The ID of the credential to fetch. If not provided, an error is thrown. + * @param userId - The user ID against which the credentials need to be verified. + * @param prisma - An instance of PrismaClient for database operations. + * + * @returns - An array containing the matching user credentials. + * + * @throws HttpError - If `credentialId` is not provided or no associated credentials are found in the database. + */ +async function getUserCredentials({ + credentialId, + userId, + prisma, +}: { + credentialId: number | null; + userId: number; + prisma: PrismaClient; +}) { + if (!credentialId) { + throw new HttpError({ + statusCode: 404, + message: `Destination calendar missing credential id`, + }); + } + const userCredentials = await prisma.credential.findMany({ + where: { id: credentialId, userId }, + select: credentialForCalendarServiceSelect, + }); + + if (!userCredentials || userCredentials.length === 0) { + throw new HttpError({ + statusCode: 400, + message: `Bad request, no associated credentials found`, + }); + } + return userCredentials; +} + +/** + * Verifies the provided credentials and retrieves the associated credential ID. + * + * This function checks if the `integration` and `externalId` properties from the parsed body are present. + * If both properties exist, it fetches the connected calendar credentials using the provided user credentials + * and checks for a matching external ID and integration from the list of connected calendars. + * + * If a match is found, it updates the `credentialId` with the one from the connected calendar. + * Otherwise, it throws an HTTP error with a 400 status indicating an invalid credential ID. + * + * If the parsed body does not contain the necessary properties, the function + * returns the `credentialId` from the destination calendar object. + * + * @param parsedBody - The parsed body from the incoming request, validated against a predefined schema. + * Checked if it contain properties like `integration` and `externalId`. + * @param userCredentials - An array of user credentials used to fetch the connected calendar credentials. + * @param destinationCalendarObject - An object representing the destination calendar. Primarily used + * to fetch the default `credentialId`. + * + * @returns - The verified `credentialId` either from the matched connected calendar in case of updating the destination calendar, + * or the provided destination calendar object in other cases. + * + * @throws HttpError - If no matching connected calendar is found for the given `integration` and `externalId`. + */ +async function verifyCredentialsAndGetId({ + parsedBody, + userCredentials, + currentCredentialId, +}: { + parsedBody: z.infer; + userCredentials: UserCredentialType[]; + currentCredentialId: number | null; +}) { + if (parsedBody.integration && parsedBody.externalId) { + const calendarCredentials = getCalendarCredentials(userCredentials); + + const { connectedCalendars } = await getConnectedCalendars( + calendarCredentials, + [], + parsedBody.externalId + ); + const eligibleCalendars = connectedCalendars[0]?.calendars?.filter((calendar) => !calendar.readOnly); + const calendar = eligibleCalendars?.find( + (c) => c.externalId === parsedBody.externalId && c.integration === parsedBody.integration + ); + + if (!calendar?.credentialId) + throw new HttpError({ + statusCode: 400, + message: "Bad request, credential id invalid", + }); + return calendar?.credentialId; + } + return currentCredentialId; +} + +/** + * Validates the request for updating a destination calendar. + * + * This function checks the validity of the provided eventTypeId against the existing destination calendar object + * in the sense that if the destination calendar is not linked to an event type, the eventTypeId can not be provided. + * + * It also ensures that the eventTypeId, if provided, belongs to the assigned user. + * + * @param destinationCalendarObject - An object representing the destination calendar. + * @param parsedBody - The parsed body from the incoming request, validated against a predefined schema. + * @param assignedUserId - The user ID assigned for the operation, which might be an admin or a regular user. + * @param prisma - An instance of PrismaClient for database operations. + * + * @throws HttpError - If the validation fails or inconsistencies are detected in the request data. + */ +async function validateRequestAndOwnership({ + destinationCalendarObject, + parsedBody, + assignedUserId, + prisma, +}: { + destinationCalendarObject: DestinationCalendarType; + parsedBody: z.infer; + assignedUserId: number; + prisma: PrismaClient; +}) { + if (parsedBody.eventTypeId) { + if (!destinationCalendarObject.eventTypeId) { + throw new HttpError({ + statusCode: 400, + message: `The provided destination calendar can not be linked to an event type`, + }); + } + + const userEventType = await prisma.eventType.findFirst({ + where: { id: parsedBody.eventTypeId }, + select: { userId: true }, + }); + + if (!userEventType || userEventType.userId !== assignedUserId) { + throw new HttpError({ + statusCode: 404, + message: `Event type with ID ${parsedBody.eventTypeId} not found`, + }); + } + } + + if (!parsedBody.eventTypeId) { + if (destinationCalendarObject.eventTypeId) { + throw new HttpError({ + statusCode: 400, + message: `The provided destination calendar can only be linked to an event type`, + }); + } + if (destinationCalendarObject.userId !== assignedUserId) { + throw new HttpError({ + statusCode: 403, + message: `Forbidden`, + }); + } + } +} + +/** + * Fetches the destination calendar based on the provided ID as the path parameter, specifically `credentialId` and `eventTypeId`. + * + * If no matching destination calendar is found for the provided ID, an HTTP error with a 404 status + * indicating that the desired destination calendar was not found is thrown. + * + * @param id - The ID of the destination calendar to be retrieved. + * @param prisma - An instance of PrismaClient for database operations. + * + * @returns - An object containing details of the matching destination calendar, specifically `credentialId` and `eventTypeId`. + * + * @throws HttpError - If no destination calendar matches the provided ID. + */ +async function getDestinationCalendar(id: number, prisma: PrismaClient) { + const destinationCalendarObject = await prisma.destinationCalendar.findFirst({ + where: { + id, + }, + select: { userId: true, eventTypeId: true, credentialId: true }, + }); + + if (!destinationCalendarObject) { + throw new HttpError({ + statusCode: 404, + message: `Destination calendar with ID ${id} not found`, + }); + } + + return destinationCalendarObject; +} + +function validateIntegrationInput(parsedBody: z.infer) { + if (parsedBody.integration && !parsedBody.externalId) { + throw new HttpError({ statusCode: 400, message: "External Id is required with integration value" }); + } + if (!parsedBody.integration && parsedBody.externalId) { + throw new HttpError({ statusCode: 400, message: "Integration value is required with external ID" }); + } +} + export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/destination-calendars/_post.ts b/apps/api/pages/api/destination-calendars/_post.ts index beccedc30a..40d3cf5e95 100644 --- a/apps/api/pages/api/destination-calendars/_post.ts +++ b/apps/api/pages/api/destination-calendars/_post.ts @@ -1,7 +1,9 @@ import type { NextApiRequest } from "next"; +import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { schemaDestinationCalendarReadPublic, @@ -38,9 +40,6 @@ import { * externalId: * type: string * description: 'The external ID of the integration' - * credentialId: - * type: integer - * description: 'The credential ID it is associated with' * eventTypeId: * type: integer * description: 'The ID of the eventType it is associated with' @@ -65,20 +64,38 @@ async function postHandler(req: NextApiRequest) { const parsedBody = schemaDestinationCalendarCreateBodyParams.parse(body); await checkPermissions(req, userId); - const assignedUserId = isAdmin ? parsedBody.userId || userId : userId; + const assignedUserId = isAdmin && parsedBody.userId ? parsedBody.userId : userId; /* Check if credentialId data matches the ownership and integration passed in */ - const credential = await prisma.credential.findFirst({ - where: { type: parsedBody.integration, userId: assignedUserId }, - select: { id: true, type: true, userId: true }, + const userCredentials = await prisma.credential.findMany({ + where: { + type: parsedBody.integration, + userId: assignedUserId, + }, + select: credentialForCalendarServiceSelect, }); - if (!credential) + if (userCredentials.length === 0) throw new HttpError({ statusCode: 400, message: "Bad request, credential id invalid", }); + const calendarCredentials = getCalendarCredentials(userCredentials); + + const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, [], parsedBody.externalId); + + const eligibleCalendars = connectedCalendars[0]?.calendars?.filter((calendar) => !calendar.readOnly); + const calendar = eligibleCalendars?.find( + (c) => c.externalId === parsedBody.externalId && c.integration === parsedBody.integration + ); + if (!calendar?.credentialId) + throw new HttpError({ + statusCode: 400, + message: "Bad request, credential id invalid", + }); + const credentialId = calendar.credentialId; + if (parsedBody.eventTypeId) { const eventType = await prisma.eventType.findFirst({ where: { id: parsedBody.eventTypeId, userId: parsedBody.userId }, @@ -91,7 +108,9 @@ async function postHandler(req: NextApiRequest) { parsedBody.userId = undefined; } - const destination_calendar = await prisma.destinationCalendar.create({ data: { ...parsedBody } }); + const destination_calendar = await prisma.destinationCalendar.create({ + data: { ...parsedBody, credentialId }, + }); return { destinationCalendar: schemaDestinationCalendarReadPublic.parse(destination_calendar),