fix: Auto-link credentials internally on destination calendar (#12055)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>pull/11887/head
parent
79a6aef0e7
commit
199d3e4c3f
|
@ -3,7 +3,6 @@ import { z } from "zod";
|
||||||
import { _DestinationCalendarModel as DestinationCalendar } from "@calcom/prisma/zod";
|
import { _DestinationCalendarModel as DestinationCalendar } from "@calcom/prisma/zod";
|
||||||
|
|
||||||
export const schemaDestinationCalendarBaseBodyParams = DestinationCalendar.pick({
|
export const schemaDestinationCalendarBaseBodyParams = DestinationCalendar.pick({
|
||||||
credentialId: true,
|
|
||||||
integration: true,
|
integration: true,
|
||||||
externalId: true,
|
externalId: true,
|
||||||
eventTypeId: true,
|
eventTypeId: true,
|
||||||
|
@ -15,7 +14,6 @@ const schemaDestinationCalendarCreateParams = z
|
||||||
.object({
|
.object({
|
||||||
integration: z.string(),
|
integration: z.string(),
|
||||||
externalId: z.string(),
|
externalId: z.string(),
|
||||||
credentialId: z.number(),
|
|
||||||
eventTypeId: z.number().optional(),
|
eventTypeId: z.number().optional(),
|
||||||
bookingId: z.number().optional(),
|
bookingId: z.number().optional(),
|
||||||
userId: z.number().optional(),
|
userId: z.number().optional(),
|
||||||
|
@ -47,5 +45,4 @@ export const schemaDestinationCalendarReadPublic = DestinationCalendar.pick({
|
||||||
eventTypeId: true,
|
eventTypeId: true,
|
||||||
bookingId: true,
|
bookingId: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
credentialId: true,
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,12 @@
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
import type { NextApiRequest } from "next";
|
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 { defaultResponder } from "@calcom/lib/server";
|
||||||
|
import type { PrismaClient } from "@calcom/prisma";
|
||||||
|
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
schemaDestinationCalendarEditBodyParams,
|
schemaDestinationCalendarEditBodyParams,
|
||||||
|
@ -56,16 +62,251 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
||||||
* 404:
|
* 404:
|
||||||
* description: Destination calendar not found
|
* 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) {
|
export async function patchHandler(req: NextApiRequest) {
|
||||||
const { prisma, query, body } = req;
|
const { userId, isAdmin, prisma, query, body } = req;
|
||||||
const { id } = schemaQueryIdParseInt.parse(query);
|
const { id } = schemaQueryIdParseInt.parse(query);
|
||||||
const parsedBody = schemaDestinationCalendarEditBodyParams.parse(body);
|
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({
|
const destinationCalendar = await prisma.destinationCalendar.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: parsedBody,
|
data: { ...parsedBody, credentialId },
|
||||||
});
|
});
|
||||||
return { destinationCalendar: schemaDestinationCalendarReadPublic.parse(destinationCalendar) };
|
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<typeof schemaDestinationCalendarEditBodyParams>;
|
||||||
|
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<typeof schemaDestinationCalendarEditBodyParams>;
|
||||||
|
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<typeof schemaDestinationCalendarEditBodyParams>) {
|
||||||
|
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);
|
export default defaultResponder(patchHandler);
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server";
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
schemaDestinationCalendarReadPublic,
|
schemaDestinationCalendarReadPublic,
|
||||||
|
@ -38,9 +40,6 @@ import {
|
||||||
* externalId:
|
* externalId:
|
||||||
* type: string
|
* type: string
|
||||||
* description: 'The external ID of the integration'
|
* description: 'The external ID of the integration'
|
||||||
* credentialId:
|
|
||||||
* type: integer
|
|
||||||
* description: 'The credential ID it is associated with'
|
|
||||||
* eventTypeId:
|
* eventTypeId:
|
||||||
* type: integer
|
* type: integer
|
||||||
* description: 'The ID of the eventType it is associated with'
|
* description: 'The ID of the eventType it is associated with'
|
||||||
|
@ -65,20 +64,38 @@ async function postHandler(req: NextApiRequest) {
|
||||||
const parsedBody = schemaDestinationCalendarCreateBodyParams.parse(body);
|
const parsedBody = schemaDestinationCalendarCreateBodyParams.parse(body);
|
||||||
await checkPermissions(req, userId);
|
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 */
|
/* Check if credentialId data matches the ownership and integration passed in */
|
||||||
const credential = await prisma.credential.findFirst({
|
const userCredentials = await prisma.credential.findMany({
|
||||||
where: { type: parsedBody.integration, userId: assignedUserId },
|
where: {
|
||||||
select: { id: true, type: true, userId: true },
|
type: parsedBody.integration,
|
||||||
|
userId: assignedUserId,
|
||||||
|
},
|
||||||
|
select: credentialForCalendarServiceSelect,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!credential)
|
if (userCredentials.length === 0)
|
||||||
throw new HttpError({
|
throw new HttpError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: "Bad request, credential id invalid",
|
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) {
|
if (parsedBody.eventTypeId) {
|
||||||
const eventType = await prisma.eventType.findFirst({
|
const eventType = await prisma.eventType.findFirst({
|
||||||
where: { id: parsedBody.eventTypeId, userId: parsedBody.userId },
|
where: { id: parsedBody.eventTypeId, userId: parsedBody.userId },
|
||||||
|
@ -91,7 +108,9 @@ async function postHandler(req: NextApiRequest) {
|
||||||
parsedBody.userId = undefined;
|
parsedBody.userId = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const destination_calendar = await prisma.destinationCalendar.create({ data: { ...parsedBody } });
|
const destination_calendar = await prisma.destinationCalendar.create({
|
||||||
|
data: { ...parsedBody, credentialId },
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
destinationCalendar: schemaDestinationCalendarReadPublic.parse(destination_calendar),
|
destinationCalendar: schemaDestinationCalendarReadPublic.parse(destination_calendar),
|
||||||
|
|
Loading…
Reference in New Issue