* 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>
1519 lines
38 KiB
TypeScript
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 };
|
|
}
|
|
}
|