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 <j.auyeung419@gmail.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 <j.auyeung419@gmail.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>
This commit is contained in:
Joe Au-Yeung
2025-11-25 07:27:07 +00:00
committed by GitHub
co-authored by joe@cal.com <j.auyeung419@gmail.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 272d97c0a3
commit 2b2bf36703
6 changed files with 428 additions and 94 deletions
@@ -418,7 +418,7 @@ export function BookingActionsDropdown({
<RescheduleDialog
isOpenDialog={isOpenRescheduleDialog}
setIsOpenDialog={setIsOpenRescheduleDialog}
bookingUId={booking.uid}
bookingUid={booking.uid}
/>
{isOpenReassignDialog && (
<ReassignDialog
@@ -13,13 +13,13 @@ import { showToast } from "@calcom/ui/components/toast";
interface IRescheduleDialog {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
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,
});
}}>
@@ -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");
});
});
@@ -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(),
},
});
}
}
@@ -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<EventType> = {};
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);
@@ -1,7 +1,7 @@
import { z } from "zod";
export const ZRequestRescheduleInputSchema = z.object({
bookingId: z.string(),
bookingUid: z.string(),
rescheduleReason: z.string().optional(),
});