Skip to content

Commit 9eb5692

Browse files
feat: api v2 team invite link endpoint (calcom#26644)
* init: team invite link endpoint * chore: add e2e tests * fix: use SUCCESS_STATUS and ERROR_STATUS constants in invite output DTO Address Cubic AI review feedback by using proper enum typing with SUCCESS_STATUS and ERROR_STATUS constants from @calcom/platform-constants for consistency with other API v2 output DTOs. Co-Authored-By: unknown <> * fixup: add missing memberships module * fixup: revert previous change, not needed * chore: use createInvite from teams service * chore: remove unused value --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 9fcddbf commit 9eb5692

6 files changed

Lines changed: 302 additions & 1 deletion

File tree

apps/api/v2/src/ee/platform-endpoints-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module
1313
import { SlotsModule_2024_04_15 } from "@/modules/slots/slots-2024-04-15/slots.module";
1414
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
1515
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
16+
import { TeamsInviteModule } from "@/modules/teams/invite/teams-invite.module";
1617
import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
1718
import { TeamsModule } from "@/modules/teams/teams/teams.module";
1819
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
@@ -32,6 +33,7 @@ import { Module } from "@nestjs/common";
3233
BookingsModule_2024_04_15,
3334
BookingsModule_2024_08_13,
3435
TeamsMembershipsModule,
36+
TeamsInviteModule,
3537
SlotsModule_2024_04_15,
3638
SlotsModule_2024_09_04,
3739
TeamsModule,
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { bootstrap } from "@/bootstrap";
2+
import { AppModule } from "@/app.module";
3+
import { PrismaModule } from "@/modules/prisma/prisma.module";
4+
import { TokensModule } from "@/modules/tokens/tokens.module";
5+
import { UsersModule } from "@/modules/users/users.module";
6+
import { INestApplication } from "@nestjs/common";
7+
import { NestExpressApplication } from "@nestjs/platform-express";
8+
import { Test } from "@nestjs/testing";
9+
import request from "supertest";
10+
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
11+
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
12+
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
13+
import { randomString } from "test/utils/randomString";
14+
import { withApiAuth } from "test/utils/withApiAuth";
15+
16+
import { SUCCESS_STATUS } from "@calcom/platform-constants";
17+
import type { Team, User } from "@calcom/prisma/client";
18+
19+
describe("Teams Invite Endpoints", () => {
20+
describe("User Authentication - User is Team Admin", () => {
21+
let app: INestApplication;
22+
23+
let userRepositoryFixture: UserRepositoryFixture;
24+
let teamsRepositoryFixture: TeamRepositoryFixture;
25+
let membershipsRepositoryFixture: MembershipRepositoryFixture;
26+
27+
let team: Team;
28+
29+
const userEmail = `teams-invite-admin-${randomString()}@api.com`;
30+
31+
let user: User;
32+
33+
beforeAll(async () => {
34+
const moduleRef = await withApiAuth(
35+
userEmail,
36+
Test.createTestingModule({
37+
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
38+
})
39+
).compile();
40+
41+
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
42+
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
43+
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
44+
45+
user = await userRepositoryFixture.create({
46+
email: userEmail,
47+
username: userEmail,
48+
});
49+
50+
team = await teamsRepositoryFixture.create({
51+
name: `teams-invite-team-${randomString()}`,
52+
isOrganization: false,
53+
});
54+
55+
// Admin of the team
56+
await membershipsRepositoryFixture.create({
57+
role: "ADMIN",
58+
user: { connect: { id: user.id } },
59+
team: { connect: { id: team.id } },
60+
});
61+
62+
app = moduleRef.createNestApplication();
63+
bootstrap(app as NestExpressApplication);
64+
await app.init();
65+
});
66+
67+
it("should create a team invite", async () => {
68+
return request(app.getHttpServer())
69+
.post(`/v2/teams/${team.id}/invite`)
70+
.expect(200)
71+
.then((response) => {
72+
expect(response.body.status).toEqual(SUCCESS_STATUS);
73+
expect(response.body.data.token.length).toBeGreaterThan(0);
74+
expect(response.body.data.inviteLink).toEqual(expect.any(String));
75+
expect(response.body.data.inviteLink).toContain(response.body.data.token);
76+
});
77+
});
78+
79+
it("should create a new invite on each request", async () => {
80+
const first = await request(app.getHttpServer()).post(`/v2/teams/${team.id}/invite`).expect(200);
81+
const firstToken = first.body.data.token as string;
82+
83+
return request(app.getHttpServer())
84+
.post(`/v2/teams/${team.id}/invite`)
85+
.expect(200)
86+
.then((response) => {
87+
expect(response.body.status).toEqual(SUCCESS_STATUS);
88+
expect(response.body.data.token).not.toEqual(firstToken);
89+
expect(response.body.data.inviteLink).toEqual(expect.any(String));
90+
expect(response.body.data.inviteLink).toContain(response.body.data.token);
91+
});
92+
});
93+
94+
afterAll(async () => {
95+
await userRepositoryFixture.deleteByEmail(user.email);
96+
await teamsRepositoryFixture.delete(team.id);
97+
await app.close();
98+
});
99+
});
100+
101+
describe("User Authentication - User is Team Member (not Admin)", () => {
102+
let app: INestApplication;
103+
104+
let userRepositoryFixture: UserRepositoryFixture;
105+
let teamsRepositoryFixture: TeamRepositoryFixture;
106+
let membershipsRepositoryFixture: MembershipRepositoryFixture;
107+
108+
let team: Team;
109+
110+
const userEmail = `teams-invite-member-${randomString()}@api.com`;
111+
112+
let user: User;
113+
114+
beforeAll(async () => {
115+
const moduleRef = await withApiAuth(
116+
userEmail,
117+
Test.createTestingModule({
118+
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
119+
})
120+
).compile();
121+
122+
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
123+
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
124+
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
125+
126+
user = await userRepositoryFixture.create({
127+
email: userEmail,
128+
username: userEmail,
129+
});
130+
131+
team = await teamsRepositoryFixture.create({
132+
name: `teams-invite-member-team-${randomString()}`,
133+
isOrganization: false,
134+
});
135+
136+
// Regular member of the team (not admin)
137+
await membershipsRepositoryFixture.create({
138+
role: "MEMBER",
139+
user: { connect: { id: user.id } },
140+
team: { connect: { id: team.id } },
141+
});
142+
143+
app = moduleRef.createNestApplication();
144+
bootstrap(app as NestExpressApplication);
145+
await app.init();
146+
});
147+
148+
it("should fail to create invite as non-admin member", async () => {
149+
return request(app.getHttpServer()).post(`/v2/teams/${team.id}/invite`).expect(403);
150+
});
151+
152+
afterAll(async () => {
153+
await userRepositoryFixture.deleteByEmail(user.email);
154+
await teamsRepositoryFixture.delete(team.id);
155+
await app.close();
156+
});
157+
});
158+
159+
describe("User Authentication - User is not a Team Member", () => {
160+
let app: INestApplication;
161+
162+
let userRepositoryFixture: UserRepositoryFixture;
163+
let teamsRepositoryFixture: TeamRepositoryFixture;
164+
165+
let team: Team;
166+
167+
const userEmail = `teams-invite-non-member-${randomString()}@api.com`;
168+
169+
let user: User;
170+
171+
beforeAll(async () => {
172+
const moduleRef = await withApiAuth(
173+
userEmail,
174+
Test.createTestingModule({
175+
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
176+
})
177+
).compile();
178+
179+
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
180+
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
181+
182+
user = await userRepositoryFixture.create({
183+
email: userEmail,
184+
username: userEmail,
185+
});
186+
187+
team = await teamsRepositoryFixture.create({
188+
name: `teams-invite-non-member-team-${randomString()}`,
189+
isOrganization: false,
190+
});
191+
192+
// User is NOT a member of this team
193+
194+
app = moduleRef.createNestApplication();
195+
bootstrap(app as NestExpressApplication);
196+
await app.init();
197+
});
198+
199+
it("should fail to create invite as non-member", async () => {
200+
return request(app.getHttpServer()).post(`/v2/teams/${team.id}/invite`).expect(403);
201+
});
202+
203+
afterAll(async () => {
204+
await userRepositoryFixture.deleteByEmail(user.email);
205+
await teamsRepositoryFixture.delete(team.id);
206+
await app.close();
207+
});
208+
});
209+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
2+
import { API_KEY_HEADER } from "@/lib/docs/headers";
3+
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
4+
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
5+
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
6+
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
7+
import { CreateInviteOutputDto } from "@/modules/teams/invite/outputs/invite.output";
8+
9+
import {
10+
Controller,
11+
UseGuards,
12+
Post,
13+
Param,
14+
ParseIntPipe,
15+
HttpCode,
16+
HttpStatus,
17+
} from "@nestjs/common";
18+
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
19+
20+
import { SUCCESS_STATUS } from "@calcom/platform-constants";
21+
import { TeamService } from "@calcom/platform-libraries";
22+
23+
@Controller({
24+
path: "/v2/teams/:teamId",
25+
version: API_VERSIONS_VALUES,
26+
})
27+
@UseGuards(ApiAuthGuard, RolesGuard)
28+
@DocsTags("Teams / Invite")
29+
@ApiHeader(API_KEY_HEADER)
30+
export class TeamsInviteController {
31+
@Post("/invite")
32+
@Roles("TEAM_ADMIN")
33+
@ApiOperation({ summary: "Create team invite link" })
34+
@HttpCode(HttpStatus.OK)
35+
async createInvite(
36+
@Param("teamId", ParseIntPipe) teamId: number
37+
): Promise<CreateInviteOutputDto> {
38+
const result = await TeamService.createInvite(teamId);
39+
return { status: SUCCESS_STATUS, data: result };
40+
}
41+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
2+
import { ApiProperty } from "@nestjs/swagger";
3+
import { Expose, Type } from "class-transformer";
4+
import { IsEnum, IsString, ValidateNested } from "class-validator";
5+
6+
export class InviteDataDto {
7+
@IsString()
8+
@Expose()
9+
@ApiProperty({
10+
description:
11+
"Unique invitation token for this team. Share this token with prospective members to allow them to join the team.",
12+
example: "f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2",
13+
})
14+
token!: string;
15+
16+
@IsString()
17+
@Expose()
18+
@ApiProperty({
19+
description:
20+
"Complete invitation URL that can be shared with prospective members. Opens the signup page with the token and redirects to getting started after signup.",
21+
example:
22+
"http://app.cal.com/signup?token=f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2&callbackUrl=/getting-started",
23+
})
24+
inviteLink!: string;
25+
}
26+
27+
export class CreateInviteOutputDto {
28+
@Expose()
29+
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
30+
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
31+
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
32+
33+
@Expose()
34+
@ValidateNested()
35+
@Type(() => InviteDataDto)
36+
@ApiProperty({ type: InviteDataDto })
37+
data!: InviteDataDto;
38+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { MembershipsModule } from "@/modules/memberships/memberships.module";
2+
import { PrismaModule } from "@/modules/prisma/prisma.module";
3+
import { RedisModule } from "@/modules/redis/redis.module";
4+
import { TeamsInviteController } from "@/modules/teams/invite/controllers/teams-invite.controller";
5+
import { Module } from "@nestjs/common";
6+
7+
@Module({
8+
imports: [PrismaModule, RedisModule, MembershipsModule],
9+
controllers: [TeamsInviteController],
10+
})
11+
export class TeamsInviteModule {}

packages/features/ee/teams/services/teamService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -564,4 +564,4 @@ export class TeamService {
564564
}),
565565
]);
566566
}
567-
}
567+
}

0 commit comments

Comments
 (0)