From 6670bbc1d7034f06b7679eeb7c3492bdec6410e4 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Thu, 27 Jun 2024 06:23:55 +0530 Subject: [PATCH] fix: Error in team members migration during org onboarding (#15349) * fix: Error in team members migration during org onboarding * Add invitationMemberHandler tests * Add unit tests * Improve tests and refactor * Improve tests and refactor * Fix type issue * Fix createNewUsersConnectToOrgIfExists args --- .../services/oauth-clients-users.service.ts | 16 +- .../emails/src/templates/TeamInviteEmail.tsx | 2 +- .../ee/dsync/lib/handleGroupEvents.ts | 3 +- .../ee/teams/lib/__mocks__/payments.ts | 21 + packages/lib/__mocks__/constants.ts | 28 + .../organizations/createTeams.handler.ts | 29 +- .../__mocks__/inviteMemberUtils.ts | 122 +++++ .../inviteMember/inviteMember.handler.test.ts | 389 ++++++++++++++ .../inviteMember/inviteMember.handler.ts | 479 +++++++----------- .../teams/inviteMember/inviteMember.schema.ts | 26 +- .../inviteMember/inviteMemberUtils.test.ts | 88 ++-- .../viewer/teams/inviteMember/utils.ts | 408 ++++++++++++--- .../viewer/teams/resendInvitation.handler.ts | 4 +- .../__mocks__/checkRateLimitAndThrowError.ts | 29 ++ tests/libs/__mocks__/getTranslation.ts | 21 + 15 files changed, 1212 insertions(+), 453 deletions(-) create mode 100644 packages/features/ee/teams/lib/__mocks__/payments.ts create mode 100644 packages/lib/__mocks__/constants.ts create mode 100644 packages/trpc/server/routers/viewer/teams/inviteMember/__mocks__/inviteMemberUtils.ts create mode 100644 packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.test.ts create mode 100644 tests/libs/__mocks__/checkRateLimitAndThrowError.ts create mode 100644 tests/libs/__mocks__/getTranslation.ts diff --git a/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts index 3ee2dbcf79..ee328bb6a7 100644 --- a/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts +++ b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts @@ -36,17 +36,15 @@ export class OAuthClientUsersService { const email = this.getOAuthUserEmail(oAuthClientId, body.email); user = ( await createNewUsersConnectToOrgIfExists({ - usernamesOrEmails: [email], - input: { - teamId: organizationId, - role: "MEMBER", - usernameOrEmail: [email], - isOrg: true, - language: "en", - }, + invitations: [{ + usernameOrEmail: email, + role: "MEMBER" + }], + teamId: organizationId, + isOrg: true, parentId: null, autoAcceptEmailDomain: "never-auto-accept-email-domain-for-managed-users", - connectionInfoMap: { + orgConnectInfoByUsernameOrEmail: { [email]: { orgId: organizationId, autoAccept: true, diff --git a/packages/emails/src/templates/TeamInviteEmail.tsx b/packages/emails/src/templates/TeamInviteEmail.tsx index 0503a50167..77ee69c4b0 100644 --- a/packages/emails/src/templates/TeamInviteEmail.tsx +++ b/packages/emails/src/templates/TeamInviteEmail.tsx @@ -234,7 +234,7 @@ export const TeamInviteEmail = ( ) : ( - {invitedBy} has added you to the team {teamName} in their organization{" "} + {invitedBy} has invited you to the team {teamName} in their organization{" "} {parentTeamName}. )}{" "} diff --git a/packages/features/ee/dsync/lib/handleGroupEvents.ts b/packages/features/ee/dsync/lib/handleGroupEvents.ts index 9f62520505..efa58f5c41 100644 --- a/packages/features/ee/dsync/lib/handleGroupEvents.ts +++ b/packages/features/ee/dsync/lib/handleGroupEvents.ts @@ -5,7 +5,6 @@ import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAn import { getTranslation } from "@calcom/lib/server/i18n"; import prisma from "@calcom/prisma"; import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums"; -import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { getTeamOrThrow, sendSignupToOrganizationEmail, @@ -115,7 +114,7 @@ const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: numb newUserEmails.map((email) => { return sendSignupToOrganizationEmail({ usernameOrEmail: email, - team: { ...group.team, metadata: teamMetadataSchema.parse(group.team.metadata) }, + team: group.team, translation, inviterName: org.name, teamId: group.teamId, diff --git a/packages/features/ee/teams/lib/__mocks__/payments.ts b/packages/features/ee/teams/lib/__mocks__/payments.ts new file mode 100644 index 0000000000..6c404c5830 --- /dev/null +++ b/packages/features/ee/teams/lib/__mocks__/payments.ts @@ -0,0 +1,21 @@ +import { beforeEach, vi, expect } from "vitest"; +import { mockReset, mockDeep } from "vitest-mock-extended"; + +import type * as payments from "@calcom/features/ee/teams/lib/payments"; + +vi.mock("@calcom/features/ee/teams/lib/payments", () => paymentsMock); + +beforeEach(() => { + mockReset(paymentsMock); +}); + +const paymentsMock = mockDeep(); + +export const paymentsScenarios = {}; +export const paymentsExpects = { + expectQuantitySubscriptionToBeUpdatedForTeam: (teamId: number) => { + expect(paymentsMock.updateQuantitySubscriptionFromStripe).toHaveBeenCalledWith(teamId); + }, +}; + +export default paymentsMock; diff --git a/packages/lib/__mocks__/constants.ts b/packages/lib/__mocks__/constants.ts new file mode 100644 index 0000000000..67090a2b3c --- /dev/null +++ b/packages/lib/__mocks__/constants.ts @@ -0,0 +1,28 @@ +import { vi, beforeEach } from "vitest"; + +import type * as constants from "@calcom/lib/constants"; + +const mockedConstants = { + IS_PRODUCTION: false, + IS_TEAM_BILLING_ENABLED: false, +} as typeof constants; + +vi.mock("@calcom/lib/constants", () => { + return mockedConstants; +}); + +beforeEach(() => { + Object.entries(mockedConstants).forEach(([key]) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete mockedConstants[key]; + }); +}); + +export const constantsScenarios = { + enableTeamBilling: () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + mockedConstants.IS_TEAM_BILLING_ENABLED = true; + }, +}; diff --git a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts index aa02ce03a2..f801d32bdb 100644 --- a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts @@ -219,22 +219,19 @@ async function moveTeam({ }, }); - await Promise.all( - // TODO: Support different role for different members in usernameOrEmail list and then remove this map - team.members.map(async (membership) => { - // Invite team members to the new org. They are already members of the team. - await inviteMemberHandler({ - ctx, - input: { - teamId: org.id, - language: "en", - role: membership.role, - usernameOrEmail: membership.user.email, - isOrg: true, - }, - }); - }) - ); + // Invite team members to the new org. They are already members of the team. + await inviteMemberHandler({ + ctx, + input: { + teamId: org.id, + language: "en", + usernameOrEmail: team.members.map((m) => ({ + email: m.user.email, + role: m.role, + })), + isOrg: true, + }, + }); await addTeamRedirect({ oldTeamSlug: team.slug, diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/__mocks__/inviteMemberUtils.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/__mocks__/inviteMemberUtils.ts new file mode 100644 index 0000000000..7029f390cd --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/__mocks__/inviteMemberUtils.ts @@ -0,0 +1,122 @@ +import { beforeEach, vi, expect } from "vitest"; +import { mockReset, mockDeep } from "vitest-mock-extended"; + +import type * as inviteMemberUtils from "../utils"; + +vi.mock("../utils", async () => { + return inviteMemberUtilsMock; +}); + +beforeEach(() => { + mockReset(inviteMemberUtilsMock); +}); +const inviteMemberUtilsMock = mockDeep(); + +export const inviteMemberutilsScenarios = { + checkPermissions: { + fakePassed: () => + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + inviteMemberUtilsMock.checkPermissions.mockResolvedValue(undefined), + }, + getTeamOrThrow: { + fakeReturnTeam: (team: { id: number } & Record, forInput: { teamId: number }) => { + const fakedVal = { + organizationSettings: null, + parent: null, + parentId: null, + ...team, + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + inviteMemberUtilsMock.getTeamOrThrow.mockImplementation((teamId) => { + if (forInput.teamId === teamId) { + return fakedVal; + } + throw new Error("Mock Error: Unhandled input"); + }); + return fakedVal; + }, + }, + getOrgState: { + /** + * `getOrgState` completely generates the return value from input without using any outside variable like DB, etc. + * So, it makes sense to let it use the actual implementation instead of mocking the output based on input + */ + useActual: async function () { + const actualImport = await vi.importActual("../utils"); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + return inviteMemberUtilsMock.getOrgState.mockImplementation(actualImport.getOrgState); + }, + }, + getUniqueInvitationsOrThrowIfEmpty: { + useActual: async function () { + const actualImport = await vi.importActual("../utils"); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + return inviteMemberUtilsMock.getUniqueInvitationsOrThrowIfEmpty.mockImplementation( + actualImport.getUniqueInvitationsOrThrowIfEmpty + ); + }, + }, + findUsersWithInviteStatus: { + useAdvancedMock: function ( + returnVal: Awaited>, + forInput: { + team: any; + invitations: { + usernameOrEmail: string; + }[]; + } + ) { + inviteMemberUtilsMock.findUsersWithInviteStatus.mockImplementation(({ invitations, team }) => { + const allInvitationsExist = invitations.every((invitation) => + forInput.invitations.find((i) => i.usernameOrEmail === invitation.usernameOrEmail) + ); + if (forInput.team.id == team.id && allInvitationsExist) return returnVal; + }); + return returnVal; + }, + }, + getOrgConnectionInfo: { + useActual: async function () { + const actualImport = await vi.importActual("../utils"); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + return inviteMemberUtilsMock.getOrgConnectionInfo.mockImplementation(actualImport.getOrgConnectionInfo); + }, + }, +}; + +export const expects = { + expectSignupEmailsToBeSent: ({ + emails, + team, + inviterName, + isOrg, + teamId, + }: { + emails: string[]; + team; + inviterName: string; + teamId: number; + isOrg: boolean; + }) => { + emails.forEach((email, index) => { + expect(inviteMemberUtilsMock.sendSignupToOrganizationEmail.mock.calls[index][0]).toEqual( + expect.objectContaining({ + usernameOrEmail: email, + team: team, + inviterName: inviterName, + teamId: teamId, + isOrg: isOrg, + }) + ); + }); + }, +}; +export default inviteMemberUtilsMock; diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.test.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.test.ts new file mode 100644 index 0000000000..50892e931e --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.test.ts @@ -0,0 +1,389 @@ +import { scenarios as checkRateLimitAndThrowErrorScenarios } from "../../../../../../../tests/libs/__mocks__/checkRateLimitAndThrowError"; +import { mock as getTranslationMock } from "../../../../../../../tests/libs/__mocks__/getTranslation"; +import { + inviteMemberutilsScenarios as inviteMemberUtilsScenarios, + default as inviteMemberUtilsMock, +} from "./__mocks__/inviteMemberUtils"; +import { default as paymentsMock } from "@calcom/features/ee/teams/lib/__mocks__/payments"; +import { constantsScenarios } from "@calcom/lib/__mocks__/constants"; + +import { describe, it, expect, beforeEach, vi } from "vitest"; + +import type { Profile } from "@calcom/prisma/client"; +import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../../trpc"; +import inviteMemberHandler from "./inviteMember.handler"; +import { INVITE_STATUS } from "./utils"; + +vi.mock("@trpc/server", () => { + return { + TRPCError: class TRPCError { + code: string; + message: unknown; + constructor({ code, message }: { code: string; message: unknown }) { + this.code = code; + this.message = message; + } + }, + }; +}); + +function fakeNoUsersFoundMatchingInvitations(args: { + team: any; + invitations: { + role: MembershipRole; + usernameOrEmail: string; + }[]; +}) { + inviteMemberUtilsScenarios.findUsersWithInviteStatus.useAdvancedMock([], args); +} + +function getPersonalProfile({ username }: { username: string }) { + return { + id: null, + upId: "abc", + organization: null, + organizationId: null, + username, + startTime: 0, + endTime: 0, + avatarUrl: "", + name: "", + bufferTime: 0, + }; +} + +function getLoggedInUser() { + return { + id: 123, + name: "John Doe", + organization: { + id: 456, + isOrgAdmin: true, + metadata: null, + requestedSlug: null, + }, + profile: getPersonalProfile({ username: "john_doe" }), + } as NonNullable; +} + +function buildExistingUser(user: { id: number; email: string; username: string }) { + return { + password: { + hash: "hash", + userId: 1, + }, + identityProvider: IdentityProvider.CAL, + profiles: [] as Profile[], + completedOnboarding: false, + ...user, + }; +} + +describe("inviteMemberHandler", () => { + beforeEach(async () => { + await inviteMemberUtilsScenarios.getOrgState.useActual(); + await inviteMemberUtilsScenarios.getUniqueInvitationsOrThrowIfEmpty.useActual(); + await inviteMemberUtilsScenarios.getOrgConnectionInfo.useActual(); + checkRateLimitAndThrowErrorScenarios.fakeRateLimitPassed(); + getTranslationMock.fakeIdentityFn(); + inviteMemberUtilsScenarios.checkPermissions.fakePassed(); + constantsScenarios.enableTeamBilling(); + }); + + describe("Regular Team", () => { + describe("with 2 emails in input and when there are no users matching the emails", () => { + it("should call appropriate utilities to send email, add users and update in stripe. It should return `numUsersInvited`=2", async () => { + const usersToBeInvited = [ + { + id: 1, + email: "user1@example.com", + }, + { + id: 2, + email: "user2@example.com", + }, + ]; + + const loggedInUser = getLoggedInUser(); + + const input = { + teamId: 1, + role: MembershipRole.MEMBER, + isOrg: false, + language: "en", + usernameOrEmail: usersToBeInvited.map((u) => u.email), + }; + + const team = { + id: input.teamId, + name: "Team 1", + parent: null, + }; + + const retValueOfGetTeamOrThrowError = inviteMemberUtilsScenarios.getTeamOrThrow.fakeReturnTeam(team, { + teamId: input.teamId, + }); + + const allExpectedInvitations = [ + { + role: input.role, + usernameOrEmail: usersToBeInvited[0].email, + }, + { + role: input.role, + usernameOrEmail: usersToBeInvited[1].email, + }, + ]; + fakeNoUsersFoundMatchingInvitations({ + team, + invitations: allExpectedInvitations, + }); + + const ctx = { + user: loggedInUser, + }; + + // Call the inviteMemberHandler function + const result = await inviteMemberHandler({ ctx, input }); + const expectedConnectionInfoMap = { + [usersToBeInvited[0].email]: { + orgId: undefined, + autoAccept: false, + }, + [usersToBeInvited[1].email]: { + orgId: undefined, + autoAccept: false, + }, + }; + + expect(inviteMemberUtilsMock.handleNewUsersInvites).toHaveBeenCalledWith({ + invitationsForNewUsers: allExpectedInvitations, + team: retValueOfGetTeamOrThrowError, + orgConnectInfoByUsernameOrEmail: expectedConnectionInfoMap, + input, + inviter: loggedInUser, + autoAcceptEmailDomain: null, + }); + + expect(paymentsMock.updateQuantitySubscriptionFromStripe).toHaveBeenCalledWith(input.teamId); + + expect(inviteMemberUtilsMock.handleExistingUsersInvites).not.toHaveBeenCalled(); + + expect(inviteMemberUtilsMock.getUniqueInvitationsOrThrowIfEmpty).toHaveBeenCalledWith([ + { + role: input.role, + usernameOrEmail: usersToBeInvited[0].email, + }, + { + role: input.role, + usernameOrEmail: usersToBeInvited[1].email, + }, + ]); + // Assert the result + expect(result).toEqual({ + ...input, + numUsersInvited: 2, + }); + }); + }); + + describe("with 2 emails in input and when there is one user matching the email", () => { + it("should call appropriate utilities to add users and update in stripe. It should return `numUsersInvited=2`", async () => { + const usersToBeInvited = [ + buildExistingUser({ + id: 1, + email: "user1@example.com", + username: "user1", + }), + { + id: null, + email: "user2@example.com", + }, + ] as const; + + const loggedInUser = getLoggedInUser(); + + const input = { + teamId: 1, + role: MembershipRole.MEMBER, + isOrg: false, + language: "en", + usernameOrEmail: usersToBeInvited.map((u) => u.email), + }; + + const team = { + id: input.teamId, + name: "Team 1", + parent: null, + }; + + const retValueOfGetTeamOrThrowError = inviteMemberUtilsScenarios.getTeamOrThrow.fakeReturnTeam(team, { + teamId: input.teamId, + }); + + const allExpectedInvitations = [ + { + role: input.role, + usernameOrEmail: usersToBeInvited[0].email, + }, + { + role: input.role, + usernameOrEmail: usersToBeInvited[1].email, + }, + ]; + + const retValueOfFindUsersWithInviteStatus = + inviteMemberUtilsScenarios.findUsersWithInviteStatus.useAdvancedMock( + [ + { + ...usersToBeInvited[0], + canBeInvited: INVITE_STATUS.CAN_BE_INVITED, + newRole: input.role, + }, + ], + { + invitations: allExpectedInvitations, + team, + } + ); + + const ctx = { + user: loggedInUser, + }; + + const result = await inviteMemberHandler({ ctx, input }); + const expectedConnectionInfoMap = { + [usersToBeInvited[0].email]: { + orgId: undefined, + autoAccept: false, + }, + [usersToBeInvited[1].email]: { + orgId: undefined, + autoAccept: false, + }, + }; + + expect(inviteMemberUtilsMock.handleNewUsersInvites).toHaveBeenCalledWith({ + invitationsForNewUsers: allExpectedInvitations.slice(1), + team: retValueOfGetTeamOrThrowError, + orgConnectInfoByUsernameOrEmail: expectedConnectionInfoMap, + input, + inviter: loggedInUser, + autoAcceptEmailDomain: null, + }); + + expect(paymentsMock.updateQuantitySubscriptionFromStripe).toHaveBeenCalledWith(input.teamId); + + expect(inviteMemberUtilsMock.handleExistingUsersInvites).toHaveBeenCalledWith({ + invitableExistingUsers: retValueOfFindUsersWithInviteStatus, + input: input, + inviter: loggedInUser, + orgConnectInfoByUsernameOrEmail: expectedConnectionInfoMap, + orgSlug: null, + team: retValueOfGetTeamOrThrowError, + }); + + // Assert the result + expect(result).toEqual({ + ...input, + numUsersInvited: 2, + }); + }); + }); + + it("With one email in input and that email is already a member of the team, it should throw error", async () => { + const userToBeInvited = buildExistingUser({ + id: 1, + email: "user1@example.com", + username: "user1", + }); + + const loggedInUser = getLoggedInUser(); + + const input = { + teamId: 1, + role: MembershipRole.MEMBER, + isOrg: false, + language: "en", + usernameOrEmail: userToBeInvited.email, + }; + + const team = { + id: input.teamId, + name: "Team 1", + parent: null, + }; + + inviteMemberUtilsScenarios.getTeamOrThrow.fakeReturnTeam(team, { + teamId: input.teamId, + }); + + inviteMemberUtilsScenarios.findUsersWithInviteStatus.useAdvancedMock( + [ + { + ...userToBeInvited, + canBeInvited: INVITE_STATUS.USER_ALREADY_INVITED_OR_MEMBER, + newRole: input.role, + }, + ], + { + invitations: [ + { + newRole: input.role, + usernameOrEmail: userToBeInvited.email, + }, + ], + team, + } + ); + + const ctx = { + user: loggedInUser, + }; + try { + await inviteMemberHandler({ ctx, input }); + throw new Error("Expected an error to be thrown"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (e: any) { + expect(e).toBeInstanceOf(TRPCError); + expect(e.code).toEqual("BAD_REQUEST"); + expect(e.message).toBe(INVITE_STATUS.USER_ALREADY_INVITED_OR_MEMBER); + } + }); + }); + it("When rate limit exceeded, it should throw error", async () => { + const userToBeInvited = buildExistingUser({ + id: 1, + email: "user1@example.com", + username: "user1", + }); + + const errorMessageForRateLimit = checkRateLimitAndThrowErrorScenarios.fakeRateLimitFailed(); + + const loggedInUser = getLoggedInUser(); + + const input = { + teamId: 1, + role: MembershipRole.MEMBER, + isOrg: false, + language: "en", + usernameOrEmail: userToBeInvited.email, + }; + + const ctx = { + user: loggedInUser, + }; + try { + await inviteMemberHandler({ ctx, input }); + throw new Error("Expected an error to be thrown"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (e: any) { + expect(e).toBeInstanceOf(Error); + expect(e.message).toEqual(errorMessageForRateLimit); + } + }); +}); diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts index e8a13489fc..1e88534d5d 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts @@ -1,14 +1,12 @@ +import { type TFunction } from "i18next"; + import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; -import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server/i18n"; -import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries"; import { isOrganisationOwner } from "@calcom/lib/server/queries/organisations"; -import { getParsedTeam } from "@calcom/lib/server/repository/teamUtils"; -import { prisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; @@ -16,20 +14,17 @@ import { TRPCError } from "@trpc/server"; import type { TInviteMemberInputSchema } from "./inviteMember.schema"; import type { TeamWithParent } from "./types"; +import type { Invitation } from "./utils"; import { - checkPermissions, - createMemberships, - createNewUsersConnectToOrgIfExists, - getExistingUsersToInvite, + ensureAtleastAdminPermissions, + getTeamOrThrow, + getUniqueInvitationsOrThrowIfEmpty, getOrgConnectionInfo, getOrgState, - getTeamOrThrow, - getUniqueUsernameOrEmailsOrThrow, - groupUsersByJoinability, + findUsersWithInviteStatus, INVITE_STATUS, - sendEmails, - sendExistingUserTeamInviteEmails, - sendSignupToOrganizationEmail, + handleExistingUsersInvites, + handleNewUsersInvites, } from "./utils"; const log = logger.getSubLogger({ prefix: ["inviteMember.handler"] }); @@ -41,138 +36,202 @@ type InviteMemberOptions = { input: TInviteMemberInputSchema; }; -export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) => { - const myLog = log.getSubLogger({ prefix: ["inviteMemberHandler"] }); - const translation = await getTranslation(input.language ?? "en", "common"); - await checkRateLimitAndThrowError({ - identifier: `invitedBy:${ctx.user.id}`, - }); - const team = await getTeamOrThrow(input.teamId); - - const isOrg = team.isOrganization; - - // Only owners can award owner role in an organization. - if (isOrg && input.role === MembershipRole.OWNER && !(await isOrganisationOwner(ctx.user.id, input.teamId))) - throw new TRPCError({ code: "UNAUTHORIZED" }); - - await checkPermissions({ - userId: ctx.user.id, - teamId: - ctx.user.organization.id && ctx.user.organization.isOrgAdmin ? ctx.user.organization.id : input.teamId, - isOrg, - }); - - const { autoAcceptEmailDomain, orgVerified } = getOrgState(isOrg, team); - - const usernameOrEmailsToInvite = await getUniqueUsernameOrEmailsOrThrow(input.usernameOrEmail); - - const isBulkInvite = usernameOrEmailsToInvite.length > 1; - const beSilentAboutErrors = isBulkInvite; - - const orgConnectInfoByUsernameOrEmail = usernameOrEmailsToInvite.reduce((acc, usernameOrEmail) => { +function getOrgConnectionInfoGroupedByUsernameOrEmail({ + uniqueInvitations, + orgState, + team, + isOrg, +}: { + uniqueInvitations: { usernameOrEmail: string; role: MembershipRole }[]; + orgState: ReturnType; + team: Pick; + isOrg: boolean; +}) { + return uniqueInvitations.reduce((acc, invitation) => { return { ...acc, - [usernameOrEmail]: getOrgConnectionInfo({ - orgVerified, - orgAutoAcceptDomain: autoAcceptEmailDomain, - usersEmail: usernameOrEmail, + [invitation.usernameOrEmail]: getOrgConnectionInfo({ + orgVerified: orgState.orgVerified, + orgAutoAcceptDomain: orgState.autoAcceptEmailDomain, + email: invitation.usernameOrEmail, team, isOrg: isOrg, }), }; }, {} as Record>); - const existingUsersWithMemberships = await getExistingUsersToInvite({ - usernamesOrEmails: usernameOrEmailsToInvite, - team, - }); +} - // Existing users have a criteria to be invited - const existingUsersWithMembershipsThatNeedToBeInvited = existingUsersWithMemberships.filter( - (invitee) => invitee.canBeInvited === INVITE_STATUS.CAN_BE_INVITED - ); - - // beSilentAboutErrors is false only when there is a single user being invited, so we just check the first item status here - // Bulk invites error are silently ignored and they should be logged differently when needed - const firstExistingUser = existingUsersWithMemberships[0]; - if ( - !beSilentAboutErrors && - firstExistingUser && - firstExistingUser.canBeInvited !== INVITE_STATUS.CAN_BE_INVITED - ) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: translation(existingUsersWithMemberships[0].canBeInvited), - }); - } - - const existingUsersEmailsAndUsernames = existingUsersWithMemberships.reduce( +function getInvitationsForNewUsers({ + existingUsersToBeInvited, + uniqueInvitations, +}: { + existingUsersToBeInvited: Awaited>; + uniqueInvitations: { usernameOrEmail: string; role: MembershipRole }[]; +}) { + const existingUsersEmailsAndUsernames = existingUsersToBeInvited.reduce( (acc, user) => ({ emails: user.email ? [...acc.emails, user.email] : acc.emails, usernames: user.username ? [...acc.usernames, user.username] : acc.usernames, }), { emails: [], usernames: [] } as { emails: string[]; usernames: string[] } ); + return uniqueInvitations.filter( + (invitation) => + !existingUsersEmailsAndUsernames.emails.includes(invitation.usernameOrEmail) && + !existingUsersEmailsAndUsernames.usernames.includes(invitation.usernameOrEmail) + ); +} - // New Users can always be invited - const newUsersEmailsOrUsernames = usernameOrEmailsToInvite.filter( - (usernameOrEmail) => - !existingUsersEmailsAndUsernames.emails.includes(usernameOrEmail) && - !existingUsersEmailsAndUsernames.usernames.includes(usernameOrEmail) +function throwIfInvalidInvitationStatus({ + firstExistingUser, + translation, +}: { + firstExistingUser: Awaited>[number] | undefined; + translation: TFunction; +}) { + if (firstExistingUser && firstExistingUser.canBeInvited !== INVITE_STATUS.CAN_BE_INVITED) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: translation(firstExistingUser.canBeInvited), + }); + } +} + +function shouldBeSilentAboutErrors(invitations: Invitation[]) { + const isBulkInvite = invitations.length > 1; + return isBulkInvite; +} + +function buildInvitationsFromInput({ + usernameOrEmail, + roleForAllInvitees, +}: { + usernameOrEmail: TInviteMemberInputSchema["usernameOrEmail"]; + roleForAllInvitees: MembershipRole | undefined; +}) { + const usernameOrEmailList = typeof usernameOrEmail === "string" ? [usernameOrEmail] : usernameOrEmail; + + return usernameOrEmailList.map((usernameOrEmail) => { + if (typeof usernameOrEmail === "string") + return { usernameOrEmail: usernameOrEmail, role: roleForAllInvitees ?? MembershipRole.MEMBER }; + return { + usernameOrEmail: usernameOrEmail.email, + role: usernameOrEmail.role, + }; + }); +} + +export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) => { + const myLog = log.getSubLogger({ prefix: ["inviteMemberHandler"] }); + const translation = await getTranslation(input.language ?? "en", "common"); + await checkRateLimitAndThrowError({ + identifier: `invitedBy:${ctx.user.id}`, + }); + + const invitations = buildInvitationsFromInput({ + usernameOrEmail: input.usernameOrEmail, + roleForAllInvitees: input.role, + }); + + const team = await getTeamOrThrow(input.teamId); + + const isTeamAnOrg = team.isOrganization; + const isAddingNewOwner = !!invitations.find((invitation) => invitation.role === MembershipRole.OWNER); + const inviter = ctx.user; + const inviterOrg = inviter.organization; + + if (isTeamAnOrg) { + await throwIfInviterCantAddOwnerToOrg(); + } + + await ensureAtleastAdminPermissions({ + userId: ctx.user.id, + teamId: inviterOrg.id && inviterOrg.isOrgAdmin ? inviterOrg.id : input.teamId, + isOrg: isTeamAnOrg, + }); + + const uniqueInvitations = await getUniqueInvitationsOrThrowIfEmpty(invitations); + const beSilentAboutErrors = shouldBeSilentAboutErrors(uniqueInvitations); + const existingUsersToBeInvited = await findUsersWithInviteStatus({ + invitations: uniqueInvitations, + team, + }); + + if (!beSilentAboutErrors) { + // beSilentAboutErrors is false only when there is a single user being invited, so we just check the first user status here + throwIfInvalidInvitationStatus({ firstExistingUser: existingUsersToBeInvited[0], translation }); + } + + const orgState = getOrgState(isTeamAnOrg, team); + + const orgConnectInfoByUsernameOrEmail = getOrgConnectionInfoGroupedByUsernameOrEmail({ + uniqueInvitations, + orgState, + team: { + parentId: team.parentId, + id: team.id, + }, + isOrg: isTeamAnOrg, + }); + + const invitationsForNewUsers = getInvitationsForNewUsers({ + existingUsersToBeInvited, + uniqueInvitations, + }); + + if (invitationsForNewUsers.length) { + await handleNewUsersInvites({ + invitationsForNewUsers, + team, + orgConnectInfoByUsernameOrEmail, + input, + inviter: ctx.user, + autoAcceptEmailDomain: orgState.autoAcceptEmailDomain, + }); + } + + // Existing users have a criteria to be invited + const invitableExistingUsers = existingUsersToBeInvited.filter( + (invitee) => invitee.canBeInvited === INVITE_STATUS.CAN_BE_INVITED ); myLog.debug( "Notable variables:", safeStringify({ - usernameOrEmailsToInvite, + uniqueInvitations, orgConnectInfoByUsernameOrEmail, - existingUsersWithMembershipsThatNeedToBeInvited: existingUsersWithMembershipsThatNeedToBeInvited, - existingUsersWithMemberships, - existingUsersEmailsAndUsernames, - newUsersEmailsOrUsernames, + invitableExistingUsers, + existingUsersToBeInvited, + invitationsForNewUsers, }) ); - // deal with users to create and invite to team/org - if (newUsersEmailsOrUsernames.length) { - await createNewUsersConnectToOrgIfExists({ - usernamesOrEmails: newUsersEmailsOrUsernames, + if (invitableExistingUsers.length) { + const organization = ctx.user.profile.organization; + const orgSlug = organization ? organization.slug || organization.requestedSlug : null; + await handleExistingUsersInvites({ + invitableExistingUsers, + team, + orgConnectInfoByUsernameOrEmail, input, - connectionInfoMap: orgConnectInfoByUsernameOrEmail, - autoAcceptEmailDomain, - parentId: team.parentId, + inviter: ctx.user, + orgSlug, }); - const sendVerifEmailsPromises = newUsersEmailsOrUsernames.map((usernameOrEmail) => { - return sendSignupToOrganizationEmail({ - usernameOrEmail, - team, - translation, - inviterName: ctx.user.name ?? "", - teamId: input.teamId, - isOrg: input.isOrg, - }); - }); - await sendEmails(sendVerifEmailsPromises); } - const organization = ctx.user.profile.organization; - const orgSlug = organization ? organization.slug || organization.requestedSlug : null; - // deal with existing users invited to join the team/org - await handleExistingUsersInvites({ - existingUsersWithMemberships: existingUsersWithMembershipsThatNeedToBeInvited, - team, - orgConnectInfoByUsernameOrEmail, - input, - inviter: ctx.user, - orgSlug, - }); - - await handleSubscriptionUpdates(team.parentId || input.teamId); + if (IS_TEAM_BILLING_ENABLED) { + await updateQuantitySubscriptionFromStripe(team.parentId ?? input.teamId); + } return { ...input, - numUsersInvited: - existingUsersWithMembershipsThatNeedToBeInvited.length + newUsersEmailsOrUsernames.length, + numUsersInvited: invitableExistingUsers.length + invitationsForNewUsers.length, }; + + async function throwIfInviterCantAddOwnerToOrg() { + const isInviterOrgOwner = await isOrganisationOwner(ctx.user.id, input.teamId); + if (isAddingNewOwner && !isInviterOrgOwner) throw new TRPCError({ code: "UNAUTHORIZED" }); + } }; async function handleSubscriptionUpdates(teamId: number) { @@ -181,195 +240,3 @@ async function handleSubscriptionUpdates(teamId: number) { } export default inviteMemberHandler; - -async function handleExistingUsersInvites({ - existingUsersWithMemberships, - team, - orgConnectInfoByUsernameOrEmail, - input, - inviter, - orgSlug, -}: { - existingUsersWithMemberships: Awaited>; - team: TeamWithParent; - orgConnectInfoByUsernameOrEmail: Record; - input: { - teamId: number; - role: "ADMIN" | "MEMBER" | "OWNER"; - isOrg: boolean; - usernameOrEmail: (string | string[]) & (string | string[] | undefined); - language: string; - }; - inviter: { - name: string | null; - }; - orgSlug: string | null; -}) { - if (!existingUsersWithMemberships.length) { - return; - } - - const translation = await getTranslation(input.language ?? "en", "common"); - if (!team.isOrganization) { - const [autoJoinUsers, regularUsers] = groupUsersByJoinability({ - existingUsersWithMemberships: existingUsersWithMemberships.map((u) => { - return { - ...u, - profile: null, - }; - }), - team, - connectionInfoMap: orgConnectInfoByUsernameOrEmail, - }); - - log.debug( - "Inviting existing users to a team", - safeStringify({ - autoJoinUsers, - regularUsers, - }) - ); - - // invited users can autojoin, create their memberships in org - if (autoJoinUsers.length) { - await createMemberships({ - input, - invitees: autoJoinUsers, - parentId: team.parentId, - accepted: true, - }); - - await Promise.all( - autoJoinUsers.map(async (userToAutoJoin) => { - await updateNewTeamMemberEventTypes(userToAutoJoin.id, team.id); - }) - ); - - await sendExistingUserTeamInviteEmails({ - currentUserName: inviter.name, - currentUserTeamName: team?.name, - existingUsersWithMemberships: autoJoinUsers, - language: translation, - isOrg: input.isOrg, - teamId: team.id, - isAutoJoin: true, - currentUserParentTeamName: team?.parent?.name, - orgSlug, - }); - } - - // invited users cannot autojoin, create provisional memberships and send email - if (regularUsers.length) { - await createMemberships({ - input, - invitees: regularUsers, - parentId: team.parentId, - accepted: false, - }); - await sendExistingUserTeamInviteEmails({ - currentUserName: inviter.name, - currentUserTeamName: team?.name, - existingUsersWithMemberships: regularUsers, - language: translation, - isOrg: input.isOrg, - teamId: team.id, - isAutoJoin: false, - currentUserParentTeamName: team?.parent?.name, - orgSlug, - }); - } - - const parentOrganization = team.parent; - if (parentOrganization) { - const parsedOrg = getParsedTeam(parentOrganization); - // Create profiles if needed - await Promise.all([ - autoJoinUsers - .concat(regularUsers) - .filter((u) => u.needToCreateProfile) - .map((user) => - createAProfileForAnExistingUser({ - user: { - id: user.id, - email: user.email, - currentUsername: user.username, - }, - organizationId: parsedOrg.id, - }) - ), - ]); - } - } else { - const organization = team; - log.debug( - "Inviting existing users to an organization", - safeStringify({ - existingUsersWithMemberships, - }) - ); - - const existingUsersWithMembershipsNew = await Promise.all( - existingUsersWithMemberships.map(async (user) => { - const shouldAutoAccept = orgConnectInfoByUsernameOrEmail[user.email].autoAccept; - let profile = null; - if (shouldAutoAccept) { - profile = await createAProfileForAnExistingUser({ - user: { - id: user.id, - email: user.email, - currentUsername: user.username, - }, - organizationId: organization.id, - }); - } - - await prisma.membership.create({ - data: { - userId: user.id, - teamId: team.id, - accepted: shouldAutoAccept, - role: input.role, - }, - }); - return { - ...user, - profile, - }; - }) - ); - - const autoJoinUsers = existingUsersWithMembershipsNew.filter( - (user) => orgConnectInfoByUsernameOrEmail[user.email].autoAccept - ); - - const regularUsers = existingUsersWithMembershipsNew.filter( - (user) => !orgConnectInfoByUsernameOrEmail[user.email].autoAccept - ); - - // Send emails to user who auto-joined - await sendExistingUserTeamInviteEmails({ - currentUserName: inviter.name, - currentUserTeamName: team?.name, - existingUsersWithMemberships: autoJoinUsers, - language: translation, - isOrg: input.isOrg, - teamId: team.id, - isAutoJoin: true, - currentUserParentTeamName: team?.parent?.name, - orgSlug, - }); - - // Send emails to user who need to accept invite - await sendExistingUserTeamInviteEmails({ - currentUserName: inviter.name, - currentUserTeamName: team?.name, - existingUsersWithMemberships: regularUsers, - language: translation, - isOrg: input.isOrg, - teamId: team.id, - isAutoJoin: false, - currentUserParentTeamName: team?.parent?.name, - orgSlug, - }); - } -} diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts index f081508bc5..c27329f839 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts @@ -6,12 +6,32 @@ import { MembershipRole } from "@calcom/prisma/enums"; export const ZInviteMemberInputSchema = z.object({ teamId: z.number(), usernameOrEmail: z - .union([z.string(), z.array(z.string())]) + .union([ + z.string(), + z + .union([ + z.string(), + z.object({ + email: z.string().email(), + role: z.nativeEnum(MembershipRole), + }), + ]) + .array(), + ]) .transform((usernameOrEmail) => { if (typeof usernameOrEmail === "string") { return usernameOrEmail.trim().toLowerCase(); } - return usernameOrEmail.map((item) => item.trim().toLowerCase()); + return usernameOrEmail.map((item) => { + if (typeof item === "string") { + return item.trim().toLowerCase(); + } + + return { + ...item, + email: item.email.trim().toLowerCase(), + }; + }); }) .refine( (value) => { @@ -33,7 +53,7 @@ export const ZInviteMemberInputSchema = z.object({ }, { message: "Bulk invitations are restricted to email addresses only." } ), - role: z.nativeEnum(MembershipRole), + role: z.nativeEnum(MembershipRole).optional(), language: z.string(), isOrg: z.boolean().default(false), }); diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts index 6ffacc1667..bdce0fbef8 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts @@ -10,8 +10,8 @@ import type { TeamWithParent } from "./types"; import type { UserWithMembership } from "./utils"; import { INVITE_STATUS } from "./utils"; import { - checkPermissions, - getUniqueUsernameOrEmailsOrThrow, + ensureAtleastAdminPermissions, + getUniqueInvitationsOrThrowIfEmpty, getOrgState, getOrgConnectionInfo, canBeInvited, @@ -28,6 +28,7 @@ vi.mock("@calcom/lib/server/queries", () => { vi.mock("@calcom/lib/server/queries/organisations", () => { return { isOrganisationAdmin: vi.fn(), + isOrganisationOwner: vi.fn(), }; }); @@ -123,37 +124,46 @@ const userInTeamNotAccepted: UserWithMembership = { }; describe("Invite Member Utils", () => { - describe("checkPermissions", () => { + describe("ensureAtleastAdminPermissions", () => { it("It should throw an error if the user is not an admin of the ORG", async () => { vi.mocked(isOrganisationAdmin).mockResolvedValue(false); - await expect(checkPermissions({ userId: 1, teamId: 1, isOrg: true })).rejects.toThrow(); - }); - it("It should NOT throw an error if the user is an admin of the ORG", async () => { - vi.mocked(isOrganisationAdmin).mockResolvedValue(mockedReturnSuccessCheckPerms); - await expect(checkPermissions({ userId: 1, teamId: 1, isOrg: true })).resolves.not.toThrow(); - }); - it("It should throw an error if the user is not an admin of the team", async () => { - vi.mocked(isTeamAdmin).mockResolvedValue(false); - await expect(checkPermissions({ userId: 1, teamId: 1 })).rejects.toThrow(); - }); - it("It should NOT throw an error if the user is an admin of a team", async () => { - vi.mocked(isTeamAdmin).mockResolvedValue(mockedReturnSuccessCheckPerms); - await expect(checkPermissions({ userId: 1, teamId: 1 })).resolves.not.toThrow(); - }); - }); - describe("getUniqueUsernameOrEmailsOrThrow", () => { - it("should throw a TRPCError with code BAD_REQUEST if no emails are provided", async () => { - await expect(getUniqueUsernameOrEmailsOrThrow([])).rejects.toThrow(TRPCError); + await expect(ensureAtleastAdminPermissions({ userId: 1, teamId: 1, isOrg: true })).rejects.toThrow( + "UNAUTHORIZED" + ); }); - it("should return an array with one email if a string is provided", async () => { - const result = await getUniqueUsernameOrEmailsOrThrow("test@example.com"); - expect(result).toEqual(["test@example.com"]); + it("It should NOT throw an error if the user is an admin of the ORG", async () => { + vi.mocked(isOrganisationAdmin).mockResolvedValue(mockedReturnSuccessCheckPerms); + await expect( + ensureAtleastAdminPermissions({ userId: 1, teamId: 1, isOrg: true }) + ).resolves.not.toThrow(); + }); + + it("It should throw an error if the user is not an admin of the team", async () => { + vi.mocked(isTeamAdmin).mockResolvedValue(false); + await expect(ensureAtleastAdminPermissions({ userId: 1, teamId: 1 })).rejects.toThrow("UNAUTHORIZED"); + }); + + it("It should NOT throw an error if the user is an admin of a team", async () => { + vi.mocked(isTeamAdmin).mockResolvedValue(mockedReturnSuccessCheckPerms); + await expect(ensureAtleastAdminPermissions({ userId: 1, teamId: 1 })).resolves.not.toThrow(); + }); + }); + + describe("getUniqueInvitationsOrThrowIfEmpty", () => { + it("should throw a TRPCError with code BAD_REQUEST if no emails are provided", async () => { + await expect(getUniqueInvitationsOrThrowIfEmpty([])).rejects.toThrow(TRPCError); }); it("should return an array with multiple emails if an array is provided", async () => { - const result = await getUniqueUsernameOrEmailsOrThrow(["test1@example.com", "test2@example.com"]); - expect(result).toEqual(["test1@example.com", "test2@example.com"]); + const result = await getUniqueInvitationsOrThrowIfEmpty([ + { usernameOrEmail: "test1@example.com", role: MembershipRole.MEMBER }, + { usernameOrEmail: "test2@example.com", role: MembershipRole.MEMBER }, + ]); + expect(result).toEqual([ + { usernameOrEmail: "test1@example.com", role: MembershipRole.MEMBER }, + { usernameOrEmail: "test2@example.com", role: MembershipRole.MEMBER }, + ]); }); }); describe("checkInputEmailIsValid", () => { @@ -172,13 +182,13 @@ describe("Invite Member Utils", () => { }); describe("getOrgConnectionInfo", () => { const orgAutoAcceptDomain = "example.com"; - const usersEmail = "user@example.com"; + const email = "user@example.com"; - it("should return autoAccept:false when orgVerified is false even if usersEmail domain matches orgAutoAcceptDomain", () => { + it("should return autoAccept:false when orgVerified is false even if email domain matches orgAutoAcceptDomain", () => { const result = getOrgConnectionInfo({ orgAutoAcceptDomain, orgVerified: false, - usersEmail, + email, team: { ...mockedRegularTeam, parentId: 2, @@ -188,11 +198,11 @@ describe("Invite Member Utils", () => { expect(result).toEqual({ orgId: 2, autoAccept: false }); }); - it("should return orgId and autoAccept as false if team has parent and usersEmail domain does not match orgAutoAcceptDomain", () => { + it("should return orgId and autoAccept as false if team has parent and email domain does not match orgAutoAcceptDomain", () => { const result = getOrgConnectionInfo({ orgAutoAcceptDomain, orgVerified: true, - usersEmail: "user@other.com", + email: "user@other.com", team: { ...mockedRegularTeam, parentId: 2, @@ -202,33 +212,33 @@ describe("Invite Member Utils", () => { expect(result).toEqual({ orgId: undefined, autoAccept: false }); }); - it("should return orgId and autoAccept as true if team has no parent and isOrg is true and usersEmail domain matches orgAutoAcceptDomain", () => { + it("should return orgId and autoAccept as true if team has no parent and isOrg is true and email domain matches orgAutoAcceptDomain", () => { const result = getOrgConnectionInfo({ orgAutoAcceptDomain, orgVerified: true, - usersEmail, + email, team: { ...mockedRegularTeam, parentId: null }, isOrg: true, }); expect(result).toEqual({ orgId: 1, autoAccept: true }); }); - it("should return orgId and autoAccept as false if team has no parent and isOrg is true and usersEmail domain does not match orgAutoAcceptDomain", () => { + it("should return orgId and autoAccept as false if team has no parent and isOrg is true and email domain does not match orgAutoAcceptDomain", () => { const result = getOrgConnectionInfo({ orgAutoAcceptDomain, orgVerified: false, - usersEmail: "user@other.com", + email: "user@other.com", team: { ...mockedRegularTeam, parentId: null }, isOrg: true, }); expect(result).toEqual({ orgId: undefined, autoAccept: false }); }); - it("should return orgId and autoAccept as false if team has no parent and isOrg is true and usersEmail domain matches orgAutoAcceptDomain but orgVerified is false", () => { + it("should return orgId and autoAccept as false if team has no parent and isOrg is true and email domain matches orgAutoAcceptDomain but orgVerified is false", () => { const result = getOrgConnectionInfo({ orgAutoAcceptDomain, orgVerified: false, - usersEmail, + email, team: { ...mockedRegularTeam, parentId: null }, isOrg: true, }); @@ -290,6 +300,10 @@ describe("Invite Member Utils", () => { const result = getOrgState(false, { ...mockedRegularTeam, ...team }); expect(result).toEqual({ isInOrgScope: false, + orgVerified: null, + orgConfigured: null, + orgPublished: null, + autoAcceptEmailDomain: null, }); }); }); diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts index 32a9d7608f..4de3f883d0 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts @@ -4,16 +4,20 @@ import type { TFunction } from "next-i18next"; import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; import { sendTeamInviteEmail } from "@calcom/emails"; import { ENABLE_PROFILE_SWITCHER, WEBAPP_URL } from "@calcom/lib/constants"; +import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries"; import { isTeamAdmin } from "@calcom/lib/server/queries"; import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations"; import { ProfileRepository } from "@calcom/lib/server/repository/profile"; +import { getParsedTeam } from "@calcom/lib/server/repository/teamUtils"; import { UserRepository } from "@calcom/lib/server/repository/user"; import slugify from "@calcom/lib/slugify"; import { prisma } from "@calcom/prisma"; import type { Membership, OrganizationSettings, Team } from "@calcom/prisma/client"; -import { Prisma, type User as UserType, type UserPassword } from "@calcom/prisma/client"; +import { type User as UserType, type UserPassword, Prisma } from "@calcom/prisma/client"; import type { Profile as ProfileType } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -35,7 +39,19 @@ export type UserWithMembership = Invitee & { password: UserPassword | null; }; -export async function checkPermissions({ +export type Invitation = { + usernameOrEmail: string; + role: MembershipRole; +}; + +type ExistingUserWithInviteStatus = Awaited>[number]; +type ExistingUserWithInviteStatusAndProfile = ExistingUserWithInviteStatus & { + profile: { + username: string; + } | null; +}; + +export async function ensureAtleastAdminPermissions({ userId, teamId, isOrg, @@ -85,19 +101,26 @@ export async function getTeamOrThrow(teamId: number) { return { ...team, metadata: teamMetadataSchema.parse(team.metadata) }; } -export async function getUniqueUsernameOrEmailsOrThrow(usernameOrEmail: string | string[]) { - const emailsToInvite = Array.isArray(usernameOrEmail) - ? Array.from(new Set(usernameOrEmail)) - : [usernameOrEmail]; +export async function getUniqueInvitationsOrThrowIfEmpty(invitations: Invitation[]) { + const usernamesOrEmailsSet = new Set(); + const uniqueInvitations: Invitation[] = []; - if (emailsToInvite.length === 0) { + invitations.forEach((usernameOrEmail) => { + if (usernamesOrEmailsSet.has(usernameOrEmail.usernameOrEmail)) { + return; + } + uniqueInvitations.push(usernameOrEmail); + usernamesOrEmailsSet.add(usernameOrEmail.usernameOrEmail); + }); + + if (uniqueInvitations.length === 0) { throw new TRPCError({ code: "BAD_REQUEST", message: "You must provide at least one email address to invite.", }); } - return emailsToInvite; + return uniqueInvitations; } export const enum INVITE_STATUS { @@ -141,14 +164,15 @@ export function canBeInvited(invitee: UserWithMembership, team: TeamWithParent) return INVITE_STATUS.CAN_BE_INVITED; } -export async function getExistingUsersToInvite({ - usernamesOrEmails, +export async function findUsersWithInviteStatus({ + invitations, team, }: { - usernamesOrEmails: string[]; + invitations: Invitation[]; team: TeamWithParent; }) { - const invitees: UserWithMembership[] = await prisma.user.findMany({ + const usernamesOrEmails = invitations.map((invitation) => invitation.usernameOrEmail); + const inviteesFromDb: UserWithMembership[] = await prisma.user.findMany({ where: { OR: [ // Either it's a username in that organization @@ -176,26 +200,43 @@ export async function getExistingUsersToInvite({ }, }); + const userToRoleMap = buildUserToRoleMap(); + const defaultMemberRole = MembershipRole.MEMBER; // Check if the users found in the database can be invited to join the team/org - return invitees.map((invitee) => { + return inviteesFromDb.map((inviteeFromDb) => { + const newRole = getRoleForUser({ email: inviteeFromDb.email, username: inviteeFromDb.username }); + return { - ...invitee, - canBeInvited: canBeInvited(invitee, team), + ...inviteeFromDb, + newRole: newRole ?? defaultMemberRole, + canBeInvited: canBeInvited(inviteeFromDb, team), }; }); + + function buildUserToRoleMap() { + const userToRoleMap = new Map(); + invitations.forEach((invitation) => { + userToRoleMap.set(invitation.usernameOrEmail, invitation.role); + }); + return userToRoleMap; + } + + function getRoleForUser({ email, username }: { email: string; username: string | null }) { + return userToRoleMap.get(email) || (username ? userToRoleMap.get(username) : defaultMemberRole); + } } export function getOrgConnectionInfo({ orgAutoAcceptDomain, orgVerified, isOrg, - usersEmail, + email, team, }: { orgAutoAcceptDomain?: string | null; - orgVerified: boolean; - usersEmail: string; - team: TeamWithParent; + orgVerified: boolean | null; + email: string; + team: Pick; isOrg: boolean; }) { let orgId: number | undefined = undefined; @@ -203,11 +244,11 @@ export function getOrgConnectionInfo({ if (team.parentId || isOrg) { orgId = team.parentId || team.id; - if (usersEmail.split("@")[1] == orgAutoAcceptDomain) { + if (email.split("@")[1] == orgAutoAcceptDomain) { // We discourage self-served organizations from being able to auto-accept feature by having a barrier of a fixed number of paying teams in the account for creating the organization // We can't put restriction of a published organization here because when we move teams during the onboarding of the organization, it isn't published at the moment and we really need those members to be auto-added // Further, sensitive operations like member editing and impersonating are disabled by default, unless reviewed by the ADMIN team - autoAccept = orgVerified; + autoAccept = !!orgVerified; } else { orgId = undefined; autoAccept = false; @@ -218,37 +259,39 @@ export function getOrgConnectionInfo({ } export async function createNewUsersConnectToOrgIfExists({ - usernamesOrEmails, - input, + invitations, + isOrg, + teamId, parentId, autoAcceptEmailDomain, - connectionInfoMap, + orgConnectInfoByUsernameOrEmail, isPlatformManaged, timeFormat, weekStart, timeZone, }: { - usernamesOrEmails: string[]; - input: InviteMemberOptions["input"]; + invitations: Invitation[]; + isOrg: boolean; + teamId: number; parentId?: number | null; - autoAcceptEmailDomain?: string; - connectionInfoMap: Record>; + autoAcceptEmailDomain: string | null; + orgConnectInfoByUsernameOrEmail: Record>; isPlatformManaged?: boolean; timeFormat?: number; weekStart?: string; timeZone?: string; }) { // fail if we have invalid emails - usernamesOrEmails.forEach((usernameOrEmail) => checkInputEmailIsValid(usernameOrEmail)); + invitations.forEach((invitation) => checkInputEmailIsValid(invitation.usernameOrEmail)); // from this point we know usernamesOrEmails contains only emails const createdUsers = await prisma.$transaction( async (tx) => { const createdUsers = []; - for (let index = 0; index < usernamesOrEmails.length; index++) { - const usernameOrEmail = usernamesOrEmails[index]; + for (let index = 0; index < invitations.length; index++) { + const invitation = invitations[index]; // Weird but orgId is defined only if the invited user email matches orgAutoAcceptEmail - const { orgId, autoAccept } = connectionInfoMap[usernameOrEmail]; - const [emailUser, emailDomain] = usernameOrEmail.split("@"); + const { orgId, autoAccept } = orgConnectInfoByUsernameOrEmail[invitation.usernameOrEmail]; + const [emailUser, emailDomain] = invitation.usernameOrEmail.split("@"); // An org member can't change username during signup, so we set the username const orgMemberUsername = @@ -259,14 +302,14 @@ export async function createNewUsersConnectToOrgIfExists({ // As a regular team member is allowed to change username during signup, we don't set any username for him const regularTeamMemberUsername = null; - const isBecomingAnOrgMember = parentId || input.isOrg; + const isBecomingAnOrgMember = parentId || isOrg; const createdUser = await tx.user.create({ data: { username: isBecomingAnOrgMember ? orgMemberUsername : regularTeamMemberUsername, - email: usernameOrEmail, + email: invitation.usernameOrEmail, verified: true, - invitedTo: input.teamId, + invitedTo: teamId, isPlatformManaged: !!isPlatformManaged, timeFormat, weekStart, @@ -289,8 +332,8 @@ export async function createNewUsersConnectToOrgIfExists({ : null), teams: { create: { - teamId: input.teamId, - role: input.role as MembershipRole, + teamId: teamId, + role: invitation.role, accepted: autoAccept, // If the user is invited to a child team, they are automatically accepted }, }, @@ -323,8 +366,8 @@ export async function createMemberships({ parentId, accepted, }: { - input: InviteMemberOptions["input"]; - invitees: (UserWithMembership & { + input: Omit; + invitees: (ExistingUserWithInviteStatus & { needToCreateOrgMembership: boolean | null; })[]; parentId: number | null; @@ -344,7 +387,7 @@ export async function createMemberships({ role: organizationRole === MembershipRole.ADMIN || organizationRole === MembershipRole.OWNER ? organizationRole - : input.role, + : invitee.newRole, }); // membership for the org @@ -360,22 +403,11 @@ export async function createMemberships({ }), }); } catch (e) { - console.error(e); if (e instanceof Prisma.PrismaClientKnownRequestError) { - // Don't throw an error if the user is already a member of the team when inviting multiple users - if (!Array.isArray(input.usernameOrEmail) && e.code === "P2002") { - throw new TRPCError({ - code: "FORBIDDEN", - message: "This user is a member of this team / has a pending invitation.", - }); - } else if (Array.isArray(input.usernameOrEmail) && e.code === "P2002") { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Trying to invite users already members of this team / have pending invitations", - }); - } logger.error("Failed to create memberships", input.teamId); - } else throw e; + } else { + throw e; + } } } @@ -388,7 +420,7 @@ export async function sendSignupToOrganizationEmail({ isOrg, }: { usernameOrEmail: string; - team: Awaited>; + team: { name: string; parent: { name: string } | null }; translation: TFunction; inviterName: string; teamId: number; @@ -457,12 +489,10 @@ export function getOrgState( return { isInOrgScope: false, - } as { - isInOrgScope: false; - orgVerified: never; - autoAcceptEmailDomain: never; - orgConfigured: never; - orgPublished: never; + orgVerified: null, + autoAcceptEmailDomain: null, + orgConfigured: null, + orgPublished: null, }; } @@ -528,32 +558,27 @@ export const groupUsersByJoinability = ({ connectionInfoMap, }: { team: TeamWithParent; - existingUsersWithMemberships: (UserWithMembership & { - profile: { - username: string; - } | null; - })[]; + existingUsersWithMemberships: ExistingUserWithInviteStatusAndProfile[]; connectionInfoMap: Record>; }) => { const usersToAutoJoin = []; const regularUsers = []; for (let index = 0; index < existingUsersWithMemberships.length; index++) { - const existingUserWithMembersips = existingUsersWithMemberships[index]; - + const existingUserWithMemberships = existingUsersWithMemberships[index]; const autoJoinStatus = getAutoJoinStatus({ - invitee: existingUserWithMembersips, + invitee: existingUserWithMemberships, team, connectionInfoMap, }); autoJoinStatus.autoAccept ? usersToAutoJoin.push({ - ...existingUserWithMembersips, + ...existingUserWithMemberships, ...autoJoinStatus, }) : regularUsers.push({ - ...existingUserWithMembersips, + ...existingUserWithMemberships, ...autoJoinStatus, }); } @@ -583,11 +608,7 @@ export const sendExistingUserTeamInviteEmails = async ({ }: { language: TFunction; isAutoJoin: boolean; - existingUsersWithMemberships: (UserWithMembership & { - profile: { - username: string; - } | null; - })[]; + existingUsersWithMemberships: Omit[]; currentUserTeamName?: string; currentUserParentTeamName: string | undefined; currentUserName?: string | null; @@ -657,3 +678,236 @@ export const sendExistingUserTeamInviteEmails = async ({ await sendEmails(sendEmailsPromises); }; + +type inviteMemberHandlerInput = { + teamId: number; + role?: "ADMIN" | "MEMBER" | "OWNER"; + isOrg: boolean; + language: string; +}; + +export async function handleExistingUsersInvites({ + invitableExistingUsers, + team, + orgConnectInfoByUsernameOrEmail, + input, + inviter, + orgSlug, +}: { + invitableExistingUsers: Awaited>; + team: TeamWithParent; + orgConnectInfoByUsernameOrEmail: Record; + input: inviteMemberHandlerInput; + inviter: { + name: string | null; + }; + orgSlug: string | null; +}) { + const translation = await getTranslation(input.language ?? "en", "common"); + if (!team.isOrganization) { + const [autoJoinUsers, regularUsers] = groupUsersByJoinability({ + existingUsersWithMemberships: invitableExistingUsers.map((u) => { + return { + ...u, + profile: null, + }; + }), + team, + connectionInfoMap: orgConnectInfoByUsernameOrEmail, + }); + + log.debug( + "Inviting existing users to a team", + safeStringify({ + autoJoinUsers, + regularUsers, + }) + ); + + // invited users can autojoin, create their memberships in org + if (autoJoinUsers.length) { + await createMemberships({ + input, + invitees: autoJoinUsers, + parentId: team.parentId, + accepted: true, + }); + + await Promise.all( + autoJoinUsers.map(async (userToAutoJoin) => { + await updateNewTeamMemberEventTypes(userToAutoJoin.id, team.id); + }) + ); + + await sendExistingUserTeamInviteEmails({ + currentUserName: inviter.name, + currentUserTeamName: team?.name, + existingUsersWithMemberships: autoJoinUsers, + language: translation, + isOrg: input.isOrg, + teamId: team.id, + isAutoJoin: true, + currentUserParentTeamName: team?.parent?.name, + orgSlug, + }); + } + + // invited users cannot autojoin, create provisional memberships and send email + if (regularUsers.length) { + await createMemberships({ + input, + invitees: regularUsers, + parentId: team.parentId, + accepted: false, + }); + await sendExistingUserTeamInviteEmails({ + currentUserName: inviter.name, + currentUserTeamName: team?.name, + existingUsersWithMemberships: regularUsers, + language: translation, + isOrg: input.isOrg, + teamId: team.id, + isAutoJoin: false, + currentUserParentTeamName: team?.parent?.name, + orgSlug, + }); + } + + const parentOrganization = team.parent; + if (parentOrganization) { + const parsedOrg = getParsedTeam(parentOrganization); + // Create profiles if needed + await Promise.all([ + autoJoinUsers + .concat(regularUsers) + .filter((u) => u.needToCreateProfile) + .map((user) => + createAProfileForAnExistingUser({ + user: { + id: user.id, + email: user.email, + currentUsername: user.username, + }, + organizationId: parsedOrg.id, + }) + ), + ]); + } + } else { + const organization = team; + log.debug( + "Inviting existing users to an organization", + safeStringify({ + invitableExistingUsers, + }) + ); + + const existingUsersWithMembershipsNew = await Promise.all( + invitableExistingUsers.map(async (user) => { + const shouldAutoAccept = orgConnectInfoByUsernameOrEmail[user.email].autoAccept; + let profile = null; + if (shouldAutoAccept) { + profile = await createAProfileForAnExistingUser({ + user: { + id: user.id, + email: user.email, + currentUsername: user.username, + }, + organizationId: organization.id, + }); + } + + await prisma.membership.create({ + data: { + userId: user.id, + teamId: team.id, + accepted: shouldAutoAccept, + role: user.newRole, + }, + }); + return { + ...user, + profile, + }; + }) + ); + + const autoJoinUsers = existingUsersWithMembershipsNew.filter( + (user) => orgConnectInfoByUsernameOrEmail[user.email].autoAccept + ); + + const regularUsers = existingUsersWithMembershipsNew.filter( + (user) => !orgConnectInfoByUsernameOrEmail[user.email].autoAccept + ); + + // Send emails to user who auto-joined + await sendExistingUserTeamInviteEmails({ + currentUserName: inviter.name, + currentUserTeamName: team?.name, + existingUsersWithMemberships: autoJoinUsers, + language: translation, + isOrg: input.isOrg, + teamId: team.id, + isAutoJoin: true, + currentUserParentTeamName: team?.parent?.name, + orgSlug, + }); + + // Send emails to user who need to accept invite + await sendExistingUserTeamInviteEmails({ + currentUserName: inviter.name, + currentUserTeamName: team?.name, + existingUsersWithMemberships: regularUsers, + language: translation, + isOrg: input.isOrg, + teamId: team.id, + isAutoJoin: false, + currentUserParentTeamName: team?.parent?.name, + orgSlug, + }); + } +} + +export async function handleNewUsersInvites({ + invitationsForNewUsers, + team, + orgConnectInfoByUsernameOrEmail, + input, + autoAcceptEmailDomain, + inviter, +}: { + invitationsForNewUsers: Invitation[]; + input: inviteMemberHandlerInput; + orgConnectInfoByUsernameOrEmail: Record; + autoAcceptEmailDomain: string | null; + team: TeamWithParent; + inviter: { + name: string | null; + }; +}) { + const translation = await getTranslation(input.language ?? "en", "common"); + + await createNewUsersConnectToOrgIfExists({ + invitations: invitationsForNewUsers, + isOrg: input.isOrg, + teamId: input.teamId, + orgConnectInfoByUsernameOrEmail, + autoAcceptEmailDomain: autoAcceptEmailDomain, + parentId: team.parentId, + }); + + const sendVerifyEmailsPromises = invitationsForNewUsers.map((invitation) => { + return sendSignupToOrganizationEmail({ + usernameOrEmail: invitation.usernameOrEmail, + team: { + name: team.name, + parent: team.parent, + }, + translation, + inviterName: inviter.name ?? "", + teamId: input.teamId, + isOrg: input.isOrg, + }); + }); + await sendEmails(sendVerifyEmailsPromises); +} diff --git a/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts b/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts index c439c2da40..3547ff9426 100644 --- a/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts @@ -4,7 +4,7 @@ import { getTranslation } from "@calcom/lib/server/i18n"; import { prisma } from "@calcom/prisma"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; -import { checkPermissions, getTeamOrThrow } from "./inviteMember/utils"; +import { ensureAtleastAdminPermissions, getTeamOrThrow } from "./inviteMember/utils"; import type { TResendInvitationInputSchema } from "./resendInvitation.schema"; type InviteMemberOptions = { @@ -17,7 +17,7 @@ type InviteMemberOptions = { export const resendInvitationHandler = async ({ ctx, input }: InviteMemberOptions) => { const team = await getTeamOrThrow(input.teamId); - await checkPermissions({ + await ensureAtleastAdminPermissions({ userId: ctx.user.id, teamId: ctx.user.organization.id && ctx.user.organization.isOrgAdmin ? ctx.user.organization.id : input.teamId, diff --git a/tests/libs/__mocks__/checkRateLimitAndThrowError.ts b/tests/libs/__mocks__/checkRateLimitAndThrowError.ts new file mode 100644 index 0000000000..8cd783f0c7 --- /dev/null +++ b/tests/libs/__mocks__/checkRateLimitAndThrowError.ts @@ -0,0 +1,29 @@ +import { beforeEach, vi } from "vitest"; +import { mockReset, mockDeep } from "vitest-mock-extended"; + +import type * as checkRateLimitAndThrowError from "@calcom/lib/checkRateLimitAndThrowError"; + +vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => checkRateLimitAndThrowErrorMock); + +beforeEach(() => { + mockReset(checkRateLimitAndThrowErrorMock); +}); + +const checkRateLimitAndThrowErrorMock = mockDeep(); + +export const scenarios = { + fakeRateLimitPassed: () => { + // It doesn't matter what the implementation is, as long as it resolves without error + checkRateLimitAndThrowErrorMock.checkRateLimitAndThrowError.mockResolvedValue(undefined); + }, + fakeRateLimitFailed: () => { + const error = new Error("FAKE_RATE_LIMIT_ERROR"); + // It doesn't matter what the implementation is, as long as it resolves without error + checkRateLimitAndThrowErrorMock.checkRateLimitAndThrowError.mockImplementation(() => { + throw error; + }); + return error.message; + }, +}; + +export default checkRateLimitAndThrowErrorMock; diff --git a/tests/libs/__mocks__/getTranslation.ts b/tests/libs/__mocks__/getTranslation.ts new file mode 100644 index 0000000000..01be0be8b3 --- /dev/null +++ b/tests/libs/__mocks__/getTranslation.ts @@ -0,0 +1,21 @@ +import { beforeEach, vi } from "vitest"; +import { mockReset, mockDeep } from "vitest-mock-extended"; + +import type * as getTranslation from "@calcom/lib/server/i18n"; + +vi.mock("@calcom/lib/server/i18n", () => getTranslationMock); + +beforeEach(() => { + mockReset(getTranslationMock); +}); + +const getTranslationMock = mockDeep(); + +export const mock = { + fakeIdentityFn: () => + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + getTranslationMock.getTranslation.mockImplementation(async () => (key: string) => key), +}; + +export default getTranslationMock;