feat: get org bookings endpoint api-v2 (#20462)

* feat: get org bookings endpoint api-v2

* fix userId filter transform

* fix: update getBookings to take filter.userIds in account

* refactor: getBooking where clause

* fixup! refactor: getBooking where clause

* feat: enable org owners to see org member bookings as attendees

* update flaky e2e tests

* fixup! feat: enable org owners to see org member bookings as attendees

* fixup! fixup! feat: enable org owners to see org member bookings as attendees

* chore: differentiate between org/team owner/admin

* chore: throw error with invalid userIds filter

* chore: clearly ensure non team orgs when orgId is not specified

* bump libraries and add comments

* bump libraries

* chore: give main profile orgId to getAllUserBookings

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
This commit is contained in:
MorganandEunjae Lee authored and GitHub committed 2025-04-03 17:36:10 -04:00
1 parent d63d15b7b2
commit da853958f7
19 files changed
+1601 -239

No files matched your search

+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.168",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.171",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -17,7 +17,8 @@ import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repo
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
Post,
@@ -36,7 +37,6 @@ import {
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { User } from "@prisma/client";
import { CreationSource } from "@prisma/client";
import { Request } from "express";
import { NextApiRequest } from "next/types";
@@ -104,7 +104,8 @@ export class BookingsController_2024_04_15 {
private readonly config: ConfigService,
private readonly apiKeyRepository: ApiKeysRepository,
private readonly platformBookingsService: PlatformBookingsService,
private readonly usersRepository: UsersRepository
private readonly usersRepository: UsersRepository,
private readonly usersService: UsersService
) {}
@Get("/")
@@ -114,18 +115,19 @@ export class BookingsController_2024_04_15 {
@ApiQuery({ name: "limit", type: "number", required: false })
@ApiQuery({ name: "cursor", type: "number", required: false })
async getBookings(
@GetUser() user: User,
@GetUser() user: UserWithProfile,
@Query() queryParams: GetBookingsInput_2024_04_15
): Promise<GetBookingsOutput_2024_04_15> {
const { filters, cursor, limit } = queryParams;
const bookingListingByStatus = filters?.status ?? Status_2024_04_15["upcoming"];
const profile = this.usersService.getUserMainProfile(user);
const bookings = await getAllUserBookings({
bookingListingByStatus: [bookingListingByStatus],
skip: cursor ?? 0,
take: limit ?? 10,
filters,
ctx: {
user: { email: user.email, id: user.id },
user: { email: user.email, id: user.id, orgId: profile?.organizationId },
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
},
});
@@ -12,6 +12,7 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
@@ -34,7 +35,6 @@ import {
ApiBody,
ApiExtraModels,
} from "@nestjs/swagger";
import { User } from "@prisma/client";
import { Request } from "express";
import { BOOKING_READ, BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
@@ -80,7 +80,10 @@ import {
export class BookingsController_2024_08_13 {
private readonly logger = new Logger("BookingsController_2024_08_13");
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
constructor(
private readonly bookingsService: BookingsService_2024_08_13,
private readonly usersService: UsersService
) {}
@Post("/")
@ApiOperation({
@@ -161,9 +164,15 @@ export class BookingsController_2024_08_13 {
@ApiOperation({ summary: "Get all bookings" })
async getBookings(
@Query() queryParams: GetBookingsInput_2024_08_13,
@GetUser() user: User
@GetUser() user: UserWithProfile
): Promise<GetBookingsOutput_2024_08_13> {
const bookings = await this.bookingsService.getBookings(queryParams, user);
const profile = this.usersService.getUserMainProfile(user);
const bookings = await this.bookingsService.getBookings(queryParams, {
email: user.email,
id: user.id,
orgId: profile?.organizationId,
});
return {
status: SUCCESS_STATUS,
@@ -591,18 +591,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
return request(app.getHttpServer())
.get(`/v2/bookings?teamId=${team2.id}&eventTypeId=90909`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(0);
});
.expect(400);
});
it("should get bookings by teamIds", async () => {
@@ -265,7 +265,11 @@ export class BookingsService_2024_08_13 {
return this.outputService.getOutputRecurringBookings(ids);
}
async getBookings(queryParams: GetBookingsInput_2024_08_13, user: { email: string; id: number }) {
async getBookings(
queryParams: GetBookingsInput_2024_08_13,
user: { email: string; id: number; orgId?: number },
userIds?: number[]
) {
if (queryParams.attendeeEmail) {
queryParams.attendeeEmail = await this.getAttendeeEmail(queryParams.attendeeEmail, user);
}
@@ -274,7 +278,10 @@ export class BookingsService_2024_08_13 {
bookingListingByStatus: queryParams.status || [],
skip: queryParams.skip ?? 0,
take: queryParams.take ?? 100,
filters: this.inputService.transformGetBookingsFilters(queryParams),
filters: {
...this.inputService.transformGetBookingsFilters(queryParams),
...(userIds?.length ? { userIds } : {}),
},
ctx: {
user,
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
@@ -4,6 +4,7 @@ import { BillingModule } from "@/modules/billing/billing.module";
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
import { DestinationCalendarsModule } from "@/modules/destination-calendars/destination-calendars.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { OrganizationsBookingsModule } from "@/modules/organizations/bookings/organizations.bookings.module";
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.module";
import { RouterModule } from "@/modules/router/router.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
@@ -27,6 +28,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
StripeModule,
ConferencingModule,
OrganizationsTeamsBookingsModule,
OrganizationsBookingsModule,
RouterModule,
],
})
@@ -0,0 +1,25 @@
import { ApiProperty } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { ArrayMinSize, IsArray, IsNumber, IsOptional } from "class-validator";
import { GetBookingsInput_2024_08_13 } from "@calcom/platform-types";
export class GetOrganizationsBookingsInput extends GetBookingsInput_2024_08_13 {
@IsArray()
@IsOptional()
@Transform(({ value }) => {
if (typeof value === "string") {
return value.split(",").map((userId: string) => parseInt(userId));
}
return value;
})
@IsNumber({}, { each: true })
@ArrayMinSize(1, { message: "userIds must contain at least 1 user id" })
@ApiProperty({
type: String,
required: false,
description: "Filter bookings by ids of users within your organization.",
example: "?userIds=100,200",
})
userIds?: number[];
}
@@ -0,0 +1,703 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import {
CAL_API_VERSION_HEADER,
SUCCESS_STATUS,
VERSION_2024_08_13,
X_CAL_CLIENT_ID,
X_CAL_SECRET_KEY,
} from "@calcom/platform-constants";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Organizations Bookings Endpoints 2024-08-13", () => {
describe("Organization bookings", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let hostsRepositoryFixture: HostsRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
const orgUserEmail = "org-user-1-bookings@api.com";
const orgUserEmail2 = "org-user-2-bookings@api.com";
const nonOrgUserEmail1 = "non-org-user-1-bookings@api.com";
let orgUser: User;
let orgUser2: User;
let nonOrgUser1: User;
let team1: Team;
let orgEventTypeId: number;
let orgEventTypeId2: number;
let nonOrgEventTypeId: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
orgUserEmail2,
Test.createTestingModule({
imports: [AppModule, OrganizationsTeamsBookingsModule],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await organizationsRepositoryFixture.create({ name: "organization bookings" });
team1 = await teamRepositoryFixture.create({
name: "team orgs booking 1",
isOrganization: false,
parent: { connect: { id: organization.id } },
});
oAuthClient = await createOAuthClient(organization.id);
nonOrgUser1 = await userRepositoryFixture.create({
email: nonOrgUserEmail1,
locale: "it",
name: "NonOrgUser1Bookings",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
orgUser = await userRepositoryFixture.create({
email: orgUserEmail,
locale: "it",
name: "orgUser1Bookings",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
orgUser2 = await userRepositoryFixture.create({
email: orgUserEmail2,
locale: "es",
name: "orgUser2Bookings",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(orgUser.id, userSchedule);
await schedulesService.createUserSchedule(orgUser2.id, userSchedule);
await schedulesService.createUserSchedule(nonOrgUser1.id, userSchedule);
const orgEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team1.id },
},
title: "Collective Event Type",
slug: "org-bookings-collective-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
const orgEventType2 = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team1.id },
},
title: "Collective Event Type",
slug: "org-bookings-round-robin-event-type",
length: 60,
assignAllTeamMembers: false,
bookingFields: [],
locations: [],
});
orgEventTypeId2 = orgEventType2.id;
await profileRepositoryFixture.create({
uid: `usr-${orgUser.id}`,
username: orgUserEmail,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: orgUser.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${orgUser2.id}`,
username: orgUserEmail2,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: orgUser2.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: orgUser.id } },
team: { connect: { id: organization.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: orgUser2.id } },
team: { connect: { id: organization.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: orgUser.id } },
team: { connect: { id: team1.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: orgUser2.id } },
team: { connect: { id: team1.id } },
accepted: true,
});
const nonOrgEventType = await eventTypesRepositoryFixture.create(
{
title: "Non Org Event Type",
slug: "non-org-event-type",
length: 60,
bookingFields: [],
locations: [],
},
nonOrgUser1.id
);
orgEventTypeId = orgEventType.id;
nonOrgEventTypeId = nonOrgEventType.id;
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: orgUser.id,
},
},
eventType: {
connect: {
id: orgEventType.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: orgUser2.id,
},
},
eventType: {
connect: {
id: orgEventType.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: orgUser2.id,
},
},
eventType: {
connect: {
id: orgEventType2.id,
},
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
describe("create organization bookings", () => {
it("should create an collective organization booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 9, 13, 0, 0)).toISOString(),
eventTypeId: orgEventTypeId,
attendee: {
name: "alice",
email: "alice@gmail.com",
timeZone: "Europe/Madrid",
language: "es",
},
meetingUrl: "https://meet.google.com/abc-def-ghi",
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts.length).toEqual(1);
expect(data.hosts[0].id).toEqual(orgUser.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 9, 14, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(orgEventTypeId);
expect(data.attendees.length).toEqual(2);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(data.meetingUrl).toEqual(body.meetingUrl);
expect(data.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
it("should create a round robin organization booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 10, 13, 0, 0)).toISOString(),
eventTypeId: orgEventTypeId2,
attendee: {
name: "alice",
email: "alice@gmail.com",
timeZone: "Europe/Madrid",
language: "es",
},
meetingUrl: "https://meet.google.com/abc-def-ghi",
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts.length).toEqual(1);
expect(data.hosts[0].id).toEqual(orgUser2.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 10, 14, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(orgEventTypeId2);
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(data.meetingUrl).toEqual(body.meetingUrl);
expect(data.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
it("should create a non organization booking for org-user-1", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: nonOrgEventTypeId,
attendee: {
name: orgUser.name ?? "",
email: orgUserEmail,
timeZone: orgUser.timeZone ?? "Europe/Madrid",
language: "en",
},
meetingUrl: "https://meet.google.com/abc-def-ghi",
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts.length).toEqual(1);
expect(data.hosts[0].id).toEqual(nonOrgUser1.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(nonOrgEventTypeId);
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: orgUserEmail,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(data.meetingUrl).toEqual(body.meetingUrl);
expect(data.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
it("should create a non organization booking for org-user-2", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 11, 13, 0, 0)).toISOString(),
eventTypeId: nonOrgEventTypeId,
attendee: {
name: orgUser2.name ?? "",
email: orgUserEmail2,
timeZone: orgUser2.timeZone ?? "Europe/Madrid",
language: "en",
},
meetingUrl: "https://meet.google.com/abc-def-ghi",
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts.length).toEqual(1);
expect(data.hosts[0].id).toEqual(nonOrgUser1.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 11, 14, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(nonOrgEventTypeId);
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: orgUserEmail2,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(data.meetingUrl).toEqual(body.meetingUrl);
expect(data.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
});
describe("get organization bookings", () => {
it("should get bookings by organizationId", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(4);
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
[orgEventTypeId, orgEventTypeId2, nonOrgEventTypeId, nonOrgEventTypeId].sort()
);
});
});
it("should get bookings by organizationId", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?userIds=${orgUser.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(2);
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
[orgEventTypeId, nonOrgEventTypeId].sort()
);
});
});
it("should get bookings by organizationId and userIds", async () => {
return request(app.getHttpServer())
.get(
`/v2/organizations/${organization.id}/bookings?userIds=${orgUser.id},${orgUser2.id}&skip=0&take=250`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(4);
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
[orgEventTypeId, orgEventTypeId2, nonOrgEventTypeId, nonOrgEventTypeId].sort()
);
});
});
it("should get bookings by organizationId and userId", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?userIds=${orgUser2.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(3);
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
[orgEventTypeId, orgEventTypeId2, nonOrgEventTypeId].sort()
);
});
});
it("should fail to get bookings by organizationId and Id of a user that does not exist", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?userIds=972930`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
it("should fail to get bookings by organizationId and Id of a user that does not belong to the org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?userIds=${nonOrgUser1.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(403);
});
it("should get bookings by organizationId and non org event-type id", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?eventTypeIds=${nonOrgEventTypeId}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(2);
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
[nonOrgEventTypeId, nonOrgEventTypeId].sort()
);
});
});
it("should get bookings by organizationId and org event-type id", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?eventTypeIds=${orgEventTypeId}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(1);
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual([orgEventTypeId].sort());
});
});
it("should get bookings by organizationId and org + non org event-type ids", async () => {
return request(app.getHttpServer())
.get(
`/v2/organizations/${organization.id}/bookings?eventTypeIds=${orgEventTypeId2},${nonOrgEventTypeId}`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(3);
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
[orgEventTypeId2, nonOrgEventTypeId, nonOrgEventTypeId].sort()
);
});
});
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(orgUser.email);
await userRepositoryFixture.deleteByEmail(orgUser2.email);
await userRepositoryFixture.deleteByEmail(nonOrgUser1.email);
await bookingsRepositoryFixture.deleteAllBookings(orgUser.id, orgUser.email);
await bookingsRepositoryFixture.deleteAllBookings(orgUser2.id, orgUser2.email);
await bookingsRepositoryFixture.deleteAllBookings(nonOrgUser1.id, nonOrgUser1.email);
await app.close();
});
});
});
@@ -0,0 +1,56 @@
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { OrganizationsUsersService } from "@/modules/organizations/users/index/services/organizations-users-service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query, HttpStatus, HttpCode } from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
import { GetOrganizationsBookingsInput } from "./inputs/get-org-bookings.input";
@Controller({
path: "/v2/organizations/:orgId/bookings",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Orgs / Bookings")
export class OrganizationsBookingsController {
constructor(
private readonly bookingsService: BookingsService_2024_08_13,
private readonly orgUsersService: OrganizationsUsersService
) {}
@Get("/")
@ApiOperation({ summary: "Get organization team bookings" })
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@HttpCode(HttpStatus.OK)
async getAllOrgTeamBookings(
@Query() queryParams: GetOrganizationsBookingsInput,
@Param("orgId", ParseIntPipe) orgId: number,
@GetUser() user: UserWithProfile
): Promise<GetBookingsOutput_2024_08_13> {
const { userIds, ...restParams } = queryParams;
const bookings = await this.bookingsService.getBookings(
{ ...restParams },
{ email: user.email, id: user.id, orgId },
userIds
);
return {
status: SUCCESS_STATUS,
data: bookings,
};
}
}
@@ -0,0 +1,23 @@
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsUsersRepository } from "@/modules/organizations//users/index/organizations-users.repository";
import { OrganizationsBookingsController } from "@/modules/organizations/bookings/organizations-bookings.controller";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { OrganizationsUsersService } from "@/modules/organizations/users/index/services/organizations-users-service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { Module } from "@nestjs/common";
@Module({
imports: [BookingsModule_2024_08_13, PrismaModule, StripeModule, RedisModule, MembershipsModule],
providers: [
OrganizationsRepository,
OrganizationsTeamsRepository,
OrganizationsUsersService,
OrganizationsUsersRepository,
],
controllers: [OrganizationsBookingsController],
})
export class OrganizationsBookingsModule {}
@@ -313,18 +313,7 @@ describe("Organizations TeamsBookings Endpoints 2024-08-13", () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/teams/${team1.id}/bookings?eventTypeId=90909`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(0);
});
.expect(400);
});
it("should not get bookings by non existing teamId", async () => {
@@ -43,9 +43,13 @@ export class OrganizationsTeamsBookingsController {
async getAllOrgTeamBookings(
@Query() queryParams: GetOrganizationsTeamsBookingsInput_2024_08_13,
@Param("teamId", ParseIntPipe) teamId: number,
@Param("orgId", ParseIntPipe) orgId: number,
@GetUser() user: UserWithProfile
): Promise<GetBookingsOutput_2024_08_13> {
const bookings = await this.bookingsService.getBookings({ ...queryParams, teamId }, user);
const bookings = await this.bookingsService.getBookings(
{ ...queryParams, teamId },
{ email: user.email, id: user.id, orgId }
);
return {
status: SUCCESS_STATUS,
@@ -37,6 +37,22 @@ export class OrganizationsUsersRepository {
});
}
async getOrganizationUsersByIds(orgId: number, userIds: number[]) {
return await this.dbRead.prisma.user.findMany({
where: {
profiles: {
some: {
organizationId: orgId,
userId: { in: userIds },
},
},
},
include: {
profiles: true,
},
});
}
async getOrganizationUserByEmail(orgId: number, email: string) {
return await this.dbRead.prisma.user.findFirst({
where: {
@@ -1,10 +1,9 @@
import { EmailService } from "@/modules/email/email.service";
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
import { CreateOrganizationUserInput } from "@/modules/organizations/users/index/inputs/create-organization-user.input";
import { UpdateOrganizationUserInput } from "@/modules/organizations/users/index/inputs/update-organization-user.input";
import { OrganizationsUsersRepository } from "@/modules/organizations/users/index/organizations-users.repository";
import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
import { Injectable, ConflictException } from "@nestjs/common";
import { Injectable, ConflictException, ForbiddenException } from "@nestjs/common";
import { Team, CreationSource } from "@prisma/client";
import { plainToInstance } from "class-transformer";
@@ -14,7 +13,6 @@ import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries";
export class OrganizationsUsersService {
constructor(
private readonly organizationsUsersRepository: OrganizationsUsersRepository,
private readonly organizationsTeamsService: OrganizationsTeamsService,
private readonly emailService: EmailService
) {}
@@ -118,4 +116,14 @@ export class OrganizationsUsersService {
if (isUsernameTaken) throw new ConflictException("Username is already taken");
}
async getUsersByIds(orgId: number, userIds: number[]) {
const orgUsers = await this.organizationsUsersRepository.getOrganizationUsersByIds(orgId, userIds);
if (!orgUsers?.length) {
throw new ForbiddenException("Provided user ids does not belong to the organization.");
}
return orgUsers;
}
}
+265
View File
@@ -1270,6 +1270,263 @@
]
}
},
"/v2/organizations/{orgId}/bookings": {
"get": {
"operationId": "OrganizationsBookingsController_getAllOrgTeamBookings",
"summary": "Get organization team bookings",
"parameters": [
{
"name": "status",
"required": false,
"in": "query",
"description": "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.",
"example": "?status=upcoming,past",
"schema": {
"type": "array",
"items": {
"type": "string",
"enum": [
"upcoming",
"recurring",
"past",
"cancelled",
"unconfirmed"
]
}
}
},
{
"name": "attendeeEmail",
"required": false,
"in": "query",
"description": "Filter bookings by the attendee's email address.",
"example": "example@domain.com",
"schema": {
"type": "string"
}
},
{
"name": "attendeeName",
"required": false,
"in": "query",
"description": "Filter bookings by the attendee's name.",
"example": "John Doe",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeIds",
"required": false,
"in": "query",
"description": "Filter bookings by event type ids belonging to the user. Event type ids must be separated by a comma.",
"example": "?eventTypeIds=100,200",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeId",
"required": false,
"in": "query",
"description": "Filter bookings by event type id belonging to the user.",
"example": "?eventTypeId=100",
"schema": {
"type": "string"
}
},
{
"name": "teamsIds",
"required": false,
"in": "query",
"description": "Filter bookings by team ids that user is part of. Team ids must be separated by a comma.",
"example": "?teamIds=50,60",
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": false,
"in": "query",
"description": "Filter bookings by team id that user is part of",
"example": "?teamId=50",
"schema": {
"type": "string"
}
},
{
"name": "afterStart",
"required": false,
"in": "query",
"description": "Filter bookings with start after this date string.",
"example": "?afterStart=2025-03-07T10:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "beforeEnd",
"required": false,
"in": "query",
"description": "Filter bookings with end before this date string.",
"example": "?beforeEnd=2025-03-07T11:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "afterCreatedAt",
"required": false,
"in": "query",
"description": "Filter bookings that have been created after this date string.",
"example": "?afterCreatedAt=2025-03-07T10:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "beforeCreatedAt",
"required": false,
"in": "query",
"description": "Filter bookings that have been created before this date string.",
"example": "?beforeCreatedAt=2025-03-14T11:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "afterUpdatedAt",
"required": false,
"in": "query",
"description": "Filter bookings that have been updated after this date string.",
"example": "?afterUpdatedAt=2025-03-07T10:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "beforeUpdatedAt",
"required": false,
"in": "query",
"description": "Filter bookings that have been updated before this date string.",
"example": "?beforeUpdatedAt=2025-03-14T11:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "sortStart",
"required": false,
"in": "query",
"description": "Sort results by their start time in ascending or descending order.",
"example": "?sortStart=asc OR ?sortStart=desc",
"schema": {
"enum": [
"asc",
"desc"
],
"type": "string"
}
},
{
"name": "sortEnd",
"required": false,
"in": "query",
"description": "Sort results by their end time in ascending or descending order.",
"example": "?sortEnd=asc OR ?sortEnd=desc",
"schema": {
"enum": [
"asc",
"desc"
],
"type": "string"
}
},
{
"name": "sortCreated",
"required": false,
"in": "query",
"description": "Sort results by their creation time (when booking was made) in ascending or descending order.",
"example": "?sortCreated=asc OR ?sortCreated=desc",
"schema": {
"enum": [
"asc",
"desc"
],
"type": "string"
}
},
{
"name": "sortUpdatedAt",
"required": false,
"in": "query",
"description": "Sort results by their updated time (for example when booking status changes) in ascending or descending order.",
"example": "?sortUpdated=asc OR ?sortUpdated=desc",
"schema": {
"enum": [
"asc",
"desc"
],
"type": "string"
}
},
{
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"schema": {
"type": "number"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"example": 0,
"schema": {
"type": "number"
}
},
{
"name": "userIds",
"required": false,
"in": "query",
"description": "Filter bookings by ids of users within your organization.",
"example": "?userIds=100,200",
"schema": {
"type": "string"
}
},
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetBookingsOutput_2024_08_13"
}
}
}
}
},
"tags": [
"Orgs / Bookings"
]
}
},
"/v2/organizations/{orgId}/delegation-credentials": {
"post": {
"operationId": "OrganizationsDelegationCredentialController_createDelegationCredential",
@@ -2706,6 +2963,14 @@
"schema": {
"type": "number"
}
},
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
+12 -3
View File
@@ -69,8 +69,11 @@ test.describe("Bookings", () => {
test("Cannot choose date range presets", async ({ page, users, bookings, webhooks }) => {
const firstUser = await users.create();
await firstUser.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.goto(`/bookings/upcoming`);
await page.waitForResponse((response) => /\/api\/trpc\/bookings\/get.*/.test(response.url()));
await bookingsGetResponse;
await page.locator('[data-testid="add-filter-button"]').click();
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
@@ -233,8 +236,11 @@ test.describe("Bookings", () => {
test("Can choose date range presets", async ({ page, users, bookings, webhooks }) => {
const firstUser = await users.create();
await firstUser.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.goto(`/bookings/past`);
await page.waitForResponse((response) => /\/api\/trpc\/bookings\/get.*/.test(response.url()));
await bookingsGetResponse;
await page.locator('[data-testid="add-filter-button"]').click();
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
@@ -447,8 +453,11 @@ test.describe("Bookings", () => {
const anotherUser = teamMatesObj.find((m) => m.name !== host.user.name)?.name;
await owner.apiLogin();
const bookingsGetResponse1 = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.goto("/bookings/upcoming");
await page.waitForResponse((response) => /\/api\/trpc\/bookings\/get.*/.test(response.url()));
await bookingsGetResponse1;
await page.locator('[data-testid="add-filter-button"]').click();
await page.locator('[data-testid="add-filter-item-userId"]').click();
+245
View File
@@ -1220,6 +1220,243 @@
"tags": ["Orgs / Attributes / Options"]
}
},
"/v2/organizations/{orgId}/bookings": {
"get": {
"operationId": "OrganizationsBookingsController_getAllOrgTeamBookings",
"summary": "Get organization team bookings",
"parameters": [
{
"name": "status",
"required": false,
"in": "query",
"description": "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.",
"example": "?status=upcoming,past",
"schema": {
"type": "array",
"items": {
"type": "string",
"enum": ["upcoming", "recurring", "past", "cancelled", "unconfirmed"]
}
}
},
{
"name": "attendeeEmail",
"required": false,
"in": "query",
"description": "Filter bookings by the attendee's email address.",
"example": "example@domain.com",
"schema": {
"type": "string"
}
},
{
"name": "attendeeName",
"required": false,
"in": "query",
"description": "Filter bookings by the attendee's name.",
"example": "John Doe",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeIds",
"required": false,
"in": "query",
"description": "Filter bookings by event type ids belonging to the user. Event type ids must be separated by a comma.",
"example": "?eventTypeIds=100,200",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeId",
"required": false,
"in": "query",
"description": "Filter bookings by event type id belonging to the user.",
"example": "?eventTypeId=100",
"schema": {
"type": "string"
}
},
{
"name": "teamsIds",
"required": false,
"in": "query",
"description": "Filter bookings by team ids that user is part of. Team ids must be separated by a comma.",
"example": "?teamIds=50,60",
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": false,
"in": "query",
"description": "Filter bookings by team id that user is part of",
"example": "?teamId=50",
"schema": {
"type": "string"
}
},
{
"name": "afterStart",
"required": false,
"in": "query",
"description": "Filter bookings with start after this date string.",
"example": "?afterStart=2025-03-07T10:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "beforeEnd",
"required": false,
"in": "query",
"description": "Filter bookings with end before this date string.",
"example": "?beforeEnd=2025-03-07T11:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "afterCreatedAt",
"required": false,
"in": "query",
"description": "Filter bookings that have been created after this date string.",
"example": "?afterCreatedAt=2025-03-07T10:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "beforeCreatedAt",
"required": false,
"in": "query",
"description": "Filter bookings that have been created before this date string.",
"example": "?beforeCreatedAt=2025-03-14T11:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "afterUpdatedAt",
"required": false,
"in": "query",
"description": "Filter bookings that have been updated after this date string.",
"example": "?afterUpdatedAt=2025-03-07T10:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "beforeUpdatedAt",
"required": false,
"in": "query",
"description": "Filter bookings that have been updated before this date string.",
"example": "?beforeUpdatedAt=2025-03-14T11:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "sortStart",
"required": false,
"in": "query",
"description": "Sort results by their start time in ascending or descending order.",
"example": "?sortStart=asc OR ?sortStart=desc",
"schema": {
"enum": ["asc", "desc"],
"type": "string"
}
},
{
"name": "sortEnd",
"required": false,
"in": "query",
"description": "Sort results by their end time in ascending or descending order.",
"example": "?sortEnd=asc OR ?sortEnd=desc",
"schema": {
"enum": ["asc", "desc"],
"type": "string"
}
},
{
"name": "sortCreated",
"required": false,
"in": "query",
"description": "Sort results by their creation time (when booking was made) in ascending or descending order.",
"example": "?sortCreated=asc OR ?sortCreated=desc",
"schema": {
"enum": ["asc", "desc"],
"type": "string"
}
},
{
"name": "sortUpdatedAt",
"required": false,
"in": "query",
"description": "Sort results by their updated time (for example when booking status changes) in ascending or descending order.",
"example": "?sortUpdated=asc OR ?sortUpdated=desc",
"schema": {
"enum": ["asc", "desc"],
"type": "string"
}
},
{
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"schema": {
"type": "number"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"example": 0,
"schema": {
"type": "number"
}
},
{
"name": "userIds",
"required": false,
"in": "query",
"description": "Filter bookings by ids of users within your organization.",
"example": "?userIds=100,200",
"schema": {
"type": "string"
}
},
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetBookingsOutput_2024_08_13"
}
}
}
}
},
"tags": ["Orgs / Bookings"]
}
},
"/v2/organizations/{orgId}/delegation-credentials": {
"post": {
"operationId": "OrganizationsDelegationCredentialController_createDelegationCredential",
@@ -2603,6 +2840,14 @@
"schema": {
"type": "number"
}
},
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
+1 -2
View File
@@ -13,7 +13,7 @@ type SortOptions = {
};
type GetOptions = {
ctx: {
user: { id: number; email: string };
user: { id: number; email: string; orgId?: number | null };
prisma: PrismaClient;
};
bookingListingByStatus: InputByStatus[];
@@ -76,7 +76,6 @@ const getAllUserBookings = async ({ ctx, filters, bookingListingByStatus, take,
const orderBy = getOrderBy(bookingListingByStatus, sort);
const combinedFilters = bookingListingByStatus.map((status) => bookingListingFilters[status]);
const { bookings, recurringInfo, totalCount } = await getBookings({
user,
prisma,
@@ -13,6 +13,8 @@ import type { Prisma } from "@calcom/prisma/client";
import { type BookingStatus } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { TGetInputSchema } from "./get.schema";
@@ -36,7 +38,10 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
const bookingListingByStatus = [input.filters.status || defaultStatus];
const { bookings, recurringInfo, totalCount } = await getAllUserBookings({
ctx: { user: { id: user.id, email: user.email }, prisma: prisma },
ctx: {
user: { id: user.id, email: user.email, orgId: user?.profile?.organizationId },
prisma: prisma,
},
bookingListingByStatus: bookingListingByStatus,
take,
skip,
@@ -59,7 +64,7 @@ export async function getBookings({
take,
skip,
}: {
user: { id: number; email: string };
user: { id: number; email: string; orgId?: number | null };
filters: TGetInputSchema["filters"];
prisma: PrismaClient;
passedBookingsStatusFilter: Prisma.BookingWhereInput;
@@ -175,190 +180,165 @@ export async function getBookings({
eventTypeIdsFromTeamIdsFilter,
attendeeEmailsFromUserIdsFilter,
eventTypeIdsFromEventTypeIdsFilter,
eventTypeIdsWhereUserIsAdminOrOwener,
userIdsWhereUserIsOrgAdminOrOwener,
eventTypeIdsWhereUserIsAdminOrOwner,
userIdsAndEmailsWhereUserIsAdminOrOwner,
] = await Promise.all([
getEventTypeIdsFromTeamIdsFilter(prisma, filters?.teamIds),
getAttendeeEmailsFromUserIdsFilter(prisma, user.email, filters?.userIds),
getEventTypeIdsFromEventTypeIdsFilter(prisma, filters?.eventTypeIds),
getEventTypeIdsWhereUserIsAdminOrOwner(prisma, membershipConditionWhereUserIsAdminOwner),
getUserIdsWhereUserIsOrgAdminOrOwner(prisma, membershipConditionWhereUserIsAdminOwner),
getUserIdsAndEmailsWhereUserIsAdminOrOwner(prisma, membershipConditionWhereUserIsAdminOwner, user.orgId),
]);
const whereClause = {
OR: [
{
userId: user.id,
},
{
// If user is organization owner/admin, contains organization members emails and ids (organization plan)
// If user is only team owner/admin, contain team members emails and ids (teams plan)
const [userIdsWhereUserIsAdminOrOwner, userEmailsWhereUserIsAdminOrOwner] =
userIdsAndEmailsWhereUserIsAdminOrOwner;
const orConditions = [];
// If userIds filter is provided
if (!!filters?.userIds && filters.userIds.length > 0) {
const areUserIdsWithinUserOrgOrTeam = filters.userIds.every((userId) =>
userIdsWhereUserIsAdminOrOwner.includes(userId)
);
// Scope depends on `user.orgId`:
// - Throw an error if trying to filter by usersIds that are not within your ORG
// - Throw an error if trying to filter by usersIds that are not within your TEAM
if (!areUserIdsWithinUserOrgOrTeam) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permissions to fetch bookings for specified userIds",
});
}
// Filtered view: Booking must match one of the specified users or their attendees
const usersFilter = { in: [...filters.userIds] };
const attendeesEmailFilter = { in: attendeeEmailsFromUserIdsFilter };
// 1. Booking created by one of the filtered users
orConditions.push({ userId: usersFilter });
// 2. Attendee email matches one of the filtered users' emails
orConditions.push({ attendees: { some: { email: attendeesEmailFilter } } });
// 3. Seat reference attendee email matches one of the filtered users' emails
orConditions.push({ seatsReferences: { some: { attendee: { email: attendeesEmailFilter } } } });
} else {
// Filter by emails for auth user.
const userEmailFilter = { equals: user.email };
// Auth user is ORG_OWNER/ADMIN or TEAM_OWNER/ADMIN, filter by emails of members of the organization or team
const userEmailsFilterWhereUserIsOrgAdminOrOwner = userEmailsWhereUserIsAdminOrOwner?.length
? { in: userEmailsWhereUserIsAdminOrOwner }
: undefined;
// 1. Current user created bookings
orConditions.push({ userId: { equals: user.id } });
// 2. Current user is an attendee
orConditions.push({ attendees: { some: { email: userEmailFilter } } });
// 3. Current user is an attendee via seats reference
orConditions.push({ seatsReferences: { some: { attendee: { email: userEmailFilter } } } });
// 4. Scope depends on `user.orgId`:
// - If Current user is ORG_OWNER/ADMIN so we get bookings where organization members are attendees
// - If Current user is TEAM_OWNER/ADMIN so we get bookings where team members are attendees
userEmailsFilterWhereUserIsOrgAdminOrOwner &&
orConditions.push({ attendees: { some: { email: userEmailsFilterWhereUserIsOrgAdminOrOwner } } });
// 5. Scope depends on `user.orgId`:
// - If Current user is ORG_OWNER/ADMIN so we get bookings where organization members are attendees via seatsReference
// - If Current user is TEAM_OWNER/ADMIN so we get bookings where team members are attendees via seatsReference
userEmailsFilterWhereUserIsOrgAdminOrOwner &&
orConditions.push({
seatsReferences: { some: { attendee: { email: userEmailsFilterWhereUserIsOrgAdminOrOwner } } },
});
// 6. Scope depends on `user.orgId`:
// - If Current user is ORG_OWNER/ADMIN, get booking created for an event type within the organization
// - If Current user is TEAM_OWNER/ADMIN, get bookings created for an event type within the team
eventTypeIdsWhereUserIsAdminOrOwner?.length &&
orConditions.push({ eventTypeId: { in: eventTypeIdsWhereUserIsAdminOrOwner } });
// 7. Scope depends on `user.orgId`:
// - If Current user is ORG_OWNER/ADMIN, get bookings created by users within the same organization
// - If Current user is TEAM_OWNER/ADMIN, get bookings created by users within the same organization
userIdsWhereUserIsAdminOrOwner?.length &&
orConditions.push({ userId: { in: userIdsWhereUserIsAdminOrOwner } });
}
const andConditions = [];
// 1. Apply mandatory status filter
andConditions.push(passedBookingsStatusFilter);
// 2. Filter by Event Type IDs derived from Team IDs (if provided)
if (eventTypeIdsFromTeamIdsFilter && eventTypeIdsFromTeamIdsFilter.length > 0) {
andConditions.push({ eventTypeId: { in: eventTypeIdsFromTeamIdsFilter } });
}
// 3. Filter by specific Event Type IDs (if provided)
// If both teamIds filter and eventTypeIds filter are provided, filter 2. ensures the event-types are within the teams
if (eventTypeIdsFromEventTypeIdsFilter && eventTypeIdsFromEventTypeIdsFilter.length > 0) {
andConditions.push({ eventTypeId: { in: eventTypeIdsFromEventTypeIdsFilter } });
}
// 4. Filter by Attendee Email (if provided)
if (filters?.attendeeEmail) {
if (typeof filters.attendeeEmail === "string") {
// Simple string match (exact)
andConditions.push({ attendees: { some: { email: filters.attendeeEmail.trim() } } });
} else if (isTextFilterValue(filters.attendeeEmail)) {
// Complex text filter (contains, startsWith, etc.) using makeWhereClause
andConditions.push({
attendees: {
some: {
email: user.email,
},
some: makeWhereClause({
columnName: "email",
filterValue: filters.attendeeEmail,
}),
},
},
{
eventTypeId: {
in: eventTypeIdsWhereUserIsAdminOrOwener,
},
},
{
userId: {
in: userIdsWhereUserIsOrgAdminOrOwener,
},
},
{
seatsReferences: {
some: {
attendee: {
email: user.email,
},
},
},
},
],
AND: [
passedBookingsStatusFilter,
...(eventTypeIdsFromTeamIdsFilter
? [
{
eventTypeId: {
in: eventTypeIdsFromTeamIdsFilter,
},
},
]
: []),
...(filters?.userIds && filters.userIds.length > 0
? [
{
OR: [
{
userId: {
in: filters.userIds,
},
},
...(attendeeEmailsFromUserIdsFilter?.length
? [
{
attendees: {
some: {
email: {
in: attendeeEmailsFromUserIdsFilter,
},
},
},
},
]
: []),
],
},
]
: []),
...(eventTypeIdsFromEventTypeIdsFilter
? [
{
eventTypeId: { in: eventTypeIdsFromEventTypeIdsFilter },
},
]
: []),
});
}
}
...(typeof filters?.attendeeEmail === "string"
? [
{
attendees: { some: { email: filters.attendeeEmail.trim() } },
},
]
: []),
...(isTextFilterValue(filters?.attendeeEmail)
? [
{
attendees: {
some: makeWhereClause({
columnName: "email",
filterValue: filters.attendeeEmail,
}),
},
},
]
: []),
// 5. Filter by Attendee Name (if provided)
if (filters?.attendeeName) {
if (typeof filters.attendeeName === "string") {
// Simple string match (exact)
andConditions.push({ attendees: { some: { name: filters.attendeeName.trim() } } });
} else if (isTextFilterValue(filters.attendeeName)) {
// Complex text filter (contains, startsWith, etc.) using makeWhereClause
andConditions.push({
attendees: {
some: makeWhereClause({
columnName: "name",
filterValue: filters.attendeeName,
}),
},
});
}
}
...(typeof filters?.attendeeName === "string"
? [
{
attendees: { some: { name: filters.attendeeName.trim() } },
},
]
: []),
...(isTextFilterValue(filters?.attendeeName)
? [
{
attendees: {
some: makeWhereClause({
columnName: "name",
filterValue: filters.attendeeName,
}),
},
},
]
: []),
// 6. Date Range Filters
if (filters?.afterStartDate) {
andConditions.push({ startTime: { gte: dayjs.utc(filters.afterStartDate).toDate() } });
}
if (filters?.beforeEndDate) {
andConditions.push({ endTime: { lte: dayjs.utc(filters.beforeEndDate).toDate() } });
}
if (filters?.afterUpdatedDate) {
andConditions.push({ updatedAt: { gte: dayjs.utc(filters.afterUpdatedDate).toDate() } });
}
if (filters?.beforeUpdatedDate) {
andConditions.push({ updatedAt: { lte: dayjs.utc(filters.beforeUpdatedDate).toDate() } });
}
if (filters?.afterCreatedDate) {
andConditions.push({ createdAt: { gte: dayjs.utc(filters.afterCreatedDate).toDate() } });
}
if (filters?.beforeCreatedDate) {
andConditions.push({ createdAt: { lte: dayjs.utc(filters.beforeCreatedDate).toDate() } });
}
...(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(),
},
},
]
: []),
...(filters?.afterCreatedDate
? [
{
createdAt: {
gte: dayjs.utc(filters.afterCreatedDate).toDate(),
},
},
]
: []),
...(filters?.beforeCreatedDate
? [
{
createdAt: {
lte: dayjs.utc(filters.beforeCreatedDate).toDate(),
},
},
]
: []),
],
const whereClause = {
OR: orConditions,
AND: andConditions,
};
log.info(`Get bookings where clause for user ${user.id}`, JSON.stringify(whereClause));
const [plainBookings, totalCount] = await Promise.all([
prisma.booking.findMany({
where: whereClause,
@@ -439,7 +419,6 @@ export async function getBookings({
// 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({
@@ -530,8 +509,14 @@ async function getAttendeeEmailsFromUserIdsFilter(
email: true,
},
})
// Include booking if current user is an attendee, regardless of user ID filter
.then((users) => users.map((user) => user.email).concat([userEmail]));
.then((users) => users.map((user) => user.email));
if (!attendeeEmailsFromUserIdsFilter || attendeeEmailsFromUserIdsFilter?.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The requested users do not exist.",
});
}
return attendeeEmailsFromUserIdsFilter;
}
@@ -568,7 +553,16 @@ async function getEventTypeIdsFromEventTypeIdsFilter(prisma: PrismaClient, event
.then((eventTypes) => eventTypes.map((eventType) => eventType.id)),
]);
return Array.from(new Set([...directEventTypeIds, ...parentEventTypeIds]));
const eventTypeIdsFromDb = Array.from(new Set([...directEventTypeIds, ...parentEventTypeIds]));
if (eventTypeIdsFromDb?.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The requested event-types do not exist.",
});
}
return eventTypeIdsFromDb;
}
async function getEventTypeIdsWhereUserIsAdminOrOwner(
@@ -608,25 +602,42 @@ async function getEventTypeIdsWhereUserIsAdminOrOwner(
return Array.from(new Set([...directTeamEventTypeIds, ...parentTeamEventTypeIds]));
}
async function getUserIdsWhereUserIsOrgAdminOrOwner(
/**
* Gets [IDs, Emails] of members where the auth user is admin/owner.
* Scope depends on `orgId`:
* - If set (number): Fetches members of that specific organization (`isOrganization: true`).
* - If unset (null/undefined): Fetches members of all teams (`isOrganization: false`)
* where the auth user meets the `membershipCondition`.
*
* @param prisma The Prisma client.
* @param membershipCondition Filter defining the auth user's required role (e.g., OWNER/ADMIN)
* to identify the target orgs/teams.
* @param orgId Optional ID to target a specific org; absence targets teams.
* @returns {Promise<[number[], string[]]>} [UserIDs, UserEmails] for members in the determined scope.
*/
async function getUserIdsAndEmailsWhereUserIsAdminOrOwner(
prisma: PrismaClient,
membershipCondition: PrismaClientType.MembershipListRelationFilter
) {
return (
await prisma.user.findMany({
where: {
teams: {
some: {
team: {
isOrganization: true,
members: membershipCondition,
},
},
membershipCondition: PrismaClientType.MembershipListRelationFilter,
orgId?: number | null
): Promise<[number[], string[]]> {
const users = await prisma.user.findMany({
where: {
teams: {
some: {
team: orgId
? {
isOrganization: true,
members: membershipCondition,
id: orgId,
}
: { isOrganization: false, members: membershipCondition, parentId: null },
},
},
select: {
id: true,
},
})
).map((user) => user.id);
},
select: {
id: true,
email: true,
},
});
return [users.map((user) => user.id), users.map((user) => user.email)];
}