From 5becc95fc430d327d4b485246678541210fa06cd Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Sat, 20 Jul 2024 12:22:42 +0530 Subject: [PATCH] feat: Add a fresh signup to matching organization after email verification (#15705) * Support automatically adding user to organization * Use Repository * Fix unit test * Code Improvements * Fix type error --------- Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> --- apps/api/v1/pages/api/invites/_post.ts | 1 - apps/web/pages/api/auth/verify-email.test.ts | 82 ++++++++++++ apps/web/pages/api/auth/verify-email.ts | 27 ++++ .../ee/teams/components/AddNewTeamMembers.tsx | 1 - .../UserTable/InviteMemberModal.tsx | 1 - .../repository/__mocks__/organization.ts | 36 ++++++ .../server/repository/organization.test.ts | 105 +++++++++++++++ .../lib/server/repository/organization.ts | 22 ++++ .../organizations/createTeams.handler.ts | 1 - .../__mocks__/inviteMemberUtils.ts | 3 +- .../inviteMember/inviteMember.handler.test.ts | 30 +++-- .../inviteMember/inviteMember.handler.ts | 121 ++++++++++++------ .../teams/inviteMember/inviteMember.schema.ts | 1 - .../viewer/teams/inviteMember/utils.ts | 72 ++++++----- 14 files changed, 419 insertions(+), 84 deletions(-) create mode 100644 apps/web/pages/api/auth/verify-email.test.ts create mode 100644 packages/lib/server/repository/__mocks__/organization.ts create mode 100644 packages/lib/server/repository/organization.test.ts diff --git a/apps/api/v1/pages/api/invites/_post.ts b/apps/api/v1/pages/api/invites/_post.ts index 4fe81569bf..7bc2a18d74 100644 --- a/apps/api/v1/pages/api/invites/_post.ts +++ b/apps/api/v1/pages/api/invites/_post.ts @@ -43,7 +43,6 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) { language: data.language, teamId: data.teamId, usernameOrEmail: data.usernameOrEmail, - isOrg: data.isOrg, }); return { success: true, message: `${data.usernameOrEmail} has been invited.` }; diff --git a/apps/web/pages/api/auth/verify-email.test.ts b/apps/web/pages/api/auth/verify-email.test.ts new file mode 100644 index 0000000000..12532aff89 --- /dev/null +++ b/apps/web/pages/api/auth/verify-email.test.ts @@ -0,0 +1,82 @@ +import { organizationScenarios } from "@calcom/lib/server/repository/__mocks__/organization"; + +import { describe, it, expect, beforeEach, vi } from "vitest"; + +import { MembershipRole } from "@calcom/prisma/client"; +import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler"; + +import { moveUserToMatchingOrg } from "./verify-email"; + +vi.mock("@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler"); +vi.mock("@calcom/lib/server/repository/organization"); + +describe("moveUserToMatchingOrg", () => { + const email = "test@example.com"; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("should not proceed if no matching organization is found", async () => { + organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeNoMatch(); + + await moveUserToMatchingOrg({ email }); + + expect(inviteMembersWithNoInviterPermissionCheck).not.toHaveBeenCalled(); + }); + + describe("should invite user to the matching organization", () => { + const argToInviteMembersWithNoInviterPermissionCheck = { + inviterName: null, + language: "en", + invitations: [ + { + usernameOrEmail: email, + role: MembershipRole.MEMBER, + }, + ], + }; + + it("when organization has a slug and requestedSlug(slug is used)", async () => { + const org = { + id: "org123", + slug: "test-org", + requestedSlug: "requested-test-org", + }; + + organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeReturnOrganization( + org, + { email } + ); + + await moveUserToMatchingOrg({ email }); + + expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith({ + ...argToInviteMembersWithNoInviterPermissionCheck, + teamId: org.id, + orgSlug: org.slug, + }); + }); + + it("when organization has requestedSlug only", async () => { + const org = { + id: "org123", + slug: null, + requestedSlug: "requested-test-org", + }; + + organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeReturnOrganization( + org, + { email } + ); + + await moveUserToMatchingOrg({ email }); + + expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith({ + ...argToInviteMembersWithNoInviterPermissionCheck, + teamId: org.id, + orgSlug: org.requestedSlug, + }); + }); + }); +}); diff --git a/apps/web/pages/api/auth/verify-email.ts b/apps/web/pages/api/auth/verify-email.ts index 21797cf9f4..0405c3226b 100644 --- a/apps/web/pages/api/auth/verify-email.ts +++ b/apps/web/pages/api/auth/verify-email.ts @@ -5,8 +5,11 @@ import stripe from "@calcom/app-store/stripepayment/lib/server"; import dayjs from "@calcom/dayjs"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { IS_STRIPE_ENABLED } from "@calcom/lib/constants"; +import { OrganizationRepository } from "@calcom/lib/server/repository/organization"; import { prisma } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/client"; import { userMetadata } from "@calcom/prisma/zod-utils"; +import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler"; const verifySchema = z.object({ token: z.string(), @@ -14,6 +17,28 @@ const verifySchema = z.object({ const USER_ALREADY_EXISTING_MESSAGE = "A User already exists with this email"; +// TODO: To be unit tested +export async function moveUserToMatchingOrg({ email }: { email: string }) { + const org = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({ email }); + + if (!org) { + return; + } + + await inviteMembersWithNoInviterPermissionCheck({ + inviterName: null, + teamId: org.id, + language: "en", + invitations: [ + { + usernameOrEmail: email, + role: MembershipRole.MEMBER, + }, + ], + orgSlug: org.slug || org.requestedSlug, + }); +} + export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { token } = verifySchema.parse(req.query); @@ -141,6 +166,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const hasCompletedOnboarding = user.completedOnboarding; + await moveUserToMatchingOrg({ email: user.email }); + return res.redirect(`${WEBAPP_URL}/${hasCompletedOnboarding ? "/event-types" : "/getting-started"}`); } diff --git a/packages/features/ee/teams/components/AddNewTeamMembers.tsx b/packages/features/ee/teams/components/AddNewTeamMembers.tsx index 446e6f9cca..0346ccd0ca 100644 --- a/packages/features/ee/teams/components/AddNewTeamMembers.tsx +++ b/packages/features/ee/teams/components/AddNewTeamMembers.tsx @@ -143,7 +143,6 @@ export const AddNewTeamMembersForm = ({ language: i18n.language, role: values.role, usernameOrEmail: values.emailOrUsername, - isOrg: !!isOrg, }, { onSuccess: async (data) => { diff --git a/packages/features/users/components/UserTable/InviteMemberModal.tsx b/packages/features/users/components/UserTable/InviteMemberModal.tsx index 05219fdff1..4330373659 100644 --- a/packages/features/users/components/UserTable/InviteMemberModal.tsx +++ b/packages/features/users/components/UserTable/InviteMemberModal.tsx @@ -68,7 +68,6 @@ export function InviteMemberModal(props: Props) { language: i18n.language, role: values.role, usernameOrEmail: values.emailOrUsername, - isOrg: true, }); }} /> diff --git a/packages/lib/server/repository/__mocks__/organization.ts b/packages/lib/server/repository/__mocks__/organization.ts new file mode 100644 index 0000000000..16bac666dd --- /dev/null +++ b/packages/lib/server/repository/__mocks__/organization.ts @@ -0,0 +1,36 @@ +import { vi, beforeEach } from "vitest"; +import { mockReset, mockDeep } from "vitest-mock-extended"; + +import type * as organization from "@calcom/lib/server/repository/organization"; + +vi.mock("@calcom/lib/server/repository/organization", () => organizationMock); +type OrganizationModule = typeof organization; +beforeEach(() => { + mockReset(organizationMock); +}); + +const organizationMock = mockDeep(); +const OrganizationRepository = organizationMock.OrganizationRepository; + +export const organizationScenarios = { + OrganizationRepository: { + findUniqueByMatchingAutoAcceptEmail: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fakeReturnOrganization: (org: any, forInput: any) => { + OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.mockImplementation((arg) => { + if (forInput.email === arg.email) { + return org; + } + const errorMsg = "Mock Error-fakeReturnOrganization: Unhandled input"; + console.log(errorMsg, { arg, forInput }); + throw new Error(errorMsg); + }); + }, + fakeNoMatch: () => { + OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.mockResolvedValue(null); + }, + }, + } satisfies Partial>, +} satisfies Partial>; + +export default organizationMock; diff --git a/packages/lib/server/repository/organization.test.ts b/packages/lib/server/repository/organization.test.ts new file mode 100644 index 0000000000..d59df04ab3 --- /dev/null +++ b/packages/lib/server/repository/organization.test.ts @@ -0,0 +1,105 @@ +import prismock from "../../../../tests/libs/__mocks__/prisma"; + +import { describe, it, expect, beforeEach, vi } from "vitest"; + +import { OrganizationRepository } from "./organization"; + +vi.mock("./teamUtils", () => ({ + getParsedTeam: (org: any) => org, +})); + +describe("Organization.findUniqueByMatchingAutoAcceptEmail", () => { + beforeEach(async () => { + vi.resetAllMocks(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await prismock.reset(); + }); + + it("should return null if no organization matches the email domain", async () => { + const result = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({ + email: "test@example.com", + }); + + expect(result).toBeNull(); + }); + + it("should throw an error if multiple organizations match the email domain", async () => { + await createOrganization({ name: "Test Org 1", orgAutoAcceptEmail: "example.com" }); + await createOrganization({ name: "Test Org 2", orgAutoAcceptEmail: "example.com" }); + + await expect( + OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({ email: "test@example.com" }) + ).rejects.toThrow("Multiple organizations found with the same auto accept email domain"); + }); + + it("should return the parsed organization if a single match is found", async () => { + const organization = await createOrganization({ name: "Test Org", orgAutoAcceptEmail: "example.com" }); + + const result = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({ + email: "test@example.com", + }); + + expect(result).toEqual(organization); + }); + + it("should not confuse a team with organization", async () => { + await createTeam({ name: "Test Team", orgAutoAcceptEmail: "example.com" }); + + const result = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({ + email: "test@example.com", + }); + + expect(result).toEqual(null); + }); + + it("should correctly match orgAutoAcceptEmail", async () => { + await createOrganization({ name: "Test Org", orgAutoAcceptEmail: "noexample.com" }); + + const result = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({ + email: "test@example.com", + }); + + expect(result).toEqual(null); + }); +}); + +async function createOrganization({ + name = "Test Org", + orgAutoAcceptEmail, +}: { + name: string; + orgAutoAcceptEmail: string; +}) { + return await prismock.team.create({ + data: { + name, + isOrganization: true, + organizationSettings: { + create: { + orgAutoAcceptEmail, + }, + }, + }, + }); +} + +async function createTeam({ + name = "Test Team", + orgAutoAcceptEmail, +}: { + name: string; + orgAutoAcceptEmail: string; +}) { + return await prismock.team.create({ + data: { + name, + isOrganization: false, + organizationSettings: { + create: { + orgAutoAcceptEmail, + }, + }, + }, + }); +} diff --git a/packages/lib/server/repository/organization.ts b/packages/lib/server/repository/organization.ts index a4929a95f2..26fa8a97d9 100644 --- a/packages/lib/server/repository/organization.ts +++ b/packages/lib/server/repository/organization.ts @@ -6,6 +6,7 @@ import { prisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; import { createAProfileForAnExistingUser } from "../../createAProfileForAnExistingUser"; +import { getParsedTeam } from "./teamUtils"; import { UserRepository } from "./user"; const orgSelect = { @@ -163,4 +164,25 @@ export class OrganizationRepository { }, }); } + + static async findUniqueByMatchingAutoAcceptEmail({ email }: { email: string }) { + const emailDomain = email.split("@").at(-1); + const orgs = await prisma.team.findMany({ + where: { + isOrganization: true, + organizationSettings: { + orgAutoAcceptEmail: emailDomain, + }, + }, + }); + if (orgs.length > 1) { + // Detect and fail just in case this situation arises. We should really identify the problem in this case and fix the data. + throw new Error("Multiple organizations found with the same auto accept email domain"); + } + const org = orgs[0]; + if (!org) { + return null; + } + return getParsedTeam(org); + } } diff --git a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts index 98e6e389e6..1c45f3e5dc 100644 --- a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts @@ -240,7 +240,6 @@ async function moveTeam({ teamId: org.id, language: "en", usernameOrEmail: invitableMembers, - isOrg: true, }, }); } diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/__mocks__/inviteMemberUtils.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/__mocks__/inviteMemberUtils.ts index 7029f390cd..653bb2bd94 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/__mocks__/inviteMemberUtils.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/__mocks__/inviteMemberUtils.ts @@ -33,7 +33,8 @@ export const inviteMemberutilsScenarios = { if (forInput.teamId === teamId) { return fakedVal; } - throw new Error("Mock Error: Unhandled input"); + console.log("Mock Error: Unhandled input", { teamId }); + throw new Error(`Mock Error: Unhandled input. teamId: ${teamId}`); }); return fakedVal; }, 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 index 50892e931e..defa5612dd 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.test.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.test.ts @@ -160,12 +160,17 @@ describe("inviteMemberHandler", () => { }, }; + const inviter = { + name: loggedInUser.name, + }; + expect(inviteMemberUtilsMock.handleNewUsersInvites).toHaveBeenCalledWith({ invitationsForNewUsers: allExpectedInvitations, team: retValueOfGetTeamOrThrowError, orgConnectInfoByUsernameOrEmail: expectedConnectionInfoMap, - input, - inviter: loggedInUser, + teamId: input.teamId, + language: input.language, + inviter, autoAcceptEmailDomain: null, }); @@ -185,7 +190,7 @@ describe("inviteMemberHandler", () => { ]); // Assert the result expect(result).toEqual({ - ...input, + usernameOrEmail: input.usernameOrEmail, numUsersInvited: 2, }); }); @@ -210,7 +215,6 @@ describe("inviteMemberHandler", () => { const input = { teamId: 1, role: MembershipRole.MEMBER, - isOrg: false, language: "en", usernameOrEmail: usersToBeInvited.map((u) => u.email), }; @@ -219,6 +223,7 @@ describe("inviteMemberHandler", () => { id: input.teamId, name: "Team 1", parent: null, + isOrganization: false, }; const retValueOfGetTeamOrThrowError = inviteMemberUtilsScenarios.getTeamOrThrow.fakeReturnTeam(team, { @@ -266,30 +271,37 @@ describe("inviteMemberHandler", () => { autoAccept: false, }, }; + const inviter = { + name: loggedInUser.name, + }; expect(inviteMemberUtilsMock.handleNewUsersInvites).toHaveBeenCalledWith({ invitationsForNewUsers: allExpectedInvitations.slice(1), team: retValueOfGetTeamOrThrowError, orgConnectInfoByUsernameOrEmail: expectedConnectionInfoMap, - input, - inviter: loggedInUser, + teamId: input.teamId, + language: input.language, + inviter, autoAcceptEmailDomain: null, + isOrg: false, }); expect(paymentsMock.updateQuantitySubscriptionFromStripe).toHaveBeenCalledWith(input.teamId); expect(inviteMemberUtilsMock.handleExistingUsersInvites).toHaveBeenCalledWith({ invitableExistingUsers: retValueOfFindUsersWithInviteStatus, - input: input, - inviter: loggedInUser, + teamId: input.teamId, + language: input.language, + inviter, orgConnectInfoByUsernameOrEmail: expectedConnectionInfoMap, orgSlug: null, team: retValueOfGetTeamOrThrowError, + isOrg: false, }); // Assert the result expect(result).toEqual({ - ...input, + usernameOrEmail: input.usernameOrEmail, numUsersInvited: 2, }); }); 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 1e88534d5d..5c85ed4a4d 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts @@ -121,34 +121,31 @@ function buildInvitationsFromInput({ }); } -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); +type TargetTeam = + | { + teamId: number; + } + | { + team: TeamWithParent; + }; +export const inviteMembersWithNoInviterPermissionCheck = async ( + data: { + // TODO: Remove `input` and instead pass the required fields directly + language: string; + inviterName: string | null; + orgSlug: string | null; + invitations: { + usernameOrEmail: string; + role: MembershipRole; + }[]; + } & TargetTeam +) => { + const { inviterName, orgSlug, invitations, language } = data; + const myLog = log.getSubLogger({ prefix: ["inviteMembers"] }); + const translation = await getTranslation(language ?? "en", "common"); + const team = "team" in data ? data.team : await getTeamOrThrow(data.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); @@ -179,13 +176,17 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = uniqueInvitations, }); + const inviter = { name: inviterName }; + if (invitationsForNewUsers.length) { await handleNewUsersInvites({ invitationsForNewUsers, team, orgConnectInfoByUsernameOrEmail, - input, - inviter: ctx.user, + teamId: team.id, + language, + isOrg: isTeamAnOrg, + inviter, autoAcceptEmailDomain: orgState.autoAcceptEmailDomain, }); } @@ -207,36 +208,76 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = ); if (invitableExistingUsers.length) { - const organization = ctx.user.profile.organization; - const orgSlug = organization ? organization.slug || organization.requestedSlug : null; await handleExistingUsersInvites({ invitableExistingUsers, team, orgConnectInfoByUsernameOrEmail, - input, - inviter: ctx.user, + teamId: team.id, + language, + isOrg: isTeamAnOrg, + inviter, orgSlug, }); } if (IS_TEAM_BILLING_ENABLED) { - await updateQuantitySubscriptionFromStripe(team.parentId ?? input.teamId); + await updateQuantitySubscriptionFromStripe(team.parentId ?? team.id); } return { - ...input, + // TODO: Better rename it to invitations only maybe? + usernameOrEmail: + invitations.length == 1 + ? invitations[0].usernameOrEmail + : invitations.map((invitation) => invitation.usernameOrEmail), numUsersInvited: invitableExistingUsers.length + invitationsForNewUsers.length, }; +}; + +const inviteMembers = async ({ ctx, input }: InviteMemberOptions) => { + const { user: inviter } = ctx; + + const inviterOrg = inviter.organization; + const team = await getTeamOrThrow(input.teamId); + const isTeamAnOrg = team.isOrganization; + + const invitations = buildInvitationsFromInput({ + usernameOrEmail: input.usernameOrEmail, + roleForAllInvitees: input.role, + }); + const isAddingNewOwner = !!invitations.find((invitation) => invitation.role === MembershipRole.OWNER); + + if (isTeamAnOrg) { + await throwIfInviterCantAddOwnerToOrg(); + } + + await ensureAtleastAdminPermissions({ + userId: inviter.id, + teamId: inviterOrg.id && inviterOrg.isOrgAdmin ? inviterOrg.id : input.teamId, + isOrg: isTeamAnOrg, + }); + + const organization = inviter.profile.organization; + const orgSlug = organization ? organization.slug || organization.requestedSlug : null; + const result = await inviteMembersWithNoInviterPermissionCheck({ + inviterName: inviter.name, + team, + language: input.language, + orgSlug, + invitations, + }); + return result; async function throwIfInviterCantAddOwnerToOrg() { - const isInviterOrgOwner = await isOrganisationOwner(ctx.user.id, input.teamId); + const isInviterOrgOwner = await isOrganisationOwner(inviter.id, input.teamId); if (isAddingNewOwner && !isInviterOrgOwner) throw new TRPCError({ code: "UNAUTHORIZED" }); } }; -async function handleSubscriptionUpdates(teamId: number) { - if (!IS_TEAM_BILLING_ENABLED) return; - await updateQuantitySubscriptionFromStripe(teamId); +export default async function inviteMemberHandler({ ctx, input }: InviteMemberOptions) { + const { user: inviter } = ctx; + await checkRateLimitAndThrowError({ + identifier: `invitedBy:${inviter.id}`, + }); + return await inviteMembers({ ctx, input }); } - -export default inviteMemberHandler; 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 c27329f839..70b9fb4796 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts @@ -55,7 +55,6 @@ export const ZInviteMemberInputSchema = z.object({ ), role: z.nativeEnum(MembershipRole).optional(), language: z.string(), - isOrg: z.boolean().default(false), }); export type TInviteMemberInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts index 4de3f883d0..b619f11678 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts @@ -25,7 +25,7 @@ import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@trpc/server"; import { isEmail } from "../util"; -import type { InviteMemberOptions, TeamWithParent } from "./types"; +import type { TeamWithParent } from "./types"; const log = logger.getSubLogger({ prefix: ["inviteMember.utils"] }); export type Invitee = Pick< @@ -44,8 +44,11 @@ export type Invitation = { role: MembershipRole; }; -type ExistingUserWithInviteStatus = Awaited>[number]; -type ExistingUserWithInviteStatusAndProfile = ExistingUserWithInviteStatus & { +type InvitableExistingUser = UserWithMembership & { + newRole: MembershipRole; +}; + +type InvitableExistingUserWithProfile = InvitableExistingUser & { profile: { username: string; } | null; @@ -361,19 +364,21 @@ export async function createNewUsersConnectToOrgIfExists({ } export async function createMemberships({ - input, + teamId, + language, invitees, parentId, accepted, }: { - input: Omit; - invitees: (ExistingUserWithInviteStatus & { + teamId: number; + language: string; + invitees: (InvitableExistingUser & { needToCreateOrgMembership: boolean | null; })[]; parentId: number | null; accepted: boolean; }) { - log.debug("Creating memberships for", safeStringify({ input, invitees, parentId, accepted })); + log.debug("Creating memberships for", safeStringify({ teamId, language, invitees, parentId, accepted })); try { await prisma.membership.createMany({ data: invitees.flatMap((invitee) => { @@ -381,7 +386,7 @@ export async function createMemberships({ const data = []; // membership for the team data.push({ - teamId: input.teamId, + teamId, userId: invitee.id, accepted, role: @@ -404,7 +409,7 @@ export async function createMemberships({ }); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { - logger.error("Failed to create memberships", input.teamId); + logger.error("Failed to create memberships", teamId); } else { throw e; } @@ -558,7 +563,7 @@ export const groupUsersByJoinability = ({ connectionInfoMap, }: { team: TeamWithParent; - existingUsersWithMemberships: ExistingUserWithInviteStatusAndProfile[]; + existingUsersWithMemberships: InvitableExistingUserWithProfile[]; connectionInfoMap: Record>; }) => { const usersToAutoJoin = []; @@ -608,7 +613,7 @@ export const sendExistingUserTeamInviteEmails = async ({ }: { language: TFunction; isAutoJoin: boolean; - existingUsersWithMemberships: Omit[]; + existingUsersWithMemberships: Omit[]; currentUserTeamName?: string; currentUserParentTeamName: string | undefined; currentUserName?: string | null; @@ -682,7 +687,6 @@ export const sendExistingUserTeamInviteEmails = async ({ type inviteMemberHandlerInput = { teamId: number; role?: "ADMIN" | "MEMBER" | "OWNER"; - isOrg: boolean; language: string; }; @@ -690,20 +694,24 @@ export async function handleExistingUsersInvites({ invitableExistingUsers, team, orgConnectInfoByUsernameOrEmail, - input, + teamId, + language, inviter, orgSlug, + isOrg, }: { - invitableExistingUsers: Awaited>; + invitableExistingUsers: InvitableExistingUser[]; team: TeamWithParent; orgConnectInfoByUsernameOrEmail: Record; - input: inviteMemberHandlerInput; + teamId: number; + language: string; inviter: { name: string | null; }; + isOrg: boolean; orgSlug: string | null; }) { - const translation = await getTranslation(input.language ?? "en", "common"); + const translation = await getTranslation(language, "common"); if (!team.isOrganization) { const [autoJoinUsers, regularUsers] = groupUsersByJoinability({ existingUsersWithMemberships: invitableExistingUsers.map((u) => { @@ -727,7 +735,8 @@ export async function handleExistingUsersInvites({ // invited users can autojoin, create their memberships in org if (autoJoinUsers.length) { await createMemberships({ - input, + teamId, + language, invitees: autoJoinUsers, parentId: team.parentId, accepted: true, @@ -744,7 +753,7 @@ export async function handleExistingUsersInvites({ currentUserTeamName: team?.name, existingUsersWithMemberships: autoJoinUsers, language: translation, - isOrg: input.isOrg, + isOrg: isOrg, teamId: team.id, isAutoJoin: true, currentUserParentTeamName: team?.parent?.name, @@ -755,7 +764,8 @@ export async function handleExistingUsersInvites({ // invited users cannot autojoin, create provisional memberships and send email if (regularUsers.length) { await createMemberships({ - input, + teamId, + language, invitees: regularUsers, parentId: team.parentId, accepted: false, @@ -765,7 +775,7 @@ export async function handleExistingUsersInvites({ currentUserTeamName: team?.name, existingUsersWithMemberships: regularUsers, language: translation, - isOrg: input.isOrg, + isOrg: isOrg, teamId: team.id, isAutoJoin: false, currentUserParentTeamName: team?.parent?.name, @@ -846,7 +856,7 @@ export async function handleExistingUsersInvites({ currentUserTeamName: team?.name, existingUsersWithMemberships: autoJoinUsers, language: translation, - isOrg: input.isOrg, + isOrg, teamId: team.id, isAutoJoin: true, currentUserParentTeamName: team?.parent?.name, @@ -859,7 +869,7 @@ export async function handleExistingUsersInvites({ currentUserTeamName: team?.name, existingUsersWithMemberships: regularUsers, language: translation, - isOrg: input.isOrg, + isOrg, teamId: team.id, isAutoJoin: false, currentUserParentTeamName: team?.parent?.name, @@ -872,25 +882,29 @@ export async function handleNewUsersInvites({ invitationsForNewUsers, team, orgConnectInfoByUsernameOrEmail, - input, + teamId, + language, + isOrg, autoAcceptEmailDomain, inviter, }: { invitationsForNewUsers: Invitation[]; - input: inviteMemberHandlerInput; + teamId: number; + language: string; orgConnectInfoByUsernameOrEmail: Record; autoAcceptEmailDomain: string | null; team: TeamWithParent; inviter: { name: string | null; }; + isOrg: boolean; }) { - const translation = await getTranslation(input.language ?? "en", "common"); + const translation = await getTranslation(language, "common"); await createNewUsersConnectToOrgIfExists({ invitations: invitationsForNewUsers, - isOrg: input.isOrg, - teamId: input.teamId, + isOrg, + teamId: teamId, orgConnectInfoByUsernameOrEmail, autoAcceptEmailDomain: autoAcceptEmailDomain, parentId: team.parentId, @@ -905,8 +919,8 @@ export async function handleNewUsersInvites({ }, translation, inviterName: inviter.name ?? "", - teamId: input.teamId, - isOrg: input.isOrg, + teamId, + isOrg, }); }); await sendEmails(sendVerifyEmailsPromises);