Skip to content

Commit 2aafa1c

Browse files
authored
feat: v2 managed organizations pagination (calcom#21359)
* feat: getPagination helper * refactor: bookings use getPagination helper * feat: getPagination helper * refactor: group pagination inputs and outputs in types/pagination * feat: paginated managed orgs * fix tests * swagger
1 parent 0081784 commit 2aafa1c

15 files changed

Lines changed: 878 additions & 375 deletions

File tree

apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servic
55
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
66
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
77
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
8+
import { getPagination } from "@/lib/pagination/pagination";
89
import { BillingService } from "@/modules/billing/services/billing.service";
910
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
1011
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
@@ -538,10 +539,13 @@ export class BookingsService_2024_08_13 {
538539
queryParams.attendeeEmail = await this.getAttendeeEmail(queryParams.attendeeEmail, user);
539540
}
540541

542+
const skip = Math.abs(queryParams?.skip ?? 0);
543+
const take = Math.abs(queryParams?.take ?? 100);
544+
541545
const fetchedBookings: { bookings: { id: number }[]; totalCount: number } = await getAllUserBookings({
542546
bookingListingByStatus: queryParams.status || [],
543-
skip: queryParams.skip ?? 0,
544-
take: queryParams.take ?? 100,
547+
skip,
548+
take,
545549
filters: {
546550
...this.inputService.transformGetBookingsFilters(queryParams),
547551
...(userIds?.length ? { userIds } : {}),
@@ -594,28 +598,11 @@ export class BookingsService_2024_08_13 {
594598
}
595599
}
596600

597-
const skip = Math.abs(queryParams?.skip ?? 0);
598-
const take = Math.abs(queryParams?.take ?? 100);
599-
const itemsPerPage = take;
600-
const totalPages = itemsPerPage !== 0 ? Math.ceil(fetchedBookings.totalCount / itemsPerPage) : 0;
601-
const currentPage = Math.floor(skip / itemsPerPage) + 1;
602-
const hasNextPage = skip + itemsPerPage < fetchedBookings.totalCount;
603-
const hasPreviousPage = skip > 0;
601+
const pagination = getPagination({ skip, take, totalCount: fetchedBookings.totalCount });
602+
604603
return {
605604
bookings: formattedBookings,
606-
pagination: {
607-
totalItems: fetchedBookings.totalCount,
608-
// clamp remainingItems between 0 and totalCount
609-
remainingItems: Math.min(
610-
Math.max(fetchedBookings.totalCount - (skip + take), 0),
611-
fetchedBookings.totalCount
612-
),
613-
itemsPerPage: itemsPerPage,
614-
currentPage: currentPage,
615-
totalPages: totalPages,
616-
hasNextPage: hasNextPage,
617-
hasPreviousPage: hasPreviousPage,
618-
},
605+
pagination,
619606
};
620607
}
621608

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { getPagination, clamp } from "./pagination";
2+
3+
describe("getPagination", () => {
4+
it("handles the first page correctly", () => {
5+
const pagination = getPagination({ skip: 0, take: 10, totalCount: 35 });
6+
expect(pagination).toEqual({
7+
returnedItems: 10,
8+
totalItems: 35,
9+
itemsPerPage: 10,
10+
remainingItems: 25,
11+
currentPage: 1,
12+
totalPages: 4,
13+
hasNextPage: true,
14+
hasPreviousPage: false,
15+
});
16+
});
17+
18+
it("handles a middle page correctly", () => {
19+
const pagination = getPagination({ skip: 10, take: 10, totalCount: 35 });
20+
expect(pagination).toEqual({
21+
returnedItems: 10,
22+
totalItems: 35,
23+
itemsPerPage: 10,
24+
remainingItems: 15,
25+
currentPage: 2,
26+
totalPages: 4,
27+
hasNextPage: true,
28+
hasPreviousPage: true,
29+
});
30+
});
31+
32+
it("handles the last page when it is not completely full", () => {
33+
const pagination = getPagination({ skip: 30, take: 10, totalCount: 35 });
34+
expect(pagination).toEqual({
35+
returnedItems: 5,
36+
totalItems: 35,
37+
itemsPerPage: 10,
38+
remainingItems: 0,
39+
currentPage: 4,
40+
totalPages: 4,
41+
hasNextPage: false,
42+
hasPreviousPage: true,
43+
});
44+
});
45+
46+
it("clamps skip values that exceed the total item count", () => {
47+
const pagination = getPagination({ skip: 40, take: 10, totalCount: 35 });
48+
expect(pagination).toEqual({
49+
returnedItems: 0,
50+
totalItems: 35,
51+
itemsPerPage: 10,
52+
remainingItems: 0,
53+
currentPage: 4,
54+
totalPages: 4,
55+
hasNextPage: false,
56+
hasPreviousPage: true,
57+
});
58+
});
59+
60+
it("works when itemsPerPage (take) is zero", () => {
61+
const pagination = getPagination({ skip: 0, take: 0, totalCount: 35 });
62+
expect(pagination).toEqual({
63+
returnedItems: 0,
64+
totalItems: 35,
65+
itemsPerPage: 0,
66+
remainingItems: 35,
67+
currentPage: 0,
68+
totalPages: 0,
69+
hasNextPage: true,
70+
hasPreviousPage: false,
71+
});
72+
});
73+
74+
it("works when the collection is empty", () => {
75+
const pagination = getPagination({ skip: 0, take: 10, totalCount: 0 });
76+
expect(pagination).toEqual({
77+
returnedItems: 0,
78+
totalItems: 0,
79+
itemsPerPage: 10,
80+
remainingItems: 0,
81+
currentPage: 0,
82+
totalPages: 0,
83+
hasNextPage: false,
84+
hasPreviousPage: false,
85+
});
86+
});
87+
});
88+
89+
describe("clamp", () => {
90+
it("returns the value unchanged when it is inside the range", () => {
91+
expect(clamp({ value: 5, min: 1, max: 10 })).toBe(5);
92+
});
93+
94+
it("clamps values below the minimum", () => {
95+
expect(clamp({ value: -3, min: 0, max: 10 })).toBe(0);
96+
});
97+
98+
it("clamps values above the maximum", () => {
99+
expect(clamp({ value: 42, min: 0, max: 10 })).toBe(10);
100+
});
101+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { PaginationMetaDto } from "@calcom/platform-types";
2+
3+
type Pagination = {
4+
skip: number;
5+
take: number;
6+
totalCount: number;
7+
};
8+
9+
export function getPagination(pagination: Pagination): PaginationMetaDto {
10+
const { skip, take, totalCount } = pagination;
11+
12+
const safeSkip = clamp({ value: skip, min: 0, max: totalCount });
13+
const itemsPerPage = take;
14+
const remainingItems = getRemainingItems(safeSkip, itemsPerPage, totalCount);
15+
const totalPages = getTotalPages(itemsPerPage, totalCount);
16+
const currentPage = getCurrentPage(safeSkip, itemsPerPage, totalPages);
17+
const hasNextPage = getHasNextPage(safeSkip, itemsPerPage, totalCount);
18+
const hasPreviousPage = getHasPreviousPage(safeSkip);
19+
const returnedItems = getReturnedItems(safeSkip, itemsPerPage, totalCount);
20+
21+
return {
22+
returnedItems,
23+
totalItems: totalCount,
24+
itemsPerPage,
25+
remainingItems,
26+
currentPage,
27+
totalPages,
28+
hasNextPage,
29+
hasPreviousPage,
30+
};
31+
}
32+
33+
function getRemainingItems(skip: number, itemsPerPage: number, totalCount: number) {
34+
return clamp({
35+
value: totalCount - (skip + itemsPerPage),
36+
min: 0,
37+
max: totalCount,
38+
});
39+
}
40+
41+
function getTotalPages(itemsPerPage: number, totalCount: number) {
42+
return itemsPerPage !== 0 ? Math.ceil(totalCount / itemsPerPage) : 0;
43+
}
44+
45+
function getCurrentPage(skip: number, itemsPerPage: number, totalPages: number) {
46+
const rawCurrentPage = Math.floor(skip / itemsPerPage) + 1;
47+
if (totalPages === 0) {
48+
return 0;
49+
}
50+
return clamp({ value: rawCurrentPage, min: 1, max: totalPages });
51+
}
52+
53+
function getHasNextPage(skip: number, itemsPerPage: number, totalCount: number) {
54+
return skip + itemsPerPage < totalCount;
55+
}
56+
57+
function getHasPreviousPage(skip: number) {
58+
return skip > 0;
59+
}
60+
61+
function getReturnedItems(skip: number, itemsPerPage: number, totalCount: number) {
62+
const remaining = totalCount - skip;
63+
return clamp({
64+
value: remaining,
65+
min: 0,
66+
max: itemsPerPage,
67+
});
68+
}
69+
70+
type ClampArgs = {
71+
value: number;
72+
min: number;
73+
max: number;
74+
};
75+
76+
export function clamp({ value, min, max }: ClampArgs): number {
77+
return Math.max(min, Math.min(value, max));
78+
}

apps/api/v2/src/modules/organizations/organizations/managed-organizations.repository.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
22
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
33
import { Injectable } from "@nestjs/common";
44

5+
import { SkipTakePagination } from "@calcom/platform-types";
56
import { Prisma } from "@calcom/prisma/client";
67

78
@Injectable()
@@ -34,11 +35,26 @@ export class ManagedOrganizationsRepository {
3435
});
3536
}
3637

37-
async getByManagerOrganizationId(managerOrganizationId: number) {
38-
return this.dbRead.prisma.managedOrganization.findMany({
39-
where: {
40-
managerOrganizationId,
41-
},
42-
});
38+
async getByManagerOrganizationIdPaginated(managerOrganizationId: number, pagination: SkipTakePagination) {
39+
const { skip, take } = pagination;
40+
41+
const where: Prisma.ManagedOrganizationWhereInput = {
42+
managerOrganizationId,
43+
};
44+
45+
const [totalItems, linkRows] = await this.dbRead.prisma.$transaction([
46+
this.dbRead.prisma.managedOrganization.count({ where }),
47+
this.dbRead.prisma.managedOrganization.findMany({
48+
where,
49+
skip,
50+
take,
51+
orderBy: { managedOrganizationId: "asc" },
52+
include: { managedOrganization: true },
53+
}),
54+
]);
55+
56+
const items = linkRows.map((l) => l.managedOrganization);
57+
58+
return { totalItems, items };
4359
}
4460
}

apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.e2e-spec.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controller
77
import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto";
88
import { CreateOrganizationInput } from "@/modules/organizations/organizations/inputs/create-managed-organization.input";
99
import { UpdateOrganizationInput } from "@/modules/organizations/organizations/inputs/update-managed-organization.input";
10+
import { GetManagedOrganizationsOutput } from "@/modules/organizations/organizations/outputs/get-managed-organizations.output";
1011
import {
1112
ManagedOrganizationWithApiKeyOutput,
1213
ManagedOrganizationOutput,
@@ -304,14 +305,22 @@ describe("Organizations Organizations Endpoints", () => {
304305
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
305306
.expect(200)
306307
.then(async (response) => {
307-
const responseBody: ApiSuccessResponse<ManagedOrganizationOutput[]> = response.body;
308+
const responseBody: GetManagedOrganizationsOutput = response.body;
308309
expect(responseBody.status).toEqual(SUCCESS_STATUS);
309310
const responseManagedOrgs = responseBody.data;
310311
expect(responseManagedOrgs?.length).toEqual(1);
311312
const responseManagedOrg = responseManagedOrgs[0];
312313
expect(responseManagedOrg?.id).toBeDefined();
313314
expect(responseManagedOrg?.name).toEqual(managedOrg.name);
314315
expect(responseManagedOrg?.metadata).toEqual(managedOrg.metadata);
316+
317+
expect(responseBody.pagination).toBeDefined();
318+
expect(responseBody.pagination.totalItems).toEqual(1);
319+
expect(responseBody.pagination.remainingItems).toEqual(0);
320+
expect(responseBody.pagination.returnedItems).toEqual(1);
321+
expect(responseBody.pagination.itemsPerPage).toEqual(250);
322+
expect(responseBody.pagination.currentPage).toEqual(1);
323+
expect(responseBody.pagination.totalPages).toEqual(1);
315324
});
316325
});
317326

apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@ import {
2828
HttpCode,
2929
HttpStatus,
3030
Delete,
31+
Query,
3132
} from "@nestjs/common";
3233
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
3334

3435
import { SUCCESS_STATUS } from "@calcom/platform-constants";
36+
import { SkipTakePagination } from "@calcom/platform-types";
3537

3638
const SCALE = "SCALE";
3739

@@ -99,14 +101,15 @@ export class OrganizationsOrganizationsController {
99101
"Requires the user to have at least the 'ORG_ADMIN' role within the organization. Additionally, for platform, the plan must be 'SCALE' or higher to access this endpoint.",
100102
})
101103
async getOrganizations(
102-
@Param("orgId", ParseIntPipe) managerOrganizationId: number
104+
@Param("orgId", ParseIntPipe) managerOrganizationId: number,
105+
@Query() queryPagination: SkipTakePagination
103106
): Promise<GetManagedOrganizationsOutput> {
104-
const organizations = await this.managedOrganizationsService.getManagedOrganizations(
105-
managerOrganizationId
106-
);
107+
const { organizations, pagination: responsePagination } =
108+
await this.managedOrganizationsService.getManagedOrganizations(managerOrganizationId, queryPagination);
107109
return {
108110
status: SUCCESS_STATUS,
109111
data: organizations,
112+
pagination: responsePagination,
110113
};
111114
}
112115

apps/api/v2/src/modules/organizations/organizations/outputs/get-managed-organizations.output.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Expose, Type } from "class-transformer";
44
import { IsEnum, ValidateNested } from "class-validator";
55

66
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
7+
import { PaginationMetaDto } from "@calcom/platform-types";
78

89
export class GetManagedOrganizationsOutput {
910
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@@ -17,4 +18,9 @@ export class GetManagedOrganizationsOutput {
1718
@ValidateNested({ each: true })
1819
@Type(() => ManagedOrganizationOutput)
1920
data!: ManagedOrganizationOutput[];
21+
22+
@ApiProperty({ type: () => PaginationMetaDto })
23+
@Type(() => PaginationMetaDto)
24+
@ValidateNested()
25+
pagination!: PaginationMetaDto;
2026
}

0 commit comments

Comments
 (0)