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>
This commit is contained in:
co-authored by
Udit Takkar
parent
a74522af3d
commit
5becc95fc4
@@ -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.` };
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +143,6 @@ export const AddNewTeamMembersForm = ({
|
||||
language: i18n.language,
|
||||
role: values.role,
|
||||
usernameOrEmail: values.emailOrUsername,
|
||||
isOrg: !!isOrg,
|
||||
},
|
||||
{
|
||||
onSuccess: async (data) => {
|
||||
|
||||
@@ -68,7 +68,6 @@ export function InviteMemberModal(props: Props) {
|
||||
language: i18n.language,
|
||||
role: values.role,
|
||||
usernameOrEmail: values.emailOrUsername,
|
||||
isOrg: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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<OrganizationModule>();
|
||||
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<Record<keyof OrganizationModule["OrganizationRepository"], unknown>>,
|
||||
} satisfies Partial<Record<keyof OrganizationModule, unknown>>;
|
||||
|
||||
export default organizationMock;
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +240,6 @@ async function moveTeam({
|
||||
teamId: org.id,
|
||||
language: "en",
|
||||
usernameOrEmail: invitableMembers,
|
||||
isOrg: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+2
-1
@@ -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;
|
||||
},
|
||||
|
||||
+21
-9
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof ZInviteMemberInputSchema>;
|
||||
|
||||
@@ -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<ReturnType<typeof findUsersWithInviteStatus>>[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<InviteMemberOptions["input"], "usernameOrEmail">;
|
||||
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<string, ReturnType<typeof getOrgConnectionInfo>>;
|
||||
}) => {
|
||||
const usersToAutoJoin = [];
|
||||
@@ -608,7 +613,7 @@ export const sendExistingUserTeamInviteEmails = async ({
|
||||
}: {
|
||||
language: TFunction;
|
||||
isAutoJoin: boolean;
|
||||
existingUsersWithMemberships: Omit<ExistingUserWithInviteStatusAndProfile, "canBeInvited" | "newRole">[];
|
||||
existingUsersWithMemberships: Omit<InvitableExistingUserWithProfile, "canBeInvited" | "newRole">[];
|
||||
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<ReturnType<typeof findUsersWithInviteStatus>>;
|
||||
invitableExistingUsers: InvitableExistingUser[];
|
||||
team: TeamWithParent;
|
||||
orgConnectInfoByUsernameOrEmail: Record<string, { orgId: number | undefined; autoAccept: boolean }>;
|
||||
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<string, { orgId: number | undefined; autoAccept: boolean }>;
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user