Files
calendar/packages/trpc/server/routers/viewer/bookings/get.handler.ts
T
Eunjae LeeandGitHub 2d372746d3 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
2025-02-24 13:11:29 -03:00

626 lines
16 KiB
TypeScript

import { Prisma as PrismaClientType } from "@prisma/client";
import dayjs from "@calcom/dayjs";
import { parseRecurringEvent, parseEventTypeColor } from "@calcom/lib";
import getAllUserBookings from "@calcom/lib/bookings/getAllUserBookings";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { PrismaClient } from "@calcom/prisma";
import { bookingMinimalSelect } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { type BookingStatus } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "../../../trpc";
import type { TGetInputSchema } from "./get.schema";
type GetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: TGetInputSchema;
};
const log = logger.getSubLogger({ prefix: ["bookings.get"] });
export const getHandler = async ({ ctx, input }: GetOptions) => {
// using offset actually because cursor pagination requires a unique column
// for orderBy, but we don't use a unique column in our orderBy
const take = input.limit ?? 10;
const skip = input.cursor ?? 0;
const { prisma, user } = ctx;
const defaultStatus = "upcoming";
const bookingListingByStatus = [input.filters.status || defaultStatus];
const { bookings, recurringInfo, nextCursor } = await getAllUserBookings({
ctx: { user: { id: user.id, email: user.email }, prisma: prisma },
bookingListingByStatus: bookingListingByStatus,
take: take,
skip: skip,
filters: input.filters,
});
return {
bookings,
recurringInfo,
nextCursor,
};
};
export async function getBookings({
user,
prisma,
passedBookingsStatusFilter,
filters,
orderBy,
take,
skip,
}: {
user: { id: number; email: string };
filters: TGetInputSchema["filters"];
prisma: PrismaClient;
passedBookingsStatusFilter: Prisma.BookingWhereInput;
orderBy: Prisma.BookingOrderByWithAggregationInput;
take: number;
skip: number;
}) {
const bookingSelect = {
...bookingMinimalSelect,
uid: true,
responses: true,
/**
* Who uses it -
* 1. We need to be able to decide which booking can have a 'Reroute' action
*/
routedFromRoutingFormReponse: {
select: {
id: true,
},
},
recurringEventId: true,
location: true,
eventType: {
select: {
slug: true,
id: true,
title: true,
eventName: true,
price: true,
recurringEvent: true,
currency: true,
metadata: true,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
eventTypeColor: true,
allowReschedulingPastBookings: true,
schedulingType: true,
length: true,
team: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
status: true,
paid: true,
payment: {
select: {
paymentOption: true,
amount: true,
currency: true,
success: true,
},
},
user: {
select: {
id: true,
name: true,
email: true,
},
},
rescheduled: true,
references: true,
isRecorded: true,
seatsReferences: {
where: {
attendee: {
email: user.email,
},
},
select: {
referenceUid: true,
attendee: {
select: {
email: true,
},
},
},
},
assignmentReason: {
orderBy: { createdAt: PrismaClientType.SortOrder.desc },
take: 1,
},
};
const membershipIdsWhereUserIsAdminOwner = (
await prisma.membership.findMany({
where: {
userId: user.id,
role: {
in: ["ADMIN", "OWNER"],
},
},
select: {
id: true,
},
})
).map((membership) => membership.id);
const membershipConditionWhereUserIsAdminOwner = {
some: {
id: { in: membershipIdsWhereUserIsAdminOwner },
},
};
const [
eventTypeIdsFromTeamIdsFilter,
{ attendeeEmailsFromUserIdsFilter, eventTypeIdsFromUserIdsFilter },
eventTypeIdsFromEventTypeIdsFilter,
eventTypeIdsWhereUserIsAdminOrOwener,
userIdsWhereUserIsOrgAdminOrOwener,
] = await Promise.all([
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,
},
},
},
{
eventTypeId: {
in: eventTypeIdsWhereUserIsAdminOrOwener,
},
},
{
userId: {
in: userIdsWhereUserIsOrgAdminOrOwener,
},
},
{
seatsReferences: {
some: {
attendee: {
email: user.email,
},
},
},
},
],
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(),
},
},
]
: []),
...(filters?.beforeEndDate
? [
{
endTime: {
lte: dayjs.utc(filters.beforeEndDate).toDate(),
},
},
]
: []),
...(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: {
startTime: true,
},
_count: {
recurringEventId: true,
},
where: {
recurringEventId: {
not: { equals: null },
},
userId: user.id,
},
}),
prisma.booking.groupBy({
by: ["recurringEventId", "status", "startTime"],
_min: {
startTime: true,
},
where: {
recurringEventId: {
not: { equals: null },
},
userId: user.id,
},
}),
]);
const recurringInfo = recurringInfoBasic.map(
(
info: (typeof recurringInfoBasic)[number]
): {
recurringEventId: string | null;
count: number;
firstDate: Date | null;
bookings: {
[key: string]: Date[];
};
} => {
const bookings = recurringInfoExtended.reduce(
(prev, curr) => {
if (curr.recurringEventId === info.recurringEventId) {
prev[curr.status].push(curr.startTime);
}
return prev;
},
{ ACCEPTED: [], CANCELLED: [], REJECTED: [], PENDING: [], AWAITING_HOST: [] } as {
[key in BookingStatus]: Date[];
}
);
return {
recurringEventId: info.recurringEventId,
count: info._count.recurringEventId,
firstDate: info._min.startTime,
bookings,
};
}
);
// 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
log.info(
`fetching all bookings for ${user.id}`,
safeStringify({
ids: plainBookings.map((booking) => booking.id),
filters,
orderBy,
take,
skip,
})
);
const bookings = await Promise.all(
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);
}
return {
...booking,
eventType: {
...booking.eventType,
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
eventTypeColor: parseEventTypeColor(booking.eventType?.eventTypeColor),
price: booking.eventType?.price || 0,
currency: booking.eventType?.currency || "usd",
metadata: EventTypeMetaDataSchema.parse(booking.eventType?.metadata || {}),
},
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
};
})
);
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);
}