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);
});
});

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

Copy link
Copy Markdown

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 be TEAM_ADMIN

Suggested change
@Roles("TEAM_MEMBER")
@Roles("TEAM_ADMIN")
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/api/v2/src/modules/teams/invite/controllers/teams-invite.controller.ts
Line: 32:32

Comment:
`@Roles("TEAM_MEMBER")` allows any team member to create invites, but tests expect only admins can. Should be `TEAM_ADMIN`

```suggestion
  @Roles("TEAM_ADMIN")
```

How can I resolve this? If you propose a fix, please make it concise.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

wrong field returned - should return the generated random value from newToken.token, not the lookup identifier

Prompt To Fix With AI
This 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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
if (isOrgContext) {
if (!isOrgContext) {
Prompt To Fix With AI
This 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);
Expand Down Expand Up @@ -564,4 +564,4 @@ export class TeamService {
}),
]);
}
}
}