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 90127e1d3a..52b1db53e4 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 @@ -25,9 +25,11 @@ import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery"; import { HttpError } from "@calcom/lib/http-error"; +import type { User } from "@calcom/prisma/client"; import { SchedulingType } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc, TRPCClientError } from "@calcom/trpc/react"; +import type { UserProfile } from "@calcom/types/UserProfile"; import { Alert, Avatar, @@ -76,6 +78,7 @@ import useMeQuery from "@lib/hooks/useMeQuery"; import SkeletonLoader from "@components/eventtype/SkeletonLoader"; type EventTypeGroups = RouterOutputs["viewer"]["eventTypes"]["getByViewer"]["eventTypeGroups"]; + type EventTypeGroupProfile = EventTypeGroups[number]["profile"]; type GetByViewerResponse = RouterOutputs["viewer"]["eventTypes"]["getByViewer"] | undefined; @@ -89,16 +92,27 @@ interface EventTypeListHeadingProps { type EventTypeGroup = EventTypeGroups[number]; type EventType = EventTypeGroup["eventTypes"][number]; +type DeNormalizedEventType = Omit & { + users: (Pick & { + nonProfileUsername: string | null; + profile: UserProfile; + })[]; +}; + +type DeNormalizedEventTypeGroup = Omit & { + eventTypes: DeNormalizedEventType[]; +}; + interface EventTypeListProps { - group: EventTypeGroup; + group: DeNormalizedEventTypeGroup; groupIndex: number; readOnly: boolean; bookerUrl: string | null; - types: EventType[]; + types: DeNormalizedEventType[]; } interface MobileTeamsTabProps { - eventTypeGroups: EventTypeGroups; + eventTypeGroups: DeNormalizedEventTypeGroup[]; } const querySchema = z.object({ @@ -136,7 +150,15 @@ const MobileTeamsTab: FC = (props) => { ); }; -const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGroup; readOnly: boolean }) => { +const Item = ({ + type, + group, + readOnly, +}: { + type: DeNormalizedEventType; + group: DeNormalizedEventTypeGroup; + readOnly: boolean; +}) => { const { t } = useLocale(); const content = () => ( @@ -238,7 +260,7 @@ export const EventTypeList = ({ await utils.viewer.eventTypes.getByViewer.cancel(); const previousValue = utils.viewer.eventTypes.getByViewer.getData(); if (previousValue) { - const newList = [...types]; + const newList = [...types.map(normalizeEventType)]; const itemIndex = newList.findIndex((item) => item.id === id); if (itemIndex !== -1 && newList[itemIndex]) { newList[itemIndex].hidden = !newList[itemIndex].hidden; @@ -267,13 +289,13 @@ export const EventTypeList = ({ }); async function moveEventType(index: number, increment: 1 | -1) { - const newList = [...types]; + const newList = [...types.map(normalizeEventType)]; const type = types[index]; const tmp = types[index + increment]; if (tmp) { - newList[index] = tmp; - newList[index + increment] = type; + newList[index] = normalizeEventType(tmp); + newList[index + increment] = normalizeEventType(type); } await utils.viewer.eventTypes.getByViewer.cancel(); @@ -301,7 +323,7 @@ export const EventTypeList = ({ } // inject selection data into url for correct router history - const openDuplicateModal = (eventType: EventType, group: EventTypeGroup) => { + const openDuplicateModal = (eventType: DeNormalizedEventType, group: DeNormalizedEventTypeGroup) => { const newSearchParams = new URLSearchParams(searchParams ?? undefined); function setParamsIfDefined(key: string, value: string | number | boolean | null | undefined) { if (value) newSearchParams.set(key, value.toString()); @@ -326,7 +348,7 @@ export const EventTypeList = ({ await utils.viewer.eventTypes.getByViewer.cancel(); const previousValue = utils.viewer.eventTypes.getByViewer.getData(); if (previousValue) { - const newList = types.filter((item) => item.id !== id); + const newList = types.filter((item) => item.id !== id).map(normalizeEventType); utils.viewer.eventTypes.getByViewer.setData(undefined, { ...previousValue, @@ -811,12 +833,12 @@ const CTA = ({ data }: { data: GetByViewerResponse }) => { const Actions = () => { return (
- +
); }; -const EmptyEventTypeList = ({ group }: { group: EventTypeGroup }) => { +const EmptyEventTypeList = ({ group }: { group: DeNormalizedEventTypeGroup }) => { const { t } = useLocale(); return ( <> @@ -838,7 +860,7 @@ const EmptyEventTypeList = ({ group }: { group: EventTypeGroup }) => { const Main = ({ status, errorMessage, - data, + data: rawData, filters, }: { status: string; @@ -849,7 +871,7 @@ const Main = ({ const isMobile = useMediaQuery("(max-width: 768px)"); const searchParams = useCompatSearchParams(); - if (!data || status === "pending") { + if (!rawData || status === "pending") { return ; } @@ -858,7 +880,11 @@ const Main = ({ } const isFilteredByOnlyOneItem = - (filters?.teamIds?.length === 1 || filters?.userIds?.length === 1) && data.eventTypeGroups.length === 1; + (filters?.teamIds?.length === 1 || filters?.userIds?.length === 1) && + rawData.eventTypeGroups.length === 1; + + const data = denormalizePayload(rawData); + return ( <> {data.eventTypeGroups.length > 1 || isFilteredByOnlyOneItem ? ( @@ -866,7 +892,7 @@ const Main = ({ {isMobile ? ( ) : ( - data.eventTypeGroups.map((group: EventTypeGroup, index: number) => ( + data.eventTypeGroups.map((group, index: number) => (
user.id), + }; +} + +function denormalizePayload(data: NonNullable) { + return { + ...data, + eventTypeGroups: data.eventTypeGroups.map((eventTypeGroup) => { + return { + ...eventTypeGroup, + eventTypes: eventTypeGroup.eventTypes.map((eventType) => { + const { userIds, ...rest } = eventType; + return { + ...rest, + users: userIds.map((userId) => { + const user = data.allUsersAcrossAllEventTypes.get(userId); + if (!user) { + throw new Error(`User with id ${userId} not found in allUsersAcrossAllEventTypes`); + } + return user; + }), + }; + }), + }; + }), + }; +} diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index 184425bce8..8581bad380 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -29,9 +29,6 @@ const userSelect = Prisma.validator()({ avatarUrl: true, username: true, id: true, - email: true, - locale: true, - defaultScheduleId: true, }); export class EventTypeRepository { @@ -106,12 +103,6 @@ export class EventTypeRepository { team: { select: { id: true, - eventTypes: { - select: { - id: true, - users: { select: userSelect }, - }, - }, }, }, hashedLink: true, @@ -175,6 +166,7 @@ export class EventTypeRepository { return await prisma.eventType.findMany({ where: { profileId, + ...where, }, select, orderBy, diff --git a/packages/lib/server/repository/membership.ts b/packages/lib/server/repository/membership.ts index 1825617e1f..aedc24c85d 100644 --- a/packages/lib/server/repository/membership.ts +++ b/packages/lib/server/repository/membership.ts @@ -38,9 +38,6 @@ const userSelect = Prisma.validator()({ avatarUrl: true, username: true, id: true, - email: true, - locale: true, - defaultScheduleId: true, }); export class MembershipRepository { @@ -109,12 +106,6 @@ export class MembershipRepository { team: { select: { id: true, - eventTypes: { - select: { - id: true, - users: { select: userSelect }, - }, - }, }, }, hashedLink: true, diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts index dacfe01be6..9b7195e8fa 100644 --- a/packages/lib/server/repository/user.ts +++ b/packages/lib/server/repository/user.ts @@ -337,22 +337,6 @@ 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/prisma/seed-huge-event-types.ts b/packages/prisma/seed-huge-event-types.ts new file mode 100644 index 0000000000..58892548ea --- /dev/null +++ b/packages/prisma/seed-huge-event-types.ts @@ -0,0 +1,94 @@ +/** + * This script can be used to seed the database with a lot of data for performance testing. + * TODO: Make it more structured and configurable from CLI + * Run it as `npx ts-node --transpile-only ./seed-huge-event-types.ts` + */ +import { createTeamAndAddUsers, createUserAndEventType } from "./seed-utils"; + +const avatar = ""; +const getEventTypes = (numberOfEventTypes: number) => { + const eventTypes = Array<{ + title: string; + slug: string; + length: number; + }>(numberOfEventTypes) + .fill({ + title: "30min", + slug: "30min", + length: 30, + }) + .map((event, index) => { + return { + ...event, + title: `30min-${index}`, + slug: `30min-${index}`, + }; + }); + return eventTypes; +}; + +async function createTeamsWithEventTypes({ + owner, + numberOfTeams, + numberOfEventTypes, +}: { + owner: { id: number; username: string | null }; + numberOfTeams: number; + numberOfEventTypes: number; +}) { + const members = [owner]; + for (let i = 0; i < 10; i++) { + members.push( + await createUserAndEventType({ + user: { + email: `enterprise-member-${i + 1}@example.com`, + name: `Enterprise Member ${i + 1}`, + password: `enterprise-member-${i + 1}`, + username: `enterprise-member-${i + 1}`, + theme: "light", + avatar, + }, + }) + ); + } + + for (let i = 0; i < numberOfTeams; i++) { + await createTeamAndAddUsers( + { + name: `Enterprise Team-${i + 1}`, + slug: `enterprise-team-${i + 1}`, + eventTypes: { + createMany: { + data: getEventTypes(numberOfEventTypes).map((eventType) => { + return { + ...eventType, + schedulingType: "COLLECTIVE", + }; + }), + }, + }, + createdAt: new Date(), + }, + members.map((member) => ({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + username: member.username!, + id: member.id, + })) + ); + } +} + +export default async function main() { + const owner = await createUserAndEventType({ + user: { + email: `enterprise@example.com`, + name: "Enterprise Owner", + password: "enterprise", + username: `enterprise`, + theme: "light", + avatar, + }, + eventTypes: getEventTypes(100), + }); + await createTeamsWithEventTypes({ owner, numberOfTeams: 10, numberOfEventTypes: 100 }); +} diff --git a/packages/prisma/seed-utils.ts b/packages/prisma/seed-utils.ts index 7a69530ac1..d42741dc2b 100644 --- a/packages/prisma/seed-utils.ts +++ b/packages/prisma/seed-utils.ts @@ -1,11 +1,14 @@ import type { Prisma, UserPermissionRole } from "@prisma/client"; import { uuid } from "short-uuid"; +import type z from "zod"; import dayjs from "@calcom/dayjs"; import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability"; +import { MembershipRole } from "@calcom/prisma/enums"; import prisma from "."; +import type { teamMetadataSchema } from "./zod-utils"; export async function createUserAndEventType({ user, @@ -21,6 +24,7 @@ export async function createUserAndEventType({ timeZone?: string; role?: UserPermissionRole; theme?: "dark" | "light"; + avatar?: string; }; eventTypes?: Array< Prisma.EventTypeUncheckedCreateInput & { @@ -167,3 +171,81 @@ export async function createUserAndEventType({ } return theUser; } + +export async function createTeamAndAddUsers( + teamInput: Prisma.TeamCreateInput, + users: { id: number; username: string; role?: MembershipRole }[] = [] +) { + const checkUnpublishedTeam = async (slug: string) => { + return await prisma.team.findFirst({ + where: { + metadata: { + path: ["requestedSlug"], + equals: slug, + }, + }, + }); + }; + const createTeam = async (team: Prisma.TeamCreateInput) => { + try { + const requestedSlug = (team.metadata as z.infer)?.requestedSlug; + if (requestedSlug) { + const unpublishedTeam = await checkUnpublishedTeam(requestedSlug); + if (unpublishedTeam) { + throw Error("Unique constraint failed on the fields"); + } + } + return await prisma.team.create({ + data: { + ...team, + }, + include: { + eventTypes: true, + }, + }); + } catch (_err) { + if (_err instanceof Error && _err.message.indexOf("Unique constraint failed on the fields") !== -1) { + console.log(`Team '${team.name}' already exists, skipping.`); + return; + } + throw _err; + } + }; + + const team = await createTeam(teamInput); + if (!team) { + return; + } + + console.log( + `šŸ¢ Created team '${teamInput.name}' - ${process.env.NEXT_PUBLIC_WEBAPP_URL}/team/${team.slug}` + ); + + for (const eventType of team.eventTypes) { + await prisma.eventType.update({ + where: { + id: eventType.id, + }, + data: { + users: { + connect: users.map((user) => ({ id: user.id })), + }, + }, + }); + } + + for (const user of users) { + const { role = MembershipRole.OWNER, id, username } = user; + await prisma.membership.create({ + data: { + teamId: team.id, + userId: id, + role: role, + accepted: true, + }, + }); + console.log(`\tšŸ‘¤ Added '${teamInput.name}' membership for '${username}' with role '${role}'`); + } + + return team; +} diff --git a/packages/prisma/seed.ts b/packages/prisma/seed.ts index f35af2f4a7..6e0d6d2727 100644 --- a/packages/prisma/seed.ts +++ b/packages/prisma/seed.ts @@ -13,6 +13,7 @@ import type { Ensure } from "@calcom/types/utils"; import prisma from "."; import mainAppStore from "./seed-app-store"; +import mainHugeEventTypesSeed from "./seed-huge-event-types"; import { createUserAndEventType } from "./seed-utils"; import type { teamMetadataSchema } from "./zod-utils"; @@ -913,6 +914,7 @@ async function main() { main() .then(() => mainAppStore()) + .then(() => mainHugeEventTypesSeed()) .catch((e) => { console.error(e); process.exit(1); diff --git a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts index 13f5fe1af6..8fd50ba6cd 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts @@ -1,16 +1,20 @@ -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"; @@ -20,6 +24,7 @@ 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; @@ -28,57 +33,6 @@ 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); @@ -86,87 +40,54 @@ 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 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: { + 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, + }, + { where: { accepted: true, }, - 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", - }, - ], - }, + } + ), + shouldListUserEvents + ? EventTypeRepository.findAllByUpId( + { + upId: lightProfile.upId, + }, + { + where: { + teamId: null, }, - }, - }, - }, - eventTypes: { - where: { - teamId: null, - userId: getPrismaWhereUserIdFromFilter(ctx.user.id, input?.filters), - }, - select: { - ...userEventTypeSelect, - }, - orderBy: [ - { - position: "desc", - }, - { - id: "asc", - }, - ], - }, - }, - }); + orderBy: [ + { + position: "desc", + }, + { + id: "asc", + }, + ], + } + ) + : [], + ]); - if (!unenrichedUser) { + if (!profile) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } - const user = UserRepository.enrichUserWithItsProfileBuiltFromUser({ user: unenrichedUser }); - const memberships = user.teams.map((membership) => ({ + const memberships = profileMemberships.map((membership) => ({ ...membership, team: { ...membership.team, @@ -174,26 +95,43 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => }, })); - type UserEventTypes = (typeof user.eventTypes)[number]; - type TeamEventTypeChildren = (typeof user.teams)[number]["team"]["eventTypes"][number]; + log.debug( + safeStringify({ + profileMemberships, + profileEventTypes, + }) + ); - 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 })), - }; - }), - }; - }; + type UserEventTypes = (typeof profileEventTypes)[number]; - const userEventTypes = user.eventTypes.map(mapEventType); + const mapEventType = async (eventType: UserEventTypes) => ({ + ...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 userEventTypes = await Promise.all(profileEventTypes.map(mapEventType)); type EventTypeGroup = { teamId?: number | null; @@ -201,8 +139,8 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => bookerUrl: string; membershipRole?: MembershipRole | null; profile: { - slug: (typeof user)["username"]; - name: (typeof user)["name"]; + slug: (typeof profile)["username"] | null; + name: (typeof profile)["name"]; image: string; }; metadata: { @@ -218,16 +156,30 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => (evType) => evType.schedulingType !== SchedulingType.MANAGED ); - if (!input?.filters || !hasFilter(input?.filters) || input?.filters?.userIds?.includes(user.id)) { - const bookerUrl = await getBookerBaseUrl(user.profile.organizationId); + log.debug(safeStringify({ profileMemberships, profileEventTypes, profile })); + + log.debug( + "Filter Settings", + safeStringify({ + isFilterSet, + isProfileIdInFilter: isUpIdInFilter, + }) + ); + + if (!isFilterSet || isUpIdInFilter) { + const bookerUrl = await getBookerBaseUrl(profile.organizationId ?? null); eventTypeGroups.push({ teamId: null, bookerUrl, membershipRole: null, profile: { - slug: user.username, - name: user.name, - image: getUserAvatarUrl(user), + slug: profile.username, + name: profile.name, + image: getUserAvatarUrl({ + username: profile.username, + avatarUrl: profile.avatarUrl, + profile: profile, + }), }, eventTypes: orderBy(unmanagedEventTypes, ["position", "id"], ["desc", "asc"]), metadata: { @@ -237,12 +189,12 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => }); } - const teamMemberships = user.teams.map((membership) => ({ + const teamMemberships = profileMemberships.map((membership) => ({ teamId: membership.team.id, membershipRole: membership.role, })); - const filterTeamsEventTypesBasedOnInput = (eventType: ReturnType) => { + const filterTeamsEventTypesBasedOnInput = async (eventType: Awaited>) => { if (!input?.filters || !hasFilter(input?.filters)) { return true; } @@ -250,79 +202,84 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => }; eventTypeGroups = ([] as EventTypeGroup[]).concat( eventTypeGroups, - memberships - .filter((mmship) => { - const metadata = mmship.team.metadata; - if (metadata?.isOrganization) { - return false; - } else { - if (!input?.filters || !hasFilter(input?.filters)) { - return true; + 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; } - return input?.filters?.teamIds?.includes(mmship?.team?.id || 0) ?? false; - } - }) - .map((membership) => { - const orgMembership = teamMemberships.find( - (teamM) => teamM.teamId === membership.team.parentId - )?.membershipRole; + }) + .map(async (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; - } - 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 - ), - }; - }) + 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 + ), + }; + }) + ) ); - return { + const denormalizedPayload = { eventTypeGroups, // so we can show a dropdown when the user has teams profiles: eventTypeGroups.map((group) => ({ @@ -332,16 +289,38 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => membershipRole: group.membershipRole, })), }; -}; -export function getPrismaWhereUserIdFromFilter( - userId: number, - filters: NonNullable["filters"] | undefined -) { - if (!filters || !hasFilter(filters)) { - return userId; - } else if (filters.userIds?.[0] === userId) { - return userId; + return normalizePayload(denormalizedPayload); + + /** + * Reduces the size of payload + */ + function normalizePayload(payload: typeof denormalizedPayload) { + const allUsersAcrossAllEventTypes = new Map< + number, + EventTypeGroup["eventTypes"][number]["users"][number] + >(); + const eventTypeGroups = payload.eventTypeGroups.map((group) => { + return { + ...group, + eventTypes: group.eventTypes.map((eventType) => { + const { users, ...rest } = eventType; + return { + ...rest, + // Send userIds per event and keep the actual users object outside + userIds: users.map((user) => { + allUsersAcrossAllEventTypes.set(user.id, user); + return user.id; + }), + }; + }), + }; + }); + + return { + ...payload, + allUsersAcrossAllEventTypes, + eventTypeGroups, + }; } - 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 b7a7424552..082b5df9dd 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 - userIds: z.number().array().max(1).optional(), + upIds: z.string().array().max(1).optional(), }); export const ZEventTypeInputSchema = z