fix: /bookings get handler (#19369)

* fix: sorting on /bookings

* fix type erorr

* add filters to logging

* fix type error

* filter with attendee emails

* clean up filters

* add test from #19376

* fix e2e tests

* split ORs into smaller queries

* add missing imports
This commit is contained in:
Eunjae Lee
2025-02-24 13:11:29 -03:00
committed by GitHub
parent 4b2265c35d
commit 2d372746d3
3 changed files with 475 additions and 290 deletions
+126
View File
@@ -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({
+1 -1
View File
@@ -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?: {
@@ -48,17 +48,6 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
};
};
const set = new Set();
const getUniqueBookings = <T extends { uid: string }>(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<string, Prisma.BookingWhereInput> = {};
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);
}