From 2b2bf367037cfa2f109a1e6664b0682ef8ffcae0 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Tue, 25 Nov 2025 02:27:07 -0500 Subject: [PATCH] fix: Grab booking organizer credentials when team admins request reschedule (#24645) * Change arg name from `bookingUId` to `bookingUid` * Lint fix * Use `BookingRepository` to find booking to reschedule * Move early return further up if no booking is found * Use `PermissionCheckService` if request rescheduling a team booking * Remove redundent check * Remove redundent eventType query * Using `BookingRepository` to update the booking to rescheduled * Update type in `getUsersCredentialsIncludeServiceAccountKey` to only require params that are required * Get booking organizer credentials * Type fixes * test: Add tests for team admin request reschedule with organizer credentials - Add test for team admin requesting reschedule with proper permissions - Add test verifying organizer's credentials are used (not requester's) - Add test for team member without permissions (should fail) These tests cover the fix in PR #24645 which ensures that when a team admin requests a reschedule, the booking organizer's credentials are used to delete calendar events instead of the requester's credentials. Co-Authored-By: joe@cal.com * fix: Address code review comments for request reschedule - Change user: true to user: { select: { id, email } } to only fetch required fields - Change eventType include to select with explicit fields including teamId - Remove sensitive information (user object, cancellationReason) from debug log - All integration tests passing locally Co-Authored-By: joe@cal.com * Type fix * Remove businesss logic references from repository methods. * Move business logic to handler * Type fix --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../actions/BookingActionsDropdown.tsx | 2 +- .../components/dialog/RescheduleDialog.tsx | 6 +- .../test/handlers/requestReschedule.test.ts | 300 +++++++++++++++++- .../repositories/BookingRepository.ts | 85 +++++ .../bookings/requestReschedule.handler.ts | 127 +++----- .../bookings/requestReschedule.schema.ts | 2 +- 6 files changed, 428 insertions(+), 94 deletions(-) diff --git a/apps/web/components/booking/actions/BookingActionsDropdown.tsx b/apps/web/components/booking/actions/BookingActionsDropdown.tsx index 45c30a8fbe..fc252992f1 100644 --- a/apps/web/components/booking/actions/BookingActionsDropdown.tsx +++ b/apps/web/components/booking/actions/BookingActionsDropdown.tsx @@ -418,7 +418,7 @@ export function BookingActionsDropdown({ {isOpenReassignDialog && ( >; - bookingUId: string; + bookingUid: string; } export const RescheduleDialog = (props: IRescheduleDialog) => { const { t } = useLocale(); const utils = trpc.useUtils(); - const { isOpenDialog, setIsOpenDialog, bookingUId: bookingId } = props; + const { isOpenDialog, setIsOpenDialog, bookingUid } = props; const [rescheduleReason, setRescheduleReason] = useState(""); const { mutate: rescheduleApi, isPending } = trpc.viewer.bookings.requestReschedule.useMutation({ @@ -66,7 +66,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => { disabled={isPending} onClick={() => { rescheduleApi({ - bookingId, + bookingUid, rescheduleReason, }); }}> diff --git a/apps/web/test/handlers/requestReschedule.test.ts b/apps/web/test/handlers/requestReschedule.test.ts index 01aa3f6d86..66c62db57b 100644 --- a/apps/web/test/handlers/requestReschedule.test.ts +++ b/apps/web/test/handlers/requestReschedule.test.ts @@ -9,14 +9,15 @@ import { getScenarioData, getMockBookingAttendee, getDate, + mockCalendar, } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; import { expectBookingRequestRescheduledEmails } from "@calcom/web/test/utils/bookingScenario/expects"; import type { Request, Response } from "express"; import type { NextApiRequest, NextApiResponse } from "next"; -import { describe } from "vitest"; +import { describe, expect } from "vitest"; -import { SchedulingType } from "@calcom/prisma/enums"; +import { SchedulingType, MembershipRole } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; import type { TRequestRescheduleInputSchema } from "@calcom/trpc/server/routers/viewer/bookings/requestReschedule.schema"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; @@ -116,7 +117,7 @@ describe("Handler: requestReschedule", () => { getTrpcHandlerData({ user: loggedInUser, input: { - bookingId: bookingUid, + bookingUid, rescheduleReason: "", }, }) @@ -236,7 +237,7 @@ describe("Handler: requestReschedule", () => { getTrpcHandlerData({ user: loggedInUser, input: { - bookingId: bookingUid, + bookingUid, rescheduleReason: "", }, }) @@ -253,6 +254,297 @@ describe("Handler: requestReschedule", () => { bookNewTimePath: "/team/team-1/event-type-1", }); }); + + test(`should allow team admin to request-reschedule for a team booking and use organizer's credentials + 1. Team admin (non-organizer) can request reschedule with proper permissions + 2. Organizer's credentials are used to delete calendar events`, async ({ emails }) => { + const { requestRescheduleHandler } = await import( + "@calcom/trpc/server/routers/viewer/bookings/requestReschedule.handler" + ); + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + teams: [ + { + membership: { + accepted: true, + role: "MEMBER", + }, + team: { + id: 1, + name: "Team 1", + slug: "team-1", + }, + }, + ], + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const teamAdmin = { + id: 102, + username: "team-admin", + name: "Team Admin", + email: "team-admin@example.com", + locale: "en", + timeZone: "America/New_York", + teams: [ + { + membership: { + accepted: true, + role: MembershipRole.ADMIN, + }, + team: { + id: 1, + name: "Team 1", + slug: "team-1", + }, + }, + ], + schedules: [TestData.schedules.IstWorkHours], + credentials: [], // No credentials + selectedCalendars: [], + }; + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const bookingUid = "MOCKED_BOOKING_UID_TEAM_ADMIN"; + const eventTypeSlug = "event-type-1"; + + const calendarMock = await mockCalendar("googlecalendar"); + + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slug: eventTypeSlug, + slotInterval: 45, + teamId: 1, + schedulingType: SchedulingType.COLLECTIVE, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + bookings: [ + { + uid: bookingUid, + eventTypeId: 1, + userId: 101, // Booking belongs to organizer + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + references: [ + { + type: "google_calendar", + uid: "MOCK_CALENDAR_EVENT_UID", + meetingId: "MOCK_MEETING_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + credentialId: 1, + }, + ], + attendees: [ + getMockBookingAttendee({ + id: 2, + name: booker.name, + email: booker.email, + locale: "hi", + timeZone: "Asia/Kolkata", + noShow: false, + }), + ], + }, + ], + organizer, + usersApartFromOrganizer: [teamAdmin], + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + const loggedInTeamAdmin = { + organizationId: null, + id: 102, // Team admin ID + username: "team-admin", + name: "Team Admin", + email: "team-admin@example.com", + }; + + await requestRescheduleHandler( + getTrpcHandlerData({ + user: loggedInTeamAdmin, + input: { + bookingUid, + rescheduleReason: "Team admin requesting reschedule", + }, + }) + ); + + expectBookingRequestRescheduledEmails({ + booking: { + uid: bookingUid, + }, + booker, + organizer: organizer, + loggedInUser: loggedInTeamAdmin, + emails, + bookNewTimePath: "/team/team-1/event-type-1", + }); + + const deleteEventCalls = calendarMock.deleteEventCalls; + expect(deleteEventCalls.length).toBe(1); + + const credentialUsed = deleteEventCalls[0].calendarServiceConstructorArgs.credential; + expect(credentialUsed.userId).toBe(organizer.id); + expect(credentialUsed.id).toBe(1); + }); + + test(`should reject request-reschedule from team member without proper permissions`, async () => { + const { requestRescheduleHandler } = await import( + "@calcom/trpc/server/routers/viewer/bookings/requestReschedule.handler" + ); + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + teams: [ + { + membership: { + accepted: true, + role: "MEMBER", + }, + team: { + id: 1, + name: "Team 1", + slug: "team-1", + }, + }, + ], + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const teamMember = { + id: 103, + username: "team-member", + name: "Team Member", + email: "team-member@example.com", + locale: "en", + timeZone: "America/New_York", + teams: [ + { + membership: { + accepted: true, + role: MembershipRole.MEMBER, + }, + team: { + id: 1, + name: "Team 1", + slug: "team-1", + }, + }, + ], + schedules: [TestData.schedules.IstWorkHours], + credentials: [], + selectedCalendars: [], + }; + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const bookingUid = "MOCKED_BOOKING_UID_MEMBER"; + const eventTypeSlug = "event-type-1"; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slug: eventTypeSlug, + slotInterval: 45, + teamId: 1, + schedulingType: SchedulingType.COLLECTIVE, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + bookings: [ + { + uid: bookingUid, + eventTypeId: 1, + userId: 101, // Booking belongs to organizer + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + attendees: [ + getMockBookingAttendee({ + id: 2, + name: booker.name, + email: booker.email, + locale: "hi", + timeZone: "Asia/Kolkata", + noShow: false, + }), + ], + }, + ], + organizer, + usersApartFromOrganizer: [teamMember], + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + const loggedInTeamMember = { + organizationId: null, + id: 103, // Team member ID + username: "team-member", + name: "Team Member", + email: "team-member@example.com", + }; + + await expect( + requestRescheduleHandler( + getTrpcHandlerData({ + user: loggedInTeamMember, + input: { + bookingUid, + rescheduleReason: "Team member trying to reschedule", + }, + }) + ) + ).rejects.toThrow("User does not have permission to request reschedule for this booking"); + }); + test.todo("Verify that the email should go to organizer as well as the team members"); }); }); diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index a2a9d5d52f..70823064e8 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -1371,4 +1371,89 @@ export class BookingRepository { }, }); } + + findByUidIncludeEventTypeAndReferences({ bookingUid }: { bookingUid: string }) { + return this.prismaClient.booking.findUniqueOrThrow({ + where: { + uid: bookingUid, + }, + select: { + id: true, + uid: true, + userId: true, + status: true, + user: { + select: { + id: true, + email: true, + }, + }, + title: true, + description: true, + startTime: true, + endTime: true, + eventTypeId: true, + userPrimaryEmail: true, + eventType: { + select: { + teamId: true, + parentId: true, + slug: true, + hideOrganizerEmail: true, + customReplyToEmail: true, + bookingFields: true, + metadata: true, + team: { + select: { + id: true, + name: true, + parentId: true, + }, + }, + }, + }, + location: true, + attendees: true, + references: true, + customInputs: true, + dynamicEventSlugRef: true, + dynamicGroupSlugRef: true, + destinationCalendar: true, + smsReminderNumber: true, + workflowReminders: true, + responses: true, + iCalUID: true, + }, + }); + } + + async updateBookingStatus({ + bookingId, + status, + cancellationReason, + cancelledBy, + rescheduledBy, + rescheduled, + }: { + bookingId: number; + status?: BookingStatus; + cancellationReason?: string; + cancelledBy?: string; + rescheduledBy?: string; + rescheduled?: boolean; + }) { + return await this.prismaClient.booking.update({ + where: { + id: bookingId, + }, + data: { + ...(status !== undefined && { status }), + ...(rescheduled !== undefined && { rescheduled }), + ...(cancellationReason !== undefined && { cancellationReason }), + ...(cancelledBy !== undefined && { cancelledBy }), + ...(rescheduledBy !== undefined && { rescheduledBy }), + updatedAt: new Date().toISOString(), + }, + }); + } } diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts index 53e082b7c5..27102492c4 100644 --- a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts @@ -6,9 +6,11 @@ import { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/app-store/d import dayjs from "@calcom/dayjs"; import { sendRequestRescheduleEmailAndSMS } from "@calcom/emails/email-manager"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; +import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { deleteMeeting } from "@calcom/features/conferencing/lib/videoClient"; import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository"; +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import { deleteWebhookScheduledTriggers, @@ -25,8 +27,8 @@ import { getTranslation } from "@calcom/lib/server/i18n"; import { BookingWebhookFactory } from "@calcom/lib/server/service/BookingWebhookFactory"; import { prisma } from "@calcom/prisma"; import type { BookingReference, EventType } from "@calcom/prisma/client"; -import type { WebhookTriggerEvents } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; +import type { WebhookTriggerEvents } from "@calcom/prisma/enums"; import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { Person } from "@calcom/types/Calendar"; @@ -45,53 +47,22 @@ type RequestRescheduleOptions = { const log = logger.getSubLogger({ prefix: ["requestRescheduleHandler"] }); export const requestRescheduleHandler = async ({ ctx, input }: RequestRescheduleOptions) => { const { user } = ctx; - const { bookingId, rescheduleReason: cancellationReason } = input; - log.debug("Started", safeStringify({ bookingId, cancellationReason, user })); - const bookingToReschedule = await prisma.booking.findUniqueOrThrow({ - select: { - id: true, - uid: true, - userId: true, - title: true, - description: true, - startTime: true, - endTime: true, - eventTypeId: true, - userPrimaryEmail: true, - eventType: { - include: { - team: { - select: { - id: true, - name: true, - parentId: true, - }, - }, - }, - }, - location: true, - attendees: true, - references: true, - customInputs: true, - dynamicEventSlugRef: true, - dynamicGroupSlugRef: true, - destinationCalendar: true, - smsReminderNumber: true, - workflowReminders: true, - responses: true, - iCalUID: true, - }, - where: { - uid: bookingId, - NOT: { - status: { - in: [BookingStatus.CANCELLED, BookingStatus.REJECTED], - }, - }, - }, - }); + const { bookingUid, rescheduleReason: cancellationReason } = input; + log.debug("Started", safeStringify({ bookingUid })); + const bookingRepository = new BookingRepository(prisma); + const bookingToReschedule = await bookingRepository.findByUidIncludeEventTypeAndReferences({ bookingUid }); - if (!bookingToReschedule.userId) { + if ( + bookingToReschedule.status === BookingStatus.CANCELLED || + bookingToReschedule.status === BookingStatus.REJECTED + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Cannot request reschedule for cancelled or rejected booking", + }); + } + + if (!bookingToReschedule.userId || !bookingToReschedule.user) { throw new TRPCError({ code: "FORBIDDEN", message: "Booking to reschedule doesn't have an owner" }); } @@ -100,20 +71,22 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule } const bookingBelongsToTeam = !!bookingToReschedule.eventType?.teamId; + const isBookingOrganizer = bookingToReschedule.userId === user.id; - const userTeams = await prisma.user.findUniqueOrThrow({ - where: { - id: user.id, - }, - select: { - teams: true, - }, - }); + if (!isBookingOrganizer && bookingBelongsToTeam && bookingToReschedule.eventType?.teamId) { + const permissionCheckService = new PermissionCheckService(); + const hasPermission = await permissionCheckService.checkPermission({ + userId: user.id, + teamId: bookingToReschedule.eventType.teamId, + permission: "booking.update", + fallbackRoles: ["ADMIN", "OWNER"], + }); - if (bookingBelongsToTeam && bookingToReschedule.eventType?.teamId) { - const userTeamIds = userTeams.teams.map((item) => item.teamId); - if (userTeamIds.indexOf(bookingToReschedule.eventType?.teamId) === -1) { - throw new TRPCError({ code: "FORBIDDEN", message: "User isn't a member on the team" }); + if (!hasPermission) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User does not have permission to request reschedule for this booking", + }); } log.debug( "Request reschedule for team booking", @@ -122,36 +95,20 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule }) ); } - if (!bookingBelongsToTeam && bookingToReschedule.userId !== user.id) { + if (!bookingBelongsToTeam && !isBookingOrganizer) { throw new TRPCError({ code: "FORBIDDEN", message: "User isn't owner of the current booking" }); } - if (!bookingToReschedule) return; - let event: Partial = {}; - if (bookingToReschedule.eventTypeId) { - event = await prisma.eventType.findUniqueOrThrow({ - select: { - title: true, - schedulingType: true, - recurringEvent: true, - }, - where: { - id: bookingToReschedule.eventTypeId, - }, - }); + if (bookingToReschedule.eventType) { + event = bookingToReschedule.eventType; } - await prisma.booking.update({ - where: { - id: bookingToReschedule.id, - }, - data: { - rescheduled: true, - cancellationReason, - status: BookingStatus.CANCELLED, - updatedAt: dayjs().toISOString(), - cancelledBy: user.email, - }, + await bookingRepository.updateBookingStatus({ + bookingId: bookingToReschedule.id, + status: BookingStatus.CANCELLED, + rescheduled: true, + cancellationReason, + cancelledBy: user.email, }); // delete scheduled jobs of previous booking @@ -232,7 +189,7 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule // Handling calendar and videos cancellation // This can set previous time as available, until virtual calendar is done - const credentials = await getUsersCredentialsIncludeServiceAccountKey(user); + const credentials = await getUsersCredentialsIncludeServiceAccountKey(bookingToReschedule.user); const credentialsMap = new Map(); credentials.forEach((credential) => { credentialsMap.set(credential.type, credential); diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.schema.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.schema.ts index 32c513e321..719364c241 100644 --- a/packages/trpc/server/routers/viewer/bookings/requestReschedule.schema.ts +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.schema.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const ZRequestRescheduleInputSchema = z.object({ - bookingId: z.string(), + bookingUid: z.string(), rescheduleReason: z.string().optional(), });