fix: invite member by username (#12591)

* fix: invite member by username

* fixup! fix: invite member by username
This commit is contained in:
Morgan
2023-11-29 19:57:51 +02:00
committed by GitHub
parent 877cd4cdff
commit ea8437b4f7
4 changed files with 78 additions and 44 deletions
@@ -9,7 +9,7 @@ import type { TInviteMemberInputSchema } from "./inviteMember.schema";
import {
checkPermissions,
getTeamOrThrow,
getEmailsToInvite,
getUsernameOrEmailsToInvite,
getOrgConnectionInfo,
getIsOrgVerified,
sendVerificationEmail,
@@ -42,43 +42,54 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
const team = await getTeamOrThrow(input.teamId, input.isOrg);
const { autoAcceptEmailDomain, orgVerified } = getIsOrgVerified(input.isOrg, team);
const emailsToInvite = await getEmailsToInvite(input.usernameOrEmail);
const orgConnectInfoByEmail = emailsToInvite.reduce((acc, email) => {
const usernameOrEmailsToInvite = await getUsernameOrEmailsToInvite(input.usernameOrEmail);
const orgConnectInfoByUsernameOrEmail = usernameOrEmailsToInvite.reduce((acc, usernameOrEmail) => {
return {
...acc,
[email]: getOrgConnectionInfo({
[usernameOrEmail]: getOrgConnectionInfo({
orgVerified,
orgAutoAcceptDomain: autoAcceptEmailDomain,
usersEmail: email,
usersEmail: usernameOrEmail,
team,
isOrg: input.isOrg,
}),
};
}, {} as Record<string, ReturnType<typeof getOrgConnectionInfo>>);
const existingUsersWithMembersips = await getUsersToInvite({
usernameOrEmail: emailsToInvite,
usernamesOrEmails: usernameOrEmailsToInvite,
isInvitedToOrg: input.isOrg,
team,
});
const existingUsersEmails = existingUsersWithMembersips.map((user) => user.email);
const newUsersEmails = emailsToInvite.filter((email) => !existingUsersEmails.includes(email));
const existingUsersEmailsAndUsernames = existingUsersWithMembersips.reduce(
(acc, user) => ({
emails: user.email ? [...acc.emails, user.email] : acc.emails,
usernames: user.username ? [...acc.usernames, user.username] : acc.usernames,
}),
{ emails: [], usernames: [] } as { emails: string[]; usernames: string[] }
);
const newUsersEmailsOrUsernames = usernameOrEmailsToInvite.filter(
(usernameOrEmail) =>
!existingUsersEmailsAndUsernames.emails.includes(usernameOrEmail) &&
!existingUsersEmailsAndUsernames.usernames.includes(usernameOrEmail)
);
// deal with users to create and invite to team/org
if (newUsersEmails.length) {
if (newUsersEmailsOrUsernames.length) {
await createNewUsersConnectToOrgIfExists({
usernamesOrEmails: newUsersEmails,
usernamesOrEmails: newUsersEmailsOrUsernames,
input,
connectionInfoMap: orgConnectInfoByEmail,
connectionInfoMap: orgConnectInfoByUsernameOrEmail,
autoAcceptEmailDomain,
parentId: team.parentId,
});
const sendVerifEmailsPromises = newUsersEmails.map((usernameOrEmail) => {
const sendVerifEmailsPromises = newUsersEmailsOrUsernames.map((usernameOrEmail) => {
return sendVerificationEmail({
usernameOrEmail,
team,
translation,
ctx,
input,
connectionInfo: orgConnectInfoByEmail[usernameOrEmail],
connectionInfo: orgConnectInfoByUsernameOrEmail[usernameOrEmail],
});
});
sendEmails(sendVerifEmailsPromises);
@@ -2,8 +2,6 @@ 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
@@ -14,27 +12,26 @@ export const ZInviteMemberInputSchema = z.object({
}
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.`,
});
.refine(
(value) => {
if (Array.isArray(value)) {
if (value.length > 100) {
return false;
}
}
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;
}),
return true;
},
{ message: "You are limited to inviting a maximum of 100 users at once." }
)
.refine(
(value) => {
if (Array.isArray(value)) {
return !value.some((email) => !z.string().email().safeParse(email).success);
}
return true;
},
{ message: "Bulk invitations are restricted to email addresses only." }
),
role: z.nativeEnum(MembershipRole),
language: z.string(),
isOrg: z.boolean().default(false),
@@ -10,11 +10,12 @@ import type { TeamWithParent } from "./types";
import type { Invitee, UserWithMembership } from "./utils";
import {
checkPermissions,
getEmailsToInvite,
getUsernameOrEmailsToInvite,
getIsOrgVerified,
getOrgConnectionInfo,
validateInviteeEligibility,
shouldAutoJoinIfInOrg,
checkInputEmailIsValid,
} from "./utils";
vi.mock("@calcom/lib/server/queries", () => {
@@ -101,22 +102,35 @@ describe("Invite Member Utils", () => {
await expect(checkPermissions({ userId: 1, teamId: 1 })).resolves.not.toThrow();
});
});
describe("getEmailsToInvite", () => {
describe("getUsernameOrEmailsToInvite", () => {
it("should throw a TRPCError with code BAD_REQUEST if no emails are provided", async () => {
await expect(getEmailsToInvite([])).rejects.toThrow(TRPCError);
await expect(getUsernameOrEmailsToInvite([])).rejects.toThrow(TRPCError);
});
it("should return an array with one email if a string is provided", async () => {
const result = await getEmailsToInvite("test@example.com");
const result = await getUsernameOrEmailsToInvite("test@example.com");
expect(result).toEqual(["test@example.com"]);
});
it("should return an array with multiple emails if an array is provided", async () => {
const result = await getEmailsToInvite(["test1@example.com", "test2@example.com"]);
const result = await getUsernameOrEmailsToInvite(["test1@example.com", "test2@example.com"]);
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";
@@ -46,6 +46,14 @@ export async function checkPermissions({
}
}
export function checkInputEmailIsValid(email: string) {
if (!isEmail(email))
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invite failed because ${email} is not a valid email address`,
});
}
export async function getTeamOrThrow(teamId: number, isOrg?: boolean) {
const team = await prisma.team.findFirst({
where: {
@@ -62,7 +70,7 @@ export async function getTeamOrThrow(teamId: number, isOrg?: boolean) {
return team;
}
export async function getEmailsToInvite(usernameOrEmail: string | string[]) {
export async function getUsernameOrEmailsToInvite(usernameOrEmail: string | string[]) {
const emailsToInvite = Array.isArray(usernameOrEmail)
? Array.from(new Set(usernameOrEmail))
: [usernameOrEmail];
@@ -128,11 +136,11 @@ export function validateInviteeEligibility(
}
export async function getUsersToInvite({
usernameOrEmail,
usernamesOrEmails,
isInvitedToOrg,
team,
}: {
usernameOrEmail: string[];
usernamesOrEmails: string[];
isInvitedToOrg: boolean;
team: TeamWithParent;
}): Promise<UserWithMembership[]> {
@@ -149,7 +157,7 @@ export async function getUsersToInvite({
const invitees: UserWithMembership[] = await prisma.user.findMany({
where: {
OR: [{ username: { in: usernameOrEmail }, ...orgWhere }, { email: { in: usernameOrEmail } }],
OR: [{ username: { in: usernamesOrEmails }, ...orgWhere }, { email: { in: usernamesOrEmails } }],
},
select: {
id: true,
@@ -217,6 +225,10 @@ export async function createNewUsersConnectToOrgIfExists({
autoAcceptEmailDomain?: string;
connectionInfoMap: Record<string, ReturnType<typeof getOrgConnectionInfo>>;
}) {
// fail if we have invalid emails
usernamesOrEmails.forEach((usernameOrEmail) => checkInputEmailIsValid(usernameOrEmail));
// from this point we know usernamesOrEmails contains only emails
await prisma.$transaction(
async (tx) => {
for (let index = 0; index < usernamesOrEmails.length; index++) {