Skip to content
This repository has been archived by the owner on Jan 23, 2024. It is now read-only.

Commit

Permalink
refactor: invite members handler (calcom#12442)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ThyMinimalDev authored Nov 27, 2023
1 parent 7b350a5 commit 2094d59
Show file tree
Hide file tree
Showing 4 changed files with 453 additions and 308 deletions.
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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:
Expand All @@ -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,
const orgConnectInfoByEmail = emailsToInvite.reduce((acc, email) => {
return {
...acc,
[email]: getOrgConnectionInfo({
orgVerified,
orgAutoAcceptDomain: autoAcceptEmailDomain,
usersEmail: email,
team,
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,
});

if (!invitee) {
checkInputEmailIsValid(usernameOrEmail);

// valid email given, create User and add to team
await createNewUserConnectToOrgIfExists({
const sendVerifEmailsPromises = newUsersEmails.map((usernameOrEmail) => {
return sendVerificationEmail({
usernameOrEmail,
team,
translation,
ctx,
input,
connectionInfo,
autoAcceptEmailDomain,
parentId: team.parentId,
connectionInfo: orgConnectInfoByEmail[usernameOrEmail],
});
});
sendEmails(sendVerifEmailsPromises);
}

await sendVerificationEmail({ usernameOrEmail, team, translation, ctx, input, connectionInfo });
} else {
throwIfInviteIsToOrgAndUserExists(invitee, team, input.isOrg);
// deal with existing users invited to join the team/org
if (existingUsersWithMembersips.length) {
const [autoJoinUsers, regularUsers] = groupUsersByJoinability({
existingUsersWithMembersips,
team,
});

const shouldAutoJoinOrgTeam = await createAndAutoJoinIfInOrg({
invitee,
role: input.role,
team,
// 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,
})),
});
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({
// invited users cannot autojoin, create provisional memberships and send email
if (regularUsers.length) {
await createProvisionalMemberships({
input,
invitee,
invitees: regularUsers,
});
await sendTeamInviteEmails({
currentUserName: ctx?.user?.name,
currentUserTeamName: team?.name,
existingUsersWithMembersips: regularUsers,
language: translation,
isOrg: input.isOrg,
teamId: team.id,
});

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,
},
},
},
});

inviteTeamOptions.joinLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`;
inviteTeamOptions.isCalcomMember = false;
}

await sendTeamInviteEmail({
language: translation,
from: ctx.user.name,
to: sendTo,
teamName: team.name,
...inviteTeamOptions,
isOrg: input.isOrg,
});
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading

0 comments on commit 2094d59

Please sign in to comment.