fix: invite member by username (#12591)
* fix: invite member by username * fixup! fix: invite member by username
This commit is contained in:
@@ -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++) {
|
||||
|
||||
Reference in New Issue
Block a user