From 19634073e9c964b1be4cb660817b33fa6eaea8ba Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Thu, 29 Aug 2024 13:32:54 +0530 Subject: [PATCH] refactor: improve team members page performance (#16155) * fix: simplify workflow page and improve load time * chore: use new endpoint * chore: save progress * refactor: code * refactor: remove not requried code * chore: remove schema * chore: fix typ * chore: improve * chore: change name * chore: remove unused * chore: remove page * refactor: teams page * feat: add auto scroll * chore: create validate unique invite * fix: auth check * fix: optimistic update * chore * fix: add loading * fix: improvements * chore: remove * chore * chore: fix teams page * fix: team profile page * fix: appearance page * fix: sso view * fix: type err * feat: defer loading connected Apps * fix: type err * fix: type error * fix: type err * fix: connectedApps type * chore: move * chore: missing export * feat: add search by name * fix: display role change * fix: use setInfiniteData * chore: save progress * test: add unit tests for loading members * fix: test * chore: update name * fix: bugs and improvements * chore: change variable name * test: add tests for checkCanAccessMembers * refactor: performance --------- Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> --- .../utils/bookingScenario/bookingScenario.ts | 1 + .../features/ee/sso/page/teams-sso-view.tsx | 11 +- .../components/DisableTeamImpersonation.tsx | 1 - .../components/InviteLinkSettingsModal.tsx | 2 + .../components/MakeTeamPrivateSwitch.tsx | 3 +- .../components/MemberChangeRoleModal.tsx | 56 ++++ .../components/MemberInvitationModal.tsx | 131 +++++++-- .../ee/teams/components/MemberListItem.tsx | 77 ++++- .../components/TeamAvailabilityModal.tsx | 6 +- .../ee/teams/components/TeamListItem.tsx | 64 +--- .../ee/teams/pages/team-appearance-view.tsx | 13 +- .../ee/teams/pages/team-members-view.tsx | 214 +++++++------- .../ee/teams/pages/team-profile-view.tsx | 6 +- packages/lib/server/queries/teams/index.ts | 98 +++++++ packages/lib/server/repository/membership.ts | 9 + .../server/routers/viewer/teams/_router.tsx | 32 ++ .../teams/checkIfMembershipExists.handler.ts | 35 +++ .../teams/checkIfMembershipExists.schema.ts | 8 + .../viewer/teams/getMinimal.handler.test.ts | 81 +++++ .../viewer/teams/getMinimal.handler.ts | 49 ++++ .../routers/viewer/teams/getMinimal.schema.ts | 8 + .../teams/getUserConnectedApps.handler.ts | 158 ++++++++++ .../teams/getUserConnectedApps.schema.ts | 8 + .../viewer/teams/lazyLoadMembers.handler.ts | 150 ++++++++++ .../viewer/teams/lazyLoadMembers.schema.ts | 10 + .../viewer/teams/lazyLoadMembers.test.ts | 277 ++++++++++++++++++ 26 files changed, 1313 insertions(+), 195 deletions(-) create mode 100644 packages/trpc/server/routers/viewer/teams/checkIfMembershipExists.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/checkIfMembershipExists.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/getMinimal.handler.test.ts create mode 100644 packages/trpc/server/routers/viewer/teams/getMinimal.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/getMinimal.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/getUserConnectedApps.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/getUserConnectedApps.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/lazyLoadMembers.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/lazyLoadMembers.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/lazyLoadMembers.test.ts diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 48868bdafa..59ab19bb54 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -116,6 +116,7 @@ type InputUser = Omit & { name: string; slug: string; parentId?: number; + isPrivate?: boolean; }; }[]; schedules: { diff --git a/packages/features/ee/sso/page/teams-sso-view.tsx b/packages/features/ee/sso/page/teams-sso-view.tsx index 9832f4f6dd..695197f0ae 100644 --- a/packages/features/ee/sso/page/teams-sso-view.tsx +++ b/packages/features/ee/sso/page/teams-sso-view.tsx @@ -19,7 +19,16 @@ const SAMLSSO = () => { const teamId = Number(params.id); - const { data: team, isPending, error } = trpc.viewer.teams.get.useQuery({ teamId }); + const { + data: team, + isPending, + error, + } = trpc.viewer.teams.getMinimal.useQuery( + { teamId }, + { + enabled: !!teamId, + } + ); useEffect(() => { if (!HOSTED_CAL_FEATURES) { diff --git a/packages/features/ee/teams/components/DisableTeamImpersonation.tsx b/packages/features/ee/teams/components/DisableTeamImpersonation.tsx index 0503f1710b..d87c84aebe 100644 --- a/packages/features/ee/teams/components/DisableTeamImpersonation.tsx +++ b/packages/features/ee/teams/components/DisableTeamImpersonation.tsx @@ -40,7 +40,6 @@ const DisableTeamImpersonation = ({ setAllowImpersonation(_allowImpersonation); mutation.mutate({ teamId, memberId, disableImpersonation: !_allowImpersonation }); }} - switchContainerClassName="mt-6" /> ); diff --git a/packages/features/ee/teams/components/InviteLinkSettingsModal.tsx b/packages/features/ee/teams/components/InviteLinkSettingsModal.tsx index 13aa175255..2efab7f0af 100644 --- a/packages/features/ee/teams/components/InviteLinkSettingsModal.tsx +++ b/packages/features/ee/teams/components/InviteLinkSettingsModal.tsx @@ -26,6 +26,7 @@ export default function InviteLinkSettingsModal(props: InvitationLinkSettingsMod showToast(t("invite_link_deleted"), "success"); trpcContext.viewer.teams.get.invalidate(); trpcContext.viewer.teams.list.invalidate(); + trpcContext.viewer.teams.getMinimal.invalidate(); props.onExit(); }, onError: (e) => { @@ -38,6 +39,7 @@ export default function InviteLinkSettingsModal(props: InvitationLinkSettingsMod showToast(t("invite_link_updated"), "success"); trpcContext.viewer.teams.get.invalidate(); trpcContext.viewer.teams.list.invalidate(); + trpcContext.viewer.teams.getMinimal.invalidate(); }, onError: (e) => { showToast(e.message, "error"); diff --git a/packages/features/ee/teams/components/MakeTeamPrivateSwitch.tsx b/packages/features/ee/teams/components/MakeTeamPrivateSwitch.tsx index d8633902b7..71e62f76ca 100644 --- a/packages/features/ee/teams/components/MakeTeamPrivateSwitch.tsx +++ b/packages/features/ee/teams/components/MakeTeamPrivateSwitch.tsx @@ -25,6 +25,7 @@ const MakeTeamPrivateSwitch = ({ }, async onSuccess() { await utils.viewer.teams.get.invalidate(); + await utils.viewer.teams.getMinimal.invalidate(); showToast(t(isOrg ? "your_org_updated_successfully" : "your_team_updated_successfully"), "success"); }, }); @@ -43,7 +44,7 @@ const MakeTeamPrivateSwitch = ({ setTeamPrivate(checked); mutation.mutate({ id: teamId, isPrivate: checked }); }} - switchContainerClassName="mt-6" + switchContainerClassName="my-6" data-testid="make-team-private-check" /> diff --git a/packages/features/ee/teams/components/MemberChangeRoleModal.tsx b/packages/features/ee/teams/components/MemberChangeRoleModal.tsx index dbe062d510..3f6379f5d9 100644 --- a/packages/features/ee/teams/components/MemberChangeRoleModal.tsx +++ b/packages/features/ee/teams/components/MemberChangeRoleModal.tsx @@ -11,6 +11,47 @@ type MembershipRoleOption = { value: MembershipRole; }; +const updateRoleInCache = ({ + utils, + teamId, + searchTerm, + role, + memberId, +}: { + utils: ReturnType; + teamId: number; + searchTerm: string | undefined; + role: MembershipRole; + memberId: number; +}) => { + utils.viewer.teams.lazyLoadMembers.setInfiniteData( + { + limit: 10, + teamId, + searchTerm, + }, + (data) => { + if (!data) { + return { + pages: [], + pageParams: [], + }; + } + + return { + ...data, + pages: data.pages.map((page) => ({ + ...page, + members: page.members.map((member) => ({ + ...member, + role: member.id === memberId ? role : member.role, + })), + })), + }; + } + ); +}; + export default function MemberChangeRoleModal(props: { isOpen: boolean; currentMember: MembershipRole; @@ -18,6 +59,7 @@ export default function MemberChangeRoleModal(props: { teamId: number; initialRole: MembershipRole; onExit: () => void; + searchTerm?: string; }) { const { t } = useLocale(); @@ -48,6 +90,20 @@ export default function MemberChangeRoleModal(props: { const utils = trpc.useUtils(); const changeRoleMutation = trpc.viewer.teams.changeMemberRole.useMutation({ + onMutate: async ({ teamId, memberId, role }) => { + await utils.viewer.teams.lazyLoadMembers.cancel(); + const previousValue = utils.viewer.teams.lazyLoadMembers.getInfiniteData({ + limit: 10, + teamId: teamId, + searchTerm: props.searchTerm, + }); + + if (previousValue) { + updateRoleInCache({ utils, teamId, memberId, role, searchTerm: props.searchTerm }); + } + + return { previousValue }; + }, async onSuccess() { await utils.viewer.teams.get.invalidate(); await utils.viewer.organizations.listMembers.invalidate(); diff --git a/packages/features/ee/teams/components/MemberInvitationModal.tsx b/packages/features/ee/teams/components/MemberInvitationModal.tsx index c499fa6973..7660dbb2e8 100644 --- a/packages/features/ee/teams/components/MemberInvitationModal.tsx +++ b/packages/features/ee/teams/components/MemberInvitationModal.tsx @@ -7,6 +7,7 @@ import { Controller, useForm } from "react-hook-form"; import TeamInviteFromOrg from "@calcom/ee/organizations/components/TeamInviteFromOrg"; import { classNames } from "@calcom/lib"; import { IS_TEAM_BILLING_ENABLED, MAX_NB_INVITES } from "@calcom/lib/constants"; +import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { MembershipRole } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc"; @@ -42,6 +43,7 @@ type MemberInvitationModalProps = { isPending?: boolean; disableCopyLink?: boolean; isOrg?: boolean; + checkMembershipMutation?: boolean; }; type MembershipRoleOption = { @@ -74,17 +76,16 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) enabled: !!session.data?.user?.org, }); + const checkIfMembershipExistsMutation = trpc.viewer.teams.checkIfMembershipExists.useMutation(); + // Check current org role and not team role const isOrgAdminOrOwner = currentOrg && (currentOrg.user.role === MembershipRole.OWNER || currentOrg.user.role === MembershipRole.ADMIN); - const canSeeOrganization = !!( - props?.orgMembers && - props.orgMembers?.length > 0 && - currentOrg?.isPrivate && - isOrgAdminOrOwner - ); + const canSeeOrganization = currentOrg?.isPrivate + ? isOrgAdminOrOwner + : !!(props?.orgMembers && props.orgMembers?.length > 0 && isOrgAdminOrOwner); const [modalImportMode, setModalInputMode] = useState( canSeeOrganization ? "ORGANIZATION" : "INDIVIDUAL" @@ -136,12 +137,19 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) const newMemberFormMethods = useForm(); - const validateUniqueInvite = (value: string) => { - if (!props?.members?.length) return true; - return !( - props?.members.some((member) => member?.username === value) || - props?.members.some((member) => member?.email === value) - ); + const checkIfMembershipExists = (value: string) => { + if (props.checkMembershipMutation) { + return checkIfMembershipExistsMutation.mutateAsync({ + teamId: props.teamId, + value, + }); + } else { + if (!props?.members?.length) return false; + return ( + props?.members.some((member) => member?.username === value) || + props?.members.some((member) => member?.email === value) + ); + } }; const handleFileUpload = (e: FileEvent) => { @@ -207,7 +215,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) ) : null }> -
+
@@ -231,11 +239,13 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) control={newMemberFormMethods.control} rules={{ required: t("enter_email"), - validate: (value) => { + validate: async (value) => { // orgs can only invite members by email if (typeof value === "string" && !isEmail(value)) return t("enter_email"); - if (typeof value === "string") - return validateUniqueInvite(value) || t("member_already_invited"); + if (typeof value === "string") { + const doesInviteExists = await checkIfMembershipExists(value); + return !doesInviteExists || t("member_already_invited"); + } }, }} render={({ field: { onChange }, fieldState: { error } }) => ( @@ -437,7 +447,9 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) {t("cancel")} +
); } const MembersView = () => { const searchParams = useCompatSearchParams(); - const { t, i18n } = useLocale(); + const { t } = useLocale(); const router = useRouter(); const session = useSession(); const org = session?.data?.user.org; - const utils = trpc.useUtils(); const params = useParamsWithFallback(); const teamId = Number(params.id); @@ -93,27 +147,17 @@ const MembersView = () => { const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(showDialog); const [showInviteLinkSettingsModal, setInviteLinkSettingsModal] = useState(false); - const { data: orgMembersNotInThisTeam, isPending: isOrgListLoading } = - trpc.viewer.organizations.getMembers.useQuery( - { - teamIdToExclude: teamId, - distinctUser: true, - }, - { - enabled: searchParams !== null && !!teamId, - } - ); - const { data: team, isPending: isTeamsLoading, error: teamError, - } = trpc.viewer.teams.get.useQuery( + } = trpc.viewer.teams.getMinimal.useQuery( { teamId }, { enabled: !!teamId, } ); + useEffect( function refactorMeWithoutEffect() { if (teamError) { @@ -123,9 +167,7 @@ const MembersView = () => { [teamError] ); - const isPending = isOrgListLoading || isTeamsLoading; - - const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation(); + const isPending = isTeamsLoading; const isInviteOpen = !team?.membership.accepted; @@ -134,6 +176,10 @@ const MembersView = () => { const isOrgAdminOrOwner = org?.role === MembershipRole.OWNER || org?.role === MembershipRole.ADMIN; + const hideInvitationModal = () => { + setShowMemberInvitationModal(false); + }; + return ( <> { )} - {((team?.isPrivate && isAdmin) || !team?.isPrivate || isOrgAdminOrOwner) && ( - <> - - - )} - - {team && session.data && ( + {team && team.id && session.data && ( { /> )} - {team && (isAdmin || isOrgAdminOrOwner) && ( + {team && team.id && (isAdmin || isOrgAdminOrOwner) && ( )} + + {((team?.isPrivate && isAdmin) || !team?.isPrivate || isOrgAdminOrOwner) && team && ( + <> + + + )} - {showMemberInvitationModal && team && ( - setShowMemberInvitationModal(false)} - onSubmit={(values, resetFields) => { - inviteMemberMutation.mutate( - { - teamId, - language: i18n.language, - role: values.role, - usernameOrEmail: values.emailOrUsername, - }, - { - onSuccess: async (data) => { - await utils.viewer.teams.get.invalidate(); - await utils.viewer.organizations.getMembers.invalidate(); - setShowMemberInvitationModal(false); - - if (Array.isArray(data.usernameOrEmail)) { - showToast( - t("email_invite_team_bulk", { - userCount: data.numUsersInvited, - }), - "success" - ); - resetFields(); - } else { - showToast( - t("email_invite_team", { - email: data.usernameOrEmail, - }), - "success" - ); - } - }, - onError: (error) => { - showToast(error.message, "error"); - }, - } - ); - }} - onSettingsOpen={() => { - setShowMemberInvitationModal(false); - setInviteLinkSettingsModal(true); - }} + onSettingsOpen={() => setInviteLinkSettingsModal(true)} /> )} - {showInviteLinkSettingsModal && team?.inviteToken && ( + + {showInviteLinkSettingsModal && team?.inviteToken && team.id && ( { data: team, isPending, error, - } = trpc.viewer.teams.get.useQuery( + } = trpc.viewer.teams.getMinimal.useQuery( { teamId }, { enabled: !!teamId, @@ -179,7 +179,7 @@ const ProfileView = () => {
)} @@ -251,7 +251,7 @@ const ProfileView = () => { ); }; -export type TeamProfileFormProps = { team: RouterOutputs["viewer"]["teams"]["get"] }; +export type TeamProfileFormProps = { team: RouterOutputs["viewer"]["teams"]["getMinimal"] }; const TeamProfileForm = ({ team }: TeamProfileFormProps) => { const utils = trpc.useUtils(); diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts index 647c4fa230..97663874f0 100644 --- a/packages/lib/server/queries/teams/index.ts +++ b/packages/lib/server/queries/teams/index.ts @@ -260,6 +260,104 @@ export async function getTeamWithMembers(args: { }; } +export async function getMinimalTeam(args: { + id?: number; + slug?: string; + userId?: number; + orgSlug?: string | null; + /** + * If true, means that you are fetching an organization and not a team + */ + isOrgView?: boolean; +}) { + const { id, slug, userId, orgSlug, isOrgView } = args; + + let lookupBy; + + if (id) { + lookupBy = { id, havingMemberWithId: userId }; + } else if (slug) { + lookupBy = { slug, havingMemberWithId: userId }; + } else { + throw new Error("Must provide either id or slug"); + } + + const arg = { + lookupBy, + forOrgWithSlug: orgSlug ?? null, + isOrg: !!isOrgView, + teamSelect: { + id: true, + name: true, + slug: true, + isOrganization: true, + logoUrl: true, + bio: true, + hideBranding: true, + hideBookATeamMember: true, + isPrivate: true, + metadata: true, + parent: { + select: { + id: true, + slug: true, + name: true, + isPrivate: true, + isOrganization: true, + logoUrl: true, + metadata: true, + }, + }, + parentId: true, + children: { + select: { + name: true, + slug: true, + }, + }, + theme: true, + brandColor: true, + darkBrandColor: true, + inviteTokens: { + select: { + token: true, + expires: true, + expiresInDays: true, + identifier: true, + }, + }, + }, + } as const; + + const teamOrOrg = isOrgView ? await getOrg(arg) : await getTeam(arg); + + if (!teamOrOrg) return null; + + // Don't leak invite tokens to the frontend + const { inviteTokens, ...teamWithoutInviteTokens } = teamOrOrg; + + // Don't leak stripe payment ids + const teamMetadata = teamOrOrg.metadata; + const { + paymentId: _, + subscriptionId: __, + subscriptionItemId: ___, + ...restTeamMetadata + } = teamMetadata || {}; + + return { + ...teamWithoutInviteTokens, + ...(teamWithoutInviteTokens.logoUrl ? { logo: teamWithoutInviteTokens.logoUrl } : {}), + /** To prevent breaking we only return non-email attached token here, if we have one */ + inviteToken: inviteTokens.find( + (token) => + token.identifier === `invite-link-for-teamId-${teamOrOrg.id}` && + token.expires > new Date(new Date().setHours(24)) + ), + metadata: restTeamMetadata, + }; +} + // also returns team export async function isTeamAdmin(userId: number, teamId: number) { const team = await prisma.membership.findFirst({ diff --git a/packages/lib/server/repository/membership.ts b/packages/lib/server/repository/membership.ts index 0b843d26cd..c0c7226edd 100644 --- a/packages/lib/server/repository/membership.ts +++ b/packages/lib/server/repository/membership.ts @@ -222,4 +222,13 @@ export class MembershipRepository { }, }); } + + static async findFirstByUserIdAndTeamId({ userId, teamId }: { userId: number; teamId: number }) { + return await prisma.membership.findFirst({ + where: { + userId, + teamId, + }, + }); + } } diff --git a/packages/trpc/server/routers/viewer/teams/_router.tsx b/packages/trpc/server/routers/viewer/teams/_router.tsx index 115799d733..2dc3bc7599 100644 --- a/packages/trpc/server/routers/viewer/teams/_router.tsx +++ b/packages/trpc/server/routers/viewer/teams/_router.tsx @@ -2,6 +2,7 @@ import authedProcedure from "../../../procedures/authedProcedure"; import { importHandler, router } from "../../../trpc"; import { ZAcceptOrLeaveInputSchema } from "./acceptOrLeave.schema"; import { ZChangeMemberRoleInputSchema } from "./changeMemberRole.schema"; +import { ZCheckIfMembershipExistsInputSchema } from "./checkIfMembershipExists.schema"; import { ZCreateInputSchema } from "./create.schema"; import { ZCreateInviteInputSchema } from "./createInvite.schema"; import { ZDeleteInputSchema } from "./delete.schema"; @@ -9,9 +10,12 @@ import { ZDeleteInviteInputSchema } from "./deleteInvite.schema"; import { ZGetInputSchema } from "./get.schema"; import { ZGetMemberAvailabilityInputSchema } from "./getMemberAvailability.schema"; import { ZGetMembershipbyUserInputSchema } from "./getMembershipbyUser.schema"; +import { ZGetMinimalSchema } from "./getMinimal.schema"; +import { ZGetUserConnectedAppsInputSchema } from "./getUserConnectedApps.schema"; import { ZHasEditPermissionForUserSchema } from "./hasEditPermissionForUser.schema"; import { ZInviteMemberInputSchema } from "./inviteMember/inviteMember.schema"; import { ZInviteMemberByTokenSchemaInputSchema } from "./inviteMemberByToken.schema"; +import { ZLazyLoadMembersInputSchema } from "./lazyLoadMembers.schema"; import { ZGetListSchema } from "./list.schema"; import { ZListMembersInputSchema } from "./listMembers.schema"; import { hasTeamPlan } from "./procedures/hasTeamPlan"; @@ -32,6 +36,11 @@ export const viewerTeamsRouter = router({ const handler = await importHandler(namespaced("get"), () => import("./get.handler")); return handler(opts); }), + // Returns team + getMinimal: authedProcedure.input(ZGetMinimalSchema).query(async (opts) => { + const handler = await importHandler(namespaced("getMinimal"), () => import("./getMinimal.handler")); + return handler(opts); + }), // Returns teams I a member of list: authedProcedure.input(ZGetListSchema).query(async (opts) => { const handler = await importHandler(namespaced("list"), () => import("./list.handler")); @@ -114,6 +123,20 @@ export const viewerTeamsRouter = router({ const handler = await importHandler(namespaced("listMembers"), () => import("./listMembers.handler")); return handler(opts); }), + lazyLoadMembers: authedProcedure.input(ZLazyLoadMembersInputSchema).query(async (opts) => { + const handler = await importHandler( + namespaced("lazyLoadMembers"), + () => import("./lazyLoadMembers.handler") + ); + return handler(opts); + }), + getUserConnectedApps: authedProcedure.input(ZGetUserConnectedAppsInputSchema).query(async (opts) => { + const handler = await importHandler( + namespaced("getUserConnectedApps"), + () => import("./getUserConnectedApps.handler") + ); + return handler(opts); + }), hasTeamPlan, listInvites: authedProcedure.query(async (opts) => { const handler = await importHandler(namespaced("listInvites"), () => import("./listInvites.handler")); @@ -162,4 +185,13 @@ export const viewerTeamsRouter = router({ ); return handler(opts); }), + checkIfMembershipExists: authedProcedure + .input(ZCheckIfMembershipExistsInputSchema) + .mutation(async (opts) => { + const handler = await importHandler( + namespaced("checkIfMembershipExists"), + () => import("./checkIfMembershipExists.handler") + ); + return handler(opts); + }), }); diff --git a/packages/trpc/server/routers/viewer/teams/checkIfMembershipExists.handler.ts b/packages/trpc/server/routers/viewer/teams/checkIfMembershipExists.handler.ts new file mode 100644 index 0000000000..efa7ffa7da --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/checkIfMembershipExists.handler.ts @@ -0,0 +1,35 @@ +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TCheckIfMembershipExistsInputSchema } from "./checkIfMembershipExists.schema"; + +type CheckIfMembershipExistsOptions = { + ctx: { + user: NonNullable; + }; + input: TCheckIfMembershipExistsInputSchema; +}; + +const checkIfMembershipExistsHandler = async ({ ctx, input }: CheckIfMembershipExistsOptions) => { + const { teamId, value } = input; + + const membership = await prisma.membership.findFirst({ + where: { + teamId, + user: { + OR: [ + { + email: value, + }, + { + username: value, + }, + ], + }, + }, + }); + + return !!membership; +}; + +export default checkIfMembershipExistsHandler; diff --git a/packages/trpc/server/routers/viewer/teams/checkIfMembershipExists.schema.ts b/packages/trpc/server/routers/viewer/teams/checkIfMembershipExists.schema.ts new file mode 100644 index 0000000000..a7b7f7e19b --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/checkIfMembershipExists.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZCheckIfMembershipExistsInputSchema = z.object({ + teamId: z.number(), + value: z.string(), +}); + +export type TCheckIfMembershipExistsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/getMinimal.handler.test.ts b/packages/trpc/server/routers/viewer/teams/getMinimal.handler.test.ts new file mode 100644 index 0000000000..a36066aab1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getMinimal.handler.test.ts @@ -0,0 +1,81 @@ +import { + createBookingScenario, + TestData, + getOrganizer, + getScenarioData, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; + +import { describe, it, beforeEach, vi, expect } from "vitest"; + +import type { TrpcSessionUser } from "../../../trpc"; +import getTeamWithMinimalData from "./getMinimal.handler"; + +describe("getTeamWithMinimalData", () => { + beforeEach(() => { + // Reset all mocks before each test + vi.clearAllMocks(); + }); + + it("should return team", async () => { + const team = { + id: 1, + name: "Team 1", + slug: "team-1", + }; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + teams: [ + { + membership: { + role: "ADMIN", + accepted: true, + }, + team, + }, + ], + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + }) + ); + + const ctx = { + user: { + id: organizer.id, + name: organizer.name, + } as NonNullable, + }; + + const result = await getTeamWithMinimalData({ + ctx, + input: { + teamId: team.id, + }, + }); + + expect(result).toContain({ + id: team.id, + name: team.name, + slug: team.slug, + isOrganization: false, + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/teams/getMinimal.handler.ts b/packages/trpc/server/routers/viewer/teams/getMinimal.handler.ts new file mode 100644 index 0000000000..f3b44e3220 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getMinimal.handler.ts @@ -0,0 +1,49 @@ +import { getMinimalTeam } from "@calcom/lib/server/queries/teams"; +import { MembershipRepository } from "@calcom/lib/server/repository/membership"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TGetMinimalInputSchema } from "./getMinimal.schema"; + +type GetMinimalDataOptions = { + ctx: { + user: NonNullable; + }; + input: TGetMinimalInputSchema; +}; + +export const getMinimal = async ({ ctx, input }: GetMinimalDataOptions) => { + const teamMembership = await MembershipRepository.findFirstByUserIdAndTeamId({ + userId: ctx.user.id, + teamId: input.teamId, + }); + + if (!teamMembership) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not a member of this team.", + }); + } + + const team = await getMinimalTeam({ + id: input.teamId, + userId: ctx.user.organization?.isOrgAdmin ? undefined : ctx.user.id, + isOrgView: input?.isOrg, + }); + + if (!team) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Team not found", + }); + } + + const membership = { + role: teamMembership.role, + accepted: teamMembership.accepted, + }; + return { ...team, membership }; +}; + +export default getMinimal; diff --git a/packages/trpc/server/routers/viewer/teams/getMinimal.schema.ts b/packages/trpc/server/routers/viewer/teams/getMinimal.schema.ts new file mode 100644 index 0000000000..0237b1fef0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getMinimal.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZGetMinimalSchema = z.object({ + teamId: z.number(), + isOrg: z.boolean().optional(), +}); + +export type TGetMinimalInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/getUserConnectedApps.handler.ts b/packages/trpc/server/routers/viewer/teams/getUserConnectedApps.handler.ts new file mode 100644 index 0000000000..2c89d9e360 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getUserConnectedApps.handler.ts @@ -0,0 +1,158 @@ +import { Prisma } from "@prisma/client"; + +import { getAppFromSlug } from "@calcom/app-store/utils"; +import { prisma } from "@calcom/prisma"; +import type { AppCategories } from "@calcom/prisma/enums"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TGetUserConnectedAppsInputSchema } from "./getUserConnectedApps.schema"; + +type GetUserConnectedAppsOptions = { + ctx: { + user: NonNullable; + }; + input: TGetUserConnectedAppsInputSchema; +}; + +const credentialSelect = Prisma.validator()({ + userId: true, + app: { + select: { + slug: true, + categories: true, + }, + }, + destinationCalendars: { + select: { + externalId: true, + }, + }, +}); + +type Credential = Prisma.CredentialGetPayload<{ select: typeof credentialSelect }>; + +type Apps = { + name: string | null; + logo: string | null; + externalId: string | null; + app: { slug: string; categories: AppCategories[] } | null; +}; + +// This should improve performance saving already app data found. +const appDataMap = new Map(); + +const checkCanUserAccessConnectedApps = async ( + user: NonNullable, + teamId: number, + userIds: number[] +) => { + // Check if the user is a member of the team or an admin/owner of the org + const team = await prisma.team.findUnique({ + where: { id: teamId }, + select: { + id: true, + parent: { + select: { + id: true, + }, + }, + }, + }); + + if (!team) { + throw new Error("Team not found"); + } + + const isMember = await prisma.membership.findFirst({ + where: { + userId: user.id, + teamId: teamId, + }, + }); + + const isOrgAdminOrOwner = + team.parent && + (await prisma.membership.findFirst({ + where: { + userId: user.id, + teamId: team.parent.id, + OR: [{ role: "ADMIN" }, { role: "OWNER" }], + }, + })); + + if (!isMember && !isOrgAdminOrOwner) { + throw new Error("User is not authorized to access this team's connected apps"); + } + + // Check if all userIds belong to the team + const teamMembers = await prisma.membership.findMany({ + where: { + teamId, + userId: { + in: userIds, + }, + }, + select: { + userId: true, + }, + }); + + if (teamMembers.length !== userIds.length) { + const teamMemberIds = teamMembers.map((member) => member.userId); + const invalidUserIds = userIds.filter((id) => !teamMemberIds.includes(id)); + + if (invalidUserIds.length > 0) { + throw new Error(`Some user IDs do not belong to the team: ${invalidUserIds.join(", ")}`); + } + } +}; + +export const getUserConnectedAppsHandler = async ({ ctx, input }: GetUserConnectedAppsOptions) => { + const { userIds, teamId } = input; + + await checkCanUserAccessConnectedApps(ctx.user, teamId, userIds); + + const credentialsPromises: Promise[] = []; + const userConnectedAppsMap: Record = {}; + + for (const userId of userIds) { + const cred = prisma.credential.findMany({ + where: { + userId, + }, + select: credentialSelect, + }); + credentialsPromises.push(cred); + } + + const credentialsList = await Promise.all(credentialsPromises); + + for (const credentials of credentialsList) { + const userId = credentials[0]?.userId; + + if (userId) { + userConnectedAppsMap[userId] = credentials?.map((cred) => { + const appSlug = cred.app?.slug; + let appData = appDataMap.get(appSlug); + + if (!appData) { + appData = getAppFromSlug(appSlug); + appDataMap.set(appSlug, appData); + } + + const isCalendar = cred?.app?.categories?.includes("calendar") ?? false; + const externalId = isCalendar ? cred.destinationCalendars?.[0]?.externalId : null; + return { + name: appData?.name ?? null, + logo: appData?.logo ?? null, + app: cred.app, + externalId: externalId ?? null, + }; + }); + } + } + + return userConnectedAppsMap; +}; + +export default getUserConnectedAppsHandler; diff --git a/packages/trpc/server/routers/viewer/teams/getUserConnectedApps.schema.ts b/packages/trpc/server/routers/viewer/teams/getUserConnectedApps.schema.ts new file mode 100644 index 0000000000..4f7a4d84d8 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getUserConnectedApps.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZGetUserConnectedAppsInputSchema = z.object({ + userIds: z.array(z.number()), + teamId: z.number(), +}); + +export type TGetUserConnectedAppsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/lazyLoadMembers.handler.ts b/packages/trpc/server/routers/viewer/teams/lazyLoadMembers.handler.ts new file mode 100644 index 0000000000..8ca563ab42 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/lazyLoadMembers.handler.ts @@ -0,0 +1,150 @@ +import { Prisma } from "@prisma/client"; + +// import { getAppFromSlug } from "@calcom/app-store/utils"; +import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client"; +import { UserRepository } from "@calcom/lib/server/repository/user"; +import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TLazyLoadMembersInputSchema } from "./lazyLoadMembers.schema"; + +type LazyLoadMembersHandlerOptions = { + ctx: { + user: NonNullable; + }; + input: TLazyLoadMembersInputSchema; +}; + +const userSelect = Prisma.validator()({ + username: true, + email: true, + name: true, + avatarUrl: true, + id: true, + bio: true, + disableImpersonation: true, +}); + +export const lazyLoadMembersHandler = async ({ ctx, input }: LazyLoadMembersHandlerOptions) => { + const { cursor, limit, teamId, searchTerm } = input; + + const canAccessMembers = await checkCanAccessMembers(ctx, teamId); + + if (!canAccessMembers) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to see members of the team", + }); + } + + const teamMembers = await prisma.membership.findMany({ + where: { + teamId, + ...(searchTerm && { + user: { + OR: [ + { + email: { + contains: searchTerm, + mode: "insensitive", + }, + }, + { + username: { + contains: searchTerm, + mode: "insensitive", + }, + }, + { + name: { + contains: searchTerm, + mode: "insensitive", + }, + }, + ], + }, + }), + }, + select: { + id: true, + role: true, + accepted: true, + user: { + select: userSelect, + }, + }, + cursor: cursor ? { id: cursor } : undefined, + take: limit + 1, // We take +1 as itll be used for the next cursor + orderBy: { + id: "asc", + }, + }); + + let nextCursor: typeof cursor | undefined = undefined; + if (teamMembers && teamMembers.length > limit) { + const nextItem = teamMembers.pop(); + nextCursor = nextItem?.id; + } + + const membersWithApps = await Promise.all( + teamMembers.map(async (member) => { + const user = await UserRepository.enrichUserWithItsProfile({ + user: member.user, + }); + const { profile, ...restUser } = user; + return { + ...restUser, + username: profile?.username ?? restUser.username, + role: member.role, + profile: profile, + organizationId: profile?.organizationId ?? null, + organization: profile?.organization, + accepted: member.accepted, + disableImpersonation: user.disableImpersonation, + bookerUrl: getBookerBaseUrlSync(profile?.organization?.slug || ""), + }; + }) + ); + + return { members: membersWithApps, nextCursor }; +}; + +const checkCanAccessMembers = async (ctx: LazyLoadMembersHandlerOptions["ctx"], teamId: number) => { + const isOrgPrivate = ctx.user.profile?.organization?.isPrivate; + const isOrgAdminOrOwner = ctx.user.organization?.isOrgAdmin; + const orgId = ctx.user.organizationId; + const isTargetingOrg = teamId === ctx.user.organizationId; + + if (isTargetingOrg) { + return isOrgPrivate && !isOrgAdminOrOwner; + } + const team = await prisma.team.findUnique({ + where: { + id: teamId, + }, + }); + + if (isOrgAdminOrOwner && team?.parentId === orgId) { + return true; + } + + const membership = await prisma.membership.findFirst({ + where: { + teamId, + userId: ctx.user.id, + }, + }); + + const isTeamAdminOrOwner = + membership?.role === MembershipRole.OWNER || membership?.role === MembershipRole.ADMIN; + + if (team?.isPrivate && !isTeamAdminOrOwner) { + return false; + } + return true; +}; + +export default lazyLoadMembersHandler; diff --git a/packages/trpc/server/routers/viewer/teams/lazyLoadMembers.schema.ts b/packages/trpc/server/routers/viewer/teams/lazyLoadMembers.schema.ts new file mode 100644 index 0000000000..a19c8a28ec --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/lazyLoadMembers.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const ZLazyLoadMembersInputSchema = z.object({ + teamId: z.number(), + limit: z.number().default(10), + searchTerm: z.string().optional(), + cursor: z.number().optional().nullable(), +}); + +export type TLazyLoadMembersInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/lazyLoadMembers.test.ts b/packages/trpc/server/routers/viewer/teams/lazyLoadMembers.test.ts new file mode 100644 index 0000000000..7da07844fa --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/lazyLoadMembers.test.ts @@ -0,0 +1,277 @@ +import { + createBookingScenario, + TestData, + getOrganizer, + getScenarioData, + addUsers, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; + +import { describe, it, beforeEach, vi, expect } from "vitest"; + +import type { TrpcSessionUser } from "../../../trpc"; +import lazyLoadMembers from "./lazyLoadMembers.handler"; + +const createTeamWithMembers = async ({ isPrivate = false }: { isPrivate?: boolean }) => { + const team = { + id: 1, + name: "Team 1", + slug: "team-1", + isPrivate, + }; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + teams: [ + { + membership: { + role: "ADMIN", + accepted: true, + }, + team, + }, + ], + }); + + const user2 = getOrganizer({ + name: "User 2", + email: "user2@example.com", + id: 102, + schedules: [TestData.schedules.IstWorkHours], + teams: [ + { + membership: { + role: "MEMBER", + accepted: true, + }, + team, + }, + ], + }); + + const user3 = getOrganizer({ + name: "User 3", + email: "user3@example.com", + id: 103, + schedules: [TestData.schedules.IstWorkHours], + teams: [ + { + membership: { + role: "MEMBER", + accepted: true, + }, + team, + }, + ], + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + usersApartFromOrganizer: [user2, user3], + }) + ); + + return { + team, + organizer, + user2, + user3, + }; +}; + +describe("lazyLoadMembers", () => { + beforeEach(() => { + // Reset all mocks before each test + vi.clearAllMocks(); + }); + + it("should return team members for teams", async () => { + const { team, organizer, user2, user3 } = await createTeamWithMembers({ isPrivate: false }); + + const ctx = { + user: { + id: organizer.id, + name: organizer.name, + } as NonNullable, + }; + + const result = await lazyLoadMembers({ + ctx, + input: { + teamId: team.id, + limit: 10, + }, + }); + + expect(result.members).toEqual([ + expect.objectContaining({ + id: organizer.id, + name: organizer.name, + username: organizer.username, + }), + expect.objectContaining({ + id: user2.id, + name: user2.name, + username: user2.username, + }), + expect.objectContaining({ + id: user3.id, + name: user3.name, + username: user3.username, + }), + ]); + }); + + it("should return team members for private teams", async () => { + const { team, organizer, user2, user3 } = await createTeamWithMembers({ isPrivate: true }); + + //Logged in user is admin of the team + const ctx = { + user: { + id: organizer.id, + name: organizer.name, + } as NonNullable, + }; + + const result = await lazyLoadMembers({ + ctx, + input: { + teamId: team.id, + limit: 10, + }, + }); + + expect(result.members).toEqual([ + expect.objectContaining({ + id: organizer.id, + name: organizer.name, + username: organizer.username, + }), + expect.objectContaining({ + id: user2.id, + name: user2.name, + username: user2.username, + }), + expect.objectContaining({ + id: user3.id, + name: user3.name, + username: user3.username, + }), + ]); + }); + + it("should throw error if user is not admin/owner of the private team", async () => { + const { team, user2 } = await createTeamWithMembers({ isPrivate: true }); + + //Logged in user is not admin/owner of the team + const nonAdminUserCtx = { + user: { + id: user2.id, + name: user2.name, + } as NonNullable, + }; + + await expect( + lazyLoadMembers({ + ctx: nonAdminUserCtx, + input: { + teamId: team.id, + limit: 10, + }, + }) + ).rejects.toThrowError("You are not authorized to see members of the team"); + }); + + it("should throw error if user is not part of the private team", async () => { + const { team } = await createTeamWithMembers({ isPrivate: true }); + + const newUser = getOrganizer({ + name: "New User", + email: "newuser@example.com", + id: 104, + schedules: [TestData.schedules.IstWorkHours], + }); + + addUsers([newUser]); + + //Logged in user is not part of the team + const nonAdminUserCtx = { + user: { + id: newUser.id, + name: newUser.name, + } as NonNullable, + }; + + await expect( + lazyLoadMembers({ + ctx: nonAdminUserCtx, + input: { + teamId: team.id, + limit: 10, + }, + }) + ).rejects.toThrowError("You are not authorized to see members of the team"); + }); + + it("can search by name or email", async () => { + const { team, organizer } = await createTeamWithMembers({ isPrivate: false }); + + const ctx = { + user: { + id: organizer.id, + name: organizer.name, + } as NonNullable, + }; + + // Search by email + const searchByEmail = await lazyLoadMembers({ + ctx, + input: { + teamId: team.id, + limit: 10, + searchTerm: "organizer", + }, + }); + + expect(searchByEmail.members).toEqual([ + expect.objectContaining({ + id: organizer.id, + name: organizer.name, + username: organizer.username, + }), + ]); + + // Search by name + const searchByName = await lazyLoadMembers({ + ctx, + input: { + teamId: team.id, + limit: 10, + searchTerm: organizer.name, + }, + }); + + expect(searchByName.members).toEqual([ + expect.objectContaining({ + id: organizer.id, + name: organizer.name, + username: organizer.username, + }), + ]); + }); +});