Files
calendar/packages/features/ee/teams/lib/inviteMemberUtils.ts
T
Benny JooGitHubunknown <>benny@cal.com <sldisek783@gmail.com>benny@cal.com <sldisek783@gmail.com>benny@cal.com <sldisek783@gmail.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
ae31e05bec refactor: decouple @calcom/features from @calcom/trpc/server [2] (#27821)
* inviteMemberUtils in featuers

* update imports

* wip

* wip

* fix: remove PII from log statements in inviteMemberUtils

- Remove email (usernameOrEmail) from error log in sendSignupToOrganizationEmail
- Replace full user object with userId in debug log in sendExistingUserTeamInviteEmails

Co-Authored-By: unknown <>

* revert: restore original code for pure relocation (no formatting/logic changes)

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix

* fix: update test mocks to use new import path for inviteMemberUtils

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* revert: restore original formatting in proration test and handleGroupEvents

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-10 09:29:32 -03:00

309 lines
9.2 KiB
TypeScript

import { randomBytes } from "node:crypto";
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
import { sendTeamInviteEmail } from "@calcom/emails/organization-email-service";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { SeatChangeTrackingService } from "@calcom/features/ee/billing/service/seatTracking/SeatChangeTrackingService";
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { prisma } from "@calcom/prisma";
import type {
Membership,
Profile as ProfileType,
UserPassword,
User as UserType,
} from "@calcom/prisma/client";
import { Prisma } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import type { TFunction } from "i18next";
const log = logger.getSubLogger({ prefix: ["inviteMember.utils"] });
const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
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;
};
type InvitableExistingUser = UserWithMembership & {
newRole: MembershipRole;
};
type InvitableExistingUserWithProfile = InvitableExistingUser & {
profile: {
username: string;
} | null;
};
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) };
}
const createVerificationToken = async (identifier: string, teamId: number) => {
const token = randomBytes(32).toString("hex");
return prisma.verificationToken.create({
data: {
identifier,
token,
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +1 week
team: {
connect: {
id: teamId,
},
},
},
});
};
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 verificationToken = await createVerificationToken(usernameOrEmail, teamId);
const gettingStartedPath = await OnboardingPathService.getGettingStartedPathWhenInvited();
await sendTeamInviteEmail({
language: translation,
from: inviterName || `${team.name}'s admin`,
to: usernameOrEmail,
teamName: team.name,
joinLink: `${WEBAPP_URL}/signup?token=${verificationToken.token}&callbackUrl=${gettingStartedPath}`,
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
);
}
}
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 verificationToken = await createVerificationToken(user.email, teamId);
const gettingStartedPath = await OnboardingPathService.getGettingStartedPathWhenInvited();
inviteTeamOptions.joinLink = `${WEBAPP_URL}/signup?token=${verificationToken.token}&callbackUrl=${gettingStartedPath}`;
inviteTeamOptions.isCalcomMember = false;
} else if (!isAutoJoin) {
let verificationToken = await prisma.verificationToken.findFirst({
where: {
identifier: user.email,
teamId: teamId,
},
});
if (!verificationToken) {
verificationToken = await createVerificationToken(user.email, teamId);
}
inviteTeamOptions.joinLink = `${WEBAPP_URL}/teams?token=${verificationToken.token}&autoAccept=true`;
}
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);
};
export async function createMemberships({
teamId,
language,
invitees,
parentId,
accepted,
}: {
teamId: number;
language: string;
invitees: (UserWithMembership & {
newRole: MembershipRole;
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 = parentId
? invitee?.teams?.find((membership) => membership.teamId === parentId)?.role
: undefined;
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;
}),
});
const seatTracker = new SeatChangeTrackingService();
const teamSeatAdditions = parentId ? 0 : invitees.length;
const organizationSeatAdditions = parentId
? invitees.filter((invitee) => invitee.needToCreateOrgMembership).length
: 0;
const trackingPromises: Promise<void>[] = [];
if (teamSeatAdditions > 0) {
trackingPromises.push(
seatTracker.logSeatAddition({
teamId,
seatCount: teamSeatAdditions,
})
);
}
if (parentId && organizationSeatAdditions > 0) {
trackingPromises.push(
seatTracker.logSeatAddition({
teamId: parentId,
seatCount: organizationSeatAdditions,
})
);
}
await Promise.all(trackingPromises);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
logger.error("Failed to create memberships", teamId);
} else {
throw e;
}
}
}