Files
calendar/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts
T
41f63582b2 feat: Add booking and user creation source (#18768)
* migration and init to accept creationSource for new bookings

* V1 create booking

* V1 user creation

* webapp booking + V1 user

* user creation in V1, V2 and webapp

* booking source V2 and fix for v1 user

* fit type

* --fix type

* add test -- WIP

* fix type

* fix type

* ^

* Need more sleep zzz

* -_-

* bump libraries platform

* adds for v2 recurring booking

* fix lint

* instant meetings

* fix: api v2 creation source

* fixup! fix: api v2 creation source

* bump libraries

* add user

* fix test

* fixup! fix test

* add more source

* more source...

* fix type & test --1

* fix type & test --2

* typefix

* fixup test

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
2025-01-27 11:01:33 +01:00

296 lines
9.0 KiB
TypeScript

import { type TFunction } from "i18next";
import { TeamBilling } from "@calcom/ee/billing/teams";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import { isOrganisationOwner } from "@calcom/lib/server/queries/organisations";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { MembershipRole } from "@calcom/prisma/enums";
import type { CreationSource } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import type { TInviteMemberInputSchema } from "./inviteMember.schema";
import type { TeamWithParent } from "./types";
import type { Invitation } from "./utils";
import {
ensureAtleastAdminPermissions,
findUsersWithInviteStatus,
getOrgConnectionInfo,
getOrgState,
getTeamOrThrow,
getUniqueInvitationsOrThrowIfEmpty,
handleExistingUsersInvites,
handleNewUsersInvites,
INVITE_STATUS,
} from "./utils";
const log = logger.getSubLogger({ prefix: ["inviteMember.handler"] });
type InviteMemberOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TInviteMemberInputSchema;
};
function getOrgConnectionInfoGroupedByUsernameOrEmail({
uniqueInvitations,
orgState,
team,
isOrg,
}: {
uniqueInvitations: { usernameOrEmail: string; role: MembershipRole }[];
orgState: ReturnType<typeof getOrgState>;
team: Pick<TeamWithParent, "parentId" | "id">;
isOrg: boolean;
}) {
return uniqueInvitations.reduce((acc, invitation) => {
return {
...acc,
[invitation.usernameOrEmail]: getOrgConnectionInfo({
orgVerified: orgState.orgVerified,
orgAutoAcceptDomain: orgState.autoAcceptEmailDomain,
email: invitation.usernameOrEmail,
team,
isOrg: isOrg,
}),
};
}, {} as Record<string, ReturnType<typeof getOrgConnectionInfo>>);
}
function getInvitationsForNewUsers({
existingUsersToBeInvited,
uniqueInvitations,
}: {
existingUsersToBeInvited: Awaited<ReturnType<typeof findUsersWithInviteStatus>>;
uniqueInvitations: { usernameOrEmail: string; role: MembershipRole }[];
}) {
const existingUsersEmailsAndUsernames = existingUsersToBeInvited.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[] }
);
return uniqueInvitations.filter(
(invitation) =>
!existingUsersEmailsAndUsernames.emails.includes(invitation.usernameOrEmail) &&
!existingUsersEmailsAndUsernames.usernames.includes(invitation.usernameOrEmail)
);
}
function throwIfInvalidInvitationStatus({
firstExistingUser,
translation,
}: {
firstExistingUser: Awaited<ReturnType<typeof findUsersWithInviteStatus>>[number] | undefined;
translation: TFunction;
}) {
if (firstExistingUser && firstExistingUser.canBeInvited !== INVITE_STATUS.CAN_BE_INVITED) {
throw new TRPCError({
code: "BAD_REQUEST",
message: translation(firstExistingUser.canBeInvited),
});
}
}
function shouldBeSilentAboutErrors(invitations: Invitation[]) {
const isBulkInvite = invitations.length > 1;
return isBulkInvite;
}
function buildInvitationsFromInput({
usernameOrEmail,
roleForAllInvitees,
}: {
usernameOrEmail: TInviteMemberInputSchema["usernameOrEmail"];
roleForAllInvitees: MembershipRole | undefined;
}) {
const usernameOrEmailList = typeof usernameOrEmail === "string" ? [usernameOrEmail] : usernameOrEmail;
return usernameOrEmailList.map((usernameOrEmail) => {
if (typeof usernameOrEmail === "string")
return { usernameOrEmail: usernameOrEmail, role: roleForAllInvitees ?? MembershipRole.MEMBER };
return {
usernameOrEmail: usernameOrEmail.email,
role: usernameOrEmail.role,
};
});
}
type TargetTeam =
| {
teamId: number;
}
| {
team: TeamWithParent;
};
export const inviteMembersWithNoInviterPermissionCheck = async (
data: {
// TODO: Remove `input` and instead pass the required fields directly
language: string;
inviterName: string | null;
orgSlug: string | null;
invitations: {
usernameOrEmail: string;
role: MembershipRole;
}[];
creationSource: CreationSource;
} & TargetTeam
) => {
const { inviterName, orgSlug, invitations, language, creationSource } = data;
const myLog = log.getSubLogger({ prefix: ["inviteMembers"] });
const translation = await getTranslation(language ?? "en", "common");
const team = "team" in data ? data.team : await getTeamOrThrow(data.teamId);
const isTeamAnOrg = team.isOrganization;
const uniqueInvitations = await getUniqueInvitationsOrThrowIfEmpty(invitations);
const beSilentAboutErrors = shouldBeSilentAboutErrors(uniqueInvitations);
const existingUsersToBeInvited = await findUsersWithInviteStatus({
invitations: uniqueInvitations,
team,
});
if (!beSilentAboutErrors) {
// beSilentAboutErrors is false only when there is a single user being invited, so we just check the first user status here
throwIfInvalidInvitationStatus({ firstExistingUser: existingUsersToBeInvited[0], translation });
}
const orgState = getOrgState(isTeamAnOrg, team);
const orgConnectInfoByUsernameOrEmail = getOrgConnectionInfoGroupedByUsernameOrEmail({
uniqueInvitations,
orgState,
team: {
parentId: team.parentId,
id: team.id,
},
isOrg: isTeamAnOrg,
});
const invitationsForNewUsers = getInvitationsForNewUsers({
existingUsersToBeInvited,
uniqueInvitations,
});
const inviter = { name: inviterName };
if (invitationsForNewUsers.length) {
await handleNewUsersInvites({
invitationsForNewUsers,
team,
orgConnectInfoByUsernameOrEmail,
teamId: team.id,
language,
isOrg: isTeamAnOrg,
inviter,
autoAcceptEmailDomain: orgState.autoAcceptEmailDomain,
creationSource,
});
}
// Existing users have a criteria to be invited
const invitableExistingUsers = existingUsersToBeInvited.filter(
(invitee) => invitee.canBeInvited === INVITE_STATUS.CAN_BE_INVITED
);
myLog.debug(
"Notable variables:",
safeStringify({
uniqueInvitations,
orgConnectInfoByUsernameOrEmail,
invitableExistingUsers,
existingUsersToBeInvited,
invitationsForNewUsers,
})
);
if (invitableExistingUsers.length) {
await handleExistingUsersInvites({
invitableExistingUsers,
team,
orgConnectInfoByUsernameOrEmail,
teamId: team.id,
language,
isOrg: isTeamAnOrg,
inviter,
orgSlug,
});
}
const teamBilling = TeamBilling.init(team);
await teamBilling.updateQuantity();
return {
// TODO: Better rename it to invitations only maybe?
usernameOrEmail:
invitations.length == 1
? invitations[0].usernameOrEmail
: invitations.map((invitation) => invitation.usernameOrEmail),
numUsersInvited: invitableExistingUsers.length + invitationsForNewUsers.length,
};
};
const inviteMembers = async ({ ctx, input }: InviteMemberOptions) => {
const { user: inviter } = ctx;
const { usernameOrEmail, role, isPlatform, creationSource } = input;
const team = await getTeamOrThrow(input.teamId);
const requestedSlugForTeam = team?.metadata?.requestedSlug ?? null;
const isTeamAnOrg = team.isOrganization;
const organization = inviter.profile.organization;
let inviterOrgId = inviter.organization.id;
let orgSlug = organization ? organization.slug || organization.requestedSlug : null;
let isInviterOrgAdmin = inviter.organization.isOrgAdmin;
const invitations = buildInvitationsFromInput({
usernameOrEmail,
roleForAllInvitees: role,
});
const isAddingNewOwner = !!invitations.find((invitation) => invitation.role === MembershipRole.OWNER);
if (isTeamAnOrg) {
await throwIfInviterCantAddOwnerToOrg();
}
if (isPlatform) {
inviterOrgId = team.id;
orgSlug = team ? team.slug || requestedSlugForTeam : null;
isInviterOrgAdmin = await UserRepository.isAdminOrOwnerOfTeam({ userId: inviter.id, teamId: team.id });
}
await ensureAtleastAdminPermissions({
userId: inviter.id,
teamId: inviterOrgId && isInviterOrgAdmin ? inviterOrgId : input.teamId,
isOrg: isTeamAnOrg,
});
const result = await inviteMembersWithNoInviterPermissionCheck({
inviterName: inviter.name,
team,
language: input.language,
creationSource,
orgSlug,
invitations,
});
return result;
async function throwIfInviterCantAddOwnerToOrg() {
const isInviterOrgOwner = await isOrganisationOwner(inviter.id, input.teamId);
if (isAddingNewOwner && !isInviterOrgOwner) throw new TRPCError({ code: "UNAUTHORIZED" });
}
};
export default async function inviteMemberHandler({ ctx, input }: InviteMemberOptions) {
const { user: inviter } = ctx;
await checkRateLimitAndThrowError({
identifier: `invitedBy:${inviter.id}`,
});
return await inviteMembers({ ctx, input });
}