Files
calendar/packages/features/ee/organizations/lib/server/orgCreationUtils.ts
T
devin-ai-integration[bot]GitHubmorgan@cal.com <morgan@cal.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>morgan@cal.com <morgan@cal.com>Morgan
e4c47640fc refactor: convert UserRepository to use dependency injection pattern (#22360)
* refactor: convert UserRepository to use dependency injection pattern

- Convert all static methods to public instance methods
- Add constructor that takes PrismaClient parameter
- Update all usage sites to use new instantiation pattern: new UserRepository(prisma).method()
- Follow same pattern as PrismaOOORepository for consistency
- Maintain all existing method logic and signatures unchanged
- Update 125+ files across the codebase to adapt to new pattern

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* optimize: reuse UserRepository instances within same function scope

- Create single UserRepository instance per function scope
- Reuse instance for multiple method calls within same function
- Reduces object instantiation overhead and improves performance
- Apply optimization pattern consistently across codebase

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: repository

* fixup! fix: repository

* fixup! fixup! fix: repository

* fixup! fixup! fixup! fix: repository

* fix: update test mocking strategies for UserRepository dependency injection

- Convert static method mocks to instance method mocks in userCreationService.test.ts
- Update vi.spyOn calls to work with constructor injection pattern in getAllCredentials.test.ts
- Fix UserRepository mocking in getRoutedUrl.test.ts to use constructor injection
- Ensure consistent mocking approach across all test files
- Fix 'UserRepository is not a constructor' errors in tests

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* feat: optimize UserRepository instance reuse and add SessionUser type

- Reuse UserRepository instance in OrganizationRepository.createWithNonExistentOwner
- Add comprehensive SessionUser type definition for type safety
- Improve type constraints in enrichUserWithTheProfile and enrichUserWithItsProfile
- Ensure proper return types with profile information

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: make UserRepository mocking strategy more robust for CI environments

- Add defensive checks for vi.mocked() to handle CI environment differences
- Ensure mockImplementation is available before calling it
- Maintain consistent mocking pattern across all test files
- Fix 'Cannot read properties of undefined' error in CI

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fixup! fix: make UserRepository mocking strategy more robust for CI environments

* refactor: convert direct UserRepository instantiations to two-step pattern

- Change await new UserRepository(prisma).method(...) to const userRepo = new UserRepository(prisma); await userRepo.method(...)
- Optimize instance reuse within same function scopes
- Apply pattern consistently across all modified files in PR
- Fix type errors in organization.ts and sessionMiddleware.ts

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* refactor: complete two-step UserRepository pattern for remaining files

- Apply two-step instantiation pattern to all remaining modified files in PR
- Ensure consistent UserRepository usage across entire codebase
- Maintain instance reuse optimization within function scopes

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* chore: bump platform libs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-10 12:11:14 +00:00

341 lines
10 KiB
TypeScript

import { lookup } from "dns";
import { type TFunction } from "i18next";
import { sendAdminOrganizationNotification } from "@calcom/emails";
import {
RESERVED_SUBDOMAINS,
ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE,
WEBAPP_URL,
IS_CALCOM,
} from "@calcom/lib/constants";
import { createDomain } from "@calcom/lib/domainManager/organization";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma";
import { UserPermissionRole } from "@calcom/prisma/enums";
const log = logger.getSubLogger({ prefix: ["orgCreationUtils"] });
/**
* We can only say for sure that the email is not a company email. We can't say for sure if it is a company email.
*/
function isNotACompanyEmail(email: string) {
// A list of popular @domains that can't be used to allow automatic acceptance of memberships to organization
const emailProviders = [
"gmail.com",
"yahoo.com",
"outlook.com",
"hotmail.com",
"aol.com",
"icloud.com",
"mail.com",
"protonmail.com",
"proton.me",
"zoho.com",
"yandex.com",
"gmx.com",
"fastmail.com",
"inbox.com",
"me.com",
"hushmail.com",
"live.com",
"rediffmail.com",
"tutanota.com",
"mail.ru",
"usa.com",
"qq.com",
"163.com",
"web.de",
"rocketmail.com",
"excite.com",
"lycos.com",
"outlook.co",
"hotmail.co.uk",
];
const emailParts = email.split("@");
if (emailParts.length < 2) return true;
return emailProviders.includes(emailParts[1]);
}
type ConflictType =
| "orgUserIsMemberOfExists"
| "teamUserIsMemberOfExists"
| "orgUserIsNotMemberOfExists"
| "teamUserIsNotMemberOfExists"
| null;
type OrgOwner = {
id: number;
email: string;
username?: string | null;
teams: {
team: {
isPlatform: boolean;
slug: string | null;
isOrganization: boolean;
};
accepted: boolean;
}[];
completedOnboarding?: boolean;
emailVerified?: Date | null;
profile?: {
organizationId: number | null;
} | null;
};
const getIPAddress = async (url: string): Promise<string> => {
return new Promise((resolve, reject) => {
lookup(url, (err, address) => {
if (err) reject(err);
resolve(address);
});
});
};
export class OrgCreationError extends Error {
constructor(message: string) {
super(message);
}
}
async function assertSlugIsAvailable({
slug,
orgOwner,
errorOnUserAlreadyPartOfOrg = true,
}: {
slug: string;
orgOwner: OrgOwner;
errorOnUserAlreadyPartOfOrg?: boolean;
}) {
let conflictType: ConflictType = null;
const conflictingTeamOrOrg = await prisma.team.findFirst({
where: {
slug,
parentId: null,
},
include: {
members: {
select: {
userId: true,
accepted: true,
},
},
},
});
if (!conflictingTeamOrOrg) {
return {
conflictType,
};
}
const isMemberOfConflictingTeamOrOrg = conflictingTeamOrOrg.members.some(
(membership) => membership.userId === orgOwner.id && membership.accepted
);
if (conflictingTeamOrOrg.isOrganization) {
conflictType = isMemberOfConflictingTeamOrOrg ? "orgUserIsMemberOfExists" : "orgUserIsNotMemberOfExists";
} else {
conflictType = isMemberOfConflictingTeamOrOrg
? "teamUserIsMemberOfExists"
: "teamUserIsNotMemberOfExists";
}
if (conflictType === "orgUserIsNotMemberOfExists") {
log.warn("orgUserIsNotMemberOfExists", safeStringify({ slug }));
throw new OrgCreationError("organization_url_taken");
} else if (conflictType === "orgUserIsMemberOfExists") {
log.warn("you_are_part_of_this_organization_already", safeStringify({ slug }));
if (errorOnUserAlreadyPartOfOrg) {
throw new OrgCreationError("you_are_part_of_this_organization_already");
}
} else if (conflictType === "teamUserIsMemberOfExists") {
log.debug("teamUserIsMemberOfExists", safeStringify({ slug }));
// Allow creating the organization
// It is needed so that later we can migrate the non-org Team(with the conflicting slug) to the newly created org
// Publishing the organization would fail if the team with the same slug is not migrated first
} else if (conflictType === "teamUserIsNotMemberOfExists") {
log.error("Team with same slug already exists, owned by another user", safeStringify({ slug }));
throw new OrgCreationError("organization_url_taken");
}
if (RESERVED_SUBDOMAINS.includes(slug)) {
// Log actual reason but show regular error message to user.
log.warn("Reserved subdomain", safeStringify({ slug }));
throw new OrgCreationError("organization_url_taken");
}
return {
conflictType,
};
}
export async function assertCanCreateOrg({
slug,
isPlatform,
orgOwner,
restrictBasedOnMinimumPublishedTeams,
errorOnUserAlreadyPartOfOrg = true,
}: {
slug: string;
isPlatform: boolean;
orgOwner: OrgOwner;
restrictBasedOnMinimumPublishedTeams: boolean;
errorOnUserAlreadyPartOfOrg?: boolean;
}) {
const verifiedUser = orgOwner.completedOnboarding && !!orgOwner.emailVerified;
if (!verifiedUser) {
log.warn(
"you_need_to_complete_user_onboarding_before_creating_an_organization",
safeStringify({ userId: orgOwner.id })
);
throw new OrgCreationError("you_need_to_complete_user_onboarding_before_creating_an_organization");
}
if (isNotACompanyEmail(orgOwner.email) && !isPlatform) {
log.warn("use_company_email_to_create_an_organization", safeStringify({ email: orgOwner.email }));
throw new OrgCreationError("use_company_email_to_create_an_organization");
}
const { conflictType: slugConflictType } = await assertSlugIsAvailable({
slug,
orgOwner,
errorOnUserAlreadyPartOfOrg,
});
// Let slug verification be done before it as that gives better error message if the user creating the org is already a part of the org(i.e. the org exist and he is a member)
const isPartOfAnotherOrg = !!orgOwner.profile?.organizationId;
if (isPartOfAnotherOrg) {
throw new OrgCreationError("you_are_part_of_another_organization_cannot_create_organization");
}
// for platform Organization we don't add org profile to orgOwner so we've to check membership to check if user is part of another platform organization.
const isPartOfAnotherPlatformOrg = orgOwner.teams.some(
(membership) => membership.team.isOrganization && membership.accepted
);
if (isPartOfAnotherPlatformOrg) {
throw new OrgCreationError("you_are_part_of_another_platform_organization_cannot_create_organization");
}
const hasExistingPlatformOrOrgTeam = orgOwner?.teams.find((team) => {
return team.team.isPlatform || team.team.isOrganization;
});
if (!!hasExistingPlatformOrOrgTeam?.team && isPlatform) {
log.warn(
"you_cannot_create_a_platform_organization_as_you_are_already_part_of_a_team",
safeStringify({ userId: orgOwner.id })
);
throw new OrgCreationError("you_cannot_create_a_platform_organization_as_you_are_already_part_of_a_team");
}
const publishedTeams = orgOwner.teams.filter((team) => !!team.team.slug);
if (
restrictBasedOnMinimumPublishedTeams &&
publishedTeams.length < ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE &&
!isPlatform
) {
log.warn(
"you_need_to_have_minimum_published_teams",
safeStringify({
userId: orgOwner.id,
publishedTeams: publishedTeams.length,
minimumPublishedTeams: ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE,
})
);
throw new OrgCreationError("you_need_to_have_minimum_published_teams");
}
return {
slugConflictType,
};
}
export const findUserToBeOrgOwner = async (email: string) => {
const user = await prisma.user.findUnique({
where: {
email,
},
include: {
teams: {
select: {
team: {
select: {
slug: true,
isOrganization: true,
isPlatform: true,
},
},
accepted: true,
},
},
},
});
if (!user) {
return null;
}
return await new UserRepository(prisma).enrichUserWithItsProfile({
user,
});
};
export const setupDomain = async ({
slug,
isPlatform,
orgOwnerEmail,
orgOwnerTranslation,
}: {
slug: string;
isPlatform: boolean;
orgOwnerEmail: string;
orgOwnerTranslation: TFunction;
}) => {
log.debug("Starting domain setup", safeStringify({ slug, isPlatform }));
const areDomainsConfigured = isPlatform ? true : await createDomain(slug);
// On Cal.com, we expect the domains to be configured.
if (IS_CALCOM && !areDomainsConfigured) {
log.error("Failed to create domain in Vercel/Cloudflare", safeStringify({ slug }));
throw new OrgCreationError("could_not_create_domain");
}
if (!areDomainsConfigured) {
log.warn("Organization domain not configured", safeStringify({ slug }));
// Otherwise, we proceed to send an administrative email to admins regarding
// the need to configure DNS registry to support the newly created org
const instanceAdmins = await prisma.user.findMany({
where: { role: UserPermissionRole.ADMIN },
select: { email: true },
});
if (instanceAdmins.length) {
log.debug(
"Sending admin notification for domain configuration",
safeStringify({ slug, adminCount: instanceAdmins.length })
);
try {
await sendAdminOrganizationNotification({
instanceAdmins,
orgSlug: slug,
ownerEmail: orgOwnerEmail,
webappIPAddress: await getIPAddress(
WEBAPP_URL.replace("https://", "")?.replace("http://", "").replace(/(:.*)/, "")
),
t: orgOwnerTranslation,
});
} catch (error) {
// TODO: Why would this error come?
log.error("Error sending admin notification", safeStringify({ error }));
}
} else {
log.warn("Organization created: subdomain not configured and couldn't notify administrators");
}
} else {
log.debug("Domain setup completed successfully", safeStringify({ slug }));
}
};