refactor: invite members handler (#12442)
* refactor: invite members handler * fixup! refactor: invite members handler * fixup! fixup! refactor: invite members handler * refactor: promise all settled send emails * fixup! refactor: promise all settled send emails * fixup! fixup! refactor: promise all settled send emails * fixup! fixup! fixup! refactor: promise all settled send emails * fix: opening team invite link in email throws error on signup page * fixup! Merge branch 'main' into cal-2698-refactor-invitemember-handler * fix: centralize validation if invitee can be invited * fix: improve select query and fix tests * fixup! Merge branch 'main' into cal-2698-refactor-invitemember-handler * rename functions and add some tests
This commit is contained in:
1 parent
7b350a5b8f
commit
2094d59856
4 files changed
+459
-314
No files matched your search
@@ -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,
|
||||
});
|
||||
|
||||
if (!invitee) {
|
||||
checkInputEmailIsValid(usernameOrEmail);
|
||||
|
||||
// valid email given, create User and add to team
|
||||
await createNewUserConnectToOrgIfExists({
|
||||
usernameOrEmail,
|
||||
input,
|
||||
connectionInfo,
|
||||
autoAcceptEmailDomain,
|
||||
parentId: team.parentId,
|
||||
});
|
||||
|
||||
await sendVerificationEmail({ usernameOrEmail, team, translation, ctx, input, connectionInfo });
|
||||
} else {
|
||||
throwIfInviteIsToOrgAndUserExists(invitee, team, input.isOrg);
|
||||
|
||||
const shouldAutoJoinOrgTeam = await createAndAutoJoinIfInOrg({
|
||||
invitee,
|
||||
role: input.role,
|
||||
const orgConnectInfoByEmail = emailsToInvite.reduce((acc, email) => {
|
||||
return {
|
||||
...acc,
|
||||
[email]: getOrgConnectionInfo({
|
||||
orgVerified,
|
||||
orgAutoAcceptDomain: autoAcceptEmailDomain,
|
||||
usersEmail: email,
|
||||
team,
|
||||
});
|
||||
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({
|
||||
isOrg: input.isOrg,
|
||||
}),
|
||||
};
|
||||
}, {} as Record<string, ReturnType<typeof getOrgConnectionInfo>>);
|
||||
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,
|
||||
});
|
||||
const sendVerifEmailsPromises = newUsersEmails.map((usernameOrEmail) => {
|
||||
return sendVerificationEmail({
|
||||
usernameOrEmail,
|
||||
team,
|
||||
translation,
|
||||
ctx,
|
||||
input,
|
||||
invitee,
|
||||
connectionInfo: orgConnectInfoByEmail[usernameOrEmail],
|
||||
});
|
||||
});
|
||||
sendEmails(sendVerifEmailsPromises);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// deal with existing users invited to join the team/org
|
||||
if (existingUsersWithMembersips.length) {
|
||||
const [autoJoinUsers, regularUsers] = groupUsersByJoinability({
|
||||
existingUsersWithMembersips,
|
||||
team,
|
||||
});
|
||||
|
||||
inviteTeamOptions.joinLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`;
|
||||
inviteTeamOptions.isCalcomMember = false;
|
||||
}
|
||||
// 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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
await sendTeamInviteEmail({
|
||||
language: translation,
|
||||
from: ctx.user.name,
|
||||
to: sendTo,
|
||||
teamName: team.name,
|
||||
...inviteTeamOptions,
|
||||
isOrg: input.isOrg,
|
||||
});
|
||||
}
|
||||
// invited users cannot autojoin, create provisional memberships and send email
|
||||
if (regularUsers.length) {
|
||||
await createProvisionalMemberships({
|
||||
input,
|
||||
invitees: regularUsers,
|
||||
});
|
||||
await sendTeamInviteEmails({
|
||||
currentUserName: ctx?.user?.name,
|
||||
currentUserTeamName: team?.name,
|
||||
existingUsersWithMembersips: regularUsers,
|
||||
language: translation,
|
||||
isOrg: input.isOrg,
|
||||
teamId: team.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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({ autoJoined: false });
|
||||
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(true);
|
||||
});
|
||||
// TODO: Add test for when the user is already a member of the organization - need to mock prisma response value
|
||||
});
|
||||
});
|
||||
@@ -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<Membership, "userId" | "teamId" | "accepted">[];
|
||||
};
|
||||
|
||||
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
|
||||
|
||||
const orgWhere = isOrg && {
|
||||
organizationId: teamId,
|
||||
};
|
||||
const invitee = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ username: usernameOrEmail, ...orgWhere }, { email: usernameOrEmail }],
|
||||
},
|
||||
});
|
||||
|
||||
// We throw on error cause we can't have two users in the same org with the same username
|
||||
if (isOrg && invitee) {
|
||||
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: "NOT_FOUND",
|
||||
message: `Email ${usernameOrEmail} already exists, you can't invite existing users.`,
|
||||
code: "BAD_REQUEST",
|
||||
message: `${invitee.email} has already been invited.`,
|
||||
});
|
||||
}
|
||||
|
||||
return invitee;
|
||||
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;
|
||||
}
|
||||
|
||||
// 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: "FORBIDDEN",
|
||||
message: `User ${invitee.username} needs to accept the invitation to join your organization first.`,
|
||||
});
|
||||
}
|
||||
|
||||
// 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.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (invitee && isOrg) {
|
||||
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.`,
|
||||
});
|
||||
}
|
||||
|
||||
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 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 getUsersToInvite({
|
||||
usernameOrEmail,
|
||||
isInvitedToOrg,
|
||||
team,
|
||||
}: {
|
||||
usernameOrEmail: string[];
|
||||
isInvitedToOrg: boolean;
|
||||
team: TeamWithParent;
|
||||
}): Promise<UserWithMembership[]> {
|
||||
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<typeof getOrgConnectionInfo>;
|
||||
connectionInfoMap: Record<string, ReturnType<typeof getOrgConnectionInfo>>;
|
||||
}) {
|
||||
const { orgId, autoAccept } = connectionInfo;
|
||||
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 [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
|
||||
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<void>[]) => {
|
||||
const sentEmails = await Promise.allSettled(emailPromises);
|
||||
sentEmails.forEach((sentEmail) => {
|
||||
if (sentEmail.status === "rejected") {
|
||||
logger.error("Could not send email to user");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
autoJoined: true,
|
||||
};
|
||||
}
|
||||
await sendEmails(sendEmailsPromises);
|
||||
};
|
||||
Reference in new issue
Block a user