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>
This commit is contained in:
co-authored by
unknown <>
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>
parent
431fbe44fc
commit
ae31e05bec
+1
-1
@@ -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";
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<ReturnType<typeof getTeamOrThrow>>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Membership, "userId" | "teamId" | "accepted" | "role">[];
|
||||
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<string>();
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<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 handleExistingUsersInvites({
|
||||
invitableExistingUsers,
|
||||
team,
|
||||
|
||||
Reference in New Issue
Block a user