Skip to content

Commit 2b2bf36

Browse files
fix: Grab booking organizer credentials when team admins request reschedule (calcom#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 calcom#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>
1 parent 272d97c commit 2b2bf36

6 files changed

Lines changed: 428 additions & 94 deletions

File tree

apps/web/components/booking/actions/BookingActionsDropdown.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ export function BookingActionsDropdown({
418418
<RescheduleDialog
419419
isOpenDialog={isOpenRescheduleDialog}
420420
setIsOpenDialog={setIsOpenRescheduleDialog}
421-
bookingUId={booking.uid}
421+
bookingUid={booking.uid}
422422
/>
423423
{isOpenReassignDialog && (
424424
<ReassignDialog

apps/web/components/dialog/RescheduleDialog.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ import { showToast } from "@calcom/ui/components/toast";
1313
interface IRescheduleDialog {
1414
isOpenDialog: boolean;
1515
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
16-
bookingUId: string;
16+
bookingUid: string;
1717
}
1818

1919
export const RescheduleDialog = (props: IRescheduleDialog) => {
2020
const { t } = useLocale();
2121
const utils = trpc.useUtils();
22-
const { isOpenDialog, setIsOpenDialog, bookingUId: bookingId } = props;
22+
const { isOpenDialog, setIsOpenDialog, bookingUid } = props;
2323
const [rescheduleReason, setRescheduleReason] = useState("");
2424

2525
const { mutate: rescheduleApi, isPending } = trpc.viewer.bookings.requestReschedule.useMutation({
@@ -66,7 +66,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
6666
disabled={isPending}
6767
onClick={() => {
6868
rescheduleApi({
69-
bookingId,
69+
bookingUid,
7070
rescheduleReason,
7171
});
7272
}}>

apps/web/test/handlers/requestReschedule.test.ts

Lines changed: 296 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@ import {
99
getScenarioData,
1010
getMockBookingAttendee,
1111
getDate,
12+
mockCalendar,
1213
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
1314
import { expectBookingRequestRescheduledEmails } from "@calcom/web/test/utils/bookingScenario/expects";
1415

1516
import type { Request, Response } from "express";
1617
import type { NextApiRequest, NextApiResponse } from "next";
17-
import { describe } from "vitest";
18+
import { describe, expect } from "vitest";
1819

19-
import { SchedulingType } from "@calcom/prisma/enums";
20+
import { SchedulingType, MembershipRole } from "@calcom/prisma/enums";
2021
import { BookingStatus } from "@calcom/prisma/enums";
2122
import type { TRequestRescheduleInputSchema } from "@calcom/trpc/server/routers/viewer/bookings/requestReschedule.schema";
2223
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
@@ -116,7 +117,7 @@ describe("Handler: requestReschedule", () => {
116117
getTrpcHandlerData({
117118
user: loggedInUser,
118119
input: {
119-
bookingId: bookingUid,
120+
bookingUid,
120121
rescheduleReason: "",
121122
},
122123
})
@@ -236,7 +237,7 @@ describe("Handler: requestReschedule", () => {
236237
getTrpcHandlerData({
237238
user: loggedInUser,
238239
input: {
239-
bookingId: bookingUid,
240+
bookingUid,
240241
rescheduleReason: "",
241242
},
242243
})
@@ -253,6 +254,297 @@ describe("Handler: requestReschedule", () => {
253254
bookNewTimePath: "/team/team-1/event-type-1",
254255
});
255256
});
257+
258+
test(`should allow team admin to request-reschedule for a team booking and use organizer's credentials
259+
1. Team admin (non-organizer) can request reschedule with proper permissions
260+
2. Organizer's credentials are used to delete calendar events`, async ({ emails }) => {
261+
const { requestRescheduleHandler } = await import(
262+
"@calcom/trpc/server/routers/viewer/bookings/requestReschedule.handler"
263+
);
264+
265+
const booker = getBooker({
266+
email: "booker@example.com",
267+
name: "Booker",
268+
});
269+
270+
const organizer = getOrganizer({
271+
name: "Organizer",
272+
email: "organizer@example.com",
273+
id: 101,
274+
teams: [
275+
{
276+
membership: {
277+
accepted: true,
278+
role: "MEMBER",
279+
},
280+
team: {
281+
id: 1,
282+
name: "Team 1",
283+
slug: "team-1",
284+
},
285+
},
286+
],
287+
schedules: [TestData.schedules.IstWorkHours],
288+
credentials: [getGoogleCalendarCredential()],
289+
selectedCalendars: [TestData.selectedCalendars.google],
290+
});
291+
292+
const teamAdmin = {
293+
id: 102,
294+
username: "team-admin",
295+
name: "Team Admin",
296+
email: "team-admin@example.com",
297+
locale: "en",
298+
timeZone: "America/New_York",
299+
teams: [
300+
{
301+
membership: {
302+
accepted: true,
303+
role: MembershipRole.ADMIN,
304+
},
305+
team: {
306+
id: 1,
307+
name: "Team 1",
308+
slug: "team-1",
309+
},
310+
},
311+
],
312+
schedules: [TestData.schedules.IstWorkHours],
313+
credentials: [], // No credentials
314+
selectedCalendars: [],
315+
};
316+
317+
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
318+
const bookingUid = "MOCKED_BOOKING_UID_TEAM_ADMIN";
319+
const eventTypeSlug = "event-type-1";
320+
321+
const calendarMock = await mockCalendar("googlecalendar");
322+
323+
await createBookingScenario(
324+
getScenarioData({
325+
webhooks: [
326+
{
327+
userId: organizer.id,
328+
eventTriggers: ["BOOKING_CREATED"],
329+
subscriberUrl: "http://my-webhook.example.com",
330+
active: true,
331+
eventTypeId: 1,
332+
appId: null,
333+
},
334+
],
335+
eventTypes: [
336+
{
337+
id: 1,
338+
slug: eventTypeSlug,
339+
slotInterval: 45,
340+
teamId: 1,
341+
schedulingType: SchedulingType.COLLECTIVE,
342+
length: 45,
343+
users: [
344+
{
345+
id: 101,
346+
},
347+
],
348+
},
349+
],
350+
bookings: [
351+
{
352+
uid: bookingUid,
353+
eventTypeId: 1,
354+
userId: 101, // Booking belongs to organizer
355+
status: BookingStatus.ACCEPTED,
356+
startTime: `${plus1DateString}T05:00:00.000Z`,
357+
endTime: `${plus1DateString}T05:15:00.000Z`,
358+
references: [
359+
{
360+
type: "google_calendar",
361+
uid: "MOCK_CALENDAR_EVENT_UID",
362+
meetingId: "MOCK_MEETING_ID",
363+
meetingPassword: "MOCK_PASSWORD",
364+
meetingUrl: "https://UNUSED_URL",
365+
credentialId: 1,
366+
},
367+
],
368+
attendees: [
369+
getMockBookingAttendee({
370+
id: 2,
371+
name: booker.name,
372+
email: booker.email,
373+
locale: "hi",
374+
timeZone: "Asia/Kolkata",
375+
noShow: false,
376+
}),
377+
],
378+
},
379+
],
380+
organizer,
381+
usersApartFromOrganizer: [teamAdmin],
382+
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
383+
})
384+
);
385+
386+
const loggedInTeamAdmin = {
387+
organizationId: null,
388+
id: 102, // Team admin ID
389+
username: "team-admin",
390+
name: "Team Admin",
391+
email: "team-admin@example.com",
392+
};
393+
394+
await requestRescheduleHandler(
395+
getTrpcHandlerData({
396+
user: loggedInTeamAdmin,
397+
input: {
398+
bookingUid,
399+
rescheduleReason: "Team admin requesting reschedule",
400+
},
401+
})
402+
);
403+
404+
expectBookingRequestRescheduledEmails({
405+
booking: {
406+
uid: bookingUid,
407+
},
408+
booker,
409+
organizer: organizer,
410+
loggedInUser: loggedInTeamAdmin,
411+
emails,
412+
bookNewTimePath: "/team/team-1/event-type-1",
413+
});
414+
415+
const deleteEventCalls = calendarMock.deleteEventCalls;
416+
expect(deleteEventCalls.length).toBe(1);
417+
418+
const credentialUsed = deleteEventCalls[0].calendarServiceConstructorArgs.credential;
419+
expect(credentialUsed.userId).toBe(organizer.id);
420+
expect(credentialUsed.id).toBe(1);
421+
});
422+
423+
test(`should reject request-reschedule from team member without proper permissions`, async () => {
424+
const { requestRescheduleHandler } = await import(
425+
"@calcom/trpc/server/routers/viewer/bookings/requestReschedule.handler"
426+
);
427+
428+
const booker = getBooker({
429+
email: "booker@example.com",
430+
name: "Booker",
431+
});
432+
433+
const organizer = getOrganizer({
434+
name: "Organizer",
435+
email: "organizer@example.com",
436+
id: 101,
437+
teams: [
438+
{
439+
membership: {
440+
accepted: true,
441+
role: "MEMBER",
442+
},
443+
team: {
444+
id: 1,
445+
name: "Team 1",
446+
slug: "team-1",
447+
},
448+
},
449+
],
450+
schedules: [TestData.schedules.IstWorkHours],
451+
credentials: [getGoogleCalendarCredential()],
452+
selectedCalendars: [TestData.selectedCalendars.google],
453+
});
454+
455+
const teamMember = {
456+
id: 103,
457+
username: "team-member",
458+
name: "Team Member",
459+
email: "team-member@example.com",
460+
locale: "en",
461+
timeZone: "America/New_York",
462+
teams: [
463+
{
464+
membership: {
465+
accepted: true,
466+
role: MembershipRole.MEMBER,
467+
},
468+
team: {
469+
id: 1,
470+
name: "Team 1",
471+
slug: "team-1",
472+
},
473+
},
474+
],
475+
schedules: [TestData.schedules.IstWorkHours],
476+
credentials: [],
477+
selectedCalendars: [],
478+
};
479+
480+
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
481+
const bookingUid = "MOCKED_BOOKING_UID_MEMBER";
482+
const eventTypeSlug = "event-type-1";
483+
484+
await createBookingScenario(
485+
getScenarioData({
486+
eventTypes: [
487+
{
488+
id: 1,
489+
slug: eventTypeSlug,
490+
slotInterval: 45,
491+
teamId: 1,
492+
schedulingType: SchedulingType.COLLECTIVE,
493+
length: 45,
494+
users: [
495+
{
496+
id: 101,
497+
},
498+
],
499+
},
500+
],
501+
bookings: [
502+
{
503+
uid: bookingUid,
504+
eventTypeId: 1,
505+
userId: 101, // Booking belongs to organizer
506+
status: BookingStatus.ACCEPTED,
507+
startTime: `${plus1DateString}T05:00:00.000Z`,
508+
endTime: `${plus1DateString}T05:15:00.000Z`,
509+
attendees: [
510+
getMockBookingAttendee({
511+
id: 2,
512+
name: booker.name,
513+
email: booker.email,
514+
locale: "hi",
515+
timeZone: "Asia/Kolkata",
516+
noShow: false,
517+
}),
518+
],
519+
},
520+
],
521+
organizer,
522+
usersApartFromOrganizer: [teamMember],
523+
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
524+
})
525+
);
526+
527+
const loggedInTeamMember = {
528+
organizationId: null,
529+
id: 103, // Team member ID
530+
username: "team-member",
531+
name: "Team Member",
532+
email: "team-member@example.com",
533+
};
534+
535+
await expect(
536+
requestRescheduleHandler(
537+
getTrpcHandlerData({
538+
user: loggedInTeamMember,
539+
input: {
540+
bookingUid,
541+
rescheduleReason: "Team member trying to reschedule",
542+
},
543+
})
544+
)
545+
).rejects.toThrow("User does not have permission to request reschedule for this booking");
546+
});
547+
256548
test.todo("Verify that the email should go to organizer as well as the team members");
257549
});
258550
});

0 commit comments

Comments
 (0)