diff --git a/apps/web/app/api/cancel/route.ts b/apps/web/app/api/cancel/route.ts index 46da34f7e7d1da..0ac786097b5170 100644 --- a/apps/web/app/api/cancel/route.ts +++ b/apps/web/app/api/cancel/route.ts @@ -29,6 +29,7 @@ async function handler(req: NextRequest) { const result = await handleCancelBooking({ bookingData, userId: session?.user?.id || -1, + userOrgRole: session?.user?.org?.role, }); const statusCode = result.success ? 200 : 400; diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 44079e90c49d18..b9aac2300423af 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -70,7 +70,14 @@ type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"] type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number]; -export type BookingItemProps = BookingItem & { +type EnrichedBookingItem = BookingItem & { + eventType: BookingItem["eventType"] & { + isUserHostOrOwner?: boolean; + hasTeamOrOrgPermissions?: boolean; + }; +}; + +export type BookingItemProps = EnrichedBookingItem & { listingStatus: BookingListingStatus; recurringInfo: RouterOutputs["viewer"]["bookings"]["get"]["recurringInfo"][number] | undefined; loggedInUser: { @@ -205,6 +212,9 @@ function BookingListItem(booking: BookingItemProps) { const isDisabledCancelling = booking.eventType.disableCancelling; const isDisabledRescheduling = booking.eventType.disableRescheduling; + const isHostOrOwner = booking.eventType?.isUserHostOrOwner || false; + const hasTeamOrOrgPermissions = booking.eventType?.hasTeamOrOrgPermissions || false; + const bookingConfirm = async (confirm: boolean) => { let body = { bookingId: booking.id, @@ -250,6 +260,8 @@ function BookingListItem(booking: BookingItemProps) { (typeof booking.location === "string" && booking.location.trim() === ""), showPendingPayment: paymentAppData.enabled && booking.payment.length && !booking.paid, cardCharged, + isHostOrOwner, + hasTeamOrOrgPermissions, attendeeList, getSeatReferenceUid, t, diff --git a/apps/web/components/booking/bookingActions.ts b/apps/web/components/booking/bookingActions.ts index cdc6982717b4cc..786d02b8e6864c 100644 --- a/apps/web/components/booking/bookingActions.ts +++ b/apps/web/components/booking/bookingActions.ts @@ -22,6 +22,8 @@ export interface BookingActionContext { isCalVideoLocation: boolean; showPendingPayment: boolean; cardCharged: boolean; + isHostOrOwner: boolean; + hasTeamOrOrgPermissions: boolean; attendeeList: Array<{ name: string; email: string; @@ -101,6 +103,8 @@ export function getEditEventActions(context: BookingActionContext): ActionType[] isBookingInPast, isDisabledRescheduling, isBookingFromRoutingForm, + isHostOrOwner, + hasTeamOrOrgPermissions, getSeatReferenceUid, t, } = context; @@ -114,7 +118,8 @@ export function getEditEventActions(context: BookingActionContext): ActionType[] booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : "" }`, disabled: - (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling, + (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || + (isDisabledRescheduling && !isHostOrOwner && !hasTeamOrOrgPermissions), }, { id: "reschedule_request", @@ -122,7 +127,8 @@ export function getEditEventActions(context: BookingActionContext): ActionType[] iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ", label: t("send_reschedule_request"), disabled: - (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling, + (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || + (isDisabledRescheduling && !isHostOrOwner && !hasTeamOrOrgPermissions), }, isBookingFromRoutingForm ? { @@ -202,15 +208,16 @@ export function shouldShowRecurringCancelAction(context: BookingActionContext): } export function isActionDisabled(actionId: string, context: BookingActionContext): boolean { - const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling, isPending, isConfirmed } = + const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling, isPending, isConfirmed, isHostOrOwner, hasTeamOrOrgPermissions } = context; switch (actionId) { case "reschedule": case "reschedule_request": - return (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling; + return (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || + (isDisabledRescheduling && !isHostOrOwner && !hasTeamOrOrgPermissions); case "cancel": - return isDisabledCancelling || isBookingInPast; + return (isDisabledCancelling && !isHostOrOwner && !hasTeamOrOrgPermissions) || isBookingInPast; case "view_recordings": return !(isBookingInPast && booking.status === BookingStatus.ACCEPTED && context.isCalVideoLocation); case "meeting_session_details": diff --git a/apps/web/lib/booking.ts b/apps/web/lib/booking.ts index 3c61015f0fff52..c44cafb5ab571f 100644 --- a/apps/web/lib/booking.ts +++ b/apps/web/lib/booking.ts @@ -71,6 +71,7 @@ export const getEventTypesFromDB = async (id: number) => { slug: true, name: true, hideBranding: true, + parentId: true, parent: { select: { hideBranding: true, diff --git a/apps/web/lib/reschedule/[uid]/getServerSideProps.ts b/apps/web/lib/reschedule/[uid]/getServerSideProps.ts index cdf3726e481bf0..3925bcbe118826 100644 --- a/apps/web/lib/reschedule/[uid]/getServerSideProps.ts +++ b/apps/web/lib/reschedule/[uid]/getServerSideProps.ts @@ -3,10 +3,13 @@ import type { GetServerSidePropsContext } from "next"; import { URLSearchParams } from "url"; import { z } from "zod"; +import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { getFullName } from "@calcom/features/form-builder/utils"; import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking"; import { getDefaultEvent } from "@calcom/lib/defaultEvents"; +import { checkIfUserIsHost } from "@calcom/lib/event-types/utils/checkIfUserIsHost"; +import { isTeamAdmin } from "@calcom/lib/server/queries/teams"; import { getSafe } from "@calcom/lib/getSafe"; import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat"; import { UserRepository } from "@calcom/lib/server/repository/user"; @@ -55,6 +58,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { users: { select: { username: true, + email: true, + id: true, }, }, slug: true, @@ -63,6 +68,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { allowReschedulingCancelledBookings: true, team: { select: { + id: true, parentId: true, slug: true, }, @@ -79,6 +85,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { user: { select: { id: true, + email: true, }, }, }, @@ -122,7 +129,28 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { const isNonRescheduleableBooking = booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED; - if (isDisabledRescheduling) { + // Check if user is a host or owner of the event type + const userId = session?.user?.id; + const userIsHost = userId + ? checkIfUserIsHost( + userId, + { + user: booking.user, + attendees: booking.attendees, + }, + { + users: booking.eventType?.users, + hosts: booking.eventType?.hosts, + } + ) + : false; + const userIsOwnerOfEventType = userId !== undefined && booking?.eventType?.owner?.id === userId; + + const hasTeamOrOrgPermissions = userId !== undefined ? !!(await isTeamAdmin(userId, booking?.eventType?.team?.id ?? 0)) : false; + const isOrgAdminOrOwner = checkAdminOrOwner(session?.user?.org?.role); + + const isHostOrOwner = !!userIsHost || !!userIsOwnerOfEventType || !!hasTeamOrOrgPermissions || isOrgAdminOrOwner; + if (isDisabledRescheduling && !isHostOrOwner) { return { redirect: { destination: `/booking/${uid}`, @@ -182,13 +210,23 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { }, }; } - const userIsHost = booking?.eventType.hosts.find((host) => { - if (host.user.id === userId) return true; - }); + const userIsHost = userId + ? checkIfUserIsHost( + userId, + { + user: booking.user, + attendees: booking.attendees, + }, + { + users: booking.eventType?.users, + hosts: booking.eventType?.hosts, + } + ) + : false; const userIsOwnerOfEventType = booking?.eventType.owner?.id === userId; - if (!userIsHost && !userIsOwnerOfEventType) { + if (!userIsHost && !userIsOwnerOfEventType && !hasTeamOrOrgPermissions && !isOrgAdminOrOwner) { return { notFound: true, } as { diff --git a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx index 75a956968cf537..915f99beb89fb5 100644 --- a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx +++ b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx @@ -1,6 +1,7 @@ import type { GetServerSidePropsContext } from "next"; import { z } from "zod"; +import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking"; @@ -8,7 +9,9 @@ import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/org import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; +import { isTeamAdmin } from "@calcom/lib/server/queries/teams"; import { shouldHideBrandingForTeamEvent } from "@calcom/lib/hideBranding"; +import { BookingRepository } from "@calcom/lib/server/repository/booking"; import slugify from "@calcom/lib/slugify"; import { prisma } from "@calcom/prisma"; import type { User } from "@calcom/prisma/client"; @@ -58,7 +61,23 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => return { notFound: true } as const; } - if (rescheduleUid && eventData.disableRescheduling) { + const userId = session?.user?.id; + const hasTeamOrOrgPermissions = userId !== undefined ? !!(await isTeamAdmin(userId, eventData.team?.id ?? 0)) : false; + const isOrgAdminOrOwner = checkAdminOrOwner(session?.user?.org?.role); + + let isHostOrOwner = (eventData.owner?.id && eventData.owner?.id === userId) || hasTeamOrOrgPermissions || isOrgAdminOrOwner; + + // We will only check the database if the user is not the owner or has team or org permissions + if (userId && rescheduleUid && !isHostOrOwner) { + const bookingRepo = new BookingRepository(prisma); + const userIsHost = await bookingRepo.checkIfUserIsHost({ + userId, + bookingId: `${rescheduleUid}`, + }); + isHostOrOwner = !!userIsHost; + } + + if (rescheduleUid && eventData.disableRescheduling && !isHostOrOwner) { return { redirect: { destination: `/booking/${rescheduleUid}`, permanent: false } }; } @@ -242,6 +261,17 @@ const getTeamWithEventsData = async ( }, }, }, + team: { + select: { + id: true, + parentId: true, + }, + }, + owner: { + select: { + id: true, + }, + }, }, }, isOrganization: true, diff --git a/apps/web/modules/bookings/views/bookings-listing-view.tsx b/apps/web/modules/bookings/views/bookings-listing-view.tsx index 33a7b76534863a..d4bf8294e28a7f 100644 --- a/apps/web/modules/bookings/views/bookings-listing-view.tsx +++ b/apps/web/modules/bookings/views/bookings-listing-view.tsx @@ -181,6 +181,7 @@ function BookingsContent({ status }: BookingsProps) { ? dayjs(dateRange?.startDate).startOf("day").toISOString() : undefined, beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined, + includeHostAndTeamPermissions: true, }, }); diff --git a/apps/web/modules/bookings/views/bookings-single-view.getServerSideProps.tsx b/apps/web/modules/bookings/views/bookings-single-view.getServerSideProps.tsx index df102f6974b960..7f4ab1de9077b2 100644 --- a/apps/web/modules/bookings/views/bookings-single-view.getServerSideProps.tsx +++ b/apps/web/modules/bookings/views/bookings-single-view.getServerSideProps.tsx @@ -7,6 +7,7 @@ import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo"; import { getDefaultEvent } from "@calcom/lib/defaultEvents"; +import { isTeamAdmin } from "@calcom/lib/server/queries/teams"; import { shouldHideBrandingForEvent } from "@calcom/lib/hideBranding"; import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; @@ -15,6 +16,7 @@ import { BookingRepository } from "@calcom/lib/server/repository/booking"; import prisma from "@calcom/prisma"; import { customInputSchema } from "@calcom/prisma/zod-utils"; import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router"; +import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner"; import type { inferSSRProps } from "@lib/types/inferSSRProps"; @@ -175,6 +177,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { }; const isLoggedInUserHost = checkIfUserIsHost(userId); + const isOrgAdminOrOwner = checkAdminOrOwner(session?.user?.org?.role); + const hasTeamOrOrgPermissions = userId !== undefined ? !!(await isTeamAdmin(userId, eventType.team?.id ?? 0)) : false; if (bookingInfo !== null && eventType.seatsPerTimeSlot) { await handleSeatsEventTypeOnBooking(eventType, bookingInfo, seatReferenceUid, isLoggedInUserHost); @@ -256,6 +260,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { requiresLoginToUpdate, rescheduledToUid, isLoggedInUserHost, + hasTeamOrOrgPermissions: hasTeamOrOrgPermissions || isOrgAdminOrOwner, internalNotePresets: internalNotes, }, }; diff --git a/apps/web/modules/bookings/views/bookings-single-view.tsx b/apps/web/modules/bookings/views/bookings-single-view.tsx index 78c8f4f141a0e4..c34192a0d993c5 100644 --- a/apps/web/modules/bookings/views/bookings-single-view.tsx +++ b/apps/web/modules/bookings/views/bookings-single-view.tsx @@ -153,6 +153,7 @@ export default function Success(props: PageProps) { ); const { data: session } = useSession(); const isHost = props.isLoggedInUserHost; + const isAdminOrOwner = props.hasTeamOrOrgPermissions; const [showUtmParams, setShowUtmParams] = useState(false); @@ -377,11 +378,13 @@ export default function Success(props: PageProps) { const isRerouting = searchParams?.get("cal.rerouting") === "true"; const isRescheduled = bookingInfo?.rescheduled; - const canCancelOrReschedule = !eventType?.disableCancelling || !eventType?.disableRescheduling; - const canCancelAndReschedule = !eventType?.disableCancelling && !eventType?.disableRescheduling; + const canCancelOrReschedule = + !eventType?.disableCancelling || !eventType?.disableRescheduling || isHost || isAdminOrOwner; + const canCancelAndReschedule = + (!eventType?.disableCancelling && !eventType?.disableRescheduling) || isHost || isAdminOrOwner; - const canCancel = !eventType?.disableCancelling; - const canReschedule = !eventType?.disableRescheduling; + const canCancel = !eventType?.disableCancelling || isHost || isAdminOrOwner; + const canReschedule = !eventType?.disableRescheduling || isHost || isAdminOrOwner; const successPageHeadline = (() => { if (needsConfirmationAndReschedulable) { diff --git a/apps/web/playwright/booking-pages.e2e.ts b/apps/web/playwright/booking-pages.e2e.ts index 213d47db96777f..d6d731ec27a4a1 100644 --- a/apps/web/playwright/booking-pages.e2e.ts +++ b/apps/web/playwright/booking-pages.e2e.ts @@ -1,5 +1,7 @@ +import type { Page } from "@playwright/test"; import { expect } from "@playwright/test"; import { JSDOM } from "jsdom"; +import type { CreateUsersFixture, createUsersFixture } from "playwright/fixtures/users"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { generateHashedLink } from "@calcom/lib/generateHashedLink"; @@ -14,13 +16,70 @@ import { bookTimeSlot, confirmBooking, confirmReschedule, + doOnOrgDomain, expectSlotNotAllowedToBook, selectFirstAvailableTimeSlotNextMonth, testEmail, testName, } from "./lib/testUtils"; +import { Team } from "@calcom/prisma/client"; const freeUserObj = { name: `Free-user-${randomString(3)}` }; + +async function testOrgMemberAction({ + page, + users, + org, + bookingId, + role, + action, + testName, +}: { + page: Page; + users: ReturnType; + org: { + id: number; + slug: string; + }; + bookingId: string | undefined; + role: "ADMIN" | "OWNER"; + action: "reschedule" | "cancel"; + testName: string; +}) { + const orgMember = await users.create({ + username: `org-${role.toLowerCase()}-${action}`, + name: `Org ${role} ${action}`, + organizationId: org.id, + roleInOrganization: role, + }); + + await orgMember.apiLogin(); + + if (!bookingId) throw new Error("Booking ID not found"); + + await doOnOrgDomain({ orgSlug: org.slug, page }, async ({ page, goToUrlWithErrorHandling }) => { + const { success } = await goToUrlWithErrorHandling(`/booking/${bookingId}`); + if (!success) { + throw new Error(`Failed to navigate to booking page: /booking/${bookingId}`); + } + await page.waitForLoadState("networkidle"); + + if (action === "reschedule") { + await expect(page.locator('[data-testid="reschedule-link"]')).toBeVisible(); + await page.locator('[data-testid="reschedule-link"]').click(); + await selectFirstAvailableTimeSlotNextMonth(page); + await confirmReschedule(page); + await expect(page.locator('[data-testid="success-page"]')).toBeVisible(); + } else { + await expect(page.locator('[data-testid="cancel"]')).toBeVisible(); + await page.locator('[data-testid="cancel"]').click(); + await page.locator('[data-testid="cancel_reason"]').fill(`${testName} cancellation test`); + await page.locator('[data-testid="confirm_cancel"]').click(); + await expect(page.locator('[data-testid="cancelled-headline"]')).toBeVisible(); + } + }); +} + test.describe.configure({ mode: "parallel" }); test.afterEach(async ({ users }) => { await users.deleteAll(); @@ -634,13 +693,15 @@ test.describe("Event type with disabled cancellation and rescheduling", () => { bookingId = pathSegments[pathSegments.length - 1]; }); - test("Reschedule and cancel buttons should be hidden on success page", async ({ page }) => { + test("Reschedule and cancel buttons should be hidden on success page for attendees", async ({ page }) => { + // For attendees, the buttons should be hidden when cancellation/rescheduling is disabled await expect(page.locator('[data-testid="reschedule-link"]')).toBeHidden(); await expect(page.locator('[data-testid="cancel"]')).toBeHidden(); }); test("Direct access to reschedule/{bookingId} should redirect to success page", async ({ page }) => { await page.goto(`/reschedule/${bookingId}`); + await expect(page).toHaveURL(`/reschedule/${bookingId}`); await expect(page.locator("[data-testid=success-page]")).toBeVisible(); @@ -649,6 +710,7 @@ test.describe("Event type with disabled cancellation and rescheduling", () => { test("Using rescheduleUid query parameter should redirect to success page", async ({ page }) => { await page.goto(`/${user.username}/no-cancel-no-reschedule?rescheduleUid=${bookingId}`); + await expect(page).toHaveURL(`/${user.username}/no-cancel-no-reschedule?rescheduleUid=${bookingId}`); await expect(page.locator("[data-testid=success-page]")).toBeVisible(); @@ -672,7 +734,33 @@ test.describe("Event type with disabled cancellation and rescheduling", () => { const responseBody = await response.json(); expect(responseBody.message).toBe("This event type does not allow cancellations"); }); + + test("Host should be able to cancel even when cancellation is disabled", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + + await page.goto(`/booking/${bookingId}`); + await expect(page).toHaveURL(`/booking/${bookingId}`); + await page.locator('[data-testid="cancel"]').click(); + await page.locator('[data-testid="cancel_reason"]').fill("Host cancellation test"); + await page.locator('[data-testid="confirm_cancel"]').click(); + await expect(page.locator('[data-testid="cancelled-headline"]')).toBeVisible(); + }); + + test("Host should be able to reschedule even when rescheduling is disabled", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + + await page.goto(`/booking/${bookingId}`); + await expect(page).toHaveURL(`/booking/${bookingId}`); + await page.locator('[data-testid="reschedule-link"]').click(); + await page.waitForURL((url) => url.searchParams.has("rescheduleUid")); + await selectFirstAvailableTimeSlotNextMonth(page); + await page.locator('[data-testid="confirm-reschedule-button"]').click(); + await expect(page.locator('[data-testid="success-page"]')).toBeVisible(); + }); }); + test("Should throw error when both seatsPerTimeSlot and recurringEvent are set", async ({ page, users }) => { const user = await users.create({ name: `Test-user-${randomString(4)}`, @@ -767,6 +855,136 @@ test.describe("GTM container", () => { }); }); +test.describe("Organization members can cancel and reschedule when disabled", () => { + let bookingId: string | undefined; + let org: Team; + let user: Awaited>; + let team: Team; + let teamEventSlug: string; + + test.beforeEach(async ({ page, users, prisma, orgs }) => { + user = await users.create( + { + name: `Test-user-${randomString(4)}`, + username: `test-user-${randomString(4)}`, + }, + { + hasTeam: true, + teammates: [{ name: "teammate-1" }], + schedulingType: SchedulingType.COLLECTIVE, + } + ); + + const { team: userTeam } = await user.getFirstTeamMembership(); + team = userTeam; + + const teamEvent = await user.getFirstTeamEvent(team.id); + teamEventSlug = teamEvent.slug; + + await prisma.eventType.update({ + where: { id: teamEvent.id }, + data: { + title: "No Cancel No Reschedule", + disableCancelling: true, + disableRescheduling: true, + }, + }); + + org = await orgs.create({ + name: "Test Org", + }); + + await prisma.team.update({ + where: { id: team.id }, + data: { parentId: org.id }, + }); + + await doOnOrgDomain( + { + orgSlug: org.slug, + page, + }, + async ({ page, goToUrlWithErrorHandling }) => { + const { success } = await goToUrlWithErrorHandling(`/team/${team.slug}/${teamEventSlug}`); + if (!success) { + throw new Error(`Failed to navigate to team event page: /team/${team.slug}/${teamEventSlug}`); + } + await selectFirstAvailableTimeSlotNextMonth(page); + await bookTimeSlot(page, { + name: "Test-user-1", + email: "test-booker@example.com", + }); + + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + + const url = new URL(page.url()); + const pathSegments = url.pathname.split("/"); + bookingId = pathSegments[pathSegments.length - 1]; + } + ); + }); + + test("Organization Admin should be able to reschedule even when disabled", async ({ page, users }) => { + await testOrgMemberAction({ + page, + users, + org: { + id: org.id, + slug: org.slug ?? "", + }, + bookingId, + role: "ADMIN", + action: "reschedule", + testName: "Org Admin", + }); + }); + + test("Organization Admin should be able to cancel even when disabled", async ({ page, users }) => { + await testOrgMemberAction({ + page, + users, + org: { + id: org.id, + slug: org.slug ?? "", + }, + bookingId, + role: "ADMIN", + action: "cancel", + testName: "Org Admin", + }); + }); + + test("Organization Owner should be able to reschedule even when disabled", async ({ page, users }) => { + await testOrgMemberAction({ + page, + users, + org: { + id: org.id, + slug: org.slug ?? "", + }, + bookingId, + role: "OWNER", + action: "reschedule", + testName: "Org Owner", + }); + }); + + test("Organization Owner should be able to cancel even when disabled", async ({ page, users }) => { + await testOrgMemberAction({ + page, + users, + org: { + id: org.id, + slug: org.slug ?? "", + }, + bookingId, + role: "OWNER", + action: "cancel", + testName: "Org Owner", + }); + }); +}); + test.describe("Past booking cancellation", () => { test("Cancel button should be hidden for past bookings", async ({ page, users, bookings }) => { const user = await users.create({ diff --git a/apps/web/playwright/organization/booking.e2e.ts b/apps/web/playwright/organization/booking.e2e.ts index 5333ac446564fb..bbd915581604a8 100644 --- a/apps/web/playwright/organization/booking.e2e.ts +++ b/apps/web/playwright/organization/booking.e2e.ts @@ -11,6 +11,7 @@ import { test } from "../lib/fixtures"; import { bookTeamEvent, bookTimeSlot, + confirmReschedule, doOnOrgDomain, expectPageToBeNotFound, selectFirstAvailableTimeSlotNextMonth, @@ -20,6 +21,8 @@ import { import { expectExistingUserToBeInvitedToOrganization } from "../team/expects"; import { gotoPathAndExpectRedirectToOrgDomain } from "./lib/gotoPathAndExpectRedirectToOrgDomain"; import { acceptTeamOrOrgInvite, inviteExistingUserToOrganization } from "./lib/inviteUser"; +import { Team } from "@calcom/prisma/client"; +import { CreateUsersFixture } from "playwright/fixtures/users"; function getOrgOrigin(orgSlug: string | null) { if (!orgSlug) { @@ -747,6 +750,157 @@ test.describe("Bookings", () => { }); }); }); + + test.describe("Organization admin actions on member bookings", () => { + const testEvent = { + title: "Test Event", + slug: "test-event", + length: 30, + disableCancelling: true, + disableRescheduling: true, + }; + test.describe("Owner with event types, admin tries to manage bookings", () => { + let bookingId: string | undefined; + let org: Team; + let admin: Awaited>; + + test.beforeEach(async ({ page, users, orgs }) => { + org = await orgs.create({ + name: "TestOrg", + }); + + const owner = await users.create({ + username: "owner", + name: "owner", + organizationId: org.id, + roleInOrganization: MembershipRole.OWNER, + eventTypes: [testEvent], + }); + + admin = await users.create({ + username: "admin", + name: "admin", + organizationId: org.id, + roleInOrganization: MembershipRole.ADMIN, + }); + + await doOnOrgDomain( + { + orgSlug: org.slug, + page, + }, + async () => { + await bookUserEvent({ page, user: owner, event: testEvent }); + + const url = new URL(page.url()); + const pathSegments = url.pathname.split("/"); + bookingId = pathSegments[pathSegments.length - 1]; + } + ); + }); + + test("Organization admin should be able to reschedule owner's booking", async ({ page }) => { + await admin.apiLogin(); + + await doOnOrgDomain({ orgSlug: org.slug, page }, async ({ page, goToUrlWithErrorHandling }) => { + await page.goto(`/booking/${bookingId}`); + + await expect(page.locator('[data-testid="reschedule-link"]')).toBeVisible(); + + const result = await goToUrlWithErrorHandling(`/reschedule/${bookingId}`); + await page.goto(result.url.replace(org.slug ?? "", "app")); + await selectFirstAvailableTimeSlotNextMonth(page); + await confirmReschedule(page); + await expect(page.locator('[data-testid="success-page"]')).toBeVisible(); + }); + }); + + test("Organization admin should be able to cancel owner's booking", async ({ page }) => { + await admin.apiLogin(); + + await doOnOrgDomain({ orgSlug: org.slug, page }, async ({ page, goToUrlWithErrorHandling }) => { + await goToUrlWithErrorHandling(`/booking/${bookingId}`); + + await expect(page.locator('[data-testid="cancel"]')).toBeVisible(); + await page.locator('[data-testid="cancel"]').click(); + await page.locator('[data-testid="cancel_reason"]').fill("Admin cancellation of owner's booking"); + await page.locator('[data-testid="confirm_cancel"]').click(); + await expect(page.locator('[data-testid="cancelled-headline"]')).toBeVisible(); + }); + }); + }); + + test.describe("Admin with event types, another admin tries to manage bookings", () => { + let bookingId: string | undefined; + let org: Team; + let managerAdmin: Awaited>; + + test.beforeEach(async ({ page, users, orgs }) => { + org = await orgs.create({ + name: "TestOrg", + }); + + const eventAdmin = await users.create({ + name: `Event-Admin`, + username: `event-admin`, + organizationId: org.id, + roleInOrganization: MembershipRole.ADMIN, + eventTypes: [testEvent], + }); + + managerAdmin = await users.create({ + username: `manager-admin`, + name: `Manager Admin`, + organizationId: org.id, + roleInOrganization: MembershipRole.ADMIN, + }); + + await doOnOrgDomain( + { + orgSlug: org.slug, + page, + }, + async () => { + await bookUserEvent({ page, user: eventAdmin, event: testEvent }); + + const url = new URL(page.url()); + const pathSegments = url.pathname.split("/"); + bookingId = pathSegments[pathSegments.length - 1]; + } + ); + }); + + test("Organization admin should be able to reschedule another admin's booking", async ({ page }) => { + await managerAdmin.apiLogin(); + + await doOnOrgDomain({ orgSlug: "app", page }, async ({ page, goToUrlWithErrorHandling }) => { + await page.goto(`/booking/${bookingId}`); + + await expect(page.locator('[data-testid="reschedule-link"]')).toBeVisible(); + + const result = await goToUrlWithErrorHandling(`/reschedule/${bookingId}`); + await page.goto(result.url.replace(org.slug ?? "", "app")); + await selectFirstAvailableTimeSlotNextMonth(page); + await confirmReschedule(page); + await expect(page.locator('[data-testid="success-page"]')).toBeVisible(); + }); + }); + + test("Organization admin should be able to cancel another admin's booking", async ({ page }) => { + await managerAdmin.apiLogin(); + + await doOnOrgDomain({ orgSlug: org.slug, page }, async ({ page }) => { + await page.goto(`/booking/${bookingId}`); + + await expect(page.locator('[data-testid="cancel"]')).toBeVisible(); + await page.locator('[data-testid="cancel"]').click(); + await page.locator('[data-testid="cancel_reason"]').fill("Admin cancellation of another admin's booking"); + await page.locator('[data-testid="confirm_cancel"]').click(); + await expect(page.locator('[data-testid="cancelled-headline"]')).toBeVisible(); + }); + }); + }); + }); }); async function bookUserEvent({ diff --git a/apps/web/server/lib/[user]/[type]/getServerSideProps.ts b/apps/web/server/lib/[user]/[type]/getServerSideProps.ts index 37f7e6c7fade36..90d45acfa7f207 100644 --- a/apps/web/server/lib/[user]/[type]/getServerSideProps.ts +++ b/apps/web/server/lib/[user]/[type]/getServerSideProps.ts @@ -2,12 +2,14 @@ import { type GetServerSidePropsContext } from "next"; import type { Session } from "next-auth"; import { z } from "zod"; +import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import type { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent"; import { getUsernameList } from "@calcom/lib/defaultEvents"; +import { checkIfUserIsHost } from "@calcom/lib/event-types/utils/checkIfUserIsHost"; import { shouldHideBrandingForUserEvent } from "@calcom/lib/hideBranding"; import { EventRepository } from "@calcom/lib/server/repository/event"; import { UserRepository } from "@calcom/lib/server/repository/user"; @@ -45,9 +47,26 @@ async function processReschedule({ }) { if (!rescheduleUid) return; - const booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id); + const booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id, true); - if (booking?.eventType?.disableRescheduling) { + // Check if user is a host or owner of the event type + const userId = session?.user?.id; + const userIsHost = checkIfUserIsHost( + userId, + { + user: booking?.user || null, + attendees: booking?.attendees || [], + }, + { + users: booking?.eventType?.users, + hosts: booking?.eventType?.hosts as unknown as { user: { id: number; email: string } }[], + } + ); + const userIsOwnerOfEventType = booking?.eventType?.owner?.id === userId; + const isHostOrOwner = !!userIsHost || !!userIsOwnerOfEventType; + const isOrgAdminOrOwner = checkAdminOrOwner(session?.user?.org?.role); + + if (booking?.eventType?.disableRescheduling && !isHostOrOwner && !isOrgAdminOrOwner) { return { redirect: { destination: `/booking/${rescheduleUid}`, diff --git a/packages/features/bookings/lib/get-booking.ts b/packages/features/bookings/lib/get-booking.ts index b66fd78de534cc..7c5e35e6f25e79 100644 --- a/packages/features/bookings/lib/get-booking.ts +++ b/packages/features/bookings/lib/get-booking.ts @@ -45,7 +45,12 @@ function getResponsesFromOldBooking( }; } -async function getBooking(prisma: PrismaClient, uid: string, isSeatedEvent?: boolean) { +async function getBooking( + prisma: PrismaClient, + uid: string, + isSeatedEvent?: boolean, + includeHostsAndOwner = false +) { const rawBooking = await prisma.booking.findUnique({ where: { uid, @@ -65,6 +70,29 @@ async function getBooking(prisma: PrismaClient, uid: string, isSeatedEvent?: boo eventType: { select: { disableRescheduling: true, + ...(includeHostsAndOwner && { + users: { + select: { + id: true, + email: true, + }, + }, + hosts: { + select: { + user: { + select: { + id: true, + email: true, + }, + }, + }, + }, + owner: { + select: { + id: true, + }, + }, + }), }, }, attendees: { @@ -123,7 +151,7 @@ export const getBookingWithResponses = < export default getBooking; -export const getBookingForReschedule = async (uid: string, userId?: number) => { +export const getBookingForReschedule = async (uid: string, userId?: number, includeHostsAndOwner = false) => { let rescheduleUid: string | null = null; const theBooking = await prisma.booking.findUnique({ where: { @@ -202,7 +230,12 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => { // and we return null here. if (!theBooking && !rescheduleUid) return null; - const booking = await getBooking(prisma, rescheduleUid || uid, bookingSeatReferenceUid ? true : false); + const booking = await getBooking( + prisma, + rescheduleUid || uid, + bookingSeatReferenceUid ? true : false, + includeHostsAndOwner + ); if (!booking) return null; @@ -275,6 +308,9 @@ export const getBookingForSeatedEvent = async (uid: string) => { location: null, eventType: { disableRescheduling: false, + hosts: [], + users: [], + owner: null, }, // mask attendee emails for seated events attendees: booking.attendees.map((attendee) => ({ diff --git a/packages/features/bookings/lib/getBookingToDelete.ts b/packages/features/bookings/lib/getBookingToDelete.ts index 7310589c515d04..238bf023c822e1 100644 --- a/packages/features/bookings/lib/getBookingToDelete.ts +++ b/packages/features/bookings/lib/getBookingToDelete.ts @@ -78,6 +78,12 @@ export async function getBookingToDelete(id: number | undefined, uid: string | u hideOrganizerEmail: true, schedulingType: true, customReplyToEmail: true, + users: { + select: { + id: true, + email: true, + }, + }, hosts: { select: { user: true, diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 79e76e31aca393..48a3c63b27aa1e 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -18,6 +18,8 @@ import { import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload"; import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload"; import EventManager from "@calcom/lib/EventManager"; +import { checkIfUserIsHost } from "@calcom/lib/event-types/utils/checkIfUserIsHost"; +import { isTeamAdmin } from "@calcom/lib/server/queries/teams"; import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server"; import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; @@ -30,7 +32,7 @@ import { getTranslation } from "@calcom/lib/server/i18n"; import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import prisma from "@calcom/prisma"; -import type { Prisma, WorkflowReminder } from "@calcom/prisma/client"; +import type { Prisma, WorkflowReminder, MembershipRole } from "@calcom/prisma/client"; import type { WebhookTriggerEvents } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; import { bookingMetadataSchema, bookingCancelInput } from "@calcom/prisma/zod-utils"; @@ -42,7 +44,7 @@ import { getAllCredentialsIncludeServiceAccountKey } from "./getAllCredentialsFo import { getBookingToDelete } from "./getBookingToDelete"; import { handleInternalNote } from "./handleInternalNote"; import cancelAttendeeSeat from "./handleSeats/cancel/cancelAttendeeSeat"; - +import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner"; const log = logger.getSubLogger({ prefix: ["handleCancelBooking"] }); type PlatformParams = { @@ -57,6 +59,7 @@ export type BookingToDelete = Awaited>; export type CancelBookingInput = { userId?: number; + userOrgRole?: MembershipRole; bookingData: z.infer; } & PlatformParams; @@ -83,6 +86,7 @@ async function handler(input: CancelBookingInput) { const bookingToDelete = await getBookingToDelete(id, uid); const { userId, + userOrgRole, platformBookingUrl, platformCancelUrl, platformClientId, @@ -107,7 +111,22 @@ async function handler(input: CancelBookingInput) { throw new HttpError({ statusCode: 400, message: "User not found" }); } - if (bookingToDelete.eventType?.disableCancelling) { + // Check if user is a host or owner of the event type + const userIsHost = checkIfUserIsHost( + userId, + { + user: bookingToDelete.user, + attendees: bookingToDelete.attendees, + }, + bookingToDelete.eventType || undefined + ); + const userIsOwnerOfEventType = userId !== undefined && bookingToDelete.eventType?.owner?.id === userId; + const isHostOrOwner = !!userIsHost || !!userIsOwnerOfEventType; + + const hasTeamOrOrgPermissions = userId !== undefined ? !!(await isTeamAdmin(userId, bookingToDelete.eventType?.team?.id ?? 0)) : false; + const isOrgAdminOrOwner = checkAdminOrOwner(userOrgRole); + + if (bookingToDelete.eventType?.disableCancelling && !isHostOrOwner && !hasTeamOrOrgPermissions && !isOrgAdminOrOwner) { throw new HttpError({ statusCode: 400, message: "This event type does not allow cancellations", @@ -139,7 +158,7 @@ async function handler(input: CancelBookingInput) { const userIsOwnerOfEventType = bookingToDelete.eventType.owner?.id === userId; - if (!userIsHost && !userIsOwnerOfEventType) { + if (!userIsHost && !userIsOwnerOfEventType && !hasTeamOrOrgPermissions && !isOrgAdminOrOwner) { throw new HttpError({ statusCode: 401, message: "User not a host of this event" }); } } diff --git a/packages/lib/bookings/getAllUserBookings.ts b/packages/lib/bookings/getAllUserBookings.ts index 39934544da4471..2f96715a84cc17 100644 --- a/packages/lib/bookings/getAllUserBookings.ts +++ b/packages/lib/bookings/getAllUserBookings.ts @@ -29,6 +29,7 @@ type GetOptions = { attendeeEmail?: string | TextFilterValue; attendeeName?: string | TextFilterValue; bookingUid?: string | undefined; + includeHostAndTeamPermissions?: boolean; }; sort?: SortOptions; }; diff --git a/packages/lib/event-types/utils/checkIfUserIsHost.ts b/packages/lib/event-types/utils/checkIfUserIsHost.ts new file mode 100644 index 00000000000000..db6a536b5ddab3 --- /dev/null +++ b/packages/lib/event-types/utils/checkIfUserIsHost.ts @@ -0,0 +1,42 @@ +/** + * Checks if a user is a host of a booking + * @param userId - The ID of the user to check + * @param bookingInfo - The booking information containing user and attendees + * @param eventType - The event type containing users and hosts + * @returns boolean - True if the user is a host, false otherwise + */ +export function checkIfUserIsHost( + userId?: number | null, + bookingInfo?: { + user?: { id: number } | null; + attendees?: { email: string }[]; + } | null, + eventType?: { + users?: { id: number; email: string }[]; + hosts?: { user: { id: number; email: string } }[]; + } +): boolean { + if (!userId || !bookingInfo) return false; + + if (bookingInfo.user?.id === userId) return true; + + if (!bookingInfo.attendees || !eventType) return false; + + const attendeeEmails = new Set(bookingInfo.attendees.map((attendee) => attendee.email)); + + if (eventType.users) { + const isUserAndAttendee = eventType.users.some( + (user) => user.id === userId && user.email && attendeeEmails.has(user.email) + ); + if (isUserAndAttendee) return true; + } + + if (eventType.hosts) { + const isHostAndAttendee = eventType.hosts.some( + ({ user }) => user.id === userId && user.email && attendeeEmails.has(user.email) + ); + if (isHostAndAttendee) return true; + } + + return false; +} diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index 67adbf554c9e12..c42eafaa55ed66 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -885,6 +885,82 @@ export class BookingRepository { }); } + /** + * Checks if a user is a host of a booking + * @param userId - The ID of the user to check + * @param bookingId - The booking ID to check against + * @returns boolean - True if the user is a host, false otherwise + */ + async checkIfUserIsHost({ userId, bookingId }: { userId: number; bookingId: string }): Promise { + if (!userId || !bookingId) return false; + + const booking = await this.prismaClient.booking.findUnique({ + where: { uid: bookingId }, + select: { + userId: true, + attendees: { + select: { + email: true, + }, + }, + eventType: { + select: { + users: { + select: { + id: true, + email: true, + }, + }, + hosts: { + select: { + userId: true, + user: { + select: { + id: true, + email: true, + }, + }, + }, + }, + owner: { + select: { + id: true, + }, + }, + }, + }, + }, + }); + + if (!booking) return false; + + if (booking.userId === userId) return true; + + if (booking.eventType?.owner?.id === userId) return true; + + if (!booking.attendees || !booking.eventType) return false; + + const attendeeEmails = new Set(booking.attendees.map((attendee: { email: string }) => attendee.email)); + + if (booking.eventType.users) { + const isUserAndAttendee = booking.eventType.users.some( + (user: { id: number; email: string }) => + user.id === userId && user.email && attendeeEmails.has(user.email) + ); + if (isUserAndAttendee) return true; + } + + if (booking.eventType.hosts) { + const isHostAndAttendee = booking.eventType.hosts.some( + (host: { user: { id: number; email: string } }) => + host.user.id === userId && host.user.email && attendeeEmails.has(host.user.email) + ); + if (isHostAndAttendee) return true; + } + + return false; + } + async countBookingsByEventTypeAndDateRange({ eventTypeId, startDate, diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts index 076a29fe666db1..d00083b43fe189 100644 --- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts @@ -7,6 +7,8 @@ import { isTextFilterValue } from "@calcom/features/data-table/lib/utils"; import type { DB } from "@calcom/kysely"; import kysely from "@calcom/kysely"; import getAllUserBookings from "@calcom/lib/bookings/getAllUserBookings"; +import { checkIfUserIsHost } from "@calcom/lib/event-types/utils/checkIfUserIsHost"; +import { isTeamAdmin } from "@calcom/lib/server/queries/teams"; import { parseEventTypeColor } from "@calcom/lib/isEventTypeColor"; import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent"; import logger from "@calcom/lib/logger"; @@ -42,6 +44,7 @@ export const getHandler = async ({ ctx, input }: GetOptions) => { const { prisma, user } = ctx; const defaultStatus = "upcoming"; const bookingListingByStatus = [input.filters.status || defaultStatus]; + const includeHostAndTeamPermissions = input.filters.includeHostAndTeamPermissions ?? false; const { bookings, recurringInfo, totalCount } = await getAllUserBookings({ ctx: { @@ -55,8 +58,90 @@ export const getHandler = async ({ ctx, input }: GetOptions) => { filters: input.filters, }); + if (!includeHostAndTeamPermissions) { + return { + bookings, + recurringInfo, + totalCount, + }; + } + + // Cache for event type permissions to avoid recalculating for multiple bookings of the same event type + const eventTypePermissionsCache = new Map< + number, + { + isUserHostOrOwner: boolean; + hasTeamOrOrgPermissions: boolean; + } + >(); + + const enrichedBookings = await Promise.all( + bookings.map(async (booking) => { + const eventTypeId = booking.eventType?.id; + + // Don't calculate permissions for bookings in the past + if (new Date(booking.endTime) < new Date()) { + return booking; + } + + // Check cache first + if (eventTypeId && eventTypePermissionsCache.has(eventTypeId)) { + const cachedPermissions = eventTypePermissionsCache.get(eventTypeId)!; + return { + ...booking, + eventType: { + ...booking.eventType, + isUserHostOrOwner: cachedPermissions.isUserHostOrOwner, + hasTeamOrOrgPermissions: cachedPermissions.hasTeamOrOrgPermissions, + }, + }; + } + + const isUserHost = checkIfUserIsHost( + user.id, + { + user: booking.user, + attendees: booking.attendees, + }, + { + users: booking.eventType?.users?.map((u) => u.user).filter(Boolean) as { + id: number; + email: string; + }[], + hosts: booking.eventType?.hosts?.filter((h) => h.user).map((h) => ({ user: h.user! })) as { + user: { id: number; email: string }; + }[], + } + ); + + const isUserOwnerOfEventType = user.id === booking.eventType?.userId; + + const hasTeamOrOrgPermissions = !!(await isTeamAdmin(user.id, booking.eventType?.team?.id ?? 0)) || (user.organization && user.organization.isOrgAdmin); + + const isUserHostOrOwner = isUserHost || isUserOwnerOfEventType; + + // Cache the results for this event type + if (eventTypeId) { + eventTypePermissionsCache.set(eventTypeId, { + isUserHostOrOwner, + hasTeamOrOrgPermissions, + }); + } + + const { users, hosts, ...eventTypeWithoutUsersAndHosts } = booking.eventType; + return { + ...booking, + eventType: { + ...eventTypeWithoutUsersAndHosts, + isUserHostOrOwner, + hasTeamOrOrgPermissions, + }, + }; + }) + ); + return { - bookings, + bookings: enrichedBookings, recurringInfo, totalCount, }; @@ -502,6 +587,7 @@ export async function getBookings({ "EventType.hideOrganizerEmail", "EventType.disableCancelling", "EventType.disableRescheduling", + "EventType.userId", eb .cast( eb @@ -535,9 +621,39 @@ export async function getBookings({ jsonObjectFrom( eb .selectFrom("Team") - .select(["Team.id", "Team.name", "Team.slug"]) + .select(["Team.id", "Team.name", "Team.slug", "Team.parentId"]) .whereRef("EventType.teamId", "=", "Team.id") ).as("team"), + ...(filters.includeHostAndTeamPermissions + ? [ + jsonArrayFrom( + eb + .selectFrom("_user_eventtype") + .select((eb) => [ + jsonObjectFrom( + eb + .selectFrom("users") + .select(["users.id", "users.email"]) + .whereRef("_user_eventtype.B", "=", "users.id") + ).as("user"), + ]) + .whereRef("_user_eventtype.A", "=", "EventType.id") + ).as("users"), + jsonArrayFrom( + eb + .selectFrom("Host") + .select((eb) => [ + jsonObjectFrom( + eb + .selectFrom("users") + .select(["users.id", "users.email"]) + .whereRef("Host.userId", "=", "users.id") + ).as("user"), + ]) + .whereRef("Host.eventTypeId", "=", "EventType.id") + ).as("hosts"), + ] + : []), jsonArrayFrom( eb .selectFrom("HostGroup") diff --git a/packages/trpc/server/routers/viewer/bookings/get.schema.ts b/packages/trpc/server/routers/viewer/bookings/get.schema.ts index 97cc56839844e3..ed9cfcb2910fd8 100644 --- a/packages/trpc/server/routers/viewer/bookings/get.schema.ts +++ b/packages/trpc/server/routers/viewer/bookings/get.schema.ts @@ -17,6 +17,7 @@ export const ZGetInputSchema = z.object({ beforeUpdatedDate: z.string().optional(), afterCreatedDate: z.string().optional(), beforeCreatedDate: z.string().optional(), + includeHostAndTeamPermissions: z.boolean().optional(), }), limit: z.number().min(1).max(100), offset: z.number().default(0),