From ae6b1d402afa9b01bbb2cc4ece25cb829da6431d Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:54:44 +0530 Subject: [PATCH] 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 --- .../organizations/organizations.module.ts | 2 + ...ations-teams-invite.controller.e2e-spec.ts | 135 ++++++++++++++++++ .../organizations-teams-invite.controller.ts | 43 ++++++ .../teams/invite/outputs/invite.output.ts | 27 ++++ apps/web/playwright/signup.e2e.ts | 2 +- docs/api-reference/v2/openapi.json | 93 ++++++++++++ packages/lib/server/service/teamService.ts | 52 +++++++ .../viewer/teams/createInvite.handler.ts | 38 +---- .../viewer/teams/inviteMember/utils.ts | 4 +- 9 files changed, 358 insertions(+), 38 deletions(-) create mode 100644 apps/api/v2/src/modules/organizations/teams/invite/organizations-teams-invite.controller.e2e-spec.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/invite/organizations-teams-invite.controller.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/invite/outputs/invite.output.ts diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts index db918dba31..86aa6eb547 100644 --- a/apps/api/v2/src/modules/organizations/organizations.module.ts +++ b/apps/api/v2/src/modules/organizations/organizations.module.ts @@ -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, diff --git a/apps/api/v2/src/modules/organizations/teams/invite/organizations-teams-invite.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/teams/invite/organizations-teams-invite.controller.e2e-spec.ts new file mode 100644 index 0000000000..ead68061fc --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/invite/organizations-teams-invite.controller.e2e-spec.ts @@ -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(); + }); + }); +}); + + diff --git a/apps/api/v2/src/modules/organizations/teams/invite/organizations-teams-invite.controller.ts b/apps/api/v2/src/modules/organizations/teams/invite/organizations-teams-invite.controller.ts new file mode 100644 index 0000000000..1b6457be5c --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/invite/organizations-teams-invite.controller.ts @@ -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 { + const result = await TeamService.createInvite(teamId); + return { status: SUCCESS_STATUS, data: result }; + } +} + + diff --git a/apps/api/v2/src/modules/organizations/teams/invite/outputs/invite.output.ts b/apps/api/v2/src/modules/organizations/teams/invite/outputs/invite.output.ts new file mode 100644 index 0000000000..af0dabb58c --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/invite/outputs/invite.output.ts @@ -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; +} + + diff --git a/apps/web/playwright/signup.e2e.ts b/apps/web/playwright/signup.e2e.ts index cd69adae5e..6727458c59 100644 --- a/apps/web/playwright/signup.e2e.ts +++ b/apps/web/playwright/signup.e2e.ts @@ -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", diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index ce0700c6c1..1f80a4bd22 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -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 ` where `` 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": { diff --git a/packages/lib/server/service/teamService.ts b/packages/lib/server/service/teamService.ts index 64c947a058..d5a85817c5 100644 --- a/packages/lib/server/service/teamService.ts +++ b/packages/lib/server/service/teamService.ts @@ -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. diff --git a/packages/trpc/server/routers/viewer/teams/createInvite.handler.ts b/packages/trpc/server/routers/viewer/teams/createInvite.handler.ts index 3b9b9db247..8bd7feec72 100644 --- a/packages/trpc/server/routers/viewer/teams/createInvite.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/createInvite.handler.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts index 0f08735544..6e55e53114 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts @@ -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,