diff --git a/apps/web/playwright/bookings-list.e2e.ts b/apps/web/playwright/bookings-list.e2e.ts index 6ee5ff75e4..9ed6838b6d 100644 --- a/apps/web/playwright/bookings-list.e2e.ts +++ b/apps/web/playwright/bookings-list.e2e.ts @@ -1,7 +1,10 @@ import { expect } from "@playwright/test"; +import prisma from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/client"; +import { MembershipRole } from "@calcom/prisma/enums"; +import { createTeamEventType } from "./fixtures/users"; import type { Fixtures } from "./lib/fixtures"; import { test } from "./lib/fixtures"; import { setupManagedEvent } from "./lib/testUtils"; @@ -207,6 +210,129 @@ test.describe("Bookings", () => { webhookReceiver.close(); }); }); + + test("People filter includes bookings where filtered person is attendee", async ({ + page, + users, + bookings, + }) => { + const firstUser = await users.create( + { name: "First" }, + { + hasTeam: true, + teamRole: MembershipRole.ADMIN, + } + ); + const teamId = (await firstUser.getFirstTeamMembership()).teamId; + const secondUser = await users.create({ name: "Second" }); + const thirdUser = await users.create({ name: "Third" }); + // Add teammates to the team + await prisma.membership.createMany({ + data: [ + { + teamId: teamId, + userId: secondUser.id, + role: MembershipRole.MEMBER, + accepted: true, + }, + { + teamId: teamId, + userId: thirdUser.id, + role: MembershipRole.MEMBER, + accepted: true, + }, + ], + }); + const teamEvent = await createTeamEventType( + { id: firstUser.id }, + { id: teamId }, + { teamEventSlug: "team-event-slug" } + ); + + //Create a TeamEventType booking where ThirdUser is attendee + const thirdUserAttendeeTeamEventBookingFixture = await createBooking({ + title: "ThirdUser is Attendee for TeamEvent", + bookingsFixture: bookings, + relativeDate: 6, + organizer: firstUser, + organizerEventType: teamEvent, + attendees: [{ name: "Third", email: thirdUser.email, timeZone: "Europe/Berlin" }], + }); + const thirdUserAttendeeTeamEvent = await thirdUserAttendeeTeamEventBookingFixture.self(); + + //Create a IndividualEventType booking where ThirdUser,SecondUser are attendees and FirstUser is organizer + const thirdUserAttendeeIndividualBookingFixture = await createBooking({ + title: "ThirdUser is Attendee and FirstUser is Organizer", + bookingsFixture: bookings, + relativeDate: 3, + organizer: firstUser, + organizerEventType: firstUser.eventTypes[0], + attendees: [ + { name: "Third", email: thirdUser.email, timeZone: "Europe/Berlin" }, + { name: "Second", email: secondUser.email, timeZone: "Europe/Berlin" }, + ], + }); + const thirdUserAttendeeIndividualBooking = await thirdUserAttendeeIndividualBookingFixture.self(); + + //Create a IndividualEventType booking where ThirdUser is organizer and FirstUser,SecondUser are attendees + const thirdUserOrganizerBookingFixture = await createBooking({ + title: "ThirdUser is Organizer and FirstUser is Attendee", + bookingsFixture: bookings, + organizer: thirdUser, + relativeDate: 2, + organizerEventType: thirdUser.eventTypes[0], + attendees: [ + { name: "First", email: firstUser.email, timeZone: "Europe/Berlin" }, + { name: "Second", email: secondUser.email, timeZone: "Europe/Berlin" }, + ], + }); + const thirdUserOrganizerBooking = await thirdUserOrganizerBookingFixture.self(); + + //Create a booking where FirstUser is organizer and SecondUser is attendee + await createBooking({ + title: "FirstUser is Organizer and SecondUser is Attendee", + bookingsFixture: bookings, + organizer: firstUser, + relativeDate: 4, + organizerEventType: firstUser.eventTypes[0], + attendees: [{ name: "Second", email: secondUser.email, timeZone: "Europe/Berlin" }], + }); + + //admin login + //Select 'ThirdUser' in people filter + await firstUser.apiLogin(); + await Promise.all([ + page.waitForResponse((response) => /\/api\/trpc\/bookings\/get.*/.test(response.url())), + page.waitForResponse((response) => /\/api\/trpc\/bookings\/get.*/.test(response.url())), + page.goto(`/bookings/upcoming?status=upcoming&userIds=${thirdUser.id}`), + ]); + + //expect only 3 bookings (out of 4 total) to be shown in list. + //where ThirdUser is either organizer or attendee + const upcomingBookingsTable = page.locator('[data-testid="upcoming-bookings"]'); + const bookingListItems = upcomingBookingsTable.locator('[data-testid="booking-item"]'); + const bookingListCount = await bookingListItems.count(); + expect(bookingListCount).toBe(3); + + //verify with the booking titles + const firstUpcomingBooking = bookingListItems.nth(0); + await expect( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + firstUpcomingBooking.locator(`text=${thirdUserOrganizerBooking!.title}`) + ).toBeVisible(); + + const secondUpcomingBooking = bookingListItems.nth(1); + await expect( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + secondUpcomingBooking.locator(`text=${thirdUserAttendeeIndividualBooking!.title}`) + ).toBeVisible(); + + const thirdUpcomingBooking = bookingListItems.nth(2); + await expect( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + thirdUpcomingBooking.locator(`text=${thirdUserAttendeeTeamEvent!.title}`) + ).toBeVisible(); + }); }); async function createBooking({ diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index b2b7340ea1..a57d38e7cf 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -86,7 +86,7 @@ const createTeamWorkflow = async (user: { id: number }, team: { id: number }) => }); }; -const createTeamEventType = async ( +export const createTeamEventType = async ( user: { id: number }, team: { id: number }, scenario?: { diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts index 0bf34b7082..c665e4c4e5 100644 --- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts @@ -48,17 +48,6 @@ export const getHandler = async ({ ctx, input }: GetOptions) => { }; }; -const set = new Set(); -const getUniqueBookings = (arr: T[]) => { - const unique = arr.filter((booking) => { - const duplicate = set.has(booking.uid); - set.add(booking.uid); - return !duplicate; - }); - set.clear(); - return unique; -}; - export async function getBookings({ user, prisma, @@ -76,154 +65,6 @@ export async function getBookings({ take: number; skip: number; }) { - const bookingWhereInputFilters: Record = {}; - - if (filters?.teamIds && filters.teamIds.length > 0) { - bookingWhereInputFilters.teamIds = { - AND: [ - { - OR: [ - { - eventType: { - teamId: { in: filters.teamIds }, - }, - }, - { - eventType: { - parent: { - teamId: { in: filters.teamIds }, - }, - }, - }, - ], - }, - ], - }; - } - - if (filters?.userIds && filters.userIds.length > 0) { - bookingWhereInputFilters.userIds = { - AND: [ - { - OR: [ - { - eventType: { - hosts: { - some: { - userId: { - in: filters.userIds, - }, - isFixed: true, - }, - }, - }, - }, - { - userId: { - in: filters.userIds, - }, - }, - { - eventType: { - users: { - some: { - id: { - in: filters.userIds, - }, - }, - }, - }, - }, - ], - }, - ], - }; - } - - if (filters?.eventTypeIds && filters.eventTypeIds.length > 0) { - bookingWhereInputFilters.eventTypeIds = { - AND: [ - { - OR: [ - { - eventTypeId: { - in: filters.eventTypeIds, - }, - }, - { - eventType: { - parent: { - id: { - in: filters.eventTypeIds, - }, - }, - }, - }, - ], - }, - ], - }; - } - - if (filters?.attendeeEmail) { - bookingWhereInputFilters.attendeeEmail = { - attendees: { - some: { - email: filters.attendeeEmail.trim(), - }, - }, - }; - } - - if (filters?.attendeeName) { - bookingWhereInputFilters.attendeeName = { - attendees: { - some: { - name: filters.attendeeName.trim(), - }, - }, - }; - } - - if (filters?.afterStartDate) { - bookingWhereInputFilters.afterStartDate = { - startTime: { - gte: dayjs.utc(filters.afterStartDate).toDate(), - }, - }; - } - - if (filters?.beforeEndDate) { - bookingWhereInputFilters.beforeEndDate = { - endTime: { - lte: dayjs.utc(filters.beforeEndDate).toDate(), - }, - }; - } - - if (filters?.afterUpdatedDate) { - bookingWhereInputFilters.afterUpdatedDate = { - updatedAt: { - gte: dayjs.utc(filters.afterUpdatedDate).toDate(), - }, - }; - } - - if (filters?.beforeUpdatedDate) { - bookingWhereInputFilters.beforeUpdatedDate = { - updatedAt: { - lte: dayjs.utc(filters.beforeUpdatedDate).toDate(), - }, - }; - } - - const filtersCombined: Prisma.BookingWhereInput[] = !filters - ? [] - : Object.keys(filters) - .map((key) => bookingWhereInputFilters[key]) - // On prisma 5.4.2 passing undefined to where "AND" causes an error - .filter(Boolean); - const bookingSelect = { ...bookingMinimalSelect, uid: true, @@ -326,123 +167,168 @@ export async function getBookings({ }; const [ - // Querying these in parallel to save time. - // Note that because we are applying `take` to individual queries, we will usually get more bookings then we need. It is okay to have more bookings faster than having what we need slower - bookingsQueryUserId, - bookingsQueryAttendees, - bookingsQueryTeamMember, - bookingsQueryManagedEvents, - bookingsQueryOrganizationMembers, - bookingsQuerySeatReference, - ////////////////////////// - - recurringInfoBasic, - recurringInfoExtended, - // We need all promises to be successful, so we are not using Promise.allSettled + eventTypeIdsFromTeamIdsFilter, + { attendeeEmailsFromUserIdsFilter, eventTypeIdsFromUserIdsFilter }, + eventTypeIdsFromEventTypeIdsFilter, + eventTypeIdsWhereUserIsAdminOrOwener, + userIdsWhereUserIsOrgAdminOrOwener, ] = await Promise.all([ - prisma.booking.findMany({ - where: { - OR: [ - { - userId: user.id, + getEventTypeIdsFromTeamIdsFilter(prisma, filters?.teamIds), + getIdsFromUserIdsFilter(prisma, user.email, filters?.userIds), + getEventTypeIdsFromEventTypeIdsFilter(prisma, filters?.eventTypeIds), + getEventTypeIdsWhereUserIsAdminOrOwner(prisma, membershipConditionWhereUserIsAdminOwner), + getUserIdsWhereUserIsOrgAdminOrOwner(prisma, membershipConditionWhereUserIsAdminOwner), + ]); + + const plainBookings = await prisma.booking.findMany({ + where: { + OR: [ + { + userId: user.id, + }, + { + attendees: { + some: { + email: user.email, + }, }, - ], - AND: [passedBookingsStatusFilter, ...filtersCombined], - }, - orderBy, - take: take + 1, - skip, - }), - prisma.booking.findMany({ - where: { - OR: [ - { - attendees: { - some: { + }, + { + eventTypeId: { + in: eventTypeIdsWhereUserIsAdminOrOwener, + }, + }, + { + userId: { + in: userIdsWhereUserIsOrgAdminOrOwener, + }, + }, + { + seatsReferences: { + some: { + attendee: { email: user.email, }, }, }, - ], - AND: [passedBookingsStatusFilter, ...filtersCombined], - }, - orderBy, - take: take + 1, - skip, - }), - prisma.booking.findMany({ - where: { - OR: [ - { - eventType: { - team: { - members: membershipConditionWhereUserIsAdminOwner, - }, - }, - }, - ], - AND: [passedBookingsStatusFilter, ...filtersCombined], - }, - orderBy, - take: take + 1, - skip, - }), - prisma.booking.findMany({ - where: { - eventType: { - parent: { - team: { - members: membershipConditionWhereUserIsAdminOwner, - }, - }, }, - AND: [passedBookingsStatusFilter, ...filtersCombined], - }, - orderBy, - take: take + 1, - skip, - }), - prisma.booking.findMany({ - where: { - OR: [ - { - user: { - teams: { - some: { - team: { - isOrganization: true, - members: membershipConditionWhereUserIsAdminOwner, + ], + AND: [ + passedBookingsStatusFilter, + ...(eventTypeIdsFromTeamIdsFilter + ? [ + { + eventTypeId: { + in: eventTypeIdsFromTeamIdsFilter, + }, + }, + ] + : []), + ...(filters?.userIds && filters.userIds.length > 0 && eventTypeIdsFromUserIdsFilter + ? [ + { + OR: [ + { + userId: { + in: filters.userIds, + }, }, + ...(eventTypeIdsFromUserIdsFilter?.length + ? [ + { + eventTypeId: { + in: eventTypeIdsFromUserIdsFilter, + }, + }, + ] + : []), + ...(attendeeEmailsFromUserIdsFilter?.length + ? [ + { + attendees: { + some: { + email: { + in: attendeeEmailsFromUserIdsFilter, + }, + }, + }, + }, + ] + : []), + ], + }, + ] + : []), + ...(eventTypeIdsFromEventTypeIdsFilter + ? [ + { + eventTypeId: { in: eventTypeIdsFromEventTypeIdsFilter }, + }, + ] + : []), + ...(filters?.attendeeEmail + ? [ + { + attendees: { some: { email: filters.attendeeEmail.trim() } }, + }, + ] + : []), + ...(filters?.attendeeName + ? [ + { + attendees: { some: { name: filters.attendeeName.trim() } }, + }, + ] + : []), + ...(filters?.afterStartDate + ? [ + { + startTime: { + gte: dayjs.utc(filters.afterStartDate).toDate(), }, }, - }, - }, - ], - AND: [passedBookingsStatusFilter, ...filtersCombined], - }, - orderBy, - take: take + 1, - skip, - }), - prisma.booking.findMany({ - where: { - OR: [ - { - seatsReferences: { - some: { - attendee: { - email: user.email, + ] + : []), + ...(filters?.beforeEndDate + ? [ + { + endTime: { + lte: dayjs.utc(filters.beforeEndDate).toDate(), }, }, - }, - }, - ], - AND: [passedBookingsStatusFilter, ...filtersCombined], - }, - orderBy, - take: take + 1, - skip, - }), + ] + : []), + ...(filters?.afterUpdatedDate + ? [ + { + updatedAt: { + gte: dayjs.utc(filters.afterUpdatedDate).toDate(), + }, + }, + ] + : []), + ...(filters?.beforeUpdatedDate + ? [ + { + updatedAt: { + lte: dayjs.utc(filters.beforeUpdatedDate).toDate(), + }, + }, + ] + : []), + ], + }, + select: bookingSelect, + orderBy, + take: take + 1, + skip, + }); + + const [ + recurringInfoBasic, + recurringInfoExtended, + // We need all promises to be successful, so we are not using Promise.allSettled + ] = await Promise.all([ prisma.booking.groupBy({ by: ["recurringEventId"], _min: { @@ -503,16 +389,6 @@ export async function getBookings({ } ); - const plainBookings = getUniqueBookings( - // It's going to mess up the orderBy as we are concatenating independent queries results - bookingsQueryUserId - .concat(bookingsQueryAttendees) - .concat(bookingsQueryTeamMember) - .concat(bookingsQueryManagedEvents) - .concat(bookingsQueryOrganizationMembers) - .concat(bookingsQuerySeatReference) - ); - // Now enrich bookings with relation data. We could have queried the relation data along with the bookings, but that would cause unnecessary queries to the database. // Because Prisma is also going to query the select relation data sequentially, we are fine querying it separately here as it would be just 1 query instead of 4 @@ -520,28 +396,15 @@ export async function getBookings({ `fetching all bookings for ${user.id}`, safeStringify({ ids: plainBookings.map((booking) => booking.id), + filters, orderBy, - filtersCombined, take, skip, }) ); const bookings = await Promise.all( - ( - await prisma.booking.findMany({ - where: { - id: { - in: plainBookings - .sort((a, b) => a.startTime.getTime() - b.startTime.getTime()) - .map((booking) => booking.id), - }, - }, - select: bookingSelect, - // We need to get the sorted bookings here as well because plainBookings array is not correctly sorted - orderBy, - }) - ).map(async (booking) => { + plainBookings.map(async (booking) => { // If seats are enabled and the event is not set to show attendees, filter out attendees that are not the current user if (booking.seatsReferences.length && !booking.eventType?.seatsShowAttendees) { booking.attendees = booking.attendees.filter((attendee) => attendee.email === user.email); @@ -564,3 +427,199 @@ export async function getBookings({ ); return { bookings, recurringInfo }; } + +async function getEventTypeIdsFromTeamIdsFilter(prisma: PrismaClient, teamIds?: number[]) { + if (!teamIds || teamIds.length === 0) { + return undefined; + } + + const [directTeamEventTypeIds, parentTeamEventTypeIds] = await Promise.all([ + prisma.eventType + .findMany({ + where: { + teamId: { in: teamIds }, + }, + select: { + id: true, + }, + }) + .then((eventTypes) => eventTypes.map((eventType) => eventType.id)), + + prisma.eventType + .findMany({ + where: { + parent: { + teamId: { in: teamIds }, + }, + }, + select: { + id: true, + }, + }) + .then((eventTypes) => eventTypes.map((eventType) => eventType.id)), + ]); + + return Array.from(new Set([...directTeamEventTypeIds, ...parentTeamEventTypeIds])); +} + +async function getIdsFromUserIdsFilter(prisma: PrismaClient, userEmail: string, userIds?: number[]) { + if (!userIds || userIds.length === 0) { + return { + eventTypeIdsFromUserIdsFilter: undefined, + attendeeEmailsFromUserIdsFilter: undefined, + }; + } + + const [attendeeEmailsFromUserIdsFilter, eventTypeIdsFromHostsFilter, eventTypeIdsFromUsersFilter] = + await Promise.all([ + prisma.user + .findMany({ + where: { + id: { + in: userIds, + }, + }, + select: { + email: true, + }, + }) + // Include booking if current user is an attendee, regardless of user ID filter + .then((users) => users.map((user) => user.email).concat([userEmail])), + + prisma.eventType + .findMany({ + where: { + hosts: { + some: { + userId: { + in: userIds, + }, + isFixed: true, + }, + }, + }, + select: { + id: true, + }, + }) + .then((eventTypes) => eventTypes.map((eventType) => eventType.id)), + + prisma.eventType + .findMany({ + where: { + users: { + some: { + id: { + in: userIds, + }, + }, + }, + }, + select: { + id: true, + }, + }) + .then((eventTypes) => eventTypes.map((eventType) => eventType.id)), + ]); + + const eventTypeIdsFromUserIdsFilter = Array.from( + new Set([...eventTypeIdsFromHostsFilter, ...eventTypeIdsFromUsersFilter]) + ); + + return { attendeeEmailsFromUserIdsFilter, eventTypeIdsFromUserIdsFilter }; +} + +async function getEventTypeIdsFromEventTypeIdsFilter(prisma: PrismaClient, eventTypeIds?: number[]) { + if (!eventTypeIds || eventTypeIds.length === 0) { + return undefined; + } + const [directEventTypeIds, parentEventTypeIds] = await Promise.all([ + prisma.eventType + .findMany({ + where: { + id: { in: eventTypeIds }, + }, + select: { + id: true, + }, + }) + .then((eventTypes) => eventTypes.map((eventType) => eventType.id)), + + prisma.eventType + .findMany({ + where: { + parent: { + id: { + in: eventTypeIds, + }, + }, + }, + select: { + id: true, + }, + }) + .then((eventTypes) => eventTypes.map((eventType) => eventType.id)), + ]); + + return Array.from(new Set([...directEventTypeIds, ...parentEventTypeIds])); +} + +async function getEventTypeIdsWhereUserIsAdminOrOwner( + prisma: PrismaClient, + membershipCondition: PrismaClientType.MembershipListRelationFilter +) { + const [directTeamEventTypeIds, parentTeamEventTypeIds] = await Promise.all([ + prisma.eventType + .findMany({ + where: { + team: { + members: membershipCondition, + }, + }, + select: { + id: true, + }, + }) + .then((eventTypes) => eventTypes.map((eventType) => eventType.id)), + + prisma.eventType + .findMany({ + where: { + parent: { + team: { + members: membershipCondition, + }, + }, + }, + select: { + id: true, + }, + }) + .then((eventTypes) => eventTypes.map((eventType) => eventType.id)), + ]); + + return Array.from(new Set([...directTeamEventTypeIds, ...parentTeamEventTypeIds])); +} + +async function getUserIdsWhereUserIsOrgAdminOrOwner( + prisma: PrismaClient, + membershipCondition: PrismaClientType.MembershipListRelationFilter +) { + return ( + await prisma.user.findMany({ + where: { + teams: { + some: { + team: { + isOrganization: true, + members: membershipCondition, + }, + }, + }, + }, + select: { + id: true, + }, + }) + ).map((user) => user.id); +}