From ae31e05becffd057c08d0e170a9276a1874d248e Mon Sep 17 00:00:00 2001 From: Benny Joo Date: Tue, 10 Feb 2026 21:29:32 +0900 Subject: [PATCH] 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 * fix * fix: update test mocks to use new import path for inviteMemberUtils Co-Authored-By: benny@cal.com * revert: restore original formatting in proration test and handleGroupEvents Co-Authored-By: benny@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...onthlyProrationService.integration-test.ts | 2 +- .../ee/dsync/lib/handleGroupEvents.ts | 12 +- .../ee/dsync/lib/handleUserEvents.test.ts | 8 +- .../features/ee/dsync/lib/handleUserEvents.ts | 12 +- .../ee/dsync/lib/inviteExistingUserToOrg.ts | 7 +- .../lib/users/inviteExistingUserToOrg.ts | 5 +- .../ee/teams/lib/inviteMemberUtils.ts | 308 ++++++++++++++++++ .../viewer/teams/inviteMember/utils.ts | 300 ++--------------- 8 files changed, 348 insertions(+), 306 deletions(-) create mode 100644 packages/features/ee/teams/lib/inviteMemberUtils.ts diff --git a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts index aa55a72f23..d5565b48e9 100644 --- a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts +++ b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts @@ -1,9 +1,9 @@ +import { createMemberships } from "@calcom/features/ee/teams/lib/inviteMemberUtils"; import { TeamService } from "@calcom/features/ee/teams/services/teamService"; import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface"; import prisma from "@calcom/prisma"; import type { Team, User } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; -import { createMemberships } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { buildMonthlyProrationMetadata } from "../../../lib/proration-utils"; diff --git a/packages/features/ee/dsync/lib/handleGroupEvents.ts b/packages/features/ee/dsync/lib/handleGroupEvents.ts index 26fb3226f2..a51b1d228c 100644 --- a/packages/features/ee/dsync/lib/handleGroupEvents.ts +++ b/packages/features/ee/dsync/lib/handleGroupEvents.ts @@ -1,5 +1,9 @@ import type { DirectorySyncEvent, Group } from "@boxyhq/saml-jackson"; - +import { + getTeamOrThrow, + sendExistingUserTeamInviteEmails, + sendSignupToOrganizationEmail, +} from "@calcom/features/ee/teams/lib/inviteMemberUtils"; import { addNewMembersToEventTypes } from "@calcom/features/ee/teams/lib/queries"; import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; import logger from "@calcom/lib/logger"; @@ -7,12 +11,6 @@ import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server/i18n"; import prisma from "@calcom/prisma"; import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums"; -import { - getTeamOrThrow, - sendSignupToOrganizationEmail, - sendExistingUserTeamInviteEmails, -} from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"; - import createUsersAndConnectToOrg from "./users/createUsersAndConnectToOrg"; const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: number) => { diff --git a/packages/features/ee/dsync/lib/handleUserEvents.test.ts b/packages/features/ee/dsync/lib/handleUserEvents.test.ts index 9742cceab3..42a592d771 100644 --- a/packages/features/ee/dsync/lib/handleUserEvents.test.ts +++ b/packages/features/ee/dsync/lib/handleUserEvents.test.ts @@ -21,7 +21,7 @@ vi.mock("@calcom/lib/server/i18n", () => ({ getTranslation: vi.fn().mockResolvedValue((key: string) => key), })); -vi.mock("@calcom/trpc/server/routers/viewer/teams/inviteMember/utils", () => ({ +vi.mock("@calcom/features/ee/teams/lib/inviteMemberUtils", () => ({ getTeamOrThrow: vi.fn(), sendExistingUserTeamInviteEmails: vi.fn(), sendSignupToOrganizationEmail: vi.fn(), @@ -112,7 +112,7 @@ describe("handleUserEvents", () => { slug: organizationSlug, }); - const { getTeamOrThrow } = await import("@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"); + const { getTeamOrThrow } = await import("@calcom/features/ee/teams/lib/inviteMemberUtils"); vi.mocked(getTeamOrThrow).mockResolvedValue({ id: organizationId, name: organizationName, @@ -299,7 +299,7 @@ describe("handleUserEvents", () => { const inviteExistingUserToOrg = (await import("./users/inviteExistingUserToOrg")).default; const sendExistingUserTeamInviteEmails = ( - await import("@calcom/trpc/server/routers/viewer/teams/inviteMember/utils") + await import("@calcom/features/ee/teams/lib/inviteMemberUtils") ).sendExistingUserTeamInviteEmails; await handleUserEvents(event, organizationId); @@ -419,7 +419,7 @@ describe("handleUserEvents", () => { }, }; - const { getTeamOrThrow } = await import("@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"); + const { getTeamOrThrow } = await import("@calcom/features/ee/teams/lib/inviteMemberUtils"); vi.mocked(getTeamOrThrow).mockResolvedValue( null as unknown as Awaited> ); diff --git a/packages/features/ee/dsync/lib/handleUserEvents.ts b/packages/features/ee/dsync/lib/handleUserEvents.ts index d117a2fadd..10d9a16a93 100644 --- a/packages/features/ee/dsync/lib/handleUserEvents.ts +++ b/packages/features/ee/dsync/lib/handleUserEvents.ts @@ -1,17 +1,17 @@ import type { DirectorySyncEvent, User } from "@boxyhq/saml-jackson"; - import removeUserFromOrg from "@calcom/features/ee/dsync/lib/removeUserFromOrg"; +import type { UserWithMembership } from "@calcom/features/ee/teams/lib/inviteMemberUtils"; +import { + getTeamOrThrow, + sendExistingUserTeamInviteEmails, + sendSignupToOrganizationEmail, +} from "@calcom/features/ee/teams/lib/inviteMemberUtils"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server/i18n"; import prisma from "@calcom/prisma"; import { IdentityProvider } from "@calcom/prisma/enums"; -import { getTeamOrThrow } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"; -import type { UserWithMembership } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"; -import { sendExistingUserTeamInviteEmails } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"; -import { sendSignupToOrganizationEmail } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"; - import { assignValueToUserInOrgBulk } from "./assignValueToUser"; import getAttributesFromScimPayload from "./getAttributesFromScimPayload"; import createUsersAndConnectToOrg from "./users/createUsersAndConnectToOrg"; diff --git a/packages/features/ee/dsync/lib/inviteExistingUserToOrg.ts b/packages/features/ee/dsync/lib/inviteExistingUserToOrg.ts index 2ca346f8f3..4c2f60be8f 100644 --- a/packages/features/ee/dsync/lib/inviteExistingUserToOrg.ts +++ b/packages/features/ee/dsync/lib/inviteExistingUserToOrg.ts @@ -1,9 +1,8 @@ -import type { TFunction } from "i18next"; - +import type { UserWithMembership } from "@calcom/features/ee/teams/lib/inviteMemberUtils"; +import { sendExistingUserTeamInviteEmails } from "@calcom/features/ee/teams/lib/inviteMemberUtils"; import { createAProfileForAnExistingUser } from "@calcom/features/profile/lib/createAProfileForAnExistingUser"; import prisma from "@calcom/prisma"; -import { sendExistingUserTeamInviteEmails } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"; -import type { UserWithMembership } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"; +import type { TFunction } from "i18next"; /** * This should only be used in a dsync context diff --git a/packages/features/ee/dsync/lib/users/inviteExistingUserToOrg.ts b/packages/features/ee/dsync/lib/users/inviteExistingUserToOrg.ts index fdcd8facc1..7e6bfb3e75 100644 --- a/packages/features/ee/dsync/lib/users/inviteExistingUserToOrg.ts +++ b/packages/features/ee/dsync/lib/users/inviteExistingUserToOrg.ts @@ -1,8 +1,7 @@ -import type { TFunction } from "i18next"; - +import type { UserWithMembership } from "@calcom/features/ee/teams/lib/inviteMemberUtils"; import { createAProfileForAnExistingUser } from "@calcom/features/profile/lib/createAProfileForAnExistingUser"; import prisma from "@calcom/prisma"; -import type { UserWithMembership } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"; +import type { TFunction } from "i18next"; /** * This should only be used in a dsync context diff --git a/packages/features/ee/teams/lib/inviteMemberUtils.ts b/packages/features/ee/teams/lib/inviteMemberUtils.ts new file mode 100644 index 0000000000..c42179d53a --- /dev/null +++ b/packages/features/ee/teams/lib/inviteMemberUtils.ts @@ -0,0 +1,308 @@ +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[]; + 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[]) => { + 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[]; + 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[] = []; + 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; + } + } +} diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts index 8ddd5b497e..4016351c84 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts @@ -1,43 +1,42 @@ -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 { getParsedTeam } from "@calcom/features/ee/teams/lib/getParsedTeam"; +import { + createMemberships, + getTeamOrThrow, + sendEmails, + sendExistingUserTeamInviteEmails, + sendSignupToOrganizationEmail, + type UserWithMembership, +} from "@calcom/features/ee/teams/lib/inviteMemberUtils"; import { updateNewTeamMemberEventTypes } from "@calcom/features/ee/teams/lib/queries"; -import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { createAProfileForAnExistingUser } from "@calcom/features/profile/lib/createAProfileForAnExistingUser"; import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability"; -import { ENABLE_PROFILE_SWITCHER, WEBAPP_URL } from "@calcom/lib/constants"; +import { ENABLE_PROFILE_SWITCHER } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server/i18n"; import slugify from "@calcom/lib/slugify"; import { prisma } from "@calcom/prisma"; -import type { Membership, OrganizationSettings, Profile as ProfileType, Team } from "@calcom/prisma/client"; -import { Prisma, type UserPassword, type User as UserType } from "@calcom/prisma/client"; +import type { OrganizationSettings, Team } 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 type { TFunction } from "i18next"; 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 { Invitee, UserWithMembership } from "@calcom/features/ee/teams/lib/inviteMemberUtils"; +export { + createMemberships, + getTeamOrThrow, + sendEmails, + sendExistingUserTeamInviteEmails, + sendSignupToOrganizationEmail, +} from "@calcom/features/ee/teams/lib/inviteMemberUtils"; -export type UserWithMembership = Invitee & { - teams?: Pick[]; - profiles: ProfileType[]; - password: UserPassword | null; -}; +const log = logger.getSubLogger({ prefix: ["inviteMember.utils"] }); export type Invitation = { usernameOrEmail: string; @@ -88,30 +87,6 @@ export function checkInputEmailIsValid(email: string) { }); } -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(); const uniqueInvitations: Invitation[] = []; @@ -425,149 +400,6 @@ export async function createNewUsersConnectToOrgIfExists({ 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 = 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[] = []; - 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; - } - } -} - -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 - ); - } -} - type TeamAndOrganizationSettings = Team & { organizationSettings?: OrganizationSettings | null; }; @@ -699,100 +531,6 @@ export const groupUsersByJoinability = ({ return [usersToAutoJoin, regularUsers]; }; -export const sendEmails = async (emailPromises: Promise[]) => { - 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[]; - 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 handleExistingUsersInvites({ invitableExistingUsers, team,