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
2 changes: 2 additions & 0 deletions apps/api/v2/src/ee/platform-endpoints-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module
import { SlotsModule_2024_04_15 } from "@/modules/slots/slots-2024-04-15/slots.module";
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { TeamsInviteModule } from "@/modules/teams/invite/teams-invite.module";
import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
Expand All @@ -32,6 +33,7 @@ import { Module } from "@nestjs/common";
BookingsModule_2024_04_15,
BookingsModule_2024_08_13,
TeamsMembershipsModule,
TeamsInviteModule,
SlotsModule_2024_04_15,
SlotsModule_2024_09_04,
TeamsModule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { bootstrap } from "@/bootstrap";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.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 { withApiAuth } from "test/utils/withApiAuth";

import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { Team, User } from "@calcom/prisma/client";

describe("Teams Invite Endpoints", () => {
describe("User Authentication - User is Team Admin", () => {
let app: INestApplication;

let userRepositoryFixture: UserRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;

let team: Team;

const userEmail = `teams-invite-admin-${randomString()}@api.com`;

let user: User;

beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();

userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);

user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});

team = await teamsRepositoryFixture.create({
name: `teams-invite-team-${randomString()}`,
isOrganization: false,
});

// Admin of the team
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: team.id } },
});

app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});

it("should create a team invite", async () => {
return request(app.getHttpServer())
.post(`/v2/teams/${team.id}/invite`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data.token.length).toBeGreaterThan(0);
expect(response.body.data.inviteLink).toEqual(expect.any(String));
expect(response.body.data.inviteLink).toContain(response.body.data.token);
});
});

it("should create a new invite on each request", async () => {
const first = await request(app.getHttpServer()).post(`/v2/teams/${team.id}/invite`).expect(200);
const firstToken = first.body.data.token as string;

return request(app.getHttpServer())
.post(`/v2/teams/${team.id}/invite`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data.token).not.toEqual(firstToken);
expect(response.body.data.inviteLink).toEqual(expect.any(String));
expect(response.body.data.inviteLink).toContain(response.body.data.token);
});
});
Comment on lines +67 to +92

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Tests are well-structured but will fail due to upstream issues.

The test logic is correct and follows good practices:

  • Validates response structure and status
  • Verifies token/inviteLink integrity
  • Confirms new tokens are generated per request

However, these tests will currently fail due to two issues identified in the reviewed files:

  1. Service bug: TeamService.createInvite returns newToken.identifier (the descriptive key) instead of the actual token, so the assertion at line 75 (inviteLink.toContain(token)) will fail.

  2. Role mismatch: The controller uses @Roles("TEAM_MEMBER"), but the test at line 149 expects a MEMBER role user to receive 403. This suggests either the role should be TEAM_ADMIN or the RolesGuard implementation differs from expectations.

Please address the upstream issues in teamService.ts and teams-invite.controller.ts to make these tests pass.

🤖 Prompt for AI Agents
In
`@apps/api/v2/src/modules/teams/invite/controllers/teams-invite.controller.e2e-spec.ts`
around lines 67 - 92, Fix two upstream issues so the invite tests pass: in
TeamService.createInvite ensure the method returns the actual token string (the
generated token value) not newToken.identifier (the descriptive key) so callers
like the tests and inviteLink assertions receive the real token; update the
return value/reference in the createInvite implementation to return the token
variable/field that holds the raw token. Also align controller role requirements
in teams-invite.controller (the `@Roles` decoration) with test expectations by
changing `@Roles`("TEAM_MEMBER") to the intended admin role (e.g.,
`@Roles`("TEAM_ADMIN") or the exact enum/value used in the tests) or vice versa so
the RolesGuard behavior matches the test that a MEMBER receives 403; ensure you
reference the same role constant/enum used elsewhere in the app.


afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await teamsRepositoryFixture.delete(team.id);
await app.close();
});
});

describe("User Authentication - User is Team Member (not Admin)", () => {
let app: INestApplication;

let userRepositoryFixture: UserRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;

let team: Team;

const userEmail = `teams-invite-member-${randomString()}@api.com`;

let user: User;

beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();

userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);

user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});

team = await teamsRepositoryFixture.create({
name: `teams-invite-member-team-${randomString()}`,
isOrganization: false,
});

// Regular member of the team (not admin)
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user.id } },
team: { connect: { id: team.id } },
});

