-
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: greptile_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"; | ||
|
|
||
| @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") | ||
| @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, | ||||||
|
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. wrong field returned - should return the generated random value from Prompt To Fix With AIThis is a comment left during a code review.
Path: packages/features/ee/teams/services/teamService.ts
Line: 99:99
Comment:
wrong field returned - should return the generated random value from `newToken.token`, not the lookup identifier
How can I resolve this? If you propose a fix, please make it concise. |
||||||
| inviteLink: await TeamService.buildInviteLink(token, isOrganizationOrATeamInOrganization), | ||||||
| }; | ||||||
| } | ||||||
|
|
||||||
| private static async buildInviteLink(token: string, isOrgContext: boolean): Promise<string> { | ||||||
| const teamInviteLink = `${WEBAPP_URL}/teams?token=${token}`; | ||||||
| if (!isOrgContext) { | ||||||
| if (isOrgContext) { | ||||||
|
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. inverted logic - organization contexts should get the signup link, not the teams link
Suggested change
Prompt To Fix With AIThis is a comment left during a code review.
Path: packages/features/ee/teams/services/teamService.ts
Line: 106:106
Comment:
inverted logic - organization contexts should get the signup link, not the teams link
```suggestion
if (!isOrgContext) {
```
How can I resolve this? If you propose a fix, please make it concise. |
||||||
| 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.
@Roles("TEAM_MEMBER")allows any team member to create invites, but tests expect only admins can. Should beTEAM_ADMINPrompt To Fix With AI