feat: api v2 event types ordering - user, team, org (#25177)

* feat: api-v2-event-types-ordering

* sort team and org event types

* revert: remove accidental changes to api-auth.strategy.ts

* docs: add ordering documentation and test for event types endpoints

- Added test assertion to verify event types are returned in descending order by ID (newest first)
- Added API documentation to user event types endpoint describing default ordering behavior
- Added API documentation to team event types endpoint describing default ordering behavior
- Added API documentation to organization event types endpoints describing default ordering behavior

Addresses PR feedback to document and test the ordering behavior introduced in the API v2 event types ordering feature.

* feat: add optional sortCreatedAt parameter to event types endpoints

- Add sortCreatedAt query parameter (SortOrderType: "asc" | "desc") to all event types endpoints
- Define SortOrder enum and SortOrderType in pagination.input.ts for reusability
- When not provided, no explicit ordering is applied (backward compatible)
- Update user, team, and organization event types endpoints
- Add comprehensive e2e tests for all sorting scenarios
- Fix circular dependency in platform-types import
- Thread sortCreatedAt through all service layers
- Use spread pattern for conditional orderBy to avoid empty array issues

Addresses PR feedback to make ordering opt-in rather than changing default behavior
This commit is contained in:
Dhairyashil Shinde
2025-12-05 19:51:48 +05:30
committed by GitHub
parent 46b88a3c81
commit b014ded5fd
13 changed files with 209 additions and 34 deletions
@@ -640,9 +640,9 @@ describe("Event types Endpoints", () => {
hiddenEventType = responseBody.data;
});
it(`/GET/event-types by username`, async () => {
it(`/GET/event-types by username with sortCreatedAt=desc`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${user.username}`)
.get(`/api/v2/event-types?username=${user.username}&sortCreatedAt=desc`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200);
@@ -653,6 +653,12 @@ describe("Event types Endpoints", () => {
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(2);
// Verify ordering: event types are returned newest to oldest when sortCreatedAt=desc
// hiddenEventType was created after eventType, so it should have a higher ID and appear first
expect(responseBody.data[0].id).toBeGreaterThan(responseBody.data[1].id);
expect(responseBody.data[0].id).toEqual(hiddenEventType.id);
expect(responseBody.data[1].id).toEqual(eventType.id);
const fetchedEventType = responseBody.data?.find((et) => et.id === eventType.id);
const fetchedHiddenEventType = responseBody.data?.find((et) => et.id === hiddenEventType.id);
@@ -711,6 +717,46 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType?.hidden).toEqual(false);
});
it(`/GET/event-types by username with sortCreatedAt=asc`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${user.username}&sortCreatedAt=asc`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(2);
// Verify ordering: event types are returned oldest to newest when sortCreatedAt=asc
// eventType was created before hiddenEventType, so it should have a lower ID and appear first
expect(responseBody.data[0].id).toBeLessThan(responseBody.data[1].id);
expect(responseBody.data[0].id).toEqual(eventType.id);
expect(responseBody.data[1].id).toEqual(hiddenEventType.id);
});
it(`/GET/event-types by username without sortCreatedAt parameter`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${user.username}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(2);
// Without sortCreatedAt, no specific order is guaranteed
// Just verify both event types are present
const ids = responseBody.data.map((et) => et.id);
expect(ids).toContain(eventType.id);
expect(ids).toContain(hiddenEventType.id);
});
it(`/GET/event-types by username should not return hidden event type if no auth provided`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${user.username}`)
@@ -155,6 +155,8 @@ export class EventTypesController_2024_06_14 {
summary: "Get all event types",
description: `Hidden event types are returned only if authentication is provided and it belongs to the event type owner.
Use the optional \`sortCreatedAt\` query parameter to order results by creation date (by ID). Accepts "asc" (oldest first) or "desc" (newest first). When not provided, no explicit ordering is applied.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
@@ -3,6 +3,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import type { SortOrderType } from "@calcom/platform-types";
import type { Prisma } from "@calcom/prisma/client";
@Injectable()
@@ -67,21 +68,23 @@ export class EventTypesRepository_2024_06_14 {
});
}
async getUserEventTypes(userId: number) {
async getUserEventTypes(userId: number, sortCreatedAt?: SortOrderType) {
return this.dbRead.prisma.eventType.findMany({
where: {
userId,
},
...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }),
include: { users: true, schedule: true, destinationCalendar: true },
});
}
async getUserEventTypesPublic(userId: number) {
async getUserEventTypesPublic(userId: number, sortCreatedAt?: SortOrderType) {
return this.dbRead.prisma.eventType.findMany({
where: {
userId,
hidden: false,
},
...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }),
include: { users: true, schedule: true, destinationCalendar: true },
});
}
@@ -22,7 +22,7 @@ import {
getEventTypesPublic,
EventTypesPublic,
} from "@calcom/platform-libraries/event-types";
import type { GetEventTypesQuery_2024_06_14 } from "@calcom/platform-types";
import type { GetEventTypesQuery_2024_06_14, SortOrderType } from "@calcom/platform-types";
import type { EventType } from "@calcom/prisma/client";
@Injectable()
@@ -160,15 +160,16 @@ export class EventTypesService_2024_06_14 {
orgSlug?: string;
orgId?: number;
authUser?: AuthOptionalUser;
sortCreatedAt?: SortOrderType;
}) {
const user = await this.usersRepository.findByUsername(params.username, params.orgSlug, params.orgId);
if (!user) {
return [];
}
if (params.authUser?.id !== user.id) {
return await this.getUserEventTypesPublic(user.id);
return await this.getUserEventTypesPublic(user.id, params.sortCreatedAt);
}
return await this.getUserEventTypes(user.id);
return await this.getUserEventTypes(user.id, params.sortCreatedAt);
}
async getUserToCreateEvent(user: UserWithProfile) {
@@ -211,16 +212,16 @@ export class EventTypesService_2024_06_14 {
};
}
async getUserEventTypes(userId: number) {
const eventTypes = await this.eventTypesRepository.getUserEventTypes(userId);
async getUserEventTypes(userId: number, sortCreatedAt?: SortOrderType) {
const eventTypes = await this.eventTypesRepository.getUserEventTypes(userId, sortCreatedAt);
return eventTypes.map((eventType) => {
return { ownerId: userId, ...eventType };
});
}
async getUserEventTypesPublic(userId: number) {
const eventTypes = await this.eventTypesRepository.getUserEventTypesPublic(userId);
async getUserEventTypesPublic(userId: number, sortCreatedAt?: SortOrderType) {
const eventTypes = await this.eventTypesRepository.getUserEventTypesPublic(userId, sortCreatedAt);
return eventTypes.map((eventType) => {
return { ownerId: userId, ...eventType };
@@ -237,7 +238,7 @@ export class EventTypesService_2024_06_14 {
}
async getEventTypes(queryParams: GetEventTypesQuery_2024_06_14, authUser?: AuthOptionalUser) {
const { username, eventSlug, usernames, orgSlug, orgId } = queryParams;
const { username, eventSlug, usernames, orgSlug, orgId, sortCreatedAt } = queryParams;
if (username && eventSlug) {
const eventType = await this.getEventTypeByUsernameAndSlug({
username,
@@ -255,6 +256,7 @@ export class EventTypesService_2024_06_14 {
orgSlug,
orgId,
authUser,
sortCreatedAt,
});
}
@@ -264,7 +266,7 @@ export class EventTypesService_2024_06_14 {
}
if (authUser?.id) {
return await this.getUserEventTypes(authUser.id);
return await this.getUserEventTypes(authUser.id, sortCreatedAt);
}
return [];
@@ -47,8 +47,8 @@ import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { handleCreatePhoneCall } from "@calcom/platform-libraries";
import {
CreateTeamEventTypeInput_2024_06_14,
GetOrganizationEventTypesQuery_2024_06_14,
GetTeamEventTypesQuery_2024_06_14,
SkipTakePagination,
TeamEventTypeOutput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
@@ -159,12 +159,16 @@ export class OrganizationsEventTypesController {
@UseGuards(IsOrgGuard, IsTeamInOrg, IsAdminAPIEnabledGuard)
@Get("/teams/:teamId/event-types")
@ApiOperation({ summary: "Get team event types" })
@ApiOperation({
summary: "Get team event types",
description:
'Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts "asc" (oldest first) or "desc" (newest first). When not provided, no explicit ordering is applied.',
})
async getTeamEventTypes(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: GetTeamEventTypesQuery_2024_06_14
): Promise<GetTeamEventTypesOutput> {
const { eventSlug, hostsLimit } = queryParams;
const { eventSlug, hostsLimit, sortCreatedAt } = queryParams;
if (eventSlug) {
const eventType = await this.organizationsEventTypesService.getTeamEventTypeBySlug(
@@ -179,7 +183,7 @@ export class OrganizationsEventTypesController {
};
}
const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId);
const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId, sortCreatedAt);
return {
status: SUCCESS_STATUS,
@@ -191,16 +195,21 @@ export class OrganizationsEventTypesController {
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Get("/teams/event-types")
@ApiOperation({ summary: "Get all team event types" })
@ApiOperation({
summary: "Get all team event types",
description:
'Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts "asc" (oldest first) or "desc" (newest first). When not provided, no explicit ordering is applied.',
})
async getTeamsEventTypes(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: SkipTakePagination
@Query() queryParams: GetOrganizationEventTypesQuery_2024_06_14
): Promise<GetTeamEventTypesOutput> {
const { skip, take } = queryParams;
const { skip, take, sortCreatedAt } = queryParams;
const eventTypes = await this.organizationsEventTypesService.getOrganizationsTeamsEventTypes(
orgId,
skip,
take
take,
sortCreatedAt
);
return {
@@ -1,16 +1,24 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable } from "@nestjs/common";
import type { SortOrderType } from "@calcom/platform-types";
@Injectable()
export class OrganizationsEventTypesRepository {
constructor(private readonly dbRead: PrismaReadService) {}
async getOrganizationTeamsEventTypes(orgId: number, skip: number, take: number) {
async getOrganizationTeamsEventTypes(
orgId: number,
skip: number,
take: number,
sortCreatedAt?: SortOrderType
) {
return this.dbRead.prisma.eventType.findMany({
where: {
team: {
parentId: orgId,
},
},
...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }),
skip,
take,
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
@@ -1,4 +1,6 @@
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import type { SortOrderType } from "@calcom/platform-types";
import { OrganizationsEventTypesRepository } from "@/modules/organizations/event-types/organizations-event-types.repository";
import {
TransformedCreateTeamEventTypeInput,
@@ -83,16 +85,22 @@ export class OrganizationsEventTypesService {
return this.teamsEventTypesService.getTeamEventTypeBySlug(teamId, eventTypeSlug, hostsLimit);
}
async getTeamEventTypes(teamId: number): Promise<DatabaseTeamEventType[]> {
return await this.teamsEventTypesService.getTeamEventTypes(teamId);
async getTeamEventTypes(teamId: number, sortCreatedAt?: SortOrderType): Promise<DatabaseTeamEventType[]> {
return await this.teamsEventTypesService.getTeamEventTypes(teamId, sortCreatedAt);
}
async getOrganizationsTeamsEventTypes(
orgId: number,
skip = 0,
take = 250
take = 250,
sortCreatedAt?: SortOrderType
): Promise<DatabaseTeamEventType[]> {
return await this.organizationEventTypesRepository.getOrganizationTeamsEventTypes(orgId, skip, take);
return await this.organizationEventTypesRepository.getOrganizationTeamsEventTypes(
orgId,
skip,
take,
sortCreatedAt
);
}
async updateOrganizationTeamEventType(
@@ -136,12 +136,16 @@ export class TeamsEventTypesController {
}
@Get("/")
@ApiOperation({ summary: "Get a team event type" })
@ApiOperation({
summary: "Get team event types",
description:
'Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts "asc" (oldest first) or "desc" (newest first). When not provided, no explicit ordering is applied.',
})
async getTeamEventTypes(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: GetTeamEventTypesQuery_2024_06_14
): Promise<GetTeamEventTypesOutput> {
const { eventSlug, hostsLimit } = queryParams;
const { eventSlug, hostsLimit, sortCreatedAt } = queryParams;
if (eventSlug) {
const eventType = await this.teamsEventTypesService.getTeamEventTypeBySlug(
@@ -156,7 +160,7 @@ export class TeamsEventTypesController {
};
}
const eventTypes = await this.teamsEventTypesService.getTeamEventTypes(teamId);
const eventTypes = await this.teamsEventTypesService.getTeamEventTypes(teamId, sortCreatedAt);
return {
status: SUCCESS_STATUS,
@@ -11,6 +11,8 @@ import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
import type { SortOrderType } from "@calcom/platform-types";
import { createEventType, updateEventType } from "@calcom/platform-libraries/event-types";
@Injectable()
@@ -101,8 +103,8 @@ export class TeamsEventTypesService {
return eventType;
}
async getTeamEventTypes(teamId: number): Promise<DatabaseTeamEventType[]> {
return await this.teamsEventTypesRepository.getTeamEventTypes(teamId);
async getTeamEventTypes(teamId: number, sortCreatedAt?: SortOrderType): Promise<DatabaseTeamEventType[]> {
return await this.teamsEventTypesRepository.getTeamEventTypes(teamId, sortCreatedAt);
}
async updateTeamEventType(
@@ -2,6 +2,8 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import type { SortOrderType } from "@calcom/platform-types";
@Injectable()
export class TeamsEventTypesRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
@@ -79,11 +81,12 @@ export class TeamsEventTypesRepository {
});
}
async getTeamEventTypes(teamId: number) {
async getTeamEventTypes(teamId: number, sortCreatedAt?: SortOrderType) {
return this.dbRead.prisma.eventType.findMany({
where: {
teamId,
},
...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }),
include: {
users: true,
schedule: true,
+54 -1
View File
@@ -4470,6 +4470,7 @@
"get": {
"operationId": "OrganizationsEventTypesController_getTeamEventTypes",
"summary": "Get team event types",
"description": "Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts \"asc\" (oldest first) or \"desc\" (newest first). When not provided, no explicit ordering is applied.",
"parameters": [
{
"name": "Authorization",
@@ -4523,6 +4524,16 @@
"schema": {
"type": "number"
}
},
{
"name": "sortCreatedAt",
"required": false,
"in": "query",
"description": "Sort event types by creation date. When not provided, no explicit ordering is applied.",
"schema": {
"enum": ["asc", "desc"],
"type": "string"
}
}
],
"responses": {
@@ -4816,6 +4827,7 @@
"get": {
"operationId": "OrganizationsEventTypesController_getTeamsEventTypes",
"summary": "Get all team event types",
"description": "Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts \"asc\" (oldest first) or \"desc\" (newest first). When not provided, no explicit ordering is applied.",
"parameters": [
{
"name": "Authorization",
@@ -4876,6 +4888,16 @@
"default": 0,
"type": "number"
}
},
{
"name": "sortCreatedAt",
"required": false,
"in": "query",
"description": "Sort event types by creation date. When not provided, no explicit ordering is applied.",
"schema": {
"enum": ["asc", "desc"],
"type": "string"
}
}
],
"responses": {
@@ -11552,7 +11574,7 @@
"get": {
"operationId": "EventTypesController_2024_06_14_getEventTypes",
"summary": "Get all event types",
"description": "Hidden event types are returned only if authentication is provided and it belongs to the event type owner.\n \n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
"description": "Hidden event types are returned only if authentication is provided and it belongs to the event type owner.\n \n Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts \"asc\" (oldest first) or \"desc\" (newest first). When not provided, no explicit ordering is applied.\n \n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
"parameters": [
{
"name": "cal-api-version",
@@ -11609,6 +11631,16 @@
"type": "number"
}
},
{
"name": "sortCreatedAt",
"required": false,
"in": "query",
"description": "Sort event types by creation date. When not provided, no explicit ordering is applied.",
"schema": {
"enum": ["asc", "desc"],
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
@@ -14420,6 +14452,7 @@
"get": {
"operationId": "TeamsEventTypesController_getTeamEventTypes",
"summary": "Get a team event type",
"description": "Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts \"asc\" (oldest first) or \"desc\" (newest first). When not provided, no explicit ordering is applied.",
"parameters": [
{
"name": "teamId",
@@ -14446,6 +14479,16 @@
"schema": {
"type": "number"
}
},
{
"name": "sortCreatedAt",
"required": false,
"in": "query",
"description": "Sort event types by creation date. When not provided, no explicit ordering is applied.",
"schema": {
"enum": ["asc", "desc"],
"type": "string"
}
}
],
"responses": {
@@ -27467,6 +27510,14 @@
"type": "string",
"nullable": true
},
"avatarUrl": {
"type": "string",
"nullable": true
},
"bio": {
"type": "string",
"nullable": true
},
"timeFormat": {
"type": "number"
},
@@ -27493,6 +27544,8 @@
"username",
"email",
"name",
"avatarUrl",
"bio",
"timeFormat",
"defaultScheduleId",
"weekStart",
@@ -1,6 +1,8 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsNumber, IsOptional, IsString } from "class-validator";
import { IsEnum, IsNumber, IsOptional, IsString } from "class-validator";
import { SkipTakePagination, SortOrder, SortOrderType } from "../../../pagination/pagination.input";
export class GetEventTypesQuery_2024_06_14 {
@IsOptional()
@@ -46,6 +48,14 @@ export class GetEventTypesQuery_2024_06_14 {
type: Number,
})
orgId?: number;
@ApiPropertyOptional({
enum: SortOrder,
description: "Sort event types by creation date. When not provided, no explicit ordering is applied.",
})
@IsOptional()
@IsEnum(SortOrder)
sortCreatedAt?: SortOrderType;
}
export class GetTeamEventTypesQuery_2024_06_14 {
@@ -64,6 +74,24 @@ export class GetTeamEventTypesQuery_2024_06_14 {
"Specifies the maximum number of hosts to include in the response. This limit helps optimize performance. If not provided, all Hosts will be fetched.",
})
hostsLimit?: number;
@ApiPropertyOptional({
enum: SortOrder,
description: "Sort event types by creation date. When not provided, no explicit ordering is applied.",
})
@IsOptional()
@IsEnum(SortOrder)
sortCreatedAt?: SortOrderType;
}
export class GetOrganizationEventTypesQuery_2024_06_14 extends SkipTakePagination {
@ApiPropertyOptional({
enum: SortOrder,
description: "Sort event types by creation date. When not provided, no explicit ordering is applied.",
})
@IsOptional()
@IsEnum(SortOrder)
sortCreatedAt?: SortOrderType;
}
function TransformUsernames() {
@@ -2,6 +2,13 @@ import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsNumber, Min, Max, IsOptional } from "class-validator";
export enum SortOrder {
ASC = "asc",
DESC = "desc",
}
export type SortOrderType = `${SortOrder}`;
export class Pagination {
@ApiPropertyOptional({ description: "The number of items to return", example: 10 })
@Transform(({ value }: { value: string }) => value && parseInt(value))