app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});

it("should fail to create invite as non-admin member", async () => {
return request(app.getHttpServer()).post(`/v2/teams/${team.id}/invite`).expect(403);
});

afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await teamsRepositoryFixture.delete(team.id);
await app.close();
});
});

describe("User Authentication - User is not a Team Member", () => {
let app: INestApplication;

let userRepositoryFixture: UserRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;

let team: Team;

const userEmail = `teams-invite-non-member-${randomString()}@api.com`;

let user: User;

beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();

userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);

user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});

team = await teamsRepositoryFixture.create({
name: `teams-invite-non-member-team-${randomString()}`,
isOrganization: false,
});

// User is NOT a member of this team

app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});

it("should fail to create invite as non-member", async () => {
return request(app.getHttpServer()).post(`/v2/teams/${team.id}/invite`).expect(403);
});

afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await teamsRepositoryFixture.delete(team.id);
await app.close();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { API_KEY_HEADER } from "@/lib/docs/headers";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { CreateInviteOutputDto } from "@/modules/teams/invite/outputs/invite.output";

import {
Controller,
UseGuards,
Post,
Param,
ParseIntPipe,
HttpCode,
HttpStatus,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";

import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { TeamService } from "@calcom/features/ee/teams/services/teamService";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how other API v2 modules import services
rg "from.*@calcom/features" apps/api/v2/src/modules --type ts | head -30

Repository: agentic-review-benchmarks/cal.com-combined-coderabbit

Length of output: 250


🏁 Script executed:

#!/bin/bash
# Also check what imports are used in other API v2 controllers
fd "\.controller\.ts$" apps/api/v2/src/modules --exec head -30 {} \; | grep "^import" | head -30

Repository: agentic-review-benchmarks/cal.com-combined-coderabbit

Length of output: 2428


🏁 Script executed:

#!/bin/bash
# Check if there's an eslint rule or import restriction config
fd "eslint\|import" apps/api/v2 --type f -e js -e ts -e json | head -20

Repository: agentic-review-benchmarks/cal.com-combined-coderabbit

Length of output: 79


🏁 Script executed:

#!/bin/bash
# Check if TeamService exists in the API v2 teams module
find apps/api/v2/src/modules/teams -name "*service*" -type f

Repository: agentic-review-benchmarks/cal.com-combined-coderabbit

Length of output: 394


🏁 Script executed:

#!/bin/bash
# Check what's exported from `@calcom/platform-libraries` related to teams
rg "export.*Team|TeamService" packages/platform/libraries --type ts | head -20

Repository: agentic-review-benchmarks/cal.com-combined-coderabbit

Length of output: 1497


🏁 Script executed:

#!/bin/bash
# Confirm the exact import structure by checking platform-libraries index
rg "export.*TeamService" packages/platform/libraries/index.ts -A 1 -B 1

Repository: agentic-review-benchmarks/cal.com-combined-coderabbit

Length of output: 176


🏁 Script executed:

#!/bin/bash
# Check how other API v2 services import from platform-libraries
rg "from.*@calcom/platform-libraries" apps/api/v2/src/modules --type ts | head -10

Repository: agentic-review-benchmarks/cal.com-combined-coderabbit

Length of output: 1501


Use @calcom/platform-libraries to import TeamService.

API v2 modules should not import directly from @calcom/features. The TeamService is already re-exported through @calcom/platform-libraries, which is the proper boundary layer for API v2 consumption.

Change line 21 from:

import { TeamService } from "@calcom/features/ee/teams/services/teamService";

To:

import { TeamService } from "@calcom/platform-libraries";
🧰 Tools
🪛 Biome (2.3.13)

[error] 21-21: API v2 should only import from @calcom/platform-constants, @calcom/platform-enums, @calcom/platform-libraries, @calcom/platform-types, @calcom/platform-utils, and @calcom/prisma. Other @calcom packages are not allowed.

(lint/style/noRestrictedImports)

🤖 Prompt for AI Agents
In `@apps/api/v2/src/modules/teams/invite/controllers/teams-invite.controller.ts`
at line 21, Replace the direct feature-layer import with the platform library
re-export: update the import of TeamService in teams-invite.controller.ts so it
comes from the public boundary export (use the TeamService symbol imported from
"@calcom/platform-libraries" instead of
"@calcom/features/ee/teams/services/teamService"); ensure any usages of
TeamService remain unchanged and remove the old import.


@Controller({
path: "/v2/teams/:teamId",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, RolesGuard)
@DocsTags("Teams / Invite")
@ApiHeader(API_KEY_HEADER)
export class TeamsInviteController {
@Post("/invite")
@Roles("TEAM_MEMBER")
Comment on lines +31 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the RolesGuard implementation to understand how roles are interpreted
find apps/api/v2/src -name "*roles*" -type f | head -20

Repository: agentic-review-benchmarks/cal.com-combined-coderabbit

Length of output: 1860


🏁 Script executed:

#!/bin/bash
# Search for the e2e tests mentioned (lines 148-150)
rg -B 5 -A 5 "MEMBER.*403|403.*MEMBER" apps/api/v2 --type ts

Repository: agentic-review-benchmarks/cal.com-combined-coderabbit

Length of output: 79


🏁 Script executed:

#!/bin/bash
# Look for role definitions
rg "TEAM_MEMBER|TEAM_ADMIN" apps/api/v2/src --type ts | head -30

Repository: agentic-review-benchmarks/cal.com-combined-coderabbit

Length of output: 3390


🏁 Script executed:

#!/bin/bash
# Read the entire teams-invite.controller.ts file to see the e2e tests
cat -n apps/api/v2/src/modules/teams/invite/controllers/teams-invite.controller.ts

Repository: agentic-review-benchmarks/cal.com-combined-coderabbit

Length of output: 1785


🏁 Script executed:

#!/bin/bash
# Read the RolesGuard implementation
cat -n apps/api/v2/src/modules/auth/guards/roles/roles.guard.ts

Repository: agentic-review-benchmarks/cal.com-combined-coderabbit

Length of output: 9214


🏁 Script executed:

#!/bin/bash
# Check if there's an e2e spec file for the invite controller
find apps/api/v2/src/modules/teams/invite -name "*.e2e-spec.ts" -o -name "*.spec.ts"

Repository: agentic-review-benchmarks/cal.com-combined-coderabbit

Length of output: 181


🏁 Script executed:

#!/bin/bash
# Read the e2e spec file to find the tests
cat -n apps/api/v2/src/modules/teams/invite/controllers/teams-invite.controller.e2e-spec.ts

Repository: agentic-review-benchmarks/cal.com-combined-coderabbit

Length of output: 8677


🏁 Script executed:

#!/bin/bash
# Find and read the TEAM_ROLES constants
rg -A 10 "export const TEAM_ROLES" apps/api/v2/src/lib/roles/constants.ts

Repository: agentic-review-benchmarks/cal.com-combined-coderabbit

Length of output: 239


Change @Roles("TEAM_MEMBER") to @Roles("TEAM_ADMIN") to match test expectations.

The e2e test at lines 148-150 expects users with MEMBER role to receive a 403 response when creating invites. However, @Roles("TEAM_MEMBER") grants access to any team member (MEMBER, ADMIN, or OWNER). Based on the role hierarchy where lower indices indicate higher privilege, a MEMBER-role user with the current decorator would pass validation (index 2 ≤ 2). To restrict invite creation to admins only, use @Roles("TEAM_ADMIN") instead, which would correctly deny MEMBER-role users (index 2 > 1).

🐛 Proposed fix
   `@Post`("/invite")
-  `@Roles`("TEAM_MEMBER")
+  `@Roles`("TEAM_ADMIN")
   `@ApiOperation`({ summary: "Create team invite link" })
🤖 Prompt for AI Agents
In `@apps/api/v2/src/modules/teams/invite/controllers/teams-invite.controller.ts`
around lines 31 - 32, Update the access decorator on the POST /invite handler so
only admins can create invites: in the TeamsInviteController replace the current
`@Roles`("TEAM_MEMBER") annotation on the method handling the "/invite" POST route
with `@Roles`("TEAM_ADMIN") to ensure MEMBER-role users are denied as expected by
the e2e tests.

@ApiOperation({ summary: "Create team invite link" })
@HttpCode(HttpStatus.OK)
async createInvite(
@Param("teamId", ParseIntPipe) teamId: number
): Promise<CreateInviteOutputDto> {
const result = await TeamService.createInvite(teamId);
return { status: SUCCESS_STATUS, data: result };
}
}
38 changes: 38 additions & 0 deletions apps/api/v2/src/modules/teams/invite/outputs/invite.output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsEnum, IsString, ValidateNested } from "class-validator";

export class InviteDataDto {
@IsString()
@Expose()
@ApiProperty({
description:
"Unique invitation token for this team. Share this token with prospective members to allow them to join the team.",
example: "f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2",
})
token!: string;

@IsString()
@Expose()
@ApiProperty({
description:
"Complete invitation URL that can be shared with prospective members. Opens the signup page with the token and redirects to getting started after signup.",
example:
"http://app.cal.com/signup?token=f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2&callbackUrl=/getting-started",
})
inviteLink!: string;
}

export class CreateInviteOutputDto {
@Expose()
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;

@Expose()
@ValidateNested()
@Type(() => InviteDataDto)
@ApiProperty({ type: InviteDataDto })
data!: InviteDataDto;
}
11 changes: 11 additions & 0 deletions apps/api/v2/src/modules/teams/invite/teams-invite.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { TeamsInviteController } from "@/modules/teams/invite/controllers/teams-invite.controller";
import { Module } from "@nestjs/common";

@Module({
imports: [PrismaModule, RedisModule, MembershipsModule],
controllers: [TeamsInviteController],
})
export class TeamsInviteModule {}
8 changes: 4 additions & 4 deletions packages/features/ee/teams/services/teamService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export class TeamService {
}

const token = randomBytes(32).toString("hex");
await prisma.verificationToken.create({
const newToken = await prisma.verificationToken.create({
data: {
identifier: `invite-link-for-teamId-${teamId}`,
token,
Expand All @@ -96,14 +96,14 @@ export class TeamService {
});

return {
token,
token: newToken.identifier,
inviteLink: await TeamService.buildInviteLink(token, isOrganizationOrATeamInOrganization),
};
Comment on lines 98 to 101

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Bug: Returns identifier instead of actual token value.

The return statement at line 99 returns newToken.identifier which is the string "invite-link-for-teamId-${teamId}", not the actual shareable token. The inviteLink is correctly built using the raw token variable, causing a mismatch.

This will break the e2e test assertion at line 75 (expect(response.body.data.inviteLink).toContain(response.body.data.token)) since the returned token won't be contained in the invite link.

🐛 Proposed fix
     return {
-      token: newToken.identifier,
+      token: newToken.token,
       inviteLink: await TeamService.buildInviteLink(token, isOrganizationOrATeamInOrganization),
     };

Or alternatively, since token variable already holds the correct value:

     return {
-      token: newToken.identifier,
+      token,
       inviteLink: await TeamService.buildInviteLink(token, isOrganizationOrATeamInOrganization),
     };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return {
token,
token: newToken.identifier,
inviteLink: await TeamService.buildInviteLink(token, isOrganizationOrATeamInOrganization),
};
return {
token: newToken.token,
inviteLink: await TeamService.buildInviteLink(token, isOrganizationOrATeamInOrganization),
};
Suggested change
return {
token,
token: newToken.identifier,
inviteLink: await TeamService.buildInviteLink(token, isOrganizationOrATeamInOrganization),
};
return {
token,
inviteLink: await TeamService.buildInviteLink(token, isOrganizationOrATeamInOrganization),
};
🤖 Prompt for AI Agents
In `@packages/features/ee/teams/services/teamService.ts` around lines 98 - 101,
The returned object mistakenly returns newToken.identifier (which is the
generated identifier string) instead of the actual shareable token stored in the
local variable token, causing inviteLink and token to mismatch; update the
return in the function that builds the invite to return token (the raw token
value) and keep inviteLink built via TeamService.buildInviteLink(token, ...),
ensuring the returned token and inviteLink are consistent (referencing
newToken.identifier, token, and TeamService.buildInviteLink to locate the
change).

}

private static async buildInviteLink(token: string, isOrgContext: boolean): Promise<string> {
const teamInviteLink = `${WEBAPP_URL}/teams?token=${token}`;
if (!isOrgContext) {
if (isOrgContext) {
return teamInviteLink;
}
const gettingStartedPath = await OnboardingPathService.getGettingStartedPathWhenInvited(prisma);
Expand Down Expand Up @@ -564,4 +564,4 @@ export class TeamService {
}),
]);
}
}
}