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;