diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts index c000b44c38..6dc57d9dca 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts @@ -182,7 +182,7 @@ export class BookingsController_2024_08_13 { ): Promise { const profile = this.usersService.getUserMainProfile(user); - const bookings = await this.bookingsService.getBookings(queryParams, { + const { bookings, pagination } = await this.bookingsService.getBookings(queryParams, { email: user.email, id: user.id, orgId: profile?.organizationId, @@ -191,6 +191,7 @@ export class BookingsController_2024_08_13 { return { status: SUCCESS_STATUS, data: bookings, + pagination, }; } diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts index fa08dfce69..503a10a180 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts @@ -376,7 +376,7 @@ export class BookingsService_2024_08_13 { queryParams.attendeeEmail = await this.getAttendeeEmail(queryParams.attendeeEmail, user); } - const fetchedBookings: { bookings: { id: number }[] } = await getAllUserBookings({ + const fetchedBookings: { bookings: { id: number }[]; totalCount: number } = await getAllUserBookings({ bookingListingByStatus: queryParams.status || [], skip: queryParams.skip ?? 0, take: queryParams.take ?? 100, @@ -432,7 +432,29 @@ export class BookingsService_2024_08_13 { } } - return formattedBookings; + const skip = Math.abs(queryParams?.skip ?? 0); + const take = Math.abs(queryParams?.take ?? 100); + const itemsPerPage = take; + const totalPages = itemsPerPage !== 0 ? Math.ceil(fetchedBookings.totalCount / itemsPerPage) : 0; + const currentPage = Math.floor(skip / itemsPerPage) + 1; + const hasNextPage = skip + itemsPerPage < fetchedBookings.totalCount; + const hasPreviousPage = skip > 0; + return { + bookings: formattedBookings, + pagination: { + totalItems: fetchedBookings.totalCount, + // clamp remainingItems between 0 and totalCount + remainingItems: Math.min( + Math.max(fetchedBookings.totalCount - (skip + take), 0), + fetchedBookings.totalCount + ), + itemsPerPage: itemsPerPage, + currentPage: currentPage, + totalPages: totalPages, + hasNextPage: hasNextPage, + hasPreviousPage: hasPreviousPage, + }, + }; } async getAttendeeEmail(queryParamsAttendeeEmail: string, user: { id: number }) { diff --git a/apps/api/v2/src/modules/organizations/bookings/organizations-bookings.controller.ts b/apps/api/v2/src/modules/organizations/bookings/organizations-bookings.controller.ts index 9873750e68..661f5920a5 100644 --- a/apps/api/v2/src/modules/organizations/bookings/organizations-bookings.controller.ts +++ b/apps/api/v2/src/modules/organizations/bookings/organizations-bookings.controller.ts @@ -45,7 +45,7 @@ export class OrganizationsBookingsController { ): Promise { const { userIds, ...restParams } = queryParams; - const bookings = await this.bookingsService.getBookings( + const { bookings, pagination } = await this.bookingsService.getBookings( { ...restParams }, { email: user.email, id: user.id, orgId }, userIds @@ -54,6 +54,7 @@ export class OrganizationsBookingsController { return { status: SUCCESS_STATUS, data: bookings, + pagination, }; } } diff --git a/apps/api/v2/src/modules/organizations/teams/bookings/organizations-teams-bookings.controller.ts b/apps/api/v2/src/modules/organizations/teams/bookings/organizations-teams-bookings.controller.ts index c5083a18b2..d9a0a47a94 100644 --- a/apps/api/v2/src/modules/organizations/teams/bookings/organizations-teams-bookings.controller.ts +++ b/apps/api/v2/src/modules/organizations/teams/bookings/organizations-teams-bookings.controller.ts @@ -54,7 +54,7 @@ export class OrganizationsTeamsBookingsController { @Param("orgId", ParseIntPipe) orgId: number, @GetUser() user: UserWithProfile ): Promise { - const bookings = await this.bookingsService.getBookings( + const { bookings, pagination } = await this.bookingsService.getBookings( { ...queryParams, teamId }, { email: user.email, id: user.id, orgId } ); @@ -62,6 +62,7 @@ export class OrganizationsTeamsBookingsController { return { status: SUCCESS_STATUS, data: bookings, + pagination, }; } diff --git a/apps/api/v2/src/modules/organizations/users/bookings/controllers/organizations-users-bookings-controller.ts b/apps/api/v2/src/modules/organizations/users/bookings/controllers/organizations-users-bookings-controller.ts index 4ce0780571..bc2fbf865f 100644 --- a/apps/api/v2/src/modules/organizations/users/bookings/controllers/organizations-users-bookings-controller.ts +++ b/apps/api/v2/src/modules/organizations/users/bookings/controllers/organizations-users-bookings-controller.ts @@ -40,7 +40,7 @@ export class OrganizationsUsersBookingsController { @Param("userId", ParseIntPipe) userId: number, @Query() query: GetBookingsInput_2024_08_13 ): Promise { - const bookings = await this.organizationUsersBookingsService.getOrganizationUserBookings( + const { bookings, pagination } = await this.organizationUsersBookingsService.getOrganizationUserBookings( orgId, userId, query @@ -49,6 +49,7 @@ export class OrganizationsUsersBookingsController { return { status: SUCCESS_STATUS, data: bookings, + pagination, }; } } diff --git a/apps/api/v2/src/modules/organizations/users/bookings/controllers/organizations-users-bookings.e2e-spec.ts b/apps/api/v2/src/modules/organizations/users/bookings/controllers/organizations-users-bookings.e2e-spec.ts index cd3e6d1cc8..4da8538a5a 100644 --- a/apps/api/v2/src/modules/organizations/users/bookings/controllers/organizations-users-bookings.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/users/bookings/controllers/organizations-users-bookings.e2e-spec.ts @@ -29,6 +29,7 @@ import { RecurringBookingOutput_2024_08_13, GetBookingsOutput_2024_08_13, GetSeatedBookingOutput_2024_08_13, + PaginationMetaDto, } from "@calcom/platform-types"; import { PlatformOAuthClient, Team } from "@calcom/prisma/client"; @@ -373,17 +374,26 @@ describe("Organizations UsersBookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); + expect(responseBody.pagination).toBeDefined(); const data: ( | BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13 | GetSeatedBookingOutput_2024_08_13 )[] = responseBody.data; + const pagination: PaginationMetaDto = responseBody.pagination; expect(data.length).toEqual(3); expect(data.find((booking) => booking.id === createdPersonalBooking.id)).toBeDefined(); expect(data.find((booking) => booking.id === createdTeamBooking.id)).toBeDefined(); expect( data.find((booking) => booking.id === createdPersonalBookingUsingUsername.id) ).toBeDefined(); + expect(pagination.totalItems).toEqual(3); + expect(pagination.remainingItems).toEqual(0); + expect(pagination.hasNextPage).toEqual(false); + expect(pagination.hasPreviousPage).toEqual(false); + expect(pagination.itemsPerPage).toEqual(100); + expect(pagination.totalPages).toEqual(1); + expect(pagination.currentPage).toEqual(1); }); }); }); diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index fd4861576e..785d34d0bd 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -1689,6 +1689,7 @@ "description": "The number of items to return", "example": 10, "schema": { + "default": 100, "type": "number" } }, @@ -1699,6 +1700,7 @@ "description": "The number of items to skip", "example": 0, "schema": { + "default": 0, "type": "number" } }, @@ -5603,6 +5605,7 @@ "description": "The number of items to return", "example": 10, "schema": { + "default": 100, "type": "number" } }, @@ -5613,6 +5616,7 @@ "description": "The number of items to skip", "example": 0, "schema": { + "default": 0, "type": "number" } } @@ -6978,6 +6982,7 @@ "description": "The number of items to return", "example": 10, "schema": { + "default": 100, "type": "number" } }, @@ -6988,6 +6993,7 @@ "description": "The number of items to skip", "example": 0, "schema": { + "default": 0, "type": "number" } }, @@ -24137,6 +24143,60 @@ "data" ] }, + "PaginationMetaDto": { + "type": "object", + "properties": { + "totalItems": { + "type": "number", + "minimum": 0, + "description": "The total number of items available across all pages, matching the query criteria.", + "example": 123 + }, + "remainingItems": { + "type": "number", + "minimum": 0, + "description": "The number of items remaining to be fetched *after* the current page. Calculated as: `totalItems - (skip + itemsPerPage)`.", + "example": 103 + }, + "itemsPerPage": { + "type": "number", + "minimum": 1, + "description": "The maximum number of items requested per page.", + "example": 10 + }, + "currentPage": { + "type": "number", + "minimum": 1, + "description": "The current page number being returned.", + "example": 2 + }, + "totalPages": { + "type": "number", + "minimum": 0, + "description": "The total number of pages available.", + "example": 13 + }, + "hasNextPage": { + "type": "boolean", + "description": "Indicates if there is a subsequent page available after the current one.", + "example": true + }, + "hasPreviousPage": { + "type": "boolean", + "description": "Indicates if there is a preceding page available before the current one.", + "example": true + } + }, + "required": [ + "totalItems", + "remainingItems", + "itemsPerPage", + "currentPage", + "totalPages", + "hasNextPage", + "hasPreviousPage" + ] + }, "GetBookingsOutput_2024_08_13": { "type": "object", "properties": { @@ -24168,13 +24228,17 @@ }, "description": "Array of booking data, which can contain either BookingOutput objects or RecurringBookingOutput objects" }, + "pagination": { + "$ref": "#/components/schemas/PaginationMetaDto" + }, "error": { "type": "object" } }, "required": [ "status", - "data" + "data", + "pagination" ] }, "RescheduleBookingInput_2024_08_13": { diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 0a7b3da779..56469e69c1 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -1617,6 +1617,7 @@ "description": "The number of items to return", "example": 10, "schema": { + "default": 100, "type": "number" } }, @@ -1627,6 +1628,7 @@ "description": "The number of items to skip", "example": 0, "schema": { + "default": 0, "type": "number" } }, @@ -5357,6 +5359,7 @@ "description": "The number of items to return", "example": 10, "schema": { + "default": 100, "type": "number" } }, @@ -5367,6 +5370,7 @@ "description": "The number of items to skip", "example": 0, "schema": { + "default": 0, "type": "number" } } @@ -6666,6 +6670,7 @@ "description": "The number of items to return", "example": 10, "schema": { + "default": 100, "type": "number" } }, @@ -6676,6 +6681,7 @@ "description": "The number of items to skip", "example": 0, "schema": { + "default": 0, "type": "number" } }, @@ -22123,6 +22129,60 @@ }, "required": ["status", "data"] }, + "PaginationMetaDto": { + "type": "object", + "properties": { + "totalItems": { + "type": "number", + "minimum": 0, + "description": "The total number of items available across all pages, matching the query criteria.", + "example": 123 + }, + "remainingItems": { + "type": "number", + "minimum": 0, + "description": "The number of items remaining to be fetched *after* the current page. Calculated as: `totalItems - (skip + itemsPerPage)`.", + "example": 103 + }, + "itemsPerPage": { + "type": "number", + "minimum": 1, + "description": "The maximum number of items requested per page.", + "example": 10 + }, + "currentPage": { + "type": "number", + "minimum": 1, + "description": "The current page number being returned.", + "example": 2 + }, + "totalPages": { + "type": "number", + "minimum": 0, + "description": "The total number of pages available.", + "example": 13 + }, + "hasNextPage": { + "type": "boolean", + "description": "Indicates if there is a subsequent page available after the current one.", + "example": true + }, + "hasPreviousPage": { + "type": "boolean", + "description": "Indicates if there is a preceding page available before the current one.", + "example": true + } + }, + "required": [ + "totalItems", + "remainingItems", + "itemsPerPage", + "currentPage", + "totalPages", + "hasNextPage", + "hasPreviousPage" + ] + }, "GetBookingsOutput_2024_08_13": { "type": "object", "properties": { @@ -22151,11 +22211,14 @@ }, "description": "Array of booking data, which can contain either BookingOutput objects or RecurringBookingOutput objects" }, + "pagination": { + "$ref": "#/components/schemas/PaginationMetaDto" + }, "error": { "type": "object" } }, - "required": ["status", "data"] + "required": ["status", "data", "pagination"] }, "RescheduleBookingInput_2024_08_13": { "type": "object", diff --git a/packages/platform/types/bookings/2024-08-13/inputs/get-bookings.input.ts b/packages/platform/types/bookings/2024-08-13/inputs/get-bookings.input.ts index bc00daee75..e302378c78 100644 --- a/packages/platform/types/bookings/2024-08-13/inputs/get-bookings.input.ts +++ b/packages/platform/types/bookings/2024-08-13/inputs/get-bookings.input.ts @@ -258,7 +258,7 @@ export class GetBookingsInput_2024_08_13 { sortUpdatedAt?: SortOrderType; // note(Lauris): pagination - @ApiProperty({ required: false, description: "The number of items to return", example: 10 }) + @ApiProperty({ required: false, description: "The number of items to return", example: 10, default: 100 }) @Transform(({ value }: { value: string }) => value && parseInt(value)) @IsNumber() @Min(1) @@ -266,7 +266,7 @@ export class GetBookingsInput_2024_08_13 { @IsOptional() take?: number; - @ApiProperty({ required: false, description: "The number of items to skip", example: 0 }) + @ApiProperty({ required: false, description: "The number of items to skip", example: 0, default: 0 }) @Transform(({ value }: { value: string }) => value && parseInt(value)) @IsNumber() @Min(0) diff --git a/packages/platform/types/bookings/2024-08-13/outputs/get-bookings.output.ts b/packages/platform/types/bookings/2024-08-13/outputs/get-bookings.output.ts index 3d8ed2e594..dabc39cf33 100644 --- a/packages/platform/types/bookings/2024-08-13/outputs/get-bookings.output.ts +++ b/packages/platform/types/bookings/2024-08-13/outputs/get-bookings.output.ts @@ -1,5 +1,6 @@ import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger"; -import { IsEnum, ValidateNested } from "class-validator"; +import { Type } from "class-transformer"; +import { IsBoolean, IsEnum, IsInt, Min, ValidateNested } from "class-validator"; import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; import { @@ -9,6 +10,68 @@ import { RecurringBookingOutput_2024_08_13, } from "@calcom/platform-types"; +export class PaginationMetaDto { + @ApiProperty({ + description: "The total number of items available across all pages, matching the query criteria.", + example: 123, + minimum: 0, + }) + @IsInt() + @Min(0) + totalItems!: number; + + @ApiProperty({ + description: + "The number of items remaining to be fetched *after* the current page. Calculated as: `totalItems - (skip + itemsPerPage)`.", + example: 103, // e.g., if totalItems=123, skip=10, itemsPerPage=10 -> 123 - (10 + 10) = 103 + minimum: 0, + }) + @IsInt() + @Min(0) + remainingItems!: number; + + @ApiProperty({ + description: "The maximum number of items requested per page.", + example: 10, + minimum: 1, + }) + @IsInt() + @Min(1) // Typically, you request at least 1 item per page + itemsPerPage!: number; + + @ApiProperty({ + description: "The current page number being returned.", + example: 2, // e.g., if skip=10, itemsPerPage=10 -> page 2 + minimum: 1, + }) + @IsInt() + @Min(1) // Page numbers usually start from 1 + currentPage!: number; + + @ApiProperty({ + description: "The total number of pages available.", + example: 13, // e.g., if totalItems=123, itemsPerPage=10 -> 13 pages + minimum: 0, // Can be 0 if totalItems is 0 + }) + @IsInt() + @Min(0) + totalPages!: number; + + @ApiProperty({ + description: "Indicates if there is a subsequent page available after the current one.", + example: true, + }) + @IsBoolean() + hasNextPage!: boolean; + + @ApiProperty({ + description: "Indicates if there is a preceding page available before the current one.", + example: true, + }) + @IsBoolean() + hasPreviousPage!: boolean; +} + @ApiExtraModels( BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13, @@ -33,13 +96,16 @@ export class GetBookingsOutput_2024_08_13 { description: "Array of booking data, which can contain either BookingOutput objects or RecurringBookingOutput objects", }) - @ValidateNested({ each: true }) data!: ( | BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13 | GetSeatedBookingOutput_2024_08_13 | GetRecurringSeatedBookingOutput_2024_08_13 )[]; - error?: Error; + + @ApiProperty({ type: () => PaginationMetaDto }) // Crucial for Swagger + @Type(() => PaginationMetaDto) + @ValidateNested() + pagination!: PaginationMetaDto; }