Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ type Input = Pick<
| "bookingRequiresAuthentication"
| "maxActiveBookingsPerBooker"
| "maxActiveBookingPerBookerOfferReschedule"
| "maxRoundRobinHosts"
>;

@Injectable()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { Locales } from "@/lib/enums/locales";
import type { CreateManagedUserData } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
import { CreateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/create-organization-team.input";
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.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 { randomString } from "test/utils/randomString";

import { SUCCESS_STATUS, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import type {
ApiSuccessResponse,
CreateTeamEventTypeInput_2024_06_14,
OrgTeamOutputDto,
TeamEventTypeOutput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
import type { PlatformOAuthClient, Team, User } from "@calcom/prisma/client";

describe("maxRoundRobinHosts for Round Robin event types", () => {
let app: INestApplication;
let oAuthClient: PlatformOAuthClient;
let organization: Team;
let managedTeam: OrgTeamOutputDto;
let platformAdmin: User;
let managedUsers: CreateManagedUserData[] = [];

// Fixtures
let userRepositoryFixture: UserRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;

// Helpers
const createManagedUser = async (name: string): Promise<CreateManagedUserData> => {
const body: CreateManagedUserInput = {
email: `max-rr-${name.toLowerCase()}-${randomString()}@api.com`,
timeZone: "Europe/Rome",
weekStart: "Monday",
timeFormat: 24,
locale: Locales.FR,
name,
};

const response = await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.send(body)
.expect(201);

return response.body.data;
};

const addUserToTeam = async (userId: number) => {
const body: CreateOrgTeamMembershipDto = { userId, accepted: true, role: "MEMBER" };
await request(app.getHttpServer())
.post(`/v2/organizations/${organization.id}/teams/${managedTeam.id}/memberships`)
.send(body)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.expect(201);
};

const createRoundRobinEventType = async (
overrides: Partial<CreateTeamEventTypeInput_2024_06_14> = {}
): Promise<TeamEventTypeOutput_2024_06_14> => {
const body: CreateTeamEventTypeInput_2024_06_14 = {
title: "Round Robin Event",
slug: `max-rr-hosts-${randomString()}`,
lengthInMinutes: 30,
// @ts-ignore - schedulingType accepts string
schedulingType: "roundRobin",
assignAllTeamMembers: true,
...overrides,
};

const response = await request(app.getHttpServer())
.post(`/v2/organizations/${organization.id}/teams/${managedTeam.id}/event-types`)
.send(body)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.expect(201);

const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
return responseBody.data;
};

const updateEventType = async (
eventTypeId: number,
body: UpdateTeamEventTypeInput_2024_06_14
): Promise<TeamEventTypeOutput_2024_06_14> => {
const response = await request(app.getHttpServer())
.patch(`/v2/organizations/${organization.id}/teams/${managedTeam.id}/event-types/${eventTypeId}`)
.send(body)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.expect(200);
Comment on lines +106 to +111
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing Authorization Check

Test validates successful update without verifying proper authorization boundaries. Attackers could potentially modify event types across organizations if authorization checks are insufficient. Business impact includes unauthorized configuration changes affecting booking availability and host assignments.

Standards
  • CWE-862
  • OWASP-A01
  • NIST-SSDF-PW.1


const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
return responseBody.data;
};

beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule],
}).compile();

app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);

// Initialize fixtures
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);

// Create organization with platform billing
organization = await teamRepositoryFixture.create({
name: `max-rr-hosts-org-${randomString()}`,
isPlatform: true,
isOrganization: true,
platformBilling: {
create: {
customerId: `cus_${randomString()}`,
plan: "ESSENTIALS",
subscriptionId: `sub_${randomString()}`,
},
},
});

// Create OAuth client
oAuthClient = await oauthClientRepositoryFixture.create(
organization.id,
{
logo: "logo-url",
name: "test-client",
redirectUris: ["http://localhost:4321"],
permissions: 1023,
areDefaultEventTypesEnabled: false,
},
"secret"
);

// Create platform admin
const adminEmail = `max-rr-hosts-admin-${randomString()}@api.com`;
platformAdmin = await userRepositoryFixture.create({ email: adminEmail });

