diff --git a/apps/web/modules/event-types/views/event-types-listing-view.tsx b/apps/web/modules/event-types/views/event-types-listing-view.tsx index 0000969f14..90127e1d3a 100644 --- a/apps/web/modules/event-types/views/event-types-listing-view.tsx +++ b/apps/web/modules/event-types/views/event-types-listing-view.tsx @@ -811,7 +811,7 @@ const CTA = ({ data }: { data: GetByViewerResponse }) => { const Actions = () => { return (
- +
); }; diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts index 9b7195e8fa..dacfe01be6 100644 --- a/packages/lib/server/repository/user.ts +++ b/packages/lib/server/repository/user.ts @@ -337,6 +337,22 @@ export class UserRepository { }; } + static enrichUserWithItsProfileBuiltFromUser({ + 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 }), + }; + } + static async enrichEntityWithProfile< T extends | { diff --git a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts index d0422a96d8..13f5fe1af6 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts @@ -1,20 +1,16 @@ +import { Prisma } from "@prisma/client"; // eslint-disable-next-line no-restricted-imports import { orderBy } from "lodash"; import { hasFilter } from "@calcom/features/filters/lib/hasFilter"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; -import { isOrganization } from "@calcom/lib/entityPermissionUtils"; import { getTeamAvatarUrl, getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client"; import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server"; -import logger from "@calcom/lib/logger"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; -import { safeStringify } from "@calcom/lib/safeStringify"; -import { EventTypeRepository } from "@calcom/lib/server/repository/eventType"; -import { MembershipRepository } from "@calcom/lib/server/repository/membership"; -import { ProfileRepository } from "@calcom/lib/server/repository/profile"; import { UserRepository } from "@calcom/lib/server/repository/user"; import type { PrismaClient } from "@calcom/prisma"; +import { baseEventTypeSelect } from "@calcom/prisma"; import { MembershipRole, SchedulingType } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; @@ -24,7 +20,6 @@ import { TRPCError } from "@trpc/server"; import type { TrpcSessionUser } from "../../../trpc"; import type { TEventTypeInputSchema } from "./getByViewer.schema"; -const log = logger.getSubLogger({ prefix: ["viewer.eventTypes.getByViewer"] }); type GetByViewerOptions = { ctx: { user: NonNullable; @@ -33,6 +28,57 @@ type GetByViewerOptions = { input: TEventTypeInputSchema; }; +const userSelect = Prisma.validator()({ + id: true, + username: true, + name: true, + organizationId: true, + avatarUrl: true, +}); + +const userEventTypeSelect = Prisma.validator()({ + // Position is required by lodash to sort on it. Don't remove it, TS won't complain but it would silently break reordering + position: true, + hashedLink: true, + destinationCalendar: true, + userId: true, + team: { + select: { + id: true, + name: true, + slug: true, + // logo: true, // Skipping to avoid 4mb limit + bio: true, + hideBranding: true, + }, + }, + metadata: true, + users: { + select: userSelect, + }, + parentId: true, + hosts: { + select: { + user: { + select: userSelect, + }, + }, + }, + seatsPerTimeSlot: true, + ...baseEventTypeSelect, +}); + +const teamEventTypeSelect = Prisma.validator()({ + ...userEventTypeSelect, + children: { + include: { + users: { + select: userSelect, + }, + }, + }, +}); + export const compareMembership = (mship1: MembershipRole, mship2: MembershipRole) => { const mshipToNumber = (mship: MembershipRole) => Object.keys(MembershipRole).findIndex((mmship) => mmship === mship); @@ -40,54 +86,87 @@ export const compareMembership = (mship1: MembershipRole, mship2: MembershipRole }; export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => { + const { prisma } = ctx; + await checkRateLimitAndThrowError({ identifier: `eventTypes:getByViewer:${ctx.user.id}`, rateLimitingType: "common", }); - const lightProfile = ctx.user.profile; - const profile = await ProfileRepository.findByUpId(lightProfile.upId); - const isFilterSet = input?.filters && hasFilter(input.filters); - const isUpIdInFilter = input?.filters?.upIds?.includes(lightProfile.upId); - const shouldListUserEvents = !isFilterSet || isUpIdInFilter; - const [profileMemberships, profileEventTypes] = await Promise.all([ - MembershipRepository.findAllByUpIdIncludeTeamWithMembersAndEventTypes( - { - upId: lightProfile.upId, - }, - { + const unenrichedUser = await prisma.user.findUnique({ + where: { + id: ctx.user.id, + }, + select: { + id: true, + username: true, + name: true, + startTime: true, + endTime: true, + bufferTime: true, + avatar: true, + avatarUrl: true, + organizationId: true, + teams: { where: { accepted: true, }, - } - ), - shouldListUserEvents - ? EventTypeRepository.findAllByUpId( + select: { + role: true, + team: { + select: { + id: true, + name: true, + slug: true, + parentId: true, + metadata: true, + parent: true, + members: { + select: { + userId: true, + }, + }, + eventTypes: { + select: teamEventTypeSelect, + orderBy: [ + { + position: "desc", + }, + { + id: "asc", + }, + ], + }, + }, + }, + }, + }, + eventTypes: { + where: { + teamId: null, + userId: getPrismaWhereUserIdFromFilter(ctx.user.id, input?.filters), + }, + select: { + ...userEventTypeSelect, + }, + orderBy: [ { - upId: lightProfile.upId, + position: "desc", }, { - where: { - teamId: null, - }, - orderBy: [ - { - position: "desc", - }, - { - id: "asc", - }, - ], - } - ) - : [], - ]); + id: "asc", + }, + ], + }, + }, + }); - if (!profile) { + if (!unenrichedUser) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } + const user = UserRepository.enrichUserWithItsProfileBuiltFromUser({ user: unenrichedUser }); - const memberships = profileMemberships.map((membership) => ({ + const memberships = user.teams.map((membership) => ({ ...membership, team: { ...membership.team, @@ -95,43 +174,26 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => }, })); - log.debug( - safeStringify({ - profileMemberships, - profileEventTypes, - }) - ); + type UserEventTypes = (typeof user.eventTypes)[number]; + type TeamEventTypeChildren = (typeof user.teams)[number]["team"]["eventTypes"][number]; - type UserEventTypes = (typeof profileEventTypes)[number]; - type TeamEventTypeChildren = NonNullable<(typeof profileEventTypes)[number]["team"]>["eventTypes"][number]; - const mapEventType = async (eventType: UserEventTypes & Partial) => ({ - ...eventType, - safeDescription: eventType?.description ? markdownToSafeHTML(eventType.description) : undefined, - users: await Promise.all( - (!!eventType?.hosts?.length ? eventType?.hosts.map((host) => host.user) : eventType.users).map( - async (u) => - await UserRepository.enrichUserWithItsProfile({ - user: u, - }) - ) - ), - metadata: eventType.metadata ? EventTypeMetaDataSchema.parse(eventType.metadata) : null, - children: await Promise.all( - (eventType.children || []).map(async (c) => ({ - ...c, - users: await Promise.all( - c.users.map( - async (u) => - await UserRepository.enrichUserWithItsProfile({ - user: u, - }) - ) - ), - })) - ), - }); + const mapEventType = (eventType: UserEventTypes & Partial) => { + const users = !!eventType?.hosts?.length ? eventType?.hosts.map((host) => host.user) : eventType.users; + return { + ...eventType, + safeDescription: eventType?.description ? markdownToSafeHTML(eventType.description) : undefined, + users: users.map((user) => UserRepository.enrichUserWithItsProfileBuiltFromUser({ user })), + metadata: eventType.metadata ? EventTypeMetaDataSchema.parse(eventType.metadata) : null, + children: eventType.children?.map((child) => { + return { + ...child, + users: child.users.map((user) => UserRepository.enrichUserWithItsProfileBuiltFromUser({ user })), + }; + }), + }; + }; - const userEventTypes = await Promise.all(profileEventTypes.map(mapEventType)); + const userEventTypes = user.eventTypes.map(mapEventType); type EventTypeGroup = { teamId?: number | null; @@ -139,8 +201,8 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => bookerUrl: string; membershipRole?: MembershipRole | null; profile: { - slug: (typeof profile)["username"] | null; - name: (typeof profile)["name"]; + slug: (typeof user)["username"]; + name: (typeof user)["name"]; image: string; }; metadata: { @@ -156,30 +218,16 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => (evType) => evType.schedulingType !== SchedulingType.MANAGED ); - log.debug(safeStringify({ profileMemberships, profileEventTypes, profile })); - - log.debug( - "Filter Settings", - safeStringify({ - isFilterSet, - isProfileIdInFilter: isUpIdInFilter, - }) - ); - - if (!isFilterSet || isUpIdInFilter) { - const bookerUrl = await getBookerBaseUrl(profile.organizationId ?? null); + if (!input?.filters || !hasFilter(input?.filters) || input?.filters?.userIds?.includes(user.id)) { + const bookerUrl = await getBookerBaseUrl(user.profile.organizationId); eventTypeGroups.push({ teamId: null, bookerUrl, membershipRole: null, profile: { - slug: profile.username, - name: profile.name, - image: getUserAvatarUrl({ - username: profile.username, - avatarUrl: profile.avatarUrl, - profile: profile, - }), + slug: user.username, + name: user.name, + image: getUserAvatarUrl(user), }, eventTypes: orderBy(unmanagedEventTypes, ["position", "id"], ["desc", "asc"]), metadata: { @@ -189,12 +237,12 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => }); } - const teamMemberships = profileMemberships.map((membership) => ({ + const teamMemberships = user.teams.map((membership) => ({ teamId: membership.team.id, membershipRole: membership.role, })); - const filterTeamsEventTypesBasedOnInput = async (eventType: Awaited>) => { + const filterTeamsEventTypesBasedOnInput = (eventType: ReturnType) => { if (!input?.filters || !hasFilter(input?.filters)) { return true; } @@ -202,81 +250,76 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => }; eventTypeGroups = ([] as EventTypeGroup[]).concat( eventTypeGroups, - await Promise.all( - memberships - .filter((mmship) => { - if (isOrganization({ team: mmship.team })) { - return false; - } else { - if (!input?.filters || !hasFilter(input?.filters)) { - return true; - } - return input?.filters?.teamIds?.includes(mmship?.team?.id || 0) ?? false; + memberships + .filter((mmship) => { + const metadata = mmship.team.metadata; + if (metadata?.isOrganization) { + return false; + } else { + if (!input?.filters || !hasFilter(input?.filters)) { + return true; } - }) - .map(async (membership) => { - const orgMembership = teamMemberships.find( - (teamM) => teamM.teamId === membership.team.parentId - )?.membershipRole; + return input?.filters?.teamIds?.includes(mmship?.team?.id || 0) ?? false; + } + }) + .map((membership) => { + const orgMembership = teamMemberships.find( + (teamM) => teamM.teamId === membership.team.parentId + )?.membershipRole; - const team = { - ...membership.team, - metadata: teamMetadataSchema.parse(membership.team.metadata), - }; + const team = { + ...membership.team, + metadata: teamMetadataSchema.parse(membership.team.metadata), + }; - let slug; + let slug; - if (input?.forRoutingForms) { - // For Routing form we want to ensure that after migration of team to an org, the URL remains same for the team - // Once we solve this https://github.com/calcom/cal.com/issues/12399, we can remove this conditional change in slug - slug = `team/${team.slug}`; - } else { - // In an Org, a team can be accessed without /team prefix as well as with /team prefix - slug = team.slug ? (!team.parentId ? `team/${team.slug}` : `${team.slug}`) : null; - } - - const eventTypes = await Promise.all(team.eventTypes.map(mapEventType)); - return { - teamId: team.id, - parentId: team.parentId, - bookerUrl: getBookerBaseUrlSync(team.parent?.slug ?? null), - membershipRole: - orgMembership && compareMembership(orgMembership, membership.role) - ? orgMembership - : membership.role, - profile: { - image: getTeamAvatarUrl({ - slug: team.slug, - requestedSlug: team.metadata?.requestedSlug ?? null, - organizationId: team.parentId, - }), - name: team.name, - slug, - }, - metadata: { - membershipCount: team.members.length, - readOnly: - membership.role === - (team.parentId - ? orgMembership && compareMembership(orgMembership, membership.role) - ? orgMembership - : MembershipRole.MEMBER - : MembershipRole.MEMBER), - }, - eventTypes: eventTypes - .filter(filterTeamsEventTypesBasedOnInput) - .filter((evType) => { - const res = evType.userId === null || evType.userId === ctx.user.id; - return res; - }) - .filter((evType) => - membership.role === MembershipRole.MEMBER - ? evType.schedulingType !== SchedulingType.MANAGED - : true - ), - }; - }) - ) + if (input?.forRoutingForms) { + // For Routing form we want to ensure that after migration of team to an org, the URL remains same for the team + // Once we solve this https://github.com/calcom/cal.com/issues/12399, we can remove this conditional change in slug + slug = `team/${team.slug}`; + } else { + // In an Org, a team can be accessed without /team prefix as well as with /team prefix + slug = team.slug ? (!team.parentId ? `team/${team.slug}` : `${team.slug}`) : null; + } + return { + teamId: team.id, + parentId: team.parentId, + bookerUrl: getBookerBaseUrlSync(team.parent?.slug ?? null), + membershipRole: + orgMembership && compareMembership(orgMembership, membership.role) + ? orgMembership + : membership.role, + profile: { + image: getTeamAvatarUrl({ + slug: team.slug, + requestedSlug: team.metadata?.requestedSlug ?? null, + organizationId: team.parentId, + }), + name: team.name, + slug, + }, + metadata: { + membershipCount: team.members.length, + readOnly: + membership.role === + (team.parentId + ? orgMembership && compareMembership(orgMembership, membership.role) + ? orgMembership + : MembershipRole.MEMBER + : MembershipRole.MEMBER), + }, + eventTypes: team.eventTypes + .map(mapEventType) + .filter(filterTeamsEventTypesBasedOnInput) + .filter((evType) => evType.userId === null || evType.userId === ctx.user.id) + .filter((evType) => + membership.role === MembershipRole.MEMBER + ? evType.schedulingType !== SchedulingType.MANAGED + : true + ), + }; + }) ); return { @@ -290,3 +333,15 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => })), }; }; + +export function getPrismaWhereUserIdFromFilter( + userId: number, + filters: NonNullable["filters"] | undefined +) { + if (!filters || !hasFilter(filters)) { + return userId; + } else if (filters.userIds?.[0] === userId) { + return userId; + } + return 0; +} diff --git a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts index 082b5df9dd..b7a7424552 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts @@ -3,7 +3,7 @@ import { z } from "zod"; export const filterQuerySchemaStrict = z.object({ teamIds: z.number().array().optional(), // A user can only filter by only his userId - upIds: z.string().array().max(1).optional(), + userIds: z.number().array().max(1).optional(), }); export const ZEventTypeInputSchema = z