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:
@@ -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,
|
||||
|
||||
+135
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+43
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user