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(),
});