Skip to content
Open
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);

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).

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",

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 uses schedulingType: "COLLECTIVE" but also includes maxRoundRobinHosts. The documentation correctly states that maxRoundRobinHosts is for Round Robin events. To avoid confusion, the example should be updated to use schedulingType: "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 adds maxRoundRobinHosts to a collective event type even though this field only has an effect for round robin scheduling, which can mislead users into thinking it affects other scheduling types as well. [possible bug]

Severity Level: Critical 🚨

Suggested change
maxRoundRobinHosts: 2,
maxRoundRobinHosts: 2, // Only add this when schedulingType is "roundRobin"
Why it matters? ⭐

This is a valid, actionable doc-improvement: the example sets schedulingType to "COLLECTIVE" but includes maxRoundRobinHosts which only matters for round-robin scheduling. That can mislead readers. Adding a short comment or moving the property behind a roundRobin example clarifies intent and avoids confusion.

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

**Path:** docs/platform/event-types-hooks.mdx
**Line:** 163:163
**Comment:**
	*Possible Bug: The example adds `maxRoundRobinHosts` to a collective event type even though this field only has an effect for round robin scheduling, which can mislead users into thinking it affects other scheduling types as well.

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