From da02cd06d66d3ae4f3a34463a5b4edb3adf7b541 Mon Sep 17 00:00:00 2001 From: Lauris Skraucis Date: Wed, 5 Feb 2025 09:58:36 +0100 Subject: [PATCH] feat: v2 organization bookings endpoints (#18875) * chore: extra team bookings e2e tests * feat: v2 organizations bookings endpoints * feat: v2 organizations bookings endpoints * test * revert seed.ts change --- .../ee/bookings/2024-08-13/bookings.module.ts | 1 + .../controllers/e2e/team-bookings.e2e-spec.ts | 37 ++ .../v2/src/ee/platform-endpoints-module.ts | 2 +- apps/api/v2/src/modules/endpoints.module.ts | 2 + .../get-organizations-teams-bookings.input.ts | 179 +++++++++ ...ions-teams-bookings.controller.e2e-spec.ts | 364 ++++++++++++++++++ ...organizations-teams-bookings.controller.ts | 47 +++ .../organizations-teams-bookings.module.ts | 16 + apps/api/v2/swagger/documentation.json | 176 +++++++++ .../organization.repository.fixture.ts | 5 +- docs/api-reference/v2/openapi.json | 159 ++++++++ 11 files changed, 986 insertions(+), 2 deletions(-) create mode 100644 apps/api/v2/src/modules/organizations/controllers/teams/bookings/inputs/get-organizations-teams-bookings.input.ts create mode 100644 apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.controller.e2e-spec.ts create mode 100644 apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.controller.ts create mode 100644 apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.module.ts diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts index 5f34f9c575..bd24568f4f 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts @@ -32,5 +32,6 @@ import { Module } from "@nestjs/common"; ApiKeyRepository, ], controllers: [BookingsController_2024_08_13], + exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13], }) export class BookingsModule_2024_08_13 {} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/team-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/team-bookings.e2e-spec.ts index 37e8adf5d3..23769c9949 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/team-bookings.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/team-bookings.e2e-spec.ts @@ -561,6 +561,43 @@ describe("Bookings Endpoints 2024-08-13", () => { }); }); + it("should get bookings by teamId and eventTypeId", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?teamId=${team2.id}&eventTypeId=${team2EventTypeId}`) + .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[0].eventTypeId).toEqual(team2EventTypeId); + }); + }); + + it("should not get bookings by teamId and non existing eventTypeId", async () => { + 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); + }); + }); + it("should should get bookings by teamIds", async () => { return request(app.getHttpServer()) .get(`/v2/bookings?teamIds=${team1.id},${team2.id}`) diff --git a/apps/api/v2/src/ee/platform-endpoints-module.ts b/apps/api/v2/src/ee/platform-endpoints-module.ts index c198fd643c..534d80d604 100644 --- a/apps/api/v2/src/ee/platform-endpoints-module.ts +++ b/apps/api/v2/src/ee/platform-endpoints-module.ts @@ -10,8 +10,8 @@ import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/ import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module"; import { SlotsModule } from "@/modules/slots/slots.module"; import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module"; -import { TeamsModule } from "@/modules/teams/teams/teams.module"; import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module"; +import { TeamsModule } from "@/modules/teams/teams/teams.module"; import type { MiddlewareConsumer, NestModule } from "@nestjs/common"; import { Module } from "@nestjs/common"; diff --git a/apps/api/v2/src/modules/endpoints.module.ts b/apps/api/v2/src/modules/endpoints.module.ts index 168f80fba9..80f62ba306 100644 --- a/apps/api/v2/src/modules/endpoints.module.ts +++ b/apps/api/v2/src/modules/endpoints.module.ts @@ -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 { OrganizationsTeamsBookingsModule } from "@/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.module"; import { RouterModule } from "@/modules/router/router.module"; import { StripeModule } from "@/modules/stripe/stripe.module"; import { TimezoneModule } from "@/modules/timezones/timezones.module"; @@ -25,6 +26,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module"; AtomsModule, StripeModule, ConferencingModule, + OrganizationsTeamsBookingsModule, RouterModule, ], }) diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/bookings/inputs/get-organizations-teams-bookings.input.ts b/apps/api/v2/src/modules/organizations/controllers/teams/bookings/inputs/get-organizations-teams-bookings.input.ts new file mode 100644 index 0000000000..423fdffc52 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/teams/bookings/inputs/get-organizations-teams-bookings.input.ts @@ -0,0 +1,179 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; +import { + ArrayMinSize, + ArrayNotEmpty, + IsArray, + IsEnum, + IsInt, + IsISO8601, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from "class-validator"; + +enum Status { + upcoming = "upcoming", + recurring = "recurring", + past = "past", + cancelled = "cancelled", + unconfirmed = "unconfirmed", +} +type StatusType = keyof typeof Status; + +enum SortOrder { + asc = "asc", + desc = "desc", +} +type SortOrderType = keyof typeof SortOrder; + +export class GetOrganizationsTeamsBookingsInput_2024_08_13 { + // note(Lauris): filters + @IsOptional() + @Transform(({ value }) => { + if (typeof value === "string") { + return value.split(",").map((status: string) => status.trim()); + } + return value; + }) + @ArrayNotEmpty({ message: "status cannot be empty." }) + @IsEnum(Status, { + each: true, + message: "Invalid status. Allowed are upcoming, recurring, past, cancelled, unconfirmed", + }) + @ApiProperty({ + required: false, + description: + "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.", + example: "?status=upcoming,past", + enum: Status, + isArray: true, + }) + status?: StatusType[]; + + @IsString() + @IsOptional() + @ApiProperty({ + type: String, + required: false, + description: "Filter bookings by the attendee's email address.", + example: "example@domain.com", + }) + attendeeEmail?: string; + + @IsString() + @IsOptional() + @ApiProperty({ + type: String, + required: false, + description: "Filter bookings by the attendee's name.", + example: "John Doe", + }) + attendeeName?: string; + + @IsOptional() + @Transform(({ value }) => { + if (typeof value === "string") { + return value.split(",").map((eventTypeId: string) => parseInt(eventTypeId)); + } + return value; + }) + @IsArray() + @IsNumber({}, { each: true }) + @ArrayMinSize(1, { message: "eventTypeIds must contain at least 1 event type id" }) + @ApiProperty({ + type: String, + required: false, + description: + "Filter bookings by event type ids belonging to the team. Event type ids must be separated by a comma.", + example: "?eventTypeIds=100,200", + }) + eventTypeIds?: number[]; + + @IsInt() + @IsOptional() + @Type(() => Number) + @ApiProperty({ + type: String, + required: false, + description: "Filter bookings by event type id belonging to the team.", + example: "?eventTypeId=100", + }) + eventTypeId?: number; + + @IsOptional() + @IsISO8601({ strict: true }, { message: "fromDate must be a valid ISO 8601 date." }) + @ApiProperty({ + type: String, + required: false, + description: "Filter bookings with start after this date string.", + example: "?afterStart=2025-03-07T10:00:00.000Z", + }) + afterStart?: string; + + @IsOptional() + @IsISO8601({ strict: true }, { message: "toDate must be a valid ISO 8601 date." }) + @ApiProperty({ + type: String, + required: false, + description: "Filter bookings with end before this date string.", + example: "?beforeEnd=2025-03-07T11:00:00.000Z", + }) + beforeEnd?: string; + + // note(Lauris): sort + @IsOptional() + @IsEnum(SortOrder, { + message: 'SortStart must be either "asc" or "desc".', + }) + @ApiProperty({ + required: false, + description: "Sort results by their start time in ascending or descending order.", + example: "?sortStart=asc OR ?sortStart=desc", + enum: SortOrder, + }) + sortStart?: SortOrderType; + + @IsOptional() + @IsEnum(SortOrder, { + message: 'SortEnd must be either "asc" or "desc".', + }) + @ApiProperty({ + required: false, + description: "Sort results by their end time in ascending or descending order.", + example: "?sortEnd=asc OR ?sortEnd=desc", + enum: SortOrder, + }) + sortEnd?: SortOrderType; + + @IsOptional() + @IsEnum(SortOrder, { + message: 'SortCreated must be either "asc" or "desc".', + }) + @ApiProperty({ + required: false, + description: + "Sort results by their creation time (when booking was made) in ascending or descending order.", + example: "?sortCreated=asc OR ?sortCreated=desc", + enum: SortOrder, + }) + sortCreated?: SortOrderType; + + // note(Lauris): pagination + @ApiProperty({ required: false, description: "The number of items to return", example: 10 }) + @Transform(({ value }: { value: string }) => value && parseInt(value)) + @IsNumber() + @Min(1) + @Max(250) + @IsOptional() + take?: number; + + @ApiProperty({ required: false, description: "The number of items to skip", example: 0 }) + @Transform(({ value }: { value: string }) => value && parseInt(value)) + @IsNumber() + @Min(0) + @IsOptional() + skip?: number; +} diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.controller.e2e-spec.ts new file mode 100644 index 0000000000..638c590558 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.controller.e2e-spec.ts @@ -0,0 +1,364 @@ +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/controllers/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 TeamsBookings Endpoints 2024-08-13", () => { + describe("Organization Team bookings", () => { + let app: INestApplication; + let organization: Team; + let team1: 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 teamUserEmail = "orgUser1team1@api.com"; + const teamUserEmail2 = "orgUser2team1@api.com"; + let teamUser: User; + let teamUser2: User; + + let team1EventTypeId: number; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + teamUserEmail, + 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); + + organization = await organizationsRepositoryFixture.create({ name: "organization team bookings" }); + oAuthClient = await createOAuthClient(organization.id); + + team1 = await teamRepositoryFixture.create({ + name: "team 1", + isOrganization: false, + parent: { connect: { id: organization.id } }, + createdByOAuthClient: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + teamUser = await userRepositoryFixture.create({ + email: teamUserEmail, + locale: "it", + name: "orgUser1team1", + platformOAuthClients: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + teamUser2 = await userRepositoryFixture.create({ + email: teamUserEmail2, + locale: "es", + name: "orgUser2team1", + platformOAuthClients: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: "working time", + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(teamUser.id, userSchedule); + await schedulesService.createUserSchedule(teamUser2.id, userSchedule); + + await profileRepositoryFixture.create({ + uid: `usr-${teamUser.id}`, + username: teamUserEmail, + organization: { + connect: { + id: organization.id, + }, + }, + user: { + connect: { + id: teamUser.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${teamUser2.id}`, + username: teamUserEmail2, + organization: { + connect: { + id: organization.id, + }, + }, + user: { + connect: { + id: teamUser2.id, + }, + }, + }); + + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: teamUser.id } }, + team: { connect: { id: team1.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: teamUser.id } }, + team: { connect: { id: organization.id } }, + accepted: true, + }); + + const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "COLLECTIVE", + team: { + connect: { id: team1.id }, + }, + title: "Collective Event Type", + slug: "collective-event-type", + length: 60, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + team1EventTypeId = team1EventType.id; + + await hostsRepositoryFixture.create({ + isFixed: true, + user: { + connect: { + id: teamUser.id, + }, + }, + eventType: { + connect: { + id: team1EventType.id, + }, + }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + describe("create team bookings", () => { + it("should create a team 1 booking", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: team1EventTypeId, + 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(teamUser.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(team1EventTypeId); + 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 possibily recurring bookings" + ); + } + }); + }); + }); + + describe("get team bookings", () => { + it("should should get bookings by teamId", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${organization.id}/teams/${team1.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(1); + expect(data[0].eventTypeId).toEqual(team1EventTypeId); + }); + }); + + it("should get bookings by teamId and eventTypeId", async () => { + return request(app.getHttpServer()) + .get( + `/v2/organizations/${organization.id}/teams/${team1.id}/bookings?eventTypeId=${team1EventTypeId}` + ) + .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(1); + expect(data[0].eventTypeId).toEqual(team1EventTypeId); + }); + }); + + it("should not get bookings by teamId and non existing eventTypeId", async () => { + 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); + }); + }); + + it("should not get bookings by non existing teamId", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${organization.id}/teams/90909/bookings`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(404); + }); + }); + + 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(teamUser.email); + await userRepositoryFixture.deleteByEmail(teamUserEmail2); + await bookingsRepositoryFixture.deleteAllBookings(teamUser.id, teamUser.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.controller.ts b/apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.controller.ts new file mode 100644 index 0000000000..1a8a5fe717 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.controller.ts @@ -0,0 +1,47 @@ +import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard"; +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 { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard"; +import { GetOrganizationsTeamsBookingsInput_2024_08_13 } from "@/modules/organizations/controllers/teams/bookings/inputs/get-organizations-teams-bookings.input"; +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"; + +@Controller({ + path: "/v2/organizations/:orgId/teams/:teamId/bookings", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) +@DocsTags("Orgs / Teams / Bookings") +export class OrganizationsTeamsBookingsController { + constructor(private readonly bookingsService: BookingsService_2024_08_13) {} + + @Get("/") + @ApiOperation({ summary: "Get organization team bookings" }) + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @HttpCode(HttpStatus.OK) + async getAllOrgTeamBookings( + @Query() queryParams: GetOrganizationsTeamsBookingsInput_2024_08_13, + @Param("teamId", ParseIntPipe) teamId: number, + @GetUser() user: UserWithProfile + ): Promise { + const bookings = await this.bookingsService.getBookings({ ...queryParams, teamId }, user); + + return { + status: SUCCESS_STATUS, + data: bookings, + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.module.ts b/apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.module.ts new file mode 100644 index 0000000000..58d88dcbe0 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.module.ts @@ -0,0 +1,16 @@ +import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module"; +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { OrganizationsTeamsBookingsController } from "@/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.controller"; +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository"; +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], + controllers: [OrganizationsTeamsBookingsController], +}) +export class OrganizationsTeamsBookingsModule {} diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 571dc3049f..1d8781c5e9 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -2220,6 +2220,182 @@ ] } }, + "/v2/organizations/{orgId}/teams/{teamId}/bookings": { + "get": { + "operationId": "OrganizationsTeamsBookingsController_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 team. 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 team.", + "example": "?eventTypeId=100", + "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": "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": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "minimum": 1, + "maximum": 250, + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "minimum": 0, + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBookingsOutput_2024_08_13" + } + } + } + } + }, + "tags": [ + "Orgs / Teams / Bookings" + ] + } + }, "/v2/organizations/{orgId}/teams/{teamId}/memberships": { "get": { "operationId": "OrganizationsTeamsMembershipsController_getAllOrgTeamMemberships", diff --git a/apps/api/v2/test/fixtures/repository/organization.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/organization.repository.fixture.ts index 50a528a007..dbf746918c 100644 --- a/apps/api/v2/test/fixtures/repository/organization.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/organization.repository.fixture.ts @@ -19,7 +19,10 @@ export class OrganizationRepositoryFixture { async create(data: Prisma.TeamCreateInput) { return await this.prismaWriteClient.$transaction(async (prisma) => { const team = await prisma.team.create({ - data, + data: { + ...data, + isOrganization: true, + }, }); await prisma.organizationSettings.create({ diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 34d4785201..c49022c31d 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -2097,6 +2097,165 @@ "tags": ["Orgs / Teams"] } }, + "/v2/organizations/{orgId}/teams/{teamId}/bookings": { + "get": { + "operationId": "OrganizationsTeamsBookingsController_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 team. 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 team.", + "example": "?eventTypeId=100", + "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": "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": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "minimum": 1, + "maximum": 250, + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "minimum": 0, + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBookingsOutput_2024_08_13" + } + } + } + } + }, + "tags": ["Orgs / Teams / Bookings"] + } + }, "/v2/organizations/{orgId}/teams/{teamId}/memberships": { "get": { "operationId": "OrganizationsTeamsMembershipsController_getAllOrgTeamMemberships",