feat: add create invite link endpoint (#24073)

* feat: add create invite link endpoint

* tests: add e2e test

* chore: feeback

* chore: feeback

* chore; udate summary

* chore; udate summary

* chore: deelte swagger
This commit is contained in:
Udit Takkar
2025-09-26 10:24:44 +00:00
committed by GitHub
parent 3054ad33b2
commit ae6b1d402a
9 changed files with 358 additions and 38 deletions
@@ -47,6 +47,7 @@ import { OrganizationsTeamsController } from "@/modules/organizations/teams/inde
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
import { OrganizationsTeamsInviteController } from "@/modules/organizations/teams/invite/organizations-teams-invite.controller";
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
@@ -183,6 +184,7 @@ import { Module } from "@nestjs/common";
OrganizationsMembershipsController,
OrganizationsEventTypesController,
OrganizationsTeamsMembershipsController,
OrganizationsTeamsInviteController,
OrganizationsAttributesController,
OrganizationsAttributesOptionsController,
OrganizationsWebhooksController,
@@ -0,0 +1,135 @@
import { bootstrap } from "@/app";
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 * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.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("Organizations Teams Invite Endpoints", () => {
describe("User Authentication - User is Org Team Admin", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let org: Team;
let orgTeam: Team;
let nonOrgTeam: Team;
const userEmail = `organizations-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);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
org = await organizationsRepositoryFixture.create({
name: `organizations-teams-invite-organization-${randomString()}`,
isOrganization: true,
});
orgTeam = await teamsRepositoryFixture.create({
name: `organizations-teams-invite-team-${randomString()}`,
isOrganization: false,
parent: { connect: { id: org.id } },
});
nonOrgTeam = await teamsRepositoryFixture.create({
name: `organizations-teams-invite-non-org-team-${randomString()}`,
isOrganization: false,
});
// Admin of the org team
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: orgTeam.id } },
});
// Also a member of the organization
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should create a team invite", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${orgTeam.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/organizations/${org.id}/teams/${orgTeam.id}/invite`)
.expect(200);
const firstToken = first.body.data.token as string;
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${orgTeam.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);
});
});
it("should fail for team not in organization", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/invite`)
.expect(404);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await teamsRepositoryFixture.delete(nonOrgTeam.id);
await app.close();
});
});
});
@@ -0,0 +1,43 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { OPTIONAL_API_KEY_HEADER, OPTIONAL_X_CAL_CLIENT_ID_HEADER, OPTIONAL_X_CAL_SECRET_KEY_HEADER } from "@/lib/docs/headers";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
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/platform-libraries";
import { CreateInviteOutputDto } from "./outputs/invite.output";
@Controller({
path: "/v2/organizations/:orgId/teams/:teamId",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Orgs / Teams / Invite")
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsTeamsInviteController {
@Post("/invite")
@Roles("TEAM_ADMIN")
@ApiOperation({ summary: "Create team invite link" })
@HttpCode(HttpStatus.OK)
async createInvite(
@Param("orgId", ParseIntPipe) _orgId: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<CreateInviteOutputDto> {
const result = await TeamService.createInvite(teamId);
return { status: SUCCESS_STATUS, data: result };
}
}
@@ -0,0 +1,27 @@
import { ApiProperty } from "@nestjs/swagger";
export class InviteDataDto {
@ApiProperty({
description:
"Unique invitation token for this team. Share this token with prospective members to allow them to join the team/organization.",
example: "f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2",
})
token!: string;
@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 {
@ApiProperty({ example: "success" })
status!: string;
@ApiProperty({ type: InviteDataDto })
data!: InviteDataDto;
}
+1 -1
View File
@@ -209,7 +209,7 @@ test.describe("Email Signup Flow Test", async () => {
data: {
identifier: userToCreate.email,
token,
expires: new Date(new Date().setHours(168)), // +1 week
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +1 week
team: {
create: {
name: "Rick's Team",
+93
View File
@@ -4421,6 +4421,70 @@
"tags": ["Orgs / Teams / Event Types / Private Links"]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/invite": {
"post": {
"operationId": "OrganizationsTeamsInviteController_createInvite",
"summary": "Create team invite link",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"in": "header",
"description": "For platform customers - OAuth client secret key",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-client-id",
"in": "header",
"description": "For platform customers - OAuth client ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateInviteOutputDto"
}
}
}
}
},
"tags": ["Orgs / Teams / Invite"]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/memberships": {
"get": {
"operationId": "OrganizationsTeamsMembershipsController_getAllOrgTeamMemberships",
@@ -19915,6 +19979,35 @@
},
"required": ["userId", "role"]
},
"InviteDataDto": {
"type": "object",
"properties": {
"token": {
"type": "string",
"description": "Unique invitation token for this team. Share this token with prospective members to allow them to join the team/organization.",
"example": "f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2"
},
"inviteLink": {
"type": "string",
"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"
}
},
"required": ["token", "inviteLink"]
},
"CreateInviteOutputDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success"
},
"data": {
"$ref": "#/components/schemas/InviteDataDto"
}
},
"required": ["status", "data"]
},
"Attribute": {
"type": "object",
"properties": {
@@ -1,4 +1,5 @@
import { TeamBilling } from "@calcom/features/ee/billing/teams";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { deleteWorkfowRemindersOfRemovedMember } from "@calcom/features/ee/teams/lib/deleteWorkflowRemindersOfRemovedMember";
import { deleteDomain } from "@calcom/lib/domainManager/organization";
import logger from "@calcom/lib/logger";
@@ -11,6 +12,7 @@ import type { Membership } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import { randomBytes } from "crypto";
const log = logger.getSubLogger({ prefix: ["TeamService"] });
@@ -47,6 +49,56 @@ export type RemoveMemberResult = {
};
export class TeamService {
static async createInvite(
teamId: number,
options?: { token?: string }
): Promise<{ token: string; inviteLink: string }> {
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { parentId: true, isOrganization: true },
});
if (!team) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
const isOrganizationOrATeamInOrganization = !!(team.parentId || team.isOrganization);
if (options?.token) {
const existingToken = await prisma.verificationToken.findFirst({
where: {
token: options.token,
identifier: `invite-link-for-teamId-${teamId}`,
teamId,
},
});
if (!existingToken) throw new TRPCError({ code: "NOT_FOUND", message: "Invite token not found" });
return {
token: existingToken.token,
inviteLink: TeamService.buildInviteLink(existingToken.token, isOrganizationOrATeamInOrganization),
};
}
const token = randomBytes(32).toString("hex");
await prisma.verificationToken.create({
data: {
identifier: `invite-link-for-teamId-${teamId}`,
token,
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +1 week
expiresInDays: 7,
teamId,
},
});
return {
token,
inviteLink: TeamService.buildInviteLink(token, isOrganizationOrATeamInOrganization),
};
}
private static buildInviteLink(token: string, isOrgContext: boolean): string {
const teamInviteLink = `${WEBAPP_URL}/teams?token=${token}`;
const orgInviteLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`;
return isOrgContext ? orgInviteLink : teamInviteLink;
}
/**
* Deletes a team and all its associated data in a safe, transactional order.
* External, critical services like billing are handled first to prevent data inconsistencies.
@@ -1,7 +1,5 @@
import { randomBytes } from "crypto";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { TeamService } from "@calcom/lib/server/service/teamService";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
@@ -38,38 +36,8 @@ export const createInviteHandler = async ({ ctx, input }: CreateInviteOptions) =
if (!hasInvitePermission) throw new TRPCError({ code: "UNAUTHORIZED" });
const isOrganizationOrATeamInOrganization = !!(team.parentId || team.isOrganization);
if (input.token) {
const existingToken = await prisma.verificationToken.findFirst({
where: { token: input.token, identifier: `invite-link-for-teamId-${teamId}`, teamId },
});
if (!existingToken) throw new TRPCError({ code: "NOT_FOUND" });
return {
token: existingToken.token,
inviteLink: await getInviteLink(existingToken.token, isOrganizationOrATeamInOrganization),
};
}
const token = randomBytes(32).toString("hex");
await prisma.verificationToken.create({
data: {
identifier: `invite-link-for-teamId-${teamId}`,
token,
expires: new Date(new Date().setHours(168)), // +1 week,
expiresInDays: 7,
teamId,
},
});
return { token, inviteLink: await getInviteLink(token, isOrganizationOrATeamInOrganization) };
const result = await TeamService.createInvite(teamId, { token: input.token });
return result;
};
async function getInviteLink(token = "", isOrgContext = false) {
const teamInviteLink = `${WEBAPP_URL}/teams?token=${token}`;
const orgInviteLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`;
if (isOrgContext) return orgInviteLink;
return teamInviteLink;
}
export default createInviteHandler;
@@ -492,7 +492,7 @@ export async function sendSignupToOrganizationEmail({
data: {
identifier: usernameOrEmail,
token,
expires: new Date(new Date().setHours(168)), // +1 week
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +1 week
team: {
connect: {
id: teamId,
@@ -717,7 +717,7 @@ export const sendExistingUserTeamInviteEmails = async ({
data: {
identifier: user.email,
token,
expires: new Date(new Date().setHours(168)), // +1 week
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +1 week
team: {
connect: {
id: teamId,