-
Notifications
You must be signed in to change notification settings - Fork 0
feat: api v2 team invite link endpoint #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: coderabbit_full_base_feat_api_v2_team_invite_link_endpoint_pr6
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| }); | ||
| }); | ||
|
|
||
| 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"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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 -30Repository: 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 -30Repository: 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 -20Repository: 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 fRepository: 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 -20Repository: 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 1Repository: 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 -10Repository: agentic-review-benchmarks/cal.com-combined-coderabbit Length of output: 1501 Use API v2 modules should not import directly from Change line 21 from: To: 🧰 Tools🪛 Biome (2.3.13)[error] 21-21: API v2 should only import from (lint/style/noRestrictedImports) 🤖 Prompt for AI Agents |
||
|
|
||
| @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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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 -20Repository: 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 tsRepository: 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 -30Repository: 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.tsRepository: 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.tsRepository: 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.tsRepository: 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.tsRepository: agentic-review-benchmarks/cal.com-combined-coderabbit Length of output: 239 Change The e2e test at lines 148-150 expects users with 🐛 Proposed fix `@Post`("/invite")
- `@Roles`("TEAM_MEMBER")
+ `@Roles`("TEAM_ADMIN")
`@ApiOperation`({ summary: "Create team invite link" })🤖 Prompt for AI Agents |
||
| @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 }; | ||
| } | ||
| } | ||
| 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; | ||
| } |
| 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 {} |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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, | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -96,14 +96,14 @@ export class TeamService { | |||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||
| token, | ||||||||||||||||||||||||||||||||||||||
| token: newToken.identifier, | ||||||||||||||||||||||||||||||||||||||
| inviteLink: await TeamService.buildInviteLink(token, isOrganizationOrATeamInOrganization), | ||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
98
to
101
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Returns identifier instead of actual token value. The return statement at line 99 returns This will break the e2e test assertion at line 75 ( 🐛 Proposed fix return {
- token: newToken.identifier,
+ token: newToken.token,
inviteLink: await TeamService.buildInviteLink(token, isOrganizationOrATeamInOrganization),
};Or alternatively, since return {
- token: newToken.identifier,
+ token,
inviteLink: await TeamService.buildInviteLink(token, isOrganizationOrATeamInOrganization),
};📝 Committable suggestion
Suggested change
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -564,4 +564,4 @@ export class TeamService { | |||||||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||||||||
| ]); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tests are well-structured but will fail due to upstream issues.
The test logic is correct and follows good practices:
However, these tests will currently fail due to two issues identified in the reviewed files:
Service bug:
TeamService.createInvitereturnsnewToken.identifier(the descriptive key) instead of the actual token, so the assertion at line 75 (inviteLink.toContain(token)) will fail.Role mismatch: The controller uses
@Roles("TEAM_MEMBER"), but the test at line 149 expects aMEMBERrole user to receive 403. This suggests either the role should beTEAM_ADMINor theRolesGuardimplementation differs from expectations.Please address the upstream issues in
teamService.tsandteams-invite.controller.tsto make these tests pass.🤖 Prompt for AI Agents