await profilesRepositoryFixture.create({
uid: randomString(),
username: adminEmail,
organization: { connect: { id: organization.id } },
user: { connect: { id: platformAdmin.id } },
});

await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: platformAdmin.id } },
team: { connect: { id: organization.id } },
accepted: true,
});

await app.init();

// Create team
const teamBody: CreateOrgTeamDto = { name: `team-${randomString()}` };
const teamResponse = await request(app.getHttpServer())
.post(`/v2/organizations/${organization.id}/teams`)
.send(teamBody)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.expect(201);
managedTeam = teamResponse.body.data;

// Create and add 3 users to team
for (const name of ["Alice", "Bob", "Charlie"]) {
const user = await createManagedUser(name);
await addUserToTeam(user.user.id);
managedUsers.push(user);
}
});

afterAll(async () => {
await Promise.all(managedUsers.map((u) => userRepositoryFixture.delete(u.user.id)));
await userRepositoryFixture.delete(platformAdmin.id);
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await app.close();
});

describe("when creating round robin event type", () => {
it("sets maxRoundRobinHosts when provided", async () => {
const eventType = await createRoundRobinEventType({ maxRoundRobinHosts: 2 });

expect(eventType.schedulingType).toEqual("roundRobin");
expect(eventType.hosts.length).toEqual(3);
expect(eventType.maxRoundRobinHosts).toEqual(2);
});

it("returns null for maxRoundRobinHosts when not provided", async () => {
const eventType = await createRoundRobinEventType();

expect(eventType.maxRoundRobinHosts).toBeNull();
});
});

describe("when updating round robin event type", () => {
it("updates maxRoundRobinHosts value", async () => {
const eventType = await createRoundRobinEventType({ maxRoundRobinHosts: 1 });
const updated = await updateEventType(eventType.id, { maxRoundRobinHosts: 3 });

expect(updated.maxRoundRobinHosts).toEqual(3);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ type Input = Pick<
| "rescheduleWithSameRoundRobinHost"
| "maxActiveBookingPerBookerOfferReschedule"
| "maxActiveBookingsPerBooker"
| "maxRoundRobinHosts"
>;

@Injectable()
Expand All @@ -103,7 +104,7 @@ export class OutputOrganizationsEventTypesService {

const emailSettings = this.transformEmailSettings(metadata);

const { teamId, userId, parentId, assignAllTeamMembers, rescheduleWithSameRoundRobinHost } =
const { teamId, userId, parentId, assignAllTeamMembers, rescheduleWithSameRoundRobinHost, maxRoundRobinHosts } =
databaseEventType;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { ownerId, users, ...rest } = this.outputEventTypesService.getResponseEventType(
Expand Down Expand Up @@ -139,6 +140,7 @@ export class OutputOrganizationsEventTypesService {
theme: databaseEventType?.team?.theme,
},
rescheduleWithSameRoundRobinHost,
maxRoundRobinHosts,
};
}

Expand Down
2 changes: 2 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -4105,5 +4105,7 @@
"actor": "Actor",
"timestamp": "Timestamp",
"json": "JSON",
"max_round_robin_hosts_count": "Maximum hosts per booking",
"max_round_robin_hosts_description": "Set the maximum number of hosts to be assigned per booking. Defaults to 1.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
27 changes: 25 additions & 2 deletions docs/api-reference/v2/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -13841,7 +13841,7 @@
],
"responses": {
"200": {
"description": "A map of available slots indexed by date, where each date is associated with an array of time slots. If format=range is specified, each slot will be an object with start and end properties denoting start and end of the slot.\n For seated slots each object will have attendeesCount and bookingUid properties.\n If no slots are available, the data object will be empty {}.",
"description": "A map of available slots indexed by date, where each date is associated with an array of time slots. If format=range is specified, each slot will be an object with start and end properties denoting start and end of the slot.\n For seated slots each object will have attendeesCount and bookingUid properties.\n If no slots are available, the data field will be an empty object {}.",
"content": {
"application/json": {
"schema": {
Expand Down Expand Up @@ -14452,7 +14452,7 @@
},
"get": {
"operationId": "TeamsEventTypesController_getTeamEventTypes",
"summary": "Get a team event type",
"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": [
{
Expand Down Expand Up @@ -19663,6 +19663,11 @@
"rescheduleWithSameRoundRobinHost": {
"type": "boolean",
"description": "Rescheduled events will be assigned to the same host as initially scheduled."
},
"maxRoundRobinHosts": {
"type": "number",
"description": "Only relevant for round robin event types. Specifies the maximum number of hosts to automatically assign per booking. When a booking is created, the system assigns up to this number of available hosts. If fewer hosts are available than the configured maximum, all available hosts are assigned. Minimum value is 1, defaults to 1.",
"example": 1
}
},
"required": [
Expand Down Expand Up @@ -21676,6 +21681,11 @@
"rescheduleWithSameRoundRobinHost": {
"type": "boolean",
"description": "Rescheduled events will be assigned to the same host as initially scheduled."
},
"maxRoundRobinHosts": {
"type": "number",
"description": "Only relevant for round robin event types. Specifies the maximum number of hosts to automatically assign per booking. When a booking is created, the system assigns up to this number of available hosts. If fewer hosts are available than the configured maximum, all available hosts are assigned. Minimum value is 1, defaults to 1.",
"example": 1
}
},
"required": ["lengthInMinutes", "title", "slug", "schedulingType"]
Expand Down Expand Up @@ -22139,6 +22149,11 @@
"rescheduleWithSameRoundRobinHost": {
"type": "boolean",
"description": "Rescheduled events will be assigned to the same host as initially scheduled."
},
"maxRoundRobinHosts": {
"type": "number",
"description": "Only relevant for round robin event types. Specifies the maximum number of hosts to automatically assign per booking. When a booking is created, the system assigns up to this number of available hosts. If fewer hosts are available than the configured maximum, all available hosts are assigned. Minimum value is 1, defaults to 1.",
"example": 1
}
}
},
Expand Down Expand Up @@ -29442,6 +29457,10 @@
"example": "success",
"enum": ["success", "error"]
},
"message": {
"type": "string",
"example": "This endpoint will require authentication in a future release."
},
"error": {
"type": "object"
},
Expand Down Expand Up @@ -29469,6 +29488,10 @@
"type": "string"
}
},
"message": {
"type": "string",
"example": "This endpoint will require authentication in a future release."
},
"error": {
"type": "object"
}
Expand Down
3 changes: 2 additions & 1 deletion docs/platform/event-types-hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export default function CreateEventType() {

### 5. `useCreateTeamEventType`

The useCreateTeamEventType hook allows you to create a new team event type. This hook returns a mutation function that handles the event type creation process. The mutation function accepts an object with the following properties: ***lengthInMinutes*** which is the length of the event in minutes, ***title*** which is the title of the event, ***slug*** which is the slug of the event, ***description*** which is the description of the event, schedulingType which can be either ***COLLECTIVE***, ***ROUND_ROBIN*** or ***MANAGED***, ***hosts*** which is an array of hosts for the event and the ***teamId*** which is the id of the team.
The useCreateTeamEventType hook allows you to create a new team event type. This hook returns a mutation function that handles the event type creation process. The mutation function accepts an object with the following properties: ***lengthInMinutes*** which is the length of the event in minutes, ***title*** which is the title of the event, ***slug*** which is the slug of the event, ***description*** which is the description of the event, schedulingType which can be either ***COLLECTIVE***, ***ROUND_ROBIN*** or ***MANAGED***, ***hosts*** which is an array of hosts for the event and the ***teamId*** which is the id of the team, and optionally ***maxRoundRobinHosts*** which specifies the maximum number of hosts to assign per booking for Round Robin events (defaults to 1).
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The description of schedulingType lists the allowed values as COLLECTIVE, ROUND_ROBIN, and MANAGED, but the underlying API input class documents the string values as collective, roundRobin, and managed, so keeping the docs aligned with the API avoids confusing consumers about the expected literals. [logic error]

Severity Level: Minor ⚠️

Suggested change
The useCreateTeamEventType hook allows you to create a new team event type. This hook returns a mutation function that handles the event type creation process. The mutation function accepts an object with the following properties: ***lengthInMinutes*** which is the length of the event in minutes, ***title*** which is the title of the event, ***slug*** which is the slug of the event, ***description*** which is the description of the event, schedulingType which can be either ***COLLECTIVE***, ***ROUND_ROBIN*** or ***MANAGED***, ***hosts*** which is an array of hosts for the event and the ***teamId*** which is the id of the team, and optionally ***maxRoundRobinHosts*** which specifies the maximum number of hosts to assign per booking for Round Robin events (defaults to 1).
The useCreateTeamEventType hook allows you to create a new team event type. This hook returns a mutation function that handles the event type creation process. The mutation function accepts an object with the following properties: ***lengthInMinutes*** which is the length of the event in minutes, ***title*** which is the title of the event, ***slug*** which is the slug of the event, ***description*** which is the description of the event, ***schedulingType*** which can be either ***collective***, ***roundRobin*** or ***managed***, ***hosts*** which is an array of hosts for the event and the ***teamId*** which is the id of the team, and optionally ***maxRoundRobinHosts*** which specifies the maximum number of hosts to assign per booking for Round Robin events (defaults to 1).
Why it matters? ⭐

Verified in the codebase: the CreateTeamEventTypeInput transforms incoming string literals "collective", "roundRobin", and "managed" into the platform SchedulingType enum and its DocsProperty enum lists ["collective","roundRobin","managed"]. The docs currently show uppercase enum values (COLLECTIVE/ROUND_ROBIN/MANAGED) which are the server-side enum constants but not the API input strings users should send. Aligning the docs to show the API literal strings prevents real confusion and is a bugfix for the docs rather than mere style.

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** docs/platform/event-types-hooks.mdx
**Line:** 133:133
**Comment:**
	*Logic Error: The description of `schedulingType` lists the allowed values as `COLLECTIVE`, `ROUND_ROBIN`, and `MANAGED`, but the underlying API input class documents the string values as `collective`, `roundRobin`, and `managed`, so keeping the docs aligned with the API avoids confusing consumers about the expected literals.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.


Below code snippet shows how to use the useCreateTeamEventType hook to set up a team event type.

Expand Down Expand Up @@ -160,6 +160,7 @@ export default function CreateTeamEventType() {
schedulingType: "COLLECTIVE",
hosts: [{"userId": 1456}, {"userId": 2357}],
teamId: 1234,
maxRoundRobinHosts: 2,
Comment on lines 160 to +163

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The example for useCreateTeamEventType is demonstrating the use of maxRoundRobinHosts, which is a feature specific to Round Robin scheduling. However, the schedulingType is set to "COLLECTIVE". This is misleading. To make the example clearer and more accurate, please change the schedulingType to "ROUND_ROBIN".

            schedulingType: "ROUND_ROBIN",
            hosts: [{"userId": 1456}, {"userId": 2357}],
            teamId: 1234,
            maxRoundRobinHosts: 2,

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The example config sets maxRoundRobinHosts while using a non–Round Robin scheduling type, which can mislead users into thinking the option affects collective events, so the line should explicitly indicate that it is only effective when schedulingType is set to a round-robin mode. [logic error]

Severity Level: Minor ⚠️

Suggested change
maxRoundRobinHosts: 2,
maxRoundRobinHosts: 2, // Only relevant when schedulingType is "ROUND_ROBIN"
Why it matters? ⭐

The CreateTeamEventTypeInput docs and implementation explain maxRoundRobinHosts is "Only relevant for round robin event types." The example in the MDX sets schedulingType to COLLECTIVE while also providing maxRoundRobinHosts, which misleads readers. Adding an inline comment (or better: changing the example to use a round-robin schedulingType) clarifies intent and prevents users from thinking the field applies to other scheduling types.

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** docs/platform/event-types-hooks.mdx
**Line:** 163:163
**Comment:**
	*Logic Error: The example config sets `maxRoundRobinHosts` while using a non–Round Robin scheduling type, which can mislead users into thinking the option affects collective events, so the line should explicitly indicate that it is only effective when `schedulingType` is set to a round-robin mode.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.

})
}}>
Create team event type
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ const getEventTypesFromDBSelect = {
bookingLimits: true,
durationLimits: true,
rescheduleWithSameRoundRobinHost: true,
maxRoundRobinHosts: true,
assignAllTeamMembers: true,
isRRWeightsEnabled: true,
beforeEventBuffer: true,
Expand Down
Loading