diff --git a/.github/workflows/merge-conflict.yml b/.github/workflows/merge-conflict.yml deleted file mode 100644 index c9f8ca5b02..0000000000 --- a/.github/workflows/merge-conflict.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Auto Comment Merge Conflicts -on: push - -permissions: - pull-requests: write - -jobs: - auto-comment-merge-conflicts: - runs-on: ubuntu-latest - steps: - - uses: codytseng/auto-comment-merge-conflicts@v1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - comment-body: "Hey there, there is a merge conflict, can you take a look?" - wait-ms: 3000 - max-retries: 5 - label-name: "🚨 merge conflict" - ignore-authors: dependabot,otherAuthor 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), diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index f600c6e697..32bb2ca515 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -141,17 +141,6 @@ function BookingListItem(booking: BookingItemProps) { : []), ]; - const showRecordingActions: ActionType[] = [ - { - id: "view_recordings", - label: t("view_recordings"), - onClick: () => { - setViewRecordingsDialogIsOpen(true); - }, - disabled: mutation.isLoading, - }, - ]; - let bookedActions: ActionType[] = [ { id: "cancel", @@ -270,11 +259,21 @@ function BookingListItem(booking: BookingItemProps) { const bookingLink = buildBookingLink(); const title = booking.title; - // To be used after we run query on legacy bookings - // const showRecordingsButtons = booking.isRecorded && isPast && isConfirmed; - const showRecordingsButtons = - (booking.location === "integrations:daily" || booking?.location?.trim() === "") && isPast && isConfirmed; + const showRecordingsButtons = !!(booking.isRecorded && isPast && isConfirmed); + const checkForRecordingsButton = + !showRecordingsButtons && (booking.location === "integrations:daily" || booking?.location?.trim() === ""); + + const showRecordingActions: ActionType[] = [ + { + id: checkForRecordingsButton ? "check_for_recordings" : "view_recordings", + label: checkForRecordingsButton ? t("check_for_recordings") : t("view_recordings"), + onClick: () => { + setViewRecordingsDialogIsOpen(true); + }, + disabled: mutation.isLoading, + }, + ]; return ( <> @@ -299,7 +298,7 @@ function BookingListItem(booking: BookingItemProps) { paymentCurrency={booking.payment[0].currency} /> )} - {showRecordingsButtons && ( + {(showRecordingsButtons || checkForRecordingsButton) && ( ) : null} {isPast && isPending && !isConfirmed ? : null} - {showRecordingsButtons && } + {(showRecordingsButtons || checkForRecordingsButton) && ( + + )} {isCancelled && booking.rescheduled && (
diff --git a/apps/web/package.json b/apps/web/package.json index 765fac9c10..3abb333ae1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@calcom/web", - "version": "3.4.5", + "version": "3.4.6", "private": true, "scripts": { "analyze": "ANALYZE=true next build", diff --git a/apps/web/pages/api/recorded-daily-video.ts b/apps/web/pages/api/recorded-daily-video.ts index 0ce22581a8..c35a9d5c7f 100644 --- a/apps/web/pages/api/recorded-daily-video.ts +++ b/apps/web/pages/api/recorded-daily-video.ts @@ -62,6 +62,46 @@ const triggerWebhook = async ({ await Promise.all(promises); }; +const checkIfUserIsPartOfTheSameTeam = async ( + teamId: number | undefined | null, + userId: number, + userEmail: string | undefined | null +) => { + if (!teamId) return false; + + const getUserQuery = () => { + if (!!userEmail) { + return { + OR: [ + { + id: userId, + }, + { + email: userEmail, + }, + ], + }; + } else { + return { + id: userId, + }; + } + }; + + const team = await prisma.team.findFirst({ + where: { + id: teamId, + members: { + some: { + user: getUserQuery(), + }, + }, + }, + }); + + return !!team; +}; + async function handler(req: NextApiRequest, res: NextApiResponse) { if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) { return res.status(405).json({ message: "No SendGrid API key or email" }); @@ -137,12 +177,22 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const isUserAttendeeOrOrganiser = booking?.user?.id === session.user.id || - attendeesList.find((attendee) => attendee.id === session.user.id); + attendeesList.find( + (attendee) => attendee.id === session.user.id || attendee.email === session.user.email + ); if (!isUserAttendeeOrOrganiser) { - return res.status(403).send({ - message: "Unauthorised", - }); + const isUserMemberOfTheTeam = checkIfUserIsPartOfTheSameTeam( + booking?.eventType?.teamId, + session.user.id, + session.user.email + ); + + if (!isUserMemberOfTheTeam) { + return res.status(403).send({ + message: "Unauthorised", + }); + } } await prisma.booking.update({ @@ -202,7 +252,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { return res.status(403).json({ message: "User does not have team plan to send out emails" }); } catch (err) { - console.warn("something_went_wrong", err); + console.warn("Error in /recorded-daily-video", err); return res.status(500).json({ message: "something went wrong" }); } } diff --git a/apps/web/pages/booking/[uid].tsx b/apps/web/pages/booking/[uid].tsx index 44eb293a1a..44094705b9 100644 --- a/apps/web/pages/booking/[uid].tsx +++ b/apps/web/pages/booking/[uid].tsx @@ -342,14 +342,17 @@ export default function Success(props: SuccessProps) {