Files
calendar/packages/lib/server/repository/user.ts
T
Omar LópezGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Benny Jooemrysal
cb78692347 feat: add calendar cache status and actions (#22532)
* feat: add calendar cache status dropdown

- Add updatedAt field to CalendarCache schema with migration
- Create tRPC cacheStatus endpoint for fetching cache timestamps
- Add action dropdown to CalendarSwitch for Google Calendar entries
- Display formatted last updated timestamp in dropdown
- Add placeholder for cache deletion functionality
- Include translation strings for dropdown content

The dropdown only appears for Google Calendar integrations that have
active cache entries and provides cache management options for future
extensibility.

Co-Authored-By: zomars@cal.com <zomars@me.com>

* fix: resolve Prisma type incompatibilities in repository files

- Remove problematic satisfies clause in selectedCalendar.ts
- Add missing cacheStatus parameter to ConnectedCalendarList component
- Fixes type errors that were preventing CI from passing

Co-Authored-By: zomars@cal.com <zomars@me.com>

* refactor: integrate cache status into connectedCalendars handler

- Remove separate cacheStatus tRPC endpoint as requested
- Return cache status as separate field in connectedCalendars response
- Update UI components to use cache data from connectedCalendars
- Fix Prisma type incompatibilities in repository files

Co-Authored-By: zomars@cal.com <zomars@me.com>

* fix: resolve Prisma type incompatibilities and fix data flow for cache status

- Fix Prisma.SortOrder usage in membership.ts orderBy clauses
- Remove problematic satisfies clause in selectedCalendar.ts
- Fix TeamSelect type reference in team.ts
- Update SelectedCalendarsSettingsWebWrapper to properly pass cacheStatus data flow

Co-Authored-By: zomars@cal.com <zomars@me.com>

* Discard changes to packages/lib/server/repository/membership.ts

* Discard changes to packages/lib/server/repository/team.ts

* fix: improve calendar cache dropdown with proper formatting and subscription logic

- Fix timestamp HTML entity encoding with interpolation escapeValue: false
- Only show dropdown for subscribed Google calendars (googleChannelId exists)
- Hide delete option when no cache data exists
- Include updatedAt and googleChannelId fields upstream in user repository
- Update data flow to pass subscription status through components

Co-Authored-By: zomars@cal.com <zomars@me.com>

* feat: update SelectedCalendar.updatedAt when Google webhooks trigger cache refresh

- Add updateManyByCredentialId method to SelectedCalendarRepository
- Update fetchAvailabilityAndSetCache to refresh SelectedCalendar timestamps
- Ensure webhook flow updates both CalendarCache and SelectedCalendar records
- Maintain proper timestamp tracking for calendar cache operations

Co-Authored-By: zomars@cal.com <zomars@me.com>

* Add script to automate Tunnelmole webhook setup

Introduces test-gcal-webhooks.sh to start Tunnelmole, extract the public URL, and update GOOGLE_WEBHOOK_URL in the .env file. Handles process management, rate limits, and ensures environment configuration for Google Calendar webhooks.

* Update dev:cron script to use npx tsx

Replaces 'ts-node' with 'npx tsx' in the dev:cron script for running cron-tester.ts, likely to improve compatibility or leverage tsx features.

* Update cache status string and improve CalendarSwitch UI

Renamed 'last_updated' to 'cache_last_updated' in locale file for clarity and updated CalendarSwitch to use the new string. Also added dark mode text color support for cache status display.

* refactor: move cache management to credential-level dropdown with Remove App

- Create CredentialActionsDropdown component consolidating cache and app removal actions
- Add deleteCache tRPC mutation for credential-level cache deletion
- Update connectedCalendars handler to include cacheUpdatedAt at credential level
- Move dropdown from individual CalendarSwitch to credential level in SelectedCalendarsSettingsWebWrapper
- Remove cache-related props from CalendarSwitch component
- Add translation strings for cache management actions
- Consolidate all credential-level actions (cache management + Remove App) in one dropdown

Co-Authored-By: zomars@cal.com <zomars@me.com>

* fix: remove duplicate translation keys in common.json

- Remove duplicate cache-related keys at lines 51-56
- Keep properly positioned keys later in file
- Addresses GitHub comment from zomars about duplicate keys

Co-Authored-By: zomars@cal.com <zomars@me.com>

* fix: rename translation key to cache_last_updated

- Address GitHub comment from zomars
- Rename 'last_updated' to 'cache_last_updated' for specificity
- Update usage in CredentialActionsDropdown component

Co-Authored-By: zomars@cal.com <zomars@me.com>

* fix: remove duplicate last_updated translation key

Co-Authored-By: zomars@cal.com <zomars@me.com>

* fix: add confirmation dialog for cache deletion and use repository pattern

- Add confirmation dialog for destructive cache deletion action
- Replace direct Prisma calls with CalendarCacheRepository pattern
- Add getCacheStatusByCredentialIds method to repository interface
- Fix import paths for UI components
- Address GitHub review comments from zomars

Co-Authored-By: zomars@cal.com <zomars@me.com>

* Update CredentialActionsDropdown.tsx

* Update common.json

* Update common.json

* fix: remove nested div wrapper to resolve HTML structure error

- Remove wrapping div around DisconnectIntegration component
- Fixes nested <p> tag validation error preventing Remove App functionality
- Maintains existing confirmation dialog patterns

Co-Authored-By: zomars@cal.com <zomars@me.com>

* Fix API handler response termination logic

Removed unnecessary return values after setting status in the integrations API handler. This clarifies response handling and prevents returning the response object when not needed. Resolves "API handler should not return a value, received object".

* fix: 400 is correct error code for computing slot for past booking (#22574)

* fix

* add test

* chore: release v5.5.1

* Refactor credential disconnect to use confirmation dialog

Replaces the DisconnectIntegration component with an inline confirmation dialog for removing app credentials. Adds disconnect mutation logic and updates UI to improve user experience and consistency.

* Set default value for CalendarCache.updatedAt

Added a default value of NOW() for the updatedAt column in the CalendarCache table to ensure existing and future rows have a valid timestamp. Updated the Prisma schema to reflect this change and provide compatibility for legacy data and raw inserts.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: emrysal <me@alexvanandel.com>
2025-07-22 03:57:57 +01:00

1032 lines
26 KiB
TypeScript

import type { z } from "zod";
import { whereClauseForOrgWithSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import { availabilityUserSelect } from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
import type { Prisma, User as UserType } from "@calcom/prisma/client";
import type { CreationSource } from "@calcom/prisma/enums";
import { MembershipRole } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import { userMetadata } from "@calcom/prisma/zod-utils";
import type { UpId, UserProfile } from "@calcom/types/UserProfile";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "../../availability";
import { buildNonDelegationCredentials } from "../../delegationCredential/clientAndServer";
import { withSelectedCalendars } from "../withSelectedCalendars";
import { ProfileRepository } from "./profile";
import { getParsedTeam } from "./teamUtils";
export type { UserWithLegacySelectedCalendars } from "../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: any;
locale: string;
timeFormat: number | null;
trialEndsAt: Date | null;
metadata: any;
role: string;
allowDynamicBooking: boolean;
allowSEOIndexing: boolean;
receiveMonthlyDigestEmail: boolean;
profiles: any[];
allSelectedCalendars: any[];
userLevelSelectedCalendars: any[];
};
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,
username: true,
name: true,
email: true,
emailVerified: true,
bio: true,
avatarUrl: true,
timeZone: true,
startTime: true,
endTime: 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,
verified: true,
disableImpersonation: true,
locked: true,
movedToProfileId: true,
metadata: true,
isPlatformManaged: true,
lastActiveAt: true,
identityProvider: true,
teams: 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 findByEmailAndIncludeProfilesAndPassword({ email }: { email: string }) {
const user = await this.prismaClient.user.findUnique({
where: {
email: email.toLowerCase(),
},
select: {
locked: true,
role: true,
id: 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 findByIds({ ids }: { ids: number[] }) {
return this.prismaClient.user.findMany({
where: {
id: {
in: ids,
},
},
select: userSelect,
});
}
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 enrichUserWithTheProfile<T extends { username: string | null; id: number }>({
user,
upId,
}: {
user: T;
upId: UpId;
}) {
const profile = await ProfileRepository.findByUpId(upId);
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;
[key: string]: any;
}
>({
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 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)!,
};
});
}
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 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,
username: true,
name: true,
email: true,
emailVerified: true,
bio: true,
avatarUrl: true,
timeZone: true,
weekStart: true,
startTime: true,
endTime: 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: {
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,
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: {
allowDynamicBooking: true,
...availabilityUserSelect,
credentials: {
select: credentialForCalendarServiceSelect,
},
},
});
}
}