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 5eaebe08444f2c..41b081433bc8da 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts @@ -1,28 +1,24 @@ -import { randomBytes } from "crypto"; - -import { sendTeamInviteEmail } from "@calcom/emails"; import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; -import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; import { getTranslation } from "@calcom/lib/server/i18n"; import { prisma } from "@calcom/prisma"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; -import { isEmail } from "../util"; import type { TInviteMemberInputSchema } from "./inviteMember.schema"; import { checkPermissions, getTeamOrThrow, getEmailsToInvite, - getUserToInviteOrThrowIfExists, - checkInputEmailIsValid, getOrgConnectionInfo, - createNewUserConnectToOrgIfExists, - throwIfInviteIsToOrgAndUserExists, - createProvisionalMembership, getIsOrgVerified, sendVerificationEmail, - createAndAutoJoinIfInOrg, + getUsersToInvite, + createNewUsersConnectToOrgIfExists, + createProvisionalMemberships, + groupUsersByJoinability, + sendTeamInviteEmails, + sendEmails, } from "./utils"; type InviteMemberOptions = { @@ -33,10 +29,10 @@ type InviteMemberOptions = { }; export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) => { + const translation = await getTranslation(input.language ?? "en", "common"); await checkRateLimitAndThrowError({ identifier: `invitedBy:${ctx.user.id}`, }); - await checkPermissions({ userId: ctx.user.id, teamId: @@ -46,100 +42,81 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = const team = await getTeamOrThrow(input.teamId, input.isOrg); const { autoAcceptEmailDomain, orgVerified } = getIsOrgVerified(input.isOrg, team); - - const translation = await getTranslation(input.language ?? "en", "common"); - const emailsToInvite = await getEmailsToInvite(input.usernameOrEmail); - - for (const usernameOrEmail of emailsToInvite) { - const connectionInfo = getOrgConnectionInfo({ - orgVerified, - orgAutoAcceptDomain: autoAcceptEmailDomain, - usersEmail: usernameOrEmail, - team, - isOrg: input.isOrg, - }); - const invitee = await getUserToInviteOrThrowIfExists({ - usernameOrEmail, - teamId: input.teamId, - isOrg: input.isOrg, + const orgConnectInfoByEmail = emailsToInvite.reduce((acc, email) => { + return { + ...acc, + [email]: getOrgConnectionInfo({ + orgVerified, + orgAutoAcceptDomain: autoAcceptEmailDomain, + usersEmail: email, + team, + isOrg: input.isOrg, + }), + }; + }, {} as Record>); + const existingUsersWithMembersips = await getUsersToInvite({ + usernameOrEmail: emailsToInvite, + isInvitedToOrg: input.isOrg, + team, + }); + const existingUsersEmails = existingUsersWithMembersips.map((user) => user.email); + const newUsersEmails = emailsToInvite.filter((email) => !existingUsersEmails.includes(email)); + // deal with users to create and invite to team/org + if (newUsersEmails.length) { + await createNewUsersConnectToOrgIfExists({ + usernamesOrEmails: newUsersEmails, + input, + connectionInfoMap: orgConnectInfoByEmail, + autoAcceptEmailDomain, + parentId: team.parentId, }); - - if (!invitee) { - checkInputEmailIsValid(usernameOrEmail); - - // valid email given, create User and add to team - await createNewUserConnectToOrgIfExists({ + const sendVerifEmailsPromises = newUsersEmails.map((usernameOrEmail) => { + return sendVerificationEmail({ usernameOrEmail, + team, + translation, + ctx, input, - connectionInfo, - autoAcceptEmailDomain, - parentId: team.parentId, + connectionInfo: orgConnectInfoByEmail[usernameOrEmail], }); + }); + sendEmails(sendVerifEmailsPromises); + } - await sendVerificationEmail({ usernameOrEmail, team, translation, ctx, input, connectionInfo }); - } else { - throwIfInviteIsToOrgAndUserExists(invitee, team, input.isOrg); + // deal with existing users invited to join the team/org + if (existingUsersWithMembersips.length) { + const [autoJoinUsers, regularUsers] = groupUsersByJoinability({ + existingUsersWithMembersips, + team, + }); - const shouldAutoJoinOrgTeam = await createAndAutoJoinIfInOrg({ - invitee, - role: input.role, - team, + // invited users can autojoin, create their memberships in org + if (autoJoinUsers.length) { + await prisma.membership.createMany({ + data: autoJoinUsers.map((userToAutoJoin) => ({ + userId: userToAutoJoin.id, + teamId: team.id, + accepted: true, + role: input.role, + })), }); - if (shouldAutoJoinOrgTeam.autoJoined) { - // Continue here because if this is true we dont need to send an email to the user - // we also dont need to update stripe as thats handled on an ORG level and not a team level. - continue; - } + } - // create provisional membership - await createProvisionalMembership({ + // invited users cannot autojoin, create provisional memberships and send email + if (regularUsers.length) { + await createProvisionalMemberships({ input, - invitee, + invitees: regularUsers, + }); + await sendTeamInviteEmails({ + currentUserName: ctx?.user?.name, + currentUserTeamName: team?.name, + existingUsersWithMembersips: regularUsers, + language: translation, + isOrg: input.isOrg, + teamId: team.id, }); - - let sendTo = usernameOrEmail; - if (!isEmail(usernameOrEmail)) { - sendTo = invitee.email; - } - // inform user of membership by email - if (ctx?.user?.name && team?.name) { - const inviteTeamOptions = { - joinLink: `${WEBAPP_URL}/auth/login?callbackUrl=/settings/teams`, - isCalcomMember: true, - }; - /** - * Here we want to redirect to a different place if onboarding has been completed or not. This prevents the flash of going to teams -> Then to onboarding - also show a different email template. - * This only changes if the user is a CAL user and has not completed onboarding and has no password - */ - if (!invitee.completedOnboarding && !invitee.password && invitee.identityProvider === "CAL") { - const token = randomBytes(32).toString("hex"); - await prisma.verificationToken.create({ - data: { - identifier: usernameOrEmail, - token, - expires: new Date(new Date().setHours(168)), // +1 week - team: { - connect: { - id: team.id, - }, - }, - }, - }); - - inviteTeamOptions.joinLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`; - inviteTeamOptions.isCalcomMember = false; - } - - await sendTeamInviteEmail({ - language: translation, - from: ctx.user.name, - to: sendTo, - teamName: team.name, - ...inviteTeamOptions, - isOrg: input.isOrg, - }); - } } } 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 69c7c24fe8044e..35c59a35238ea3 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts @@ -2,14 +2,39 @@ import { z } from "zod"; import { MembershipRole } from "@calcom/prisma/enums"; +import { TRPCError } from "@trpc/server"; + export const ZInviteMemberInputSchema = z.object({ teamId: z.number(), - usernameOrEmail: z.union([z.string(), z.array(z.string())]).transform((usernameOrEmail) => { - if (typeof usernameOrEmail === "string") { - return usernameOrEmail.trim().toLowerCase(); - } - return usernameOrEmail.map((item) => item.trim().toLowerCase()); - }), + usernameOrEmail: z + .union([z.string(), z.array(z.string())]) + .transform((usernameOrEmail) => { + if (typeof usernameOrEmail === "string") { + return usernameOrEmail.trim().toLowerCase(); + } + return usernameOrEmail.map((item) => item.trim().toLowerCase()); + }) + .refine((value) => { + let invalidEmail; + if (Array.isArray(value)) { + if (value.length > 100) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `You are limited to inviting a maximum of 100 users at once.`, + }); + } + invalidEmail = value.find((email) => !z.string().email().safeParse(email).success); + } else { + invalidEmail = !z.string().email().safeParse(value).success ? value : null; + } + if (invalidEmail) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invite failed because '${invalidEmail}' is not a valid email address`, + }); + } + return true; + }), role: z.nativeEnum(MembershipRole), 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 da6a9a754a88ad..6dc4ea15df532c 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts @@ -2,20 +2,19 @@ import { describe, it, vi, expect } from "vitest"; import { isTeamAdmin } from "@calcom/lib/server/queries"; import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations"; -import type { User } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; import type { TeamWithParent } from "./types"; +import type { Invitee, UserWithMembership } from "./utils"; import { - checkInputEmailIsValid, checkPermissions, getEmailsToInvite, getIsOrgVerified, getOrgConnectionInfo, - throwIfInviteIsToOrgAndUserExists, - createAndAutoJoinIfInOrg, + validateInviteeEligibility, + shouldAutoJoinIfInOrg, } from "./utils"; vi.mock("@calcom/lib/server/queries", () => { @@ -60,46 +59,29 @@ const mockedTeam: TeamWithParent = { parentId: null, parent: null, isPrivate: false, + logoUrl: "", }; -const mockUser: User = { +const mockUser: Invitee = { id: 4, username: "pro", - name: "Pro Example", email: "pro@example.com", - emailVerified: new Date(), password: "", - bio: null, - avatar: null, - timeZone: "Europe/London", - weekStart: "Sunday", - startTime: 0, - endTime: 1440, - bufferTime: 0, - hideBranding: false, - theme: null, - createdDate: new Date(), - trialEndsAt: null, - defaultScheduleId: null, completedOnboarding: true, - locale: "en", - timeFormat: 12, - twoFactorSecret: null, - twoFactorEnabled: false, identityProvider: "CAL", - identityProviderId: null, - invitedTo: null, - brandColor: "#292929", - darkBrandColor: "#fafafa", - away: false, - allowDynamicBooking: true, - metadata: null, - verified: false, - role: "USER", - disableImpersonation: false, organizationId: null, }; +const userInTeamAccepted: UserWithMembership = { + ...mockUser, + teams: [{ teamId: mockedTeam.id, accepted: true, userId: mockUser.id }], +}; + +const userInTeamNotAccepted: UserWithMembership = { + ...mockUser, + teams: [{ teamId: mockedTeam.id, accepted: false, userId: mockUser.id }], +}; + describe("Invite Member Utils", () => { describe("checkPermissions", () => { it("It should throw an error if the user is not an admin of the ORG", async () => { @@ -134,20 +116,7 @@ describe("Invite Member Utils", () => { expect(result).toEqual(["test1@example.com", "test2@example.com"]); }); }); - describe("checkInputEmailIsValid", () => { - it("should throw a TRPCError with code BAD_REQUEST if the email is invalid", () => { - const invalidEmail = "invalid-email"; - expect(() => checkInputEmailIsValid(invalidEmail)).toThrow(TRPCError); - expect(() => checkInputEmailIsValid(invalidEmail)).toThrowError( - "Invite failed because invalid-email is not a valid email address" - ); - }); - it("should not throw an error if the email is valid", () => { - const validEmail = "valid-email@example.com"; - expect(() => checkInputEmailIsValid(validEmail)).not.toThrow(); - }); - }); describe("getOrgConnectionInfo", () => { const orgAutoAcceptDomain = "example.com"; const usersEmail = "user@example.com"; @@ -270,8 +239,8 @@ describe("Invite Member Utils", () => { }); }); - describe("throwIfInviteIsToOrgAndUserExists", () => { - const invitee: User = { + describe("validateInviteeEligibility: Check if user can be invited to the team/org", () => { + const invitee: Invitee = { ...mockUser, id: 1, username: "testuser", @@ -280,8 +249,8 @@ describe("Invite Member Utils", () => { }; const isOrg = false; - it("should not throw when inviting an existing user to the same organization", () => { - const inviteeWithOrg: User = { + it("should not throw when inviting to an organization's team an existing org user", () => { + const inviteeWithOrg: Invitee = { ...invitee, organizationId: 2, }; @@ -289,10 +258,36 @@ describe("Invite Member Utils", () => { ...mockedTeam, parentId: 2, }; - expect(() => throwIfInviteIsToOrgAndUserExists(inviteeWithOrg, teamWithOrg, isOrg)).not.toThrow(); + expect(() => validateInviteeEligibility(inviteeWithOrg, teamWithOrg, isOrg)).not.toThrow(); + }); + + it("should throw a TRPCError when inviting a user who is already a member of the org", () => { + const inviteeWithOrg: Invitee = { + ...invitee, + organizationId: 1, + }; + const teamWithOrg = { + ...mockedTeam, + id: 1, + }; + expect(() => validateInviteeEligibility(inviteeWithOrg, teamWithOrg, isOrg)).toThrow(TRPCError); + }); + + it("should throw a TRPCError when inviting a user who is already a member of the team", () => { + const inviteeWithOrg: UserWithMembership = { + ...invitee, + organizationId: null, + teams: [{ teamId: 1, accepted: true, userId: invitee.id }], + }; + const teamWithOrg = { + ...mockedTeam, + id: 1, + }; + expect(() => validateInviteeEligibility(inviteeWithOrg, teamWithOrg, isOrg)).toThrow(TRPCError); }); + it("should throw a TRPCError with code FORBIDDEN if the invitee is already a member of another organization", () => { - const inviteeWithOrg: User = { + const inviteeWithOrg: Invitee = { ...invitee, organizationId: 2, }; @@ -300,36 +295,48 @@ describe("Invite Member Utils", () => { ...mockedTeam, parentId: 3, }; - expect(() => throwIfInviteIsToOrgAndUserExists(inviteeWithOrg, teamWithOrg, isOrg)).toThrow(TRPCError); + expect(() => validateInviteeEligibility(inviteeWithOrg, teamWithOrg, isOrg)).toThrow(TRPCError); }); it("should throw a TRPCError with code FORBIDDEN if the invitee already exists in Cal.com and is being invited to an organization", () => { const isOrg = true; - expect(() => throwIfInviteIsToOrgAndUserExists(invitee, mockedTeam, isOrg)).toThrow(TRPCError); + expect(() => validateInviteeEligibility(invitee, mockedTeam, isOrg)).toThrow(TRPCError); }); it("should not throw an error if the invitee does not already belong to another organization and is not being invited to an organization", () => { - expect(() => throwIfInviteIsToOrgAndUserExists(invitee, mockedTeam, isOrg)).not.toThrow(); + expect(() => validateInviteeEligibility(invitee, mockedTeam, isOrg)).not.toThrow(); }); }); - describe("createAndAutoJoinIfInOrg", () => { + describe("shouldAutoJoinIfInOrg", () => { it("should return autoJoined: false if the user is not in the same organization as the team", async () => { - const result = await createAndAutoJoinIfInOrg({ + const result = await shouldAutoJoinIfInOrg({ team: mockedTeam, - role: MembershipRole.ADMIN, - invitee: mockUser, + invitee: userInTeamAccepted, }); - expect(result).toEqual({ autoJoined: false }); + expect(result).toEqual(false); }); it("should return autoJoined: false if the team does not have a parent organization", async () => { - const result = await createAndAutoJoinIfInOrg({ + const result = await shouldAutoJoinIfInOrg({ team: { ...mockedTeam, parentId: null }, - role: MembershipRole.ADMIN, - invitee: mockUser, + invitee: userInTeamAccepted, + }); + expect(result).toEqual(false); + }); + + it("should return `autoJoined: false` if team has parent organization and invitee has not accepted membership to organization", async () => { + const result = await shouldAutoJoinIfInOrg({ + team: { ...mockedTeam, parentId: mockedTeam.id }, + invitee: { ...userInTeamNotAccepted, organizationId: mockedTeam.id }, + }); + expect(result).toEqual(false); + }); + it("should return `autoJoined: true` if team has parent organization and invitee has accepted membership to organization", async () => { + const result = await shouldAutoJoinIfInOrg({ + team: { ...mockedTeam, parentId: mockedTeam.id }, + invitee: { ...userInTeamAccepted, organizationId: mockedTeam.id }, }); - expect(result).toEqual({ autoJoined: false }); + expect(result).toEqual(true); }); - // TODO: Add test for when the user is already a member of the organization - need to mock prisma response value }); }); diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts index ddde7024d81fce..fb0a38f8caae05 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts @@ -3,11 +3,12 @@ import type { TFunction } from "next-i18next"; import { sendTeamInviteEmail, sendOrganizationAutoJoinEmail } from "@calcom/emails"; import { WEBAPP_URL } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; import { isTeamAdmin } from "@calcom/lib/server/queries"; import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations"; import slugify from "@calcom/lib/slugify"; import { prisma } from "@calcom/prisma"; -import type { Team } from "@calcom/prisma/client"; +import type { Membership, Team } from "@calcom/prisma/client"; import { Prisma, type User } from "@calcom/prisma/client"; import type { MembershipRole } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -18,6 +19,15 @@ import type { TrpcSessionUser } from "../../../../trpc"; import { isEmail } from "../util"; import type { InviteMemberOptions, TeamWithParent } from "./types"; +export type Invitee = Pick< + User, + "id" | "email" | "organizationId" | "username" | "password" | "identityProvider" | "completedOnboarding" +>; + +export type UserWithMembership = Invitee & { + teams?: Pick[]; +}; + export async function checkPermissions({ userId, teamId, @@ -53,7 +63,9 @@ export async function getTeamOrThrow(teamId: number, isOrg?: boolean) { } export async function getEmailsToInvite(usernameOrEmail: string | string[]) { - const emailsToInvite = Array.isArray(usernameOrEmail) ? usernameOrEmail : [usernameOrEmail]; + const emailsToInvite = Array.isArray(usernameOrEmail) + ? Array.from(new Set(usernameOrEmail)) + : [usernameOrEmail]; if (emailsToInvite.length === 0) { throw new TRPCError({ @@ -65,43 +77,102 @@ export async function getEmailsToInvite(usernameOrEmail: string | string[]) { return emailsToInvite; } -export async function getUserToInviteOrThrowIfExists({ - usernameOrEmail, - teamId, - isOrg, -}: { - usernameOrEmail: string; - teamId: number; - isOrg?: boolean; -}) { - // Check if user exists in ORG or exists all together +export function validateInviteeEligibility( + invitee: UserWithMembership, + team: TeamWithParent, + isOrg: boolean +) { + const alreadyInvited = invitee.teams?.find(({ teamId: membershipTeamId }) => team.id === membershipTeamId); + if (alreadyInvited) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `${invitee.email} has already been invited.`, + }); + } - const orgWhere = isOrg && { - organizationId: teamId, - }; - const invitee = await prisma.user.findFirst({ - where: { - OR: [{ username: usernameOrEmail, ...orgWhere }, { email: usernameOrEmail }], - }, - }); + const orgMembership = invitee.teams?.find((membersip) => membersip.teamId === team.parentId); + // invitee is invited to the org's team and is already part of the organization + if (invitee.organizationId && team.parentId && invitee.organizationId === team.parentId) { + return; + } - // We throw on error cause we can't have two users in the same org with the same username - if (isOrg && invitee) { + // user invited to join a team inside an org, but has not accepted invite to org yet + if (team.parentId && orgMembership && !orgMembership.accepted) { throw new TRPCError({ - code: "NOT_FOUND", - message: `Email ${usernameOrEmail} already exists, you can't invite existing users.`, + code: "FORBIDDEN", + message: `User ${invitee.username} needs to accept the invitation to join your organization first.`, }); } - return invitee; -} + // user is invited to join a team which is not in his organization + if (invitee.organizationId && invitee.organizationId !== team.parentId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `User ${invitee.username} is already a member of another organization.`, + }); + } -export function checkInputEmailIsValid(email: string) { - if (!isEmail(email)) + if (invitee && isOrg) { throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invite failed because ${email} is not a valid email address`, + code: "FORBIDDEN", + message: `You cannot add a user that already exists in Cal.com to an organization. If they wish to join via this email address, they must update their email address in their profile to that of your organization.`, }); + } + + if (team.parentId && invitee) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `You cannot add a user that already exists in Cal.com to an organization's team. If they wish to join via this email address, they must update their email address in their profile to that of your organization.`, + }); + } +} + +export async function getUsersToInvite({ + usernameOrEmail, + isInvitedToOrg, + team, +}: { + usernameOrEmail: string[]; + isInvitedToOrg: boolean; + team: TeamWithParent; +}): Promise { + const orgWhere = isInvitedToOrg && { + organizationId: team.id, + }; + const memberships = []; + if (isInvitedToOrg) { + memberships.push({ teamId: team.id }); + } else { + memberships.push({ teamId: team.id }); + team.parentId && memberships.push({ teamId: team.parentId }); + } + + const invitees: UserWithMembership[] = await prisma.user.findMany({ + where: { + OR: [{ username: { in: usernameOrEmail }, ...orgWhere }, { email: { in: usernameOrEmail } }], + }, + select: { + id: true, + email: true, + organizationId: true, + username: true, + password: true, + completedOnboarding: true, + identityProvider: true, + teams: { + select: { teamId: true, userId: true, accepted: true }, + where: { + OR: memberships, + }, + }, + }, + }); + + // Check if the users found in the database can be invited to join the team/org + invitees.forEach((invitee) => { + validateInviteeEligibility(invitee, team, isInvitedToOrg); + }); + return invitees; } export function getOrgConnectionInfo({ @@ -133,84 +204,92 @@ export function getOrgConnectionInfo({ return { orgId, autoAccept }; } -export async function createNewUserConnectToOrgIfExists({ - usernameOrEmail, +export async function createNewUsersConnectToOrgIfExists({ + usernamesOrEmails, input, parentId, autoAcceptEmailDomain, - connectionInfo, + connectionInfoMap, }: { - usernameOrEmail: string; + usernamesOrEmails: string[]; input: InviteMemberOptions["input"]; parentId?: number | null; autoAcceptEmailDomain?: string; - connectionInfo: ReturnType; + connectionInfoMap: Record>; }) { - const { orgId, autoAccept } = connectionInfo; - - const [emailUser, emailDomain] = usernameOrEmail.split("@"); - const username = - emailDomain === autoAcceptEmailDomain - ? slugify(emailUser) - : slugify(`${emailUser}-${emailDomain.split(".")[0]}`); - - const createdUser = await prisma.user.create({ - data: { - username, - email: usernameOrEmail, - verified: true, - invitedTo: input.teamId, - organizationId: orgId || null, // If the user is invited to a child team, they are automatically added to the parent org - teams: { - create: { - teamId: input.teamId, - role: input.role as MembershipRole, - accepted: autoAccept, // If the user is invited to a child team, they are automatically accepted + await prisma.$transaction(async (tx) => { + for (let index = 0; index < usernamesOrEmails.length; index++) { + const usernameOrEmail = usernamesOrEmails[index]; + const { orgId, autoAccept } = connectionInfoMap[usernameOrEmail]; + const [emailUser, emailDomain] = usernameOrEmail.split("@"); + const username = + emailDomain === autoAcceptEmailDomain + ? slugify(emailUser) + : slugify(`${emailUser}-${emailDomain.split(".")[0]}`); + + const createdUser = await tx.user.create({ + data: { + username, + email: usernameOrEmail, + verified: true, + invitedTo: input.teamId, + organizationId: orgId || null, // If the user is invited to a child team, they are automatically added to the parent org + teams: { + create: { + teamId: input.teamId, + role: input.role as MembershipRole, + accepted: autoAccept, // If the user is invited to a child team, they are automatically accepted + }, + }, }, - }, - }, - }); + }); - // We also need to create the membership in the parent org if it exists - if (parentId) { - await prisma.membership.create({ - data: { - teamId: parentId, - userId: createdUser.id, - role: input.role as MembershipRole, - accepted: autoAccept, - }, - }); - } + // We also need to create the membership in the parent org if it exists + if (parentId) { + await tx.membership.create({ + data: { + teamId: parentId, + userId: createdUser.id, + role: input.role as MembershipRole, + accepted: autoAccept, + }, + }); + } + } + }); } -export async function createProvisionalMembership({ +export async function createProvisionalMemberships({ input, - invitee, + invitees, parentId, }: { input: InviteMemberOptions["input"]; - invitee: User; + invitees: UserWithMembership[]; parentId?: number; }) { try { - await prisma.membership.create({ - data: { - teamId: input.teamId, - userId: invitee.id, - role: input.role as MembershipRole, - }, - }); - // Create the membership in the parent also if it exists - if (parentId) { - await prisma.membership.create({ - data: { - teamId: parentId, + await prisma.membership.createMany({ + data: invitees.flatMap((invitee) => { + const data = []; + // membership for the team + data.push({ + teamId: input.teamId, userId: invitee.id, role: input.role as MembershipRole, - }, - }); - } + }); + + // membership for the org + if (parentId) { + data.push({ + teamId: parentId, + userId: invitee.id, + role: input.role as MembershipRole, + }); + } + return data; + }), + }); } catch (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 @@ -219,9 +298,13 @@ export async function createProvisionalMembership({ code: "FORBIDDEN", message: "This user is a member of this team / has a pending invitation.", }); - } else { - console.log(`User ${invitee.id} is already a member of this team.`); + } 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 provisional memberships", input.teamId); } else throw e; } } @@ -282,26 +365,6 @@ export async function sendVerificationEmail({ } } -export function throwIfInviteIsToOrgAndUserExists(invitee: User, team: TeamWithParent, isOrg: boolean) { - if (invitee.organizationId && invitee.organizationId === team.parentId) { - return; - } - - if (invitee.organizationId && invitee.organizationId !== team.parentId) { - throw new TRPCError({ - code: "FORBIDDEN", - message: `User ${invitee.username} is already a member of another organization.`, - }); - } - - if ((invitee && isOrg) || (team.parentId && invitee)) { - throw new TRPCError({ - code: "FORBIDDEN", - message: `You cannot add a user that already exists in Cal.com to an organization. If they wish to join via this email address, they must update their email address in their profile to that of your organization.`, - }); - } -} - export function getIsOrgVerified( isOrg: boolean, team: Team & { @@ -331,52 +394,125 @@ export function getIsOrgVerified( } as { isInOrgScope: false; orgVerified: never; autoAcceptEmailDomain: never }; } -export async function createAndAutoJoinIfInOrg({ +export function shouldAutoJoinIfInOrg({ team, - role, invitee, }: { team: TeamWithParent; - invitee: User; - role: MembershipRole; + invitee: UserWithMembership; }) { + // Not a member of the org if (invitee.organizationId && invitee.organizationId !== team.parentId) { - return { - autoJoined: false, - }; + return false; } - + // team is an Org if (!team.parentId) { - return { - autoJoined: false, - }; + return false; } - const orgMembership = await prisma.membership.findFirst({ - where: { - userId: invitee.id, - teamId: team.parentId, - }, - }); + const orgMembership = invitee.teams?.find((membership) => membership.teamId === team.parentId); if (!orgMembership?.accepted) { - return { - autoJoined: false, - }; + return false; } - // Since we early return if the user is not a member of the org. Or the team they are being invited to is an org (not having a parentID) - // We create the membership in the child team - await prisma.membership.create({ - data: { - userId: invitee.id, - teamId: team.id, - accepted: true, - role: role, - }, + return true; +} +// split invited users between ones that can autojoin and the others who cannot autojoin +export const groupUsersByJoinability = ({ + existingUsersWithMembersips, + team, +}: { + team: TeamWithParent; + existingUsersWithMembersips: UserWithMembership[]; +}) => { + const usersToAutoJoin = []; + const regularUsers = []; + + for (let index = 0; index < existingUsersWithMembersips.length; index++) { + const existingUserWithMembersips = existingUsersWithMembersips[index]; + + const canAutojoin = shouldAutoJoinIfInOrg({ + invitee: existingUserWithMembersips, + team, + }); + + canAutojoin + ? usersToAutoJoin.push(existingUserWithMembersips) + : regularUsers.push(existingUserWithMembersips); + } + + return [usersToAutoJoin, regularUsers]; +}; + +export const sendEmails = async (emailPromises: Promise[]) => { + const sentEmails = await Promise.allSettled(emailPromises); + sentEmails.forEach((sentEmail) => { + if (sentEmail.status === "rejected") { + logger.error("Could not send email to user"); + } }); +}; - return { - autoJoined: true, - }; -} +export const sendTeamInviteEmails = async ({ + existingUsersWithMembersips, + language, + currentUserTeamName, + currentUserName, + isOrg, + teamId, +}: { + language: TFunction; + existingUsersWithMembersips: UserWithMembership[]; + currentUserTeamName?: string; + currentUserName?: string | null; + isOrg: boolean; + teamId: number; +}) => { + const sendEmailsPromises = existingUsersWithMembersips.map(async (user) => { + let sendTo = user.email; + if (!isEmail(user.email)) { + sendTo = user.email; + } + // inform user of membership by email + if (currentUserName && currentUserTeamName) { + const inviteTeamOptions = { + joinLink: `${WEBAPP_URL}/auth/login?callbackUrl=/settings/teams`, + isCalcomMember: true, + }; + /** + * Here we want to redirect to a different place if onboarding has been completed or not. This prevents the flash of going to teams -> Then to onboarding - also show a different email template. + * This only changes if the user is a CAL user and has not completed onboarding and has no password + */ + if (!user.completedOnboarding && !user.password && user.identityProvider === "CAL") { + const token = randomBytes(32).toString("hex"); + await prisma.verificationToken.create({ + data: { + identifier: user.email, + token, + expires: new Date(new Date().setHours(168)), // +1 week + team: { + connect: { + id: teamId, + }, + }, + }, + }); + + inviteTeamOptions.joinLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`; + inviteTeamOptions.isCalcomMember = false; + } + + return sendTeamInviteEmail({ + language, + from: currentUserName, + to: sendTo, + teamName: currentUserTeamName, + ...inviteTeamOptions, + isOrg: isOrg, + }); + } + }); + + await sendEmails(sendEmailsPromises); +};