Files
calendar/packages/features/users/repositories/UserRepository.ts
T
Joe Au-YeungGitHubjoe@cal.com <j.auyeung419@gmail.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Udit Takkar
b539adf47b perf: add paginated host endpoints and repository methods for large teams (#28156)
* perf: add paginated host endpoints and delta-based host updates for event type editor

- New cursor-paginated tRPC endpoints: getHostsForAssignment, getHostsForAvailability, searchTeamMembers, getChildrenForAssignment, exportHostsForWeights, getHostsWithLocationOptions
- Delta-based host update support in update.handler.ts (pendingHostChanges, pendingChildrenChanges)
- Repository additions: EventTypeRepository.findChildrenByParentId, HostRepository pagination, MembershipRepository.searchMembers, UserRepository.findByIdsWithPagination
- Remove teamMembers from getEventTypeById initial load
- Shared types: PendingHostChangesInput, PendingChildrenChangesInput, HostUpdateInput

Co-Authored-By: unknown <>

* fix: revert getTranslation import path to @calcom/i18n/server

Co-Authored-By: unknown <>

* fix: guard findChildrenByParentId to only run when pendingChildrenChanges exists

Co-Authored-By: unknown <>

* refactor: remove delta-based saving logic from backend PR

Move pendingHostChanges/pendingChildrenChanges processing out of backend PR.
These changes belong in the frontend PR since they are tightly coupled
to the new frontend delta tracking components.

Backend PR now contains only read-side optimizations:
- Paginated host/children/member endpoints
- Repository methods
- getEventTypeById optimizations

Co-Authored-By: unknown <>

* refactor: move getEventTypeById changes to frontend PR for type safety

Reverts getEventTypeById.ts, eventTypeRepository.ts, API v2 atom service,
and platform libraries to main. The backend PR now only adds new
infrastructure (paginated endpoints, repository methods, findChildrenByParentId)
without changing existing return types. The getEventTypeById optimizations
will be in the frontend PR instead.

Co-Authored-By: unknown <>

* refactor: move findTeamMembersMatchingAttributeLogic pagination to frontend PR

The handler's return type change (adding nextCursor/total) breaks frontend
files on main that expect the old shape. Moving these changes to the
frontend PR keeps the backend PR purely additive.

Co-Authored-By: unknown <>

* fix: address Cubic review comments - empty array filter, stable total count, Set lookup

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Fix inifnite pagination loop

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: derive teamId from event type to prevent cross-team enumeration in exportHostsForWeights

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: restore doc comment to correct method hasAnyTeamMembershipByUserId

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: use memberUserIds?.length to handle empty array filter in findHostsForAssignmentPaginated

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: use explicit undefined check for memberUserIds to preserve empty array semantics

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: rename findChildrenByParentId to findChildrenByParentIdIncludeOwner

The method selects owner with user profile data, so the name should
reflect the included relation per Cal.com repository naming conventions.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: rename host repository methods to follow naming conventions

- findHostsForAvailabilityPaginated -> findHostsPaginatedIncludeUser
- findHostsForAssignmentPaginated -> findHostsPaginatedIncludeUserForAssignment

Repository methods should not be named after use-cases (Availability/Assignment)
but should describe what data they include, per Cal.com conventions.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: standardize slice(0, limit) across all pagination methods

Replace slice(0, -1) with slice(0, limit) in all HostRepository
pagination methods for consistency. slice(0, limit) is clearer about
intent since it directly references the limit parameter.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* perf: only run total count query on first page in findByIdsWithPagination

Wrap the count query in a !cursor guard so it only runs on the first
page request, avoiding an extra database query on every scroll.
Consistent with the hasFixedHosts optimization in HostRepository.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add integration tests for paginated host endpoints

Tests cover getHostsForAvailability and getHostsForAssignment handlers:
- Basic host retrieval
- Cursor-based pagination across multiple pages
- Host data fields (isFixed, priority, weight, name, email)
- Search filtering by name
- memberUserIds filtering (including empty array returning zero results)
- hasFixedHosts only present on first page

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: extract EventTypeHostService and make TRPC handlers thin

- Create EventTypeHostService at packages/features/host/services/ with all
  DTO types and business logic for 5 event-type-host endpoints
- Refactor all 5 handlers to delegate to the service (thin handlers)
- Add 17 unit tests covering DTO mapping, authorization, segment filtering,
  default values, and pagination pass-through

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* docs: add PR review context comments to EventTypeHostService

Reference key review decisions from PR #28156 as code comments:
- searchTeamMembers: membership check + repository delegation per @eunjae-lee
- exportHostsForWeights: cross-team enumeration security fix per @hariombalhara
- exportHostsForWeights: repository method instead of direct Prisma per @hariombalhara

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Revert "docs: add PR review context comments to EventTypeHostService"

This reverts commit 1a1596e012e971f349f01339ce7572516042f1b3.

* fix: use explicit undefined/null check for memberUserIds in searchMembers

Fixes empty array semantics so memberUserIds: [] correctly returns zero
results instead of all members. Now consistent with HostRepository pattern
which uses 'memberUserIds !== undefined' instead of 'memberUserIds?.length'.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: replace TRPCError with ErrorWithCode in EventTypeHostService

Per AGENTS.md rules, services in packages/features/ should use
ErrorWithCode instead of TRPCError. The errorConversionMiddleware
will automatically convert it to the appropriate TRPCError at the
router layer.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Remove comment

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: remove unused teamId from exportHostsForWeights schema

teamId was originally accepted by the schema when the handler used it
directly. After the security fix to derive teamId server-side from the
event type, the field became dead code. Removing it to keep the API
contract accurate.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Abstract types

* Update imports

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2026-03-09 11:28:33 -04:00

1519 lines
38 KiB
TypeScript

import { whereClauseForOrgWithSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains";
import { getParsedTeam } from "@calcom/features/ee/teams/lib/getParsedTeam";
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
import { getTranslation } from "@calcom/i18n/server";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
import { buildNonDelegationCredentials } from "@calcom/lib/delegationCredential";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { withSelectedCalendars } from "@calcom/lib/server/withSelectedCalendars";
import type { PrismaClient } from "@calcom/prisma";
import { availabilityUserSelect } from "@calcom/prisma";
import type { DestinationCalendar, SelectedCalendar, User as UserType } from "@calcom/prisma/client";
import { Prisma } from "@calcom/prisma/client";
import type { CreationSource } from "@calcom/prisma/enums";
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import { userSelect as prismaUserSelect } from "@calcom/prisma/selects/user";
import { userMetadata } from "@calcom/prisma/zod-utils";
import type { UpId, UserProfile } from "@calcom/types/UserProfile";
import type { z } from "zod";
export type { UserWithLegacySelectedCalendars } from "@calcom/lib/server/withSelectedCalendars";
export { withSelectedCalendars };
export type UserAdminTeams = number[];
export type SessionUser = {
id: number;
username: string | null;
name: string | null;
email: string;
emailVerified: Date | null;
bio: string | null;
avatarUrl: string | null;
timeZone: string;
weekStart: string;
startTime: number;
endTime: number;
defaultScheduleId: number | null;
bufferTime: number;
theme: string | null;
appTheme: string | null;
createdDate: Date;
hideBranding: boolean;
twoFactorEnabled: boolean;
disableImpersonation: boolean;
identityProvider: string | null;
identityProviderId: string | null;
brandColor: string | null;
darkBrandColor: string | null;
movedToProfileId: number | null;
completedOnboarding: boolean;
destinationCalendar: DestinationCalendar | null;
locale: string;
timeFormat: number | null;
trialEndsAt: Date | null;
metadata: z.infer<typeof userMetadata>;
role: string;
allowDynamicBooking: boolean;
allowSEOIndexing: boolean;
receiveMonthlyDigestEmail: boolean;
requiresBookerEmailVerification: boolean;
profiles: UserProfile[];
allSelectedCalendars: SelectedCalendar[];
userLevelSelectedCalendars: SelectedCalendar[];
};
const log = logger.getSubLogger({ prefix: ["[repository/user]"] });
export const ORGANIZATION_ID_UNKNOWN = "ORGANIZATION_ID_UNKNOWN";
const teamSelect = {
id: true,
name: true,
slug: true,
metadata: true,
logoUrl: true,
organizationSettings: true,
isOrganization: true,
isPlatform: true,
} satisfies Prisma.TeamSelect;
const userSelect = {
id: true,
uuid: true,
username: true,
name: true,
email: true,
emailVerified: true,
bio: true,
avatarUrl: true,
timeZone: true,
weekStart: true,
bufferTime: true,
hideBranding: true,
theme: true,
createdDate: true,
trialEndsAt: true,
completedOnboarding: true,
locale: true,
timeFormat: true,
twoFactorSecret: true,
twoFactorEnabled: true,
backupCodes: true,
identityProviderId: true,
invitedTo: true,
brandColor: true,
darkBrandColor: true,
allowDynamicBooking: true,
allowSEOIndexing: true,
receiveMonthlyDigestEmail: true,
requiresBookerEmailVerification: true,
verified: true,
disableImpersonation: true,
locked: true,
movedToProfileId: true,
metadata: true,
isPlatformManaged: true,
lastActiveAt: true,
identityProvider: true,
teams: true,
profiles: true,
} satisfies Prisma.UserSelect;
export class UserRepository {
constructor(private prismaClient: PrismaClient) {}
async findTeamsByUserId({ userId }: { userId: UserType["id"] }) {
const teamMemberships = await this.prismaClient.membership.findMany({
where: {
userId: userId,
},
include: {
team: {
select: teamSelect,
},
},
});
const acceptedTeamMemberships = teamMemberships.filter((membership) => membership.accepted);
const pendingTeamMemberships = teamMemberships.filter((membership) => !membership.accepted);
return {
teams: acceptedTeamMemberships.map((membership) => membership.team),
memberships: teamMemberships,
acceptedTeamMemberships,
pendingTeamMemberships,
};
}
async findOrganizations({ userId }: { userId: UserType["id"] }) {
const { acceptedTeamMemberships } = await this.findTeamsByUserId({
userId,
});
const acceptedOrgMemberships = acceptedTeamMemberships.filter(
(membership) => membership.team.isOrganization
);
const organizations = acceptedOrgMemberships.map((membership) => membership.team);
return {
organizations,
};
}
/**
* It is aware of the fact that a user can be part of multiple organizations.
*/
async findUsersByUsername({ orgSlug, usernameList }: { orgSlug: string | null; usernameList: string[] }) {
const { where, profiles } = await this._getWhereClauseForFindingUsersByUsername({
orgSlug,
usernameList,
});
return (
await this.prismaClient.user.findMany({
select: userSelect,
where,
})
).map((user) => {
// User isn't part of any organization
if (!profiles) {
return {
...user,
profile: ProfileRepository.buildPersonalProfileFromUser({ user }),
};
}
const profile = profiles.find((profile) => profile.user.id === user.id) ?? null;
if (!profile) {
log.error("Profile not found for user", safeStringify({ user, profiles }));
// Profile must be there because profile itself was used to retrieve the user
throw new Error("Profile couldn't be found");
}
const { user: _1, ...profileWithoutUser } = profile;
return {
...user,
profile: profileWithoutUser,
};
});
}
async findPlatformMembersByUsernames({ usernameList }: { usernameList: string[] }) {
return (
await this.prismaClient.user.findMany({
select: userSelect,
where: {
username: {
in: usernameList,
},
isPlatformManaged: false,
profiles: {
some: {
organization: {
isPlatform: true,
},
},
},
},
})
).map((user) => {
return {
...user,
profile: ProfileRepository.buildPersonalProfileFromUser({ user }),
};
});
}
async _getWhereClauseForFindingUsersByUsername({
orgSlug,
usernameList,
}: {
orgSlug: string | null;
usernameList: string[];
}) {
// Lookup in profiles because that's where the organization usernames exist
const profiles = orgSlug
? (
await ProfileRepository.findManyByOrgSlugOrRequestedSlug({
orgSlug: orgSlug,
usernames: usernameList,
})
).map((profile) => ({
...profile,
organization: getParsedTeam(profile.organization),
}))
: null;
const where =
profiles && profiles.length > 0
? {
// Get UserIds from profiles
id: {
in: profiles.map((profile) => profile.user.id),
},
}
: {
username: {
in: usernameList,
},
...(orgSlug
? {
organization: whereClauseForOrgWithSlugOrRequestedSlug(orgSlug),
}
: {
organization: null,
}),
};
return { where, profiles };
}
async findByEmail({ email }: { email: string }) {
const user = await this.prismaClient.user.findUnique({
where: {
email: email.toLowerCase(),
},
select: userSelect,
});
return user;
}
async findManyByEmailsWithEmailVerificationSettings({ emails }: { emails: string[] }) {
const normalizedEmails = emails.map((e) => e.toLowerCase());
if (!normalizedEmails.length) return [];
const users = await this.findVerifiedUsersByEmailsRaw(normalizedEmails);
if (!users.length) return [];
return users.map((u) => ({
email: u.email,
matchedEmail: u.matchedEmail,
requiresBookerEmailVerification: u.requiresBookerEmailVerification,
}));
}
private async findVerifiedUsersByEmailsRaw(emails: string[]) {
const emailListSql = Prisma.join(emails.map((e) => Prisma.sql`${e}`));
return this.prismaClient.$queryRaw<
Array<{
id: number;
email: string;
matchedEmail: string;
requiresBookerEmailVerification: boolean;
}>
>(Prisma.sql`
SELECT
u."id",
u."email",
u."email" AS "matchedEmail",
u."requiresBookerEmailVerification"
FROM
"public"."users" AS u
WHERE
u."email" IN (${emailListSql})
AND u."emailVerified" IS NOT NULL
AND u."locked" = FALSE
UNION
SELECT
u."id",
u."email",
t0."email" AS "matchedEmail",
u."requiresBookerEmailVerification"
FROM
"public"."users" AS u
INNER JOIN "public"."SecondaryEmail" AS t0
ON t0."userId" = u."id"
WHERE
t0."email" IN (${emailListSql})
AND t0."emailVerified" IS NOT NULL
AND u."locked" = FALSE
`);
}
async findByEmailAndIncludeProfilesAndPassword({ email }: { email: string }) {
const user = await this.prismaClient.user.findUnique({
where: {
email: email.toLowerCase(),
},
select: {
locked: true,
role: true,
id: true,
uuid: true,
username: true,
name: true,
email: true,
metadata: true,
identityProvider: true,
password: true,
twoFactorEnabled: true,
twoFactorSecret: true,
backupCodes: true,
locale: true,
teams: {
include: {
team: {
select: teamSelect,
},
},
},
createdDate: true,
},
});
if (!user) {
return null;
}
const allProfiles = await ProfileRepository.findAllProfilesForUserIncludingMovedUser(user);
return {
...user,
allProfiles,
};
}
async findById({ id }: { id: number }) {
const user = await this.prismaClient.user.findUnique({
where: {
id,
},
select: userSelect,
});
if (!user) {
return null;
}
return {
...user,
metadata: userMetadata.parse(user.metadata),
};
}
async findSecondaryEmailByUserIdAndEmail({ userId, email }: { userId: number; email: string }) {
return this.prismaClient.secondaryEmail.findUnique({
where: {
userId_email: {
userId,
email,
},
},
select: {
id: true,
emailVerified: true,
},
});
}
async findByUuid({ uuid }: { uuid: string }) {
return this.prismaClient.user.findUnique({
where: {
uuid,
},
select: {
name: true,
email: true,
avatarUrl: true,
},
});
}
async findByIds({ ids }: { ids: number[] }) {
return this.prismaClient.user.findMany({
where: {
id: {
in: ids,
},
},
select: userSelect,
});
}
async findByIdsWithPagination({
ids,
search,
cursor,
limit,
}: {
ids: number[];
search?: string | null;
cursor?: number | null;
limit?: number | null;
}) {
const where: Record<string, unknown> = {
id: cursor ? { in: ids, gt: cursor } : { in: ids },
};
if (search) {
where.OR = [
{ name: { contains: search, mode: "insensitive" } },
{ email: { contains: search, mode: "insensitive" } },
];
}
const users = await this.prismaClient.user.findMany({
where,
select: {
id: true,
name: true,
email: true,
},
orderBy: { id: "asc" },
...(limit ? { take: limit + 1 } : {}),
});
if (!limit) {
return { users, nextCursor: undefined, total: users.length };
}
const hasMore = users.length > limit;
const items = hasMore ? users.slice(0, limit) : users;
const nextCursor = hasMore ? items[items.length - 1].id : undefined;
// Only count on the first page to avoid an extra query on every scroll
let total: number | undefined;
if (!cursor) {
const countWhere: Record<string, unknown> = {
id: { in: ids },
};
if (search) {
countWhere.OR = [
{ name: { contains: search, mode: "insensitive" } },
{ email: { contains: search, mode: "insensitive" } },
];
}
total = await this.prismaClient.user.count({ where: countWhere });
}
return { users: items, nextCursor, total };
}
async findByUuids({ uuids }: { uuids: string[] }) {
if (uuids.length === 0) return [];
return this.prismaClient.user.findMany({
where: {
uuid: {
in: uuids,
},
},
select: {
id: true,
uuid: true,
name: true,
email: true,
avatarUrl: true,
},
});
}
async findByIdOrThrow({ id }: { id: number }) {
const user = await this.findById({ id });
if (!user) {
throw new Error(`User with id ${id} not found`);
}
return user;
}
async findManyByOrganization({ organizationId }: { organizationId: number }) {
const profiles = await ProfileRepository.findManyForOrg({ organizationId });
return profiles.map((profile) => profile.user);
}
isAMemberOfOrganization({
user,
organizationId,
}: {
user: { profiles: { organizationId: number }[] };
organizationId: number;
}) {
return user.profiles.some((profile) => profile.organizationId === organizationId);
}
async findIfAMemberOfSomeOrganization({ user }: { user: { id: number } }) {
return !!(
await ProfileRepository.findManyForUser({
id: user.id,
})
).length;
}
isMigratedToOrganization({
user,
}: {
user: {
metadata?: {
migratedToOrgFrom?: unknown;
} | null;
};
}) {
return !!user.metadata?.migratedToOrgFrom;
}
async isMovedToAProfile({ user }: { user: Pick<UserType, "movedToProfileId"> }) {
return !!user.movedToProfileId;
}
async swapPrimaryEmailWithSecondaryEmail({
userId,
secondaryEmailId,
oldPrimaryEmail,
oldPrimaryEmailVerified,
newPrimaryEmail,
userUpdateData,
}: {
userId: number;
secondaryEmailId: number;
oldPrimaryEmail: string;
oldPrimaryEmailVerified: Date | null;
newPrimaryEmail: string;
userUpdateData?: Prisma.UserUpdateInput;
}) {
const [, updatedUser] = await this.prismaClient.$transaction([
this.prismaClient.secondaryEmail.update({
where: {
id: secondaryEmailId,
userId: userId,
},
data: {
email: oldPrimaryEmail,
emailVerified: oldPrimaryEmailVerified,
},
}),
this.prismaClient.user.update({
where: { id: userId },
data: {
...userUpdateData,
email: newPrimaryEmail,
},
}),
]);
return updatedUser;
}
async enrichUserWithTheProfile<T extends { username: string | null; id: number }>({
user,
upId,
}: {
user: T;
upId: UpId;
}) {
const profile = await ProfileRepository.findByUpIdWithAuth(upId, user.id);
if (!profile) {
return {
...user,
profile: ProfileRepository.buildPersonalProfileFromUser({ user }),
};
}
return {
...user,
profile,
};
}
/**
* Use this method instead of `enrichUserWithTheProfile` if you don't directly have the profileId.
* It can happen in following cases:
* 1. While dealing with a User that hasn't been added to any organization yet and thus have no Profile entries.
* 2. While dealing with a User that has been moved to a Profile i.e. he was invited to an organization when he was an existing user.
* 3. We haven't added profileId to all the entities, so they aren't aware of which profile they belong to. So, we still mostly use this function to enrich the user with its profile.
*/
async enrichUserWithItsProfile<
T extends {
id: number;
username: string | null;
},
>({
user,
}: {
user: T;
}): Promise<
T & {
nonProfileUsername: string | null;
profile: UserProfile;
}
> {
const profiles = await ProfileRepository.findManyForUser({ id: user.id });
if (profiles.length) {
const profile = profiles[0];
// platform org user doesn't need org profile
if (profile?.organization?.isPlatform) {
return {
...user,
nonProfileUsername: user.username,
profile: ProfileRepository.buildPersonalProfileFromUser({ user }),
};
}
return {
...user,
username: profile.username,
nonProfileUsername: user.username,
profile,
};
}
// If no organization profile exists, use the personal profile so that the returned user is normalized to have a profile always
return {
...user,
nonProfileUsername: user.username,
profile: ProfileRepository.buildPersonalProfileFromUser({ user }),
};
}
async enrichUserWithItsProfileExcludingOrgMetadata<
T extends {
id: number;
username: string | null;
},
>({
user,
}: {
user: T;
}): Promise<
T & {
nonProfileUsername: string | null;
profile: UserProfile;
}
> {
const enrichedUser = await this.enrichUserWithItsProfile({ user });
const { profile } = enrichedUser;
if (!profile || !profile.organization) {
return enrichedUser;
}
// Exclude organization metadata
const sanitizedProfile: UserProfile = {
...profile,
organization: {
...profile.organization,
metadata: {},
organizationSettings: profile.organization.organizationSettings,
},
};
return {
...enrichedUser,
profile: sanitizedProfile,
};
}
async enrichUsersWithTheirProfiles<T extends { id: number; username: string | null }>(
users: T[]
): Promise<
Array<
T & {
nonProfileUsername: string | null;
profile: UserProfile;
}
>
> {
if (users.length === 0) return [];
const userIds = users.map((user) => user.id);
const profiles = await ProfileRepository.findManyForUsers(userIds);
// Create a Map for faster lookups, preserving arrays of profiles per user
const profileMap = new Map<number, UserProfile[]>();
profiles.forEach((profile) => {
if (!profileMap.has(profile.userId)) {
profileMap.set(profile.userId, []);
}
profileMap.get(profile.userId)!.push(profile);
});
// Precompute personal profiles for all users
const personalProfileMap = new Map<number, UserProfile>();
users.forEach((user) => {
personalProfileMap.set(user.id, ProfileRepository.buildPersonalProfileFromUser({ user }));
});
return users.map((user) => {
const userProfiles = profileMap.get(user.id) || [];
if (userProfiles.length > 0) {
const profile = userProfiles[0];
if (profile?.organization?.isPlatform) {
return {
...user,
nonProfileUsername: user.username,
profile: personalProfileMap.get(user.id)!,
};
}
return {
...user,
username: profile.username,
nonProfileUsername: user.username,
profile,
};
}
// If no organization profile exists, use the precomputed personal profile
return {
...user,
nonProfileUsername: user.username,
profile: personalProfileMap.get(user.id)!,
};
});
}
async enrichUsersWithTheirProfileExcludingOrgMetadata<T extends { id: number; username: string | null }>(
users: T[]
): Promise<
Array<
T & {
nonProfileUsername: string | null;
profile: UserProfile;
}
>
> {
const enrichedUsers = await this.enrichUsersWithTheirProfiles(users);
return enrichedUsers.map((enrichedUser) => {
const { profile } = enrichedUser;
if (!profile || !profile.organization) {
return enrichedUser;
}
// Exclude organization metadata
const sanitizedProfile: UserProfile = {
...profile,
organization: {
...profile.organization,
metadata: {},
organizationSettings: profile.organization.organizationSettings,
},
};
return {
...enrichedUser,
profile: sanitizedProfile,
};
});
}
enrichUserWithItsProfileBuiltFromUser<T extends { id: number; username: string | null }>({
user,
}: {
user: T;
}): T & {
nonProfileUsername: string | null;
profile: UserProfile;
} {
// If no organization profile exists, use the personal profile so that the returned user is normalized to have a profile always
return {
...user,
nonProfileUsername: user.username,
profile: ProfileRepository.buildPersonalProfileFromUser({ user }),
};
}
async enrichEntityWithProfile<
T extends
| {
profile: {
id: number;
username: string | null;
organizationId: number | null;
organization?: {
id: number;
name: string;
calVideoLogo?: string | null;
bannerUrl: string | null;
slug: string | null;
metadata: Prisma.JsonValue;
};
};
}
| {
user: {
username: string | null;
id: number;
};
},
>(entity: T) {
if ("profile" in entity) {
const { profile, ...entityWithoutProfile } = entity;
const { organization, ...profileWithoutOrganization } = profile || {};
const parsedOrg = organization ? getParsedTeam(organization) : null;
const ret = {
...entityWithoutProfile,
profile: {
...profileWithoutOrganization,
...(parsedOrg
? {
organization: parsedOrg,
}
: {
organization: null,
}),
},
};
return ret;
} else {
const profiles = await ProfileRepository.findManyForUser(entity.user);
if (!profiles.length) {
return {
...entity,
profile: ProfileRepository.buildPersonalProfileFromUser({
user: entity.user,
}),
};
} else {
return {
...entity,
profile: profiles[0],
};
}
}
}
async updateWhereId({
whereId,
data,
}: {
whereId: number;
data: {
movedToProfileId?: number | null;
};
}) {
return this.prismaClient.user.update({
where: {
id: whereId,
},
data: {
movedToProfile: data.movedToProfileId
? {
connect: {
id: data.movedToProfileId,
},
}
: undefined,
},
});
}
async create(
data: Omit<Prisma.UserCreateInput, "password" | "organization" | "movedToProfile"> & {
username: string;
hashedPassword?: string;
organizationId: number | null;
creationSource: CreationSource;
locked: boolean;
}
) {
const organizationIdValue = data.organizationId;
const { email, username, creationSource, locked, hashedPassword, ...rest } = data;
logger.info("create user", {
email,
username,
organizationIdValue,
locked,
});
const t = await getTranslation("en", "common");
const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE);
const user = await this.prismaClient.user.create({
data: {
username,
email: email,
...(hashedPassword && {
password: { create: { hash: hashedPassword } },
}),
// Default schedule
schedules: {
create: {
name: t("default_schedule_name"),
availability: {
createMany: {
data: availability.map((schedule) => ({
days: schedule.days,
startTime: schedule.startTime,
endTime: schedule.endTime,
})),
},
},
},
},
creationSource,
locked,
...(organizationIdValue
? {
organizationId: organizationIdValue,
profiles: {
create: {
username,
organizationId: organizationIdValue,
uid: ProfileRepository.generateProfileUid(),
},
},
}
: {}),
...rest,
},
});
return user;
}
async getUserAdminTeams({ userId }: { userId: number }) {
return await this.prismaClient.user.findUnique({
where: {
id: userId,
},
select: {
id: true,
avatarUrl: true,
name: true,
username: true,
teams: {
where: {
accepted: true,
OR: [
{
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
},
{
team: {
parent: {
members: {
some: {
id: userId,
role: {
in: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
},
},
},
},
],
},
select: {
team: {
select: {
id: true,
name: true,
logoUrl: true,
isOrganization: true,
parent: {
select: {
logoUrl: true,
name: true,
id: true,
},
},
},
},
},
},
},
});
}
async isAdminOfTeamOrParentOrg({ userId, teamId }: { userId: number; teamId: number }) {
const membershipQuery = {
members: {
some: {
userId,
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
},
},
};
const teams = await this.prismaClient.team.findMany({
where: {
id: teamId,
OR: [
membershipQuery,
{
parent: { ...membershipQuery },
},
],
},
select: {
id: true,
},
});
return !!teams.length;
}
async isAdminOrOwnerOfTeam({ userId, teamId }: { userId: number; teamId: number }) {
const isAdminOrOwnerOfTeam = await this.prismaClient.membership.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
accepted: true,
},
select: {
id: true,
},
});
return !!isAdminOrOwnerOfTeam;
}
async getUserOrganizationAndTeams({ userId }: { userId: number }) {
return await this.prismaClient.user.findUnique({
where: { id: userId },
select: {
organizationId: true,
teams: {
where: { accepted: true },
select: { teamId: true },
},
},
});
}
async getTimeZoneAndDefaultScheduleId({ userId }: { userId: number }) {
return await this.prismaClient.user.findUnique({
where: {
id: userId,
},
select: {
timeZone: true,
defaultScheduleId: true,
},
});
}
async adminFindById(userId: number) {
return await this.prismaClient.user.findUniqueOrThrow({
where: {
id: userId,
},
});
}
async findUserTeams({ id }: { id: number }) {
const user = await this.prismaClient.user.findUnique({
where: {
id,
},
select: {
completedOnboarding: true,
teams: {
select: {
accepted: true,
team: {
select: {
id: true,
name: true,
logoUrl: true,
},
},
},
},
},
});
if (!user) {
return null;
}
return user;
}
async updateAvatar({ id, avatarUrl }: { id: number; avatarUrl: string }) {
// Using updateMany here since if the user already has a profile it would throw an error
// because no records were found to update the profile picture
await this.prismaClient.user.updateMany({
where: {
id,
avatarUrl: {
equals: null,
},
},
data: {
avatarUrl,
},
});
}
async findUserWithCredentials({ id }: { id: number }) {
const user = await this.prismaClient.user.findUnique({
where: {
id,
},
select: {
credentials: {
select: credentialForCalendarServiceSelect,
},
timeZone: true,
id: true,
selectedCalendars: true,
},
});
if (!user) {
return null;
}
const { credentials, ...userWithSelectedCalendars } = withSelectedCalendars(user);
return {
...userWithSelectedCalendars,
credentials: buildNonDelegationCredentials(credentials),
};
}
async findUnlockedUserForSession({ userId }: { userId: number }) {
const user = await this.prismaClient.user.findUnique({
where: {
id: userId,
// Locked users can't login
locked: false,
},
select: {
id: true,
uuid: true,
username: true,
name: true,
email: true,
emailVerified: true,
bio: true,
avatarUrl: true,
timeZone: true,
weekStart: true,
defaultScheduleId: true,
bufferTime: true,
theme: true,
appTheme: true,
createdDate: true,
hideBranding: true,
twoFactorEnabled: true,
disableImpersonation: true,
identityProvider: true,
identityProviderId: true,
brandColor: true,
darkBrandColor: true,
movedToProfileId: true,
selectedCalendars: {
select: {
id: true,
eventTypeId: true,
externalId: true,
integration: true,
updatedAt: true,
googleChannelId: true,
},
},
completedOnboarding: true,
destinationCalendar: true,
locale: true,
timeFormat: true,
trialEndsAt: true,
metadata: true,
role: true,
allowDynamicBooking: true,
allowSEOIndexing: true,
receiveMonthlyDigestEmail: true,
requiresBookerEmailVerification: true,
profiles: true,
},
});
if (!user) {
return null;
}
return withSelectedCalendars(user);
}
async getUserStats({ userId }: { userId: number }) {
const user = await this.prismaClient.user.findUnique({
where: {
id: userId,
},
select: {
_count: {
select: {
bookings: true,
// We only need user level selected calendars
selectedCalendars: {
where: {
eventTypeId: null,
},
},
teams: true,
eventTypes: true,
},
},
teams: {
select: {
team: {
select: {
eventTypes: {
select: {
id: true,
},
},
},
},
},
},
},
});
if (!user) {
return null;
}
const { _count, ...restUser } = user;
const { selectedCalendars, ...restCount } = _count;
return {
...restUser,
_count: {
...restCount,
userLevelSelectedCalendars: selectedCalendars,
},
};
}
async findManyByIdsIncludeDestinationAndSelectedCalendars({ ids }: { ids: number[] }) {
const users = await this.prismaClient.user.findMany({
where: { id: { in: ids } },
include: {
selectedCalendars: true,
destinationCalendar: true,
},
});
return users.map(withSelectedCalendars);
}
async updateStripeCustomerId({
id,
stripeCustomerId,
existingMetadata,
}: {
id: number;
stripeCustomerId: string;
existingMetadata: z.infer<typeof userMetadata>;
}) {
return this.prismaClient.user.update({
where: { id },
data: { metadata: { ...existingMetadata, stripeCustomerId } },
});
}
async updateWhitelistWorkflows({ id, whitelistWorkflows }: { id: number; whitelistWorkflows: boolean }) {
return this.prismaClient.user.update({
where: { id },
data: { whitelistWorkflows },
});
}
async findManyUsersForDynamicEventType({
currentOrgDomain,
usernameList,
}: {
currentOrgDomain: string | null;
usernameList: string[];
}) {
const { where } = await this._getWhereClauseForFindingUsersByUsername({
orgSlug: currentOrgDomain,
usernameList,
});
// TODO: Should be moved to UserRepository
return this.prismaClient.user.findMany({
where,
select: {
locked: true,
allowDynamicBooking: true,
...availabilityUserSelect,
credentials: {
select: credentialForCalendarServiceSelect,
},
},
});
}
async findUsersByIds(userIds: number[]) {
return this.prismaClient.user.findMany({
where: {
id: { in: userIds },
},
select: {
id: true,
name: true,
email: true,
},
});
}
async findUsersWithLastBooking({ userIds, eventTypeId }: { userIds: number[]; eventTypeId: number }) {
return this.prismaClient.user.findMany({
where: {
id: {
in: userIds,
},
},
select: {
id: true,
bookings: {
select: {
createdAt: true,
},
where: {
eventTypeId,
status: BookingStatus.ACCEPTED,
attendees: {
some: {
noShow: false,
},
},
OR: [
{
noShowHost: false,
},
{
noShowHost: null,
},
],
},
orderBy: {
createdAt: "desc",
},
take: 1,
},
},
});
}
async findUserWithHideBranding({ userId }: { userId: number }) {
return this.prismaClient.user.findUnique({
where: { id: userId },
select: {
hideBranding: true,
profiles: {
select: {
organization: {
select: {
hideBranding: true,
},
},
},
},
},
});
}
async findByIdWithCredentialsAndCalendar({ userId }: { userId: number }) {
return this.prismaClient.user.findUnique({
where: { id: userId },
select: {
id: true,
username: true,
email: true,
name: true,
timeZone: true,
locale: true,
timeFormat: true,
metadata: true,
credentials: {
select: credentialForCalendarServiceSelect,
},
destinationCalendar: true,
},
});
}
async findForPasswordReset({ id }: { id: number }) {
return this.prismaClient.user.findUnique({
where: { id },
select: {
email: true,
name: true,
locale: true,
},
});
}
/**
* Finds a user by ID returning only their username
* @param userId - The user ID
* @returns User with username or null
*/
async findByIdWithUsername(userId: number): Promise<{ username: string | null } | null> {
return this.prismaClient.user.findUnique({
where: { id: userId },
select: { username: true },
});
}
async findManyByIdsWithCredentialsAndSelectedCalendars({ userIds }: { userIds: number[] }) {
const users = await this.prismaClient.user.findMany({
where: {
id: {
in: userIds,
},
},
select: {
...prismaUserSelect, // Use the proper userSelect from @calcom/prisma/selects/user which includes schedules
credentials: {
select: credentialForCalendarServiceSelect,
},
selectedCalendars: {
select: {
eventTypeId: true,
},
},
},
});
return users.map(withSelectedCalendars);
}
async findByEmailAndTeamId({ email, teamId }: { email: string; teamId: number }) {
return this.prismaClient.user.findFirst({
where: {
email: email.toLowerCase(),
teams: {
some: {
teamId,
accepted: true,
},
},
},
select: userSelect,
});
}
async findByIdWithSelectedCalendars({ userId }: { userId: number }) {
return this.prismaClient.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
selectedCalendars: true,
destinationCalendar: true,
},
});
}
async lockByEmail({ email }: { email: string }) {
await this.prismaClient.user.updateMany({
where: { email },
data: { locked: true },
});
}
async unlockByEmail({
email,
}: {
email: string;
}): Promise<{ email: string; username: string | null } | null> {
const user = await this.prismaClient.user.findFirst({
where: { email, locked: true },
select: { id: true, email: true, username: true },
});
if (!user) return null;
await this.prismaClient.user.update({
where: { id: user.id },
data: { locked: false },
});
return { email: user.email, username: user.username };
}
}