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:
1 parent
d63d15b7b2
commit
da853958f7
19 files changed
+1601
-239
No files matched your search
@@ -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[];
|
||||
}
|
||||
+703
@@ -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 {}
|
||||
+1
-12
@@ -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 () => {
|
||||
|
||||
+5
-1
@@ -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: {
|
||||
|
||||
+11
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
Reference in new issue
Block a user