Skip to content

Commit ae6b1d4

Browse files
authored
feat: add create invite link endpoint (calcom#24073)
* feat: add create invite link endpoint * tests: add e2e test * chore: feeback * chore: feeback * chore; udate summary * chore; udate summary * chore: deelte swagger
1 parent 3054ad3 commit ae6b1d4

9 files changed

Lines changed: 358 additions & 38 deletions

File tree

apps/api/v2/src/modules/organizations/organizations.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { OrganizationsTeamsController } from "@/modules/organizations/teams/inde
4747
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
4848
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
4949
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
50+
import { OrganizationsTeamsInviteController } from "@/modules/organizations/teams/invite/organizations-teams-invite.controller";
5051
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
5152
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
5253
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
@@ -183,6 +184,7 @@ import { Module } from "@nestjs/common";
183184
OrganizationsMembershipsController,
184185
OrganizationsEventTypesController,
185186
OrganizationsTeamsMembershipsController,
187+
OrganizationsTeamsInviteController,
186188
OrganizationsAttributesController,
187189
OrganizationsAttributesOptionsController,
188190
OrganizationsWebhooksController,
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { bootstrap } from "@/app";
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 * as request from "supertest";
10+
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
11+
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
12+
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
13+
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
14+
import { randomString } from "test/utils/randomString";
15+
import { withApiAuth } from "test/utils/withApiAuth";
16+
17+
import { SUCCESS_STATUS } from "@calcom/platform-constants";
18+
import type { Team, User } from "@calcom/prisma/client";
19+
20+
describe("Organizations Teams Invite Endpoints", () => {
21+
describe("User Authentication - User is Org Team Admin", () => {
22+
let app: INestApplication;
23+
24+
let userRepositoryFixture: UserRepositoryFixture;
25+
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
26+
let teamsRepositoryFixture: TeamRepositoryFixture;
27+
let membershipsRepositoryFixture: MembershipRepositoryFixture;
28+
29+
let org: Team;
30+
let orgTeam: Team;
31+
let nonOrgTeam: Team;
32+
33+
const userEmail = `organizations-teams-invite-admin-${randomString()}@api.com`;
34+
35+
let user: User;
36+
37+
beforeAll(async () => {
38+
const moduleRef = await withApiAuth(
39+
userEmail,
40+
Test.createTestingModule({
41+
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
42+
})
43+
).compile();
44+
45+
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
46+
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
47+
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
48+
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
49+
50+
user = await userRepositoryFixture.create({
51+
email: userEmail,
52+
username: userEmail,
53+
});
54+
55+
org = await organizationsRepositoryFixture.create({
56+
name: `organizations-teams-invite-organization-${randomString()}`,
57+
isOrganization: true,
58+
});
59+
60+
orgTeam = await teamsRepositoryFixture.create({
61+
name: `organizations-teams-invite-team-${randomString()}`,
62+
isOrganization: false,
63+
parent: { connect: { id: org.id } },
64+
});
65+
66+
nonOrgTeam = await teamsRepositoryFixture.create({
67+
name: `organizations-teams-invite-non-org-team-${randomString()}`,
68+
isOrganization: false,
69+
});
70+
71+
// Admin of the org team
72+
await membershipsRepositoryFixture.create({
73+
role: "ADMIN",
74+
user: { connect: { id: user.id } },
75+
team: { connect: { id: orgTeam.id } },
76+
});
77+
78+
// Also a member of the organization
79+
await membershipsRepositoryFixture.create({
80+
role: "ADMIN",
81+
user: { connect: { id: user.id } },
82+
team: { connect: { id: org.id } },
83+
});
84+
85+
app = moduleRef.createNestApplication();
86+
bootstrap(app as NestExpressApplication);
87+
await app.init();
88+
});
89+
90+
it("should create a team invite", async () => {
91+
return request(app.getHttpServer())
92+
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/invite`)
93+
.expect(200)
94+
.then((response) => {
95+
expect(response.body.status).toEqual(SUCCESS_STATUS);
96+
expect(response.body.data.token.length).toBeGreaterThan(0);
97+
expect(response.body.data.inviteLink).toEqual(expect.any(String));
98+
expect(response.body.data.inviteLink).toContain(response.body.data.token);
99+
});
100+
});
101+
102+
it("should create a new invite on each request", async () => {
103+
const first = await request(app.getHttpServer())
104+
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/invite`)
105+
.expect(200);
106+
const firstToken = first.body.data.token as string;
107+
108+
return request(app.getHttpServer())
109+
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/invite`)
110+
.expect(200)
111+
.then((response) => {
112+
expect(response.body.status).toEqual(SUCCESS_STATUS);
113+
expect(response.body.data.token).not.toEqual(firstToken);
114+
expect(response.body.data.inviteLink).toEqual(expect.any(String));
115+
expect(response.body.data.inviteLink).toContain(response.body.data.token);
116+
});
117+
});
118+
119+
it("should fail for team not in organization", async () => {
120+
return request(app.getHttpServer())
121+
.post(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/invite`)
122+
.expect(404);
123+
});
124+
125+
126+
afterAll(async () => {
127+
await userRepositoryFixture.deleteByEmail(user.email);
128+
await organizationsRepositoryFixture.delete(org.id);
129+
await teamsRepositoryFixture.delete(nonOrgTeam.id);
130+
await app.close();
131+
});
132+
});
133+
});
134+
135+
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
2+
import { OPTIONAL_API_KEY_HEADER, OPTIONAL_X_CAL_CLIENT_ID_HEADER, OPTIONAL_X_CAL_SECRET_KEY_HEADER } from "@/lib/docs/headers";
3+
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
4+
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
5+
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
6+
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
7+
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
8+
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
9+
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
10+
import { Controller, UseGuards, Post, Param, ParseIntPipe, HttpCode, HttpStatus } from "@nestjs/common";
11+
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
12+
13+
import { SUCCESS_STATUS } from "@calcom/platform-constants";
14+
15+
import { TeamService } from "@calcom/platform-libraries";
16+
17+
18+
import { CreateInviteOutputDto } from "./outputs/invite.output";
19+
20+
@Controller({
21+
path: "/v2/organizations/:orgId/teams/:teamId",
22+
version: API_VERSIONS_VALUES,
23+
})
24+
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
25+
@DocsTags("Orgs / Teams / Invite")
26+
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
27+
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
28+
@ApiHeader(OPTIONAL_API_KEY_HEADER)
29+
export class OrganizationsTeamsInviteController {
30+
@Post("/invite")
31+
@Roles("TEAM_ADMIN")
32+
@ApiOperation({ summary: "Create team invite link" })
33+
@HttpCode(HttpStatus.OK)
34+
async createInvite(
35+
@Param("orgId", ParseIntPipe) _orgId: number,
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+
}
42+
43+
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { ApiProperty } from "@nestjs/swagger";
2+
3+
export class InviteDataDto {
4+
@ApiProperty({
5+
description:
6+
"Unique invitation token for this team. Share this token with prospective members to allow them to join the team/organization.",
7+
example: "f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2",
8+
})
9+
token!: string;
10+
11+
@ApiProperty({
12+
description:
13+
"Complete invitation URL that can be shared with prospective members. Opens the signup page with the token and redirects to getting started after signup.",
14+
example: "http://app.cal.com/signup?token=f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2&callbackUrl=/getting-started",
15+
})
16+
inviteLink!: string;
17+
}
18+
19+
export class CreateInviteOutputDto {
20+
@ApiProperty({ example: "success" })
21+
status!: string;
22+
23+
@ApiProperty({ type: InviteDataDto })
24+
data!: InviteDataDto;
25+
}
26+
27+

apps/web/playwright/signup.e2e.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ test.describe("Email Signup Flow Test", async () => {
209209
data: {
210210
identifier: userToCreate.email,
211211
token,
212-
expires: new Date(new Date().setHours(168)), // +1 week
212+
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +1 week
213213
team: {
214214
create: {
215215
name: "Rick's Team",

docs/api-reference/v2/openapi.json

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4421,6 +4421,70 @@
44214421
"tags": ["Orgs / Teams / Event Types / Private Links"]
44224422
}
44234423
},
4424+
"/v2/organizations/{orgId}/teams/{teamId}/invite": {
4425+
"post": {
4426+
"operationId": "OrganizationsTeamsInviteController_createInvite",
4427+
"summary": "Create team invite link",
4428+
"parameters": [
4429+
{
4430+
"name": "Authorization",
4431+
"in": "header",
4432+
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
4433+
"required": false,
4434+
"schema": {
4435+
"type": "string"
4436+
}
4437+
},
4438+
{
4439+
"name": "x-cal-secret-key",
4440+
"in": "header",
4441+
"description": "For platform customers - OAuth client secret key",
4442+
"required": false,
4443+
"schema": {
4444+
"type": "string"
4445+
}
4446+
},
4447+
{
4448+
"name": "x-cal-client-id",
4449+
"in": "header",
4450+
"description": "For platform customers - OAuth client ID",
4451+
"required": false,
4452+
"schema": {
4453+
"type": "string"
4454+
}
4455+
},
4456+
{
4457+
"name": "orgId",
4458+
"required": true,
4459+
"in": "path",
4460+
"schema": {
4461+
"type": "number"
4462+
}
4463+
},
4464+
{
4465+
"name": "teamId",
4466+
"required": true,
4467+
"in": "path",
4468+
"schema": {
4469+
"type": "number"
4470+
}
4471+
}
4472+
],
4473+
"responses": {
4474+
"200": {
4475+
"description": "",
4476+
"content": {
4477+
"application/json": {
4478+
"schema": {
4479+
"$ref": "#/components/schemas/CreateInviteOutputDto"
4480+
}
4481+
}
4482+
}
4483+
}
4484+
},
4485+
"tags": ["Orgs / Teams / Invite"]
4486+
}
4487+
},
44244488
"/v2/organizations/{orgId}/teams/{teamId}/memberships": {
44254489
"get": {
44264490
"operationId": "OrganizationsTeamsMembershipsController_getAllOrgTeamMemberships",
@@ -19915,6 +19979,35 @@
1991519979
},
1991619980
"required": ["userId", "role"]
1991719981
},
19982+
"InviteDataDto": {
19983+
"type": "object",
19984+
"properties": {
19985+
"token": {
19986+
"type": "string",
19987+
"description": "Unique invitation token for this team. Share this token with prospective members to allow them to join the team/organization.",
19988+
"example": "f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2"
19989+
},
19990+
"inviteLink": {
19991+
"type": "string",
19992+
"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.",
19993+
"example": "http://app.cal.com/signup?token=f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2&callbackUrl=/getting-started"
19994+
}
19995+
},
19996+
"required": ["token", "inviteLink"]
19997+
},
19998+
"CreateInviteOutputDto": {
19999+
"type": "object",
20000+
"properties": {
20001+
"status": {
20002+
"type": "string",
20003+
"example": "success"
20004+
},
20005+
"data": {
20006+
"$ref": "#/components/schemas/InviteDataDto"
20007+
}
20008+
},
20009+
"required": ["status", "data"]
20010+
},
1991820011
"Attribute": {
1991920012
"type": "object",
1992020013
"properties": {

0 commit comments

Comments
 (0)