Files
cal.diy/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts
T
Hariom Balhara b7fda31678 fix: Issues observed when inviting members to organization either through teams migration during onboarding or explicit user invite (#22901)
* fix: make team migrations sequential to avoid race conditions

Instead of processing team migrations in parallel using Promise.all,
process them sequentially to avoid race conditions when creating
profiles and memberships for team members.

This simplifies the implementation and avoids the need for complex
retry logic or database-level concurrency controls.

* Promise.all accepts an array of promises and not an array with array in it as the first item. This fixes the issue where these promises were unhandled

* Fix the issue where an existing pending membership with team stays pending even though theorganization membership gets created as accepted
2025-08-13 10:48:17 +00:00

1007 lines
30 KiB
TypeScript

import { randomBytes } from "crypto";
import type { TFunction } from "i18next";
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
import { sendTeamInviteEmail } from "@calcom/emails";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
import { ENABLE_PROFILE_SWITCHER, WEBAPP_URL } from "@calcom/lib/constants";
import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries/teams";
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { getParsedTeam } from "@calcom/lib/server/repository/teamUtils";
import { UserRepository } from "@calcom/lib/server/repository/user";
import slugify from "@calcom/lib/slugify";
import { prisma } from "@calcom/prisma";
import type { Membership, OrganizationSettings, Team } from "@calcom/prisma/client";
import { type User as UserType, type UserPassword, Prisma } from "@calcom/prisma/client";
import type { Profile as ProfileType } from "@calcom/prisma/client";
import type { CreationSource } from "@calcom/prisma/enums";
import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import { isEmail } from "../util";
import type { TeamWithParent } from "./types";
const log = logger.getSubLogger({ prefix: ["inviteMember.utils"] });
export type Invitee = Pick<
UserType,
"id" | "email" | "username" | "identityProvider" | "completedOnboarding"
>;
export type UserWithMembership = Invitee & {
teams?: Pick<Membership, "userId" | "teamId" | "accepted" | "role">[];
profiles: ProfileType[];
password: UserPassword | null;
};
export type Invitation = {
usernameOrEmail: string;
role: MembershipRole;
};
type InvitableExistingUser = UserWithMembership & {
newRole: MembershipRole;
};
type InvitableExistingUserWithProfile = InvitableExistingUser & {
profile: {
username: string;
} | null;
};
export async function ensureAtleastAdminPermissions({
userId,
teamId,
isOrg,
}: {
userId: number;
teamId: number;
isOrg?: boolean;
}) {
// Checks if the team they are inviting to IS the org. Not a child team
if (isOrg) {
if (!(await isOrganisationAdmin(userId, teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
} else {
// TODO: do some logic here to check if the user is inviting a NEW user to a team that ISNT in the same org
if (!(await isTeamAdmin(userId, teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
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) {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
include: {
organizationSettings: true,
parent: {
include: {
organizationSettings: true,
},
},
},
});
if (!team)
throw new TRPCError({
code: "NOT_FOUND",
message: `Team not found`,
});
return { ...team, metadata: teamMetadataSchema.parse(team.metadata) };
}
export async function getUniqueInvitationsOrThrowIfEmpty(invitations: Invitation[]) {
const usernamesOrEmailsSet = new Set<string>();
const uniqueInvitations: Invitation[] = [];
invitations.forEach((usernameOrEmail) => {
if (usernamesOrEmailsSet.has(usernameOrEmail.usernameOrEmail)) {
return;
}
uniqueInvitations.push(usernameOrEmail);
usernamesOrEmailsSet.add(usernameOrEmail.usernameOrEmail);
});
if (uniqueInvitations.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You must provide at least one email address to invite.",
});
}
return uniqueInvitations;
}
export const enum INVITE_STATUS {
USER_PENDING_MEMBER_OF_THE_ORG = "USER_PENDING_MEMBER_OF_THE_ORG",
USER_ALREADY_INVITED_OR_MEMBER = "USER_ALREADY_INVITED_OR_MEMBER",
USER_MEMBER_OF_OTHER_ORGANIZATION = "USER_MEMBER_OF_OTHER_ORGANIZATION",
CAN_BE_INVITED = "CAN_BE_INVITED",
}
export function canBeInvited(invitee: UserWithMembership, team: TeamWithParent) {
const myLog = log.getSubLogger({ prefix: ["canBeInvited"] });
myLog.debug("Checking if user can be invited", safeStringify({ invitee, team }));
const alreadyInvited = invitee.teams?.find(({ teamId: membershipTeamId }) => team.id === membershipTeamId);
if (alreadyInvited) {
return INVITE_STATUS.USER_ALREADY_INVITED_OR_MEMBER;
}
const orgMembership = invitee.teams?.find((membership) => membership.teamId === team.parentId);
// An invitee here won't be a member of the team
// If he is invited to a sub-team and is already part of the organization.
if (
team.parentId &&
new UserRepository(prisma).isAMemberOfOrganization({ user: invitee, organizationId: team.parentId })
) {
return INVITE_STATUS.CAN_BE_INVITED;
}
// user invited to join a team inside an org, but has not accepted invite to org yet
if (team.parentId && orgMembership && !orgMembership.accepted) {
return INVITE_STATUS.USER_PENDING_MEMBER_OF_THE_ORG;
}
const hasDifferentOrganizationProfile = invitee.profiles.some((profile) => {
const isRegularTeam = !team.isOrganization && !team.parentId;
if (isRegularTeam) {
// ⚠️ Inviting to a regular team but the user has a profile with some organization
return true;
}
const isOrganization = team.isOrganization && !team.parentId;
if (isOrganization) {
// ⚠️ User has profile with different organization than the organization being invited to
return profile.organizationId !== team.id;
}
// ⚠️ User having profile with an organization is invited to join a sub-team that is not part of the organization
return profile.organizationId != team.parentId;
});
if (
!ENABLE_PROFILE_SWITCHER &&
// User having profile with an organization is invited to join a sub-team that is not part of the organization
hasDifferentOrganizationProfile
) {
return INVITE_STATUS.USER_MEMBER_OF_OTHER_ORGANIZATION;
}
return INVITE_STATUS.CAN_BE_INVITED;
}
export async function findUsersWithInviteStatus({
invitations,
team,
}: {
invitations: Invitation[];
team: TeamWithParent;
}) {
const usernamesOrEmails = invitations.map((invitation) => invitation.usernameOrEmail);
const inviteesFromDb: UserWithMembership[] = await prisma.user.findMany({
where: {
OR: [
// Either it's a username in that organization
{
profiles: {
some: {
organizationId: team.id,
username: { in: usernamesOrEmails },
},
},
},
// Or it's an email
{ email: { in: usernamesOrEmails } },
],
},
select: {
id: true,
email: true,
username: true,
password: true,
completedOnboarding: true,
identityProvider: true,
profiles: true,
teams: true,
},
});
const userToRoleMap = buildUserToRoleMap();
const defaultMemberRole = MembershipRole.MEMBER;
// Check if the users found in the database can be invited to join the team/org
return inviteesFromDb.map((inviteeFromDb) => {
const newRole = getRoleForUser({ email: inviteeFromDb.email, username: inviteeFromDb.username });
return {
...inviteeFromDb,
newRole: newRole ?? defaultMemberRole,
canBeInvited: canBeInvited(inviteeFromDb, team),
};
});
function buildUserToRoleMap() {
const userToRoleMap = new Map<string, MembershipRole>();
invitations.forEach((invitation) => {
userToRoleMap.set(invitation.usernameOrEmail, invitation.role);
});
return userToRoleMap;
}
function getRoleForUser({ email, username }: { email: string; username: string | null }) {
return userToRoleMap.get(email) || (username ? userToRoleMap.get(username) : defaultMemberRole);
}
}
export function getOrgConnectionInfo({
orgAutoAcceptDomain,
orgVerified,
isOrg,
email,
team,
}: {
orgAutoAcceptDomain?: string | null;
orgVerified: boolean | null;
email: string;
team: Pick<TeamWithParent, "parentId" | "id">;
isOrg: boolean;
}) {
let orgId: number | undefined = undefined;
let autoAccept = false;
if (team.parentId || isOrg) {
orgId = team.parentId || team.id;
if (email.split("@")[1] == orgAutoAcceptDomain) {
// We discourage self-served organizations from being able to use auto-accept feature by having a barrier of a fixed number of paying teams in the account for creating the organization
// We can't put restriction of a published organization here because when we move teams during the onboarding of the organization, it isn't published at the moment and we really need those members to be auto-added
// Further, sensitive operations like member editing and impersonating are disabled by default, unless reviewed by the ADMIN team
autoAccept = !!orgVerified;
} else {
orgId = undefined;
autoAccept = false;
}
}
return { orgId, autoAccept };
}
export async function createNewUsersConnectToOrgIfExists({
invitations,
isOrg,
teamId,
parentId,
autoAcceptEmailDomain,
orgConnectInfoByUsernameOrEmail,
isPlatformManaged,
timeFormat,
weekStart,
timeZone,
language,
creationSource,
}: {
invitations: Invitation[];
isOrg: boolean;
teamId: number;
parentId?: number | null;
autoAcceptEmailDomain: string | null;
orgConnectInfoByUsernameOrEmail: Record<string, ReturnType<typeof getOrgConnectionInfo>>;
isPlatformManaged?: boolean;
timeFormat?: number;
weekStart?: string;
timeZone?: string;
language: string;
creationSource: CreationSource;
}) {
// fail if we have invalid emails
invitations.forEach((invitation) => checkInputEmailIsValid(invitation.usernameOrEmail));
// from this point we know usernamesOrEmails contains only emails
const createdUsers = await prisma.$transaction(
async (tx) => {
const createdUsers = [];
for (let index = 0; index < invitations.length; index++) {
const invitation = invitations[index];
// Weird but orgId is defined only if the invited user email matches orgAutoAcceptEmail
const { orgId, autoAccept } = orgConnectInfoByUsernameOrEmail[invitation.usernameOrEmail];
const [emailUser, emailDomain] = invitation.usernameOrEmail.split("@");
const [domainName, TLD] = emailDomain.split(".");
// An org member can't change username during signup, so we set the username
const orgMemberUsername =
emailDomain === autoAcceptEmailDomain
? slugify(emailUser)
: slugify(`${emailUser}-${domainName}${isPlatformManaged ? `-${TLD}` : ""}`);
// As a regular team member is allowed to change username during signup, we don't set any username for him
const regularTeamMemberUsername = null;
const isBecomingAnOrgMember = parentId || isOrg;
const defaultAvailability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE);
const t = await getTranslation(language ?? "en", "common");
const createdUser = await tx.user.create({
data: {
username: isBecomingAnOrgMember ? orgMemberUsername : regularTeamMemberUsername,
email: invitation.usernameOrEmail,
verified: true,
invitedTo: teamId,
isPlatformManaged: !!isPlatformManaged,
timeFormat,
weekStart,
timeZone,
creationSource,
organizationId: orgId || null, // If the user is invited to a child team, they are automatically added to the parent org
...(orgId
? {
profiles: {
createMany: {
data: [
{
uid: ProfileRepository.generateProfileUid(),
username: orgMemberUsername,
organizationId: orgId,
},
],
},
},
}
: null),
teams: {
create: {
teamId: teamId,
role: invitation.role,
accepted: autoAccept, // If the user is invited to a child team, they are automatically accepted
},
},
...(!isPlatformManaged
? {
schedules: {
create: {
name: t("default_schedule_name"),
availability: {
createMany: {
data: defaultAvailability.map((schedule) => ({
days: schedule.days,
startTime: schedule.startTime,
endTime: schedule.endTime,
})),
},
},
},
},
}
: {}),
},
});
// We also need to create the membership in the parent org if it exists
if (parentId) {
await tx.membership.create({
data: {
createdAt: new Date(),
teamId: parentId,
userId: createdUser.id,
role: MembershipRole.MEMBER,
accepted: autoAccept,
},
});
}
createdUsers.push(createdUser);
}
return createdUsers;
},
{ timeout: 10000 }
);
return createdUsers;
}
export async function createMemberships({
teamId,
language,
invitees,
parentId,
accepted,
}: {
teamId: number;
language: string;
invitees: (InvitableExistingUser & {
needToCreateOrgMembership: boolean | null;
})[];
parentId: number | null;
accepted: boolean;
}) {
log.debug("Creating memberships for", safeStringify({ teamId, language, invitees, parentId, accepted }));
try {
await prisma.membership.createMany({
data: invitees.flatMap((invitee) => {
const organizationRole = invitee?.teams?.[0]?.role;
const data = [];
const createdAt = new Date();
// membership for the team
data.push({
createdAt,
teamId,
userId: invitee.id,
accepted,
role: checkAdminOrOwner(organizationRole) ? organizationRole : invitee.newRole,
});
// membership for the org
if (parentId && invitee.needToCreateOrgMembership) {
data.push({
createdAt,
accepted,
teamId: parentId,
userId: invitee.id,
role: MembershipRole.MEMBER,
});
}
return data;
}),
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
logger.error("Failed to create memberships", teamId);
} else {
throw e;
}
}
}
export async function sendSignupToOrganizationEmail({
usernameOrEmail,
team,
translation,
inviterName,
teamId,
isOrg,
}: {
usernameOrEmail: string;
team: { name: string; parent: { name: string } | null };
translation: TFunction;
inviterName: string;
teamId: number;
isOrg: boolean;
}) {
try {
const token: string = randomBytes(32).toString("hex");
await prisma.verificationToken.create({
data: {
identifier: usernameOrEmail,
token,
expires: new Date(new Date().setHours(168)), // +1 week
team: {
connect: {
id: teamId,
},
},
},
});
await sendTeamInviteEmail({
language: translation,
from: inviterName || `${team.name}'s admin`,
to: usernameOrEmail,
teamName: team.name,
joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`,
isCalcomMember: false,
isOrg: isOrg,
parentTeamName: team?.parent?.name,
isAutoJoin: false,
isExistingUserMovedToOrg: false,
// For a new user there is no prev and new links.
prevLink: null,
newLink: null,
});
} catch (error) {
logger.error(
"Failed to send signup to organization email",
safeStringify({
usernameOrEmail,
orgId: teamId,
}),
error
);
}
}
type TeamAndOrganizationSettings = Team & {
organizationSettings?: OrganizationSettings | null;
};
export function getOrgState(
isOrg: boolean,
team: TeamAndOrganizationSettings & {
parent: TeamAndOrganizationSettings | null;
}
) {
const parentSettings = team.parent?.organizationSettings;
if (isOrg && team.organizationSettings?.orgAutoAcceptEmail) {
return {
isInOrgScope: true,
orgVerified: team.organizationSettings.isOrganizationVerified,
orgConfigured: team.organizationSettings.isOrganizationConfigured,
autoAcceptEmailDomain: team.organizationSettings.orgAutoAcceptEmail,
orgPublished: !!team.slug,
};
} else if (parentSettings?.orgAutoAcceptEmail) {
return {
isInOrgScope: true,
orgVerified: parentSettings.isOrganizationVerified,
orgConfigured: parentSettings.isOrganizationConfigured,
autoAcceptEmailDomain: parentSettings.orgAutoAcceptEmail,
orgPublished: !!team.parent?.slug,
};
}
return {
isInOrgScope: false,
orgVerified: null,
autoAcceptEmailDomain: null,
orgConfigured: null,
orgPublished: null,
};
}
export function getAutoJoinStatus({
team,
invitee,
connectionInfoMap,
}: {
team: TeamWithParent;
invitee: UserWithMembership;
connectionInfoMap: Record<string, ReturnType<typeof getOrgConnectionInfo>>;
}) {
const isRegularTeam = !team.isOrganization && !team.parentId;
if (isRegularTeam) {
// There are no-auto join in regular teams ever
return {
autoAccept: false,
// Following are not relevant for regular teams
needToCreateProfile: null,
needToCreateOrgMembership: null,
};
}
const isAutoAcceptEmail = connectionInfoMap[invitee.email].autoAccept;
const isUserMemberOfTheTeamsParentOrganization = team.parentId
? new UserRepository(prisma).isAMemberOfOrganization({ user: invitee, organizationId: team.parentId })
: null;
if (isUserMemberOfTheTeamsParentOrganization) {
const orgMembership = invitee.teams?.find((membership) => membership.teamId === team.parentId);
const isAMemberOfOrg = orgMembership?.accepted;
return {
autoAccept: isAMemberOfOrg,
// User is a member of parent organization already - So, no need to create profile and membership with Org
needToCreateProfile: false,
needToCreateOrgMembership: false,
};
}
if (isAutoAcceptEmail) {
// User is not a member of parent organization but has autoAccept email
// We need to create profile as well as membership with the Org in this case
return {
autoAccept: true,
needToCreateProfile: true,
needToCreateOrgMembership: true,
};
}
return {
autoAccept: false,
needToCreateProfile: false,
needToCreateOrgMembership: true,
};
}
// split invited users between ones that can autojoin and the others who cannot autojoin
export const groupUsersByJoinability = ({
existingUsersWithMemberships,
team,
connectionInfoMap,
}: {
team: TeamWithParent;
existingUsersWithMemberships: InvitableExistingUserWithProfile[];
connectionInfoMap: Record<string, ReturnType<typeof getOrgConnectionInfo>>;
}) => {
const usersToAutoJoin = [];
const regularUsers = [];
for (let index = 0; index < existingUsersWithMemberships.length; index++) {
const existingUserWithMemberships = existingUsersWithMemberships[index];
const autoJoinStatus = getAutoJoinStatus({
invitee: existingUserWithMemberships,
team,
connectionInfoMap,
});
autoJoinStatus.autoAccept
? usersToAutoJoin.push({
...existingUserWithMemberships,
...autoJoinStatus,
})
: regularUsers.push({
...existingUserWithMemberships,
...autoJoinStatus,
});
}
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. Reason:", sentEmail.reason);
}
});
};
export const sendExistingUserTeamInviteEmails = async ({
existingUsersWithMemberships,
language,
currentUserTeamName,
currentUserName,
currentUserParentTeamName,
isOrg,
teamId,
isAutoJoin,
orgSlug,
}: {
language: TFunction;
isAutoJoin: boolean;
existingUsersWithMemberships: Omit<InvitableExistingUserWithProfile, "canBeInvited" | "newRole">[];
currentUserTeamName?: string;
currentUserParentTeamName: string | undefined;
currentUserName?: string | null;
isOrg: boolean;
teamId: number;
orgSlug: string | null;
}) => {
const sendEmailsPromises = existingUsersWithMemberships.map(async (user) => {
let sendTo = user.email;
if (!isEmail(user.email)) {
sendTo = user.email;
}
log.debug("Sending team invite email to", safeStringify({ user, currentUserName, currentUserTeamName }));
if (!currentUserTeamName) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "The team doesn't have a name",
});
}
// inform user of membership by email
if (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?.hash && 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,
isAutoJoin,
from: currentUserName ?? `${currentUserTeamName}'s admin`,
to: sendTo,
teamName: currentUserTeamName,
...inviteTeamOptions,
isOrg: isOrg,
parentTeamName: currentUserParentTeamName,
isExistingUserMovedToOrg: true,
prevLink: `${getOrgFullOrigin("")}/${user.username || ""}`,
newLink: user.profile ? `${getOrgFullOrigin(orgSlug ?? "")}/${user.profile.username}` : null,
});
}
});
await sendEmails(sendEmailsPromises);
};
type inviteMemberHandlerInput = {
teamId: number;
role?: "ADMIN" | "MEMBER" | "OWNER";
language: string;
};
export async function handleExistingUsersInvites({
invitableExistingUsers,
team,
orgConnectInfoByUsernameOrEmail,
teamId,
language,
inviter,
orgSlug,
isOrg,
}: {
invitableExistingUsers: InvitableExistingUser[];
team: TeamWithParent;
orgConnectInfoByUsernameOrEmail: Record<string, { orgId: number | undefined; autoAccept: boolean }>;
teamId: number;
language: string;
inviter: {
name: string | null;
};
isOrg: boolean;
orgSlug: string | null;
}) {
const translation = await getTranslation(language, "common");
if (!team.isOrganization) {
const [autoJoinUsers, regularUsers] = groupUsersByJoinability({
existingUsersWithMemberships: invitableExistingUsers.map((u) => {
return {
...u,
profile: null,
};
}),
team,
connectionInfoMap: orgConnectInfoByUsernameOrEmail,
});
log.debug(
"Inviting existing users to a team",
safeStringify({
autoJoinUsers,
regularUsers,
})
);
// invited users can autojoin, create their memberships in org
if (autoJoinUsers.length) {
await createMemberships({
teamId,
language,
invitees: autoJoinUsers,
parentId: team.parentId,
accepted: true,
});
await Promise.all(
autoJoinUsers.map(async (userToAutoJoin) => {
await updateNewTeamMemberEventTypes(userToAutoJoin.id, team.id);
})
);
await sendExistingUserTeamInviteEmails({
currentUserName: inviter.name,
currentUserTeamName: team?.name,
existingUsersWithMemberships: autoJoinUsers,
language: translation,
isOrg: isOrg,
teamId: team.id,
isAutoJoin: true,
currentUserParentTeamName: team?.parent?.name,
orgSlug,
});
}
// invited users cannot autojoin, create provisional memberships and send email
if (regularUsers.length) {
await createMemberships({
teamId,
language,
invitees: regularUsers,
parentId: team.parentId,
accepted: false,
});
await sendExistingUserTeamInviteEmails({
currentUserName: inviter.name,
currentUserTeamName: team?.name,
existingUsersWithMemberships: regularUsers,
language: translation,
isOrg: isOrg,
teamId: team.id,
isAutoJoin: false,
currentUserParentTeamName: team?.parent?.name,
orgSlug,
});
}
const parentOrganization = team.parent;
if (parentOrganization) {
const parsedOrg = getParsedTeam(parentOrganization);
// Create profiles if needed
await Promise.all(
autoJoinUsers
.concat(regularUsers)
.filter((u) => u.needToCreateProfile)
.map((user) =>
createAProfileForAnExistingUser({
user: {
id: user.id,
email: user.email,
currentUsername: user.username,
},
organizationId: parsedOrg.id,
})
)
);
}
} else {
const organization = team;
log.debug(
"Inviting existing users to an organization",
safeStringify({
invitableExistingUsers,
})
);
const existingUsersWithMembershipsNew = await Promise.all(
invitableExistingUsers.map(async (user) => {
const shouldAutoAccept = orgConnectInfoByUsernameOrEmail[user.email].autoAccept;
let profile = null;
if (shouldAutoAccept) {
profile = await createAProfileForAnExistingUser({
user: {
id: user.id,
email: user.email,
currentUsername: user.username,
},
organizationId: organization.id,
});
}
await prisma.membership.create({
data: {
createdAt: new Date(),
userId: user.id,
teamId: team.id,
accepted: shouldAutoAccept,
role: user.newRole,
},
});
// If auto-accepting into org, also accept any pending sub-team memberships
if (shouldAutoAccept) {
await prisma.membership.updateMany({
where: {
userId: user.id,
accepted: false,
team: {
parentId: organization.id,
},
},
data: {
accepted: true,
},
});
}
return {
...user,
profile,
};
})
);
const autoJoinUsers = existingUsersWithMembershipsNew.filter(
(user) => orgConnectInfoByUsernameOrEmail[user.email].autoAccept
);
const regularUsers = existingUsersWithMembershipsNew.filter(
(user) => !orgConnectInfoByUsernameOrEmail[user.email].autoAccept
);
// Send emails to user who auto-joined
await sendExistingUserTeamInviteEmails({
currentUserName: inviter.name,
currentUserTeamName: team?.name,
existingUsersWithMemberships: autoJoinUsers,
language: translation,
isOrg,
teamId: team.id,
isAutoJoin: true,
currentUserParentTeamName: team?.parent?.name,
orgSlug,
});
// Send emails to user who need to accept invite
await sendExistingUserTeamInviteEmails({
currentUserName: inviter.name,
currentUserTeamName: team?.name,
existingUsersWithMemberships: regularUsers,
language: translation,
isOrg,
teamId: team.id,
isAutoJoin: false,
currentUserParentTeamName: team?.parent?.name,
orgSlug,
});
}
}
export async function handleNewUsersInvites({
invitationsForNewUsers,
team,
orgConnectInfoByUsernameOrEmail,
teamId,
language,
isOrg,
autoAcceptEmailDomain,
inviter,
creationSource,
}: {
invitationsForNewUsers: Invitation[];
teamId: number;
language: string;
orgConnectInfoByUsernameOrEmail: Record<string, { orgId: number | undefined; autoAccept: boolean }>;
autoAcceptEmailDomain: string | null;
team: TeamWithParent;
inviter: {
name: string | null;
};
isOrg: boolean;
creationSource: CreationSource;
}) {
const translation = await getTranslation(language, "common");
await createNewUsersConnectToOrgIfExists({
invitations: invitationsForNewUsers,
isOrg,
teamId: teamId,
orgConnectInfoByUsernameOrEmail,
autoAcceptEmailDomain: autoAcceptEmailDomain,
parentId: team.parentId,
language,
creationSource,
});
const sendVerifyEmailsPromises = invitationsForNewUsers.map((invitation) => {
return sendSignupToOrganizationEmail({
usernameOrEmail: invitation.usernameOrEmail,
team: {
name: team.name,
parent: team.parent,
},
translation,
inviterName: inviter.name ?? "",
teamId,
isOrg,
});
});
await sendEmails(sendVerifyEmailsPromises);
}