From 13966e0ec82911044eac2fc7b43c9431aed16c5d Mon Sep 17 00:00:00 2001 From: Benny Joo Date: Tue, 10 Jun 2025 20:44:51 -0400 Subject: [PATCH] perf: use repository for queries & caching in /teams RSC (#21399) * cache trpc queries in /teams * fix * revalidate * refactor: migrate listHandler to TeamRepository.findTeamsByUserId Co-Authored-By: benny@cal.com * add comment * refactor * refactor * make it even more performant --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: benny@cal.com --- .../(main-nav)/teams/actions.ts | 4 +- .../(main-nav)/teams/page.tsx | 2 +- .../(main-nav)/teams/server-page.tsx | 43 ++++++++++-------- .../components/InviteLinkSettingsModal.tsx | 3 ++ .../components/MemberInvitationModal.tsx | 2 + packages/lib/server/repository/team.ts | 42 ++++++++++++++++++ .../routers/viewer/teams/list.handler.ts | 44 ++----------------- 7 files changed, 79 insertions(+), 61 deletions(-) diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/actions.ts b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/actions.ts index 1abab6d0d2..468819e093 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/actions.ts +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/actions.ts @@ -1,7 +1,7 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { revalidateTag } from "next/cache"; export async function revalidateTeamsList() { - revalidatePath("/teams"); + revalidateTag("viewer.teams.list"); } diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/page.tsx index 769a3a4644..66117c09bb 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/page.tsx @@ -30,7 +30,7 @@ const ServerPage = async ({ searchParams: _searchParams }: ServerPageProps) => { } const t = await getTranslate(); - const { Main, CTA } = await ServerTeamsListing({ searchParams }); + const { Main, CTA } = await ServerTeamsListing({ searchParams, session }); return ( diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/server-page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/server-page.tsx index cadba3be39..6947ddc079 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/server-page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/server-page.tsx @@ -1,44 +1,51 @@ import { createRouterCaller } from "app/_trpc/context"; import type { SearchParams } from "app/_types"; +import type { Session } from "next-auth"; +import { unstable_cache } from "next/cache"; import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing"; -import { CreationSource } from "@calcom/prisma/enums"; +import { TeamRepository } from "@calcom/lib/server/repository/team"; import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router"; -import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router"; import { TRPCError } from "@trpc/server"; import { TeamsCTA } from "./CTA"; -export const ServerTeamsListing = async ({ searchParams }: { searchParams: SearchParams }) => { - const token = Array.isArray(searchParams?.token) ? searchParams.token[0] : searchParams?.token; +const getCachedTeams = unstable_cache( + async (userId: number) => { + return await TeamRepository.findTeamsByUserId({ + userId, + includeOrgs: true, + }); + }, + undefined, + { revalidate: 3600, tags: ["viewer.teams.list"] } // Cache for 1 hour +); - const [teamsCaller, meCaller] = await Promise.all([ - createRouterCaller(viewerTeamsRouter), - createRouterCaller(meRouter), - ]); +export const ServerTeamsListing = async ({ + searchParams, + session, +}: { + searchParams: SearchParams; + session: Session; +}) => { + const token = Array.isArray(searchParams?.token) ? searchParams.token[0] : searchParams?.token; + const userId = session.user.id; let teamNameFromInvite, errorMsgFromInvite = null; if (token) { try { - teamNameFromInvite = await teamsCaller.inviteMemberByToken({ - token, - creationSource: CreationSource.WEBAPP, - }); + teamNameFromInvite = await TeamRepository.inviteMemberByToken(token, userId); } catch (e) { errorMsgFromInvite = "Error while fetching teams"; if (e instanceof TRPCError) errorMsgFromInvite = e.message; } } - const [user, teams] = await Promise.all([ - meCaller.get(), - teamsCaller.list({ - includeOrgs: true, - }), - ]); + const meCaller = await createRouterCaller(meRouter); + const [user, teams] = await Promise.all([meCaller.get(), getCachedTeams(userId)]); return { Main: ( diff --git a/packages/features/ee/teams/components/InviteLinkSettingsModal.tsx b/packages/features/ee/teams/components/InviteLinkSettingsModal.tsx index dacf0261cf..4efab6743d 100644 --- a/packages/features/ee/teams/components/InviteLinkSettingsModal.tsx +++ b/packages/features/ee/teams/components/InviteLinkSettingsModal.tsx @@ -10,6 +10,7 @@ import { Form } from "@calcom/ui/components/form"; import { Label } from "@calcom/ui/components/form"; import { Select } from "@calcom/ui/components/form"; import { showToast } from "@calcom/ui/components/toast"; +import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions"; type InvitationLinkSettingsModalProps = { isOpen: boolean; @@ -32,6 +33,7 @@ export default function InviteLinkSettingsModal(props: InvitationLinkSettingsMod showToast(t("invite_link_deleted"), "success"); trpcContext.viewer.teams.get.invalidate(); trpcContext.viewer.teams.list.invalidate(); + revalidateTeamsList(); props.onExit(); }, onError: (e) => { @@ -44,6 +46,7 @@ export default function InviteLinkSettingsModal(props: InvitationLinkSettingsMod showToast(t("invite_link_updated"), "success"); trpcContext.viewer.teams.get.invalidate(); trpcContext.viewer.teams.list.invalidate(); + revalidateTeamsList(); }, onError: (e) => { showToast(e.message, "error"); diff --git a/packages/features/ee/teams/components/MemberInvitationModal.tsx b/packages/features/ee/teams/components/MemberInvitationModal.tsx index cbb21fe61f..cca733f845 100644 --- a/packages/features/ee/teams/components/MemberInvitationModal.tsx +++ b/packages/features/ee/teams/components/MemberInvitationModal.tsx @@ -26,6 +26,7 @@ import { Select } from "@calcom/ui/components/form"; import { ToggleGroup } from "@calcom/ui/components/form"; import { Icon } from "@calcom/ui/components/icon"; import { showToast } from "@calcom/ui/components/toast"; +import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions"; import type { PendingMember } from "../lib/types"; import { GoogleWorkspaceInviteButton } from "./GoogleWorkspaceInviteButton"; @@ -92,6 +93,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) async onSuccess({ inviteLink }) { trpcContext.viewer.teams.get.invalidate(); trpcContext.viewer.teams.list.invalidate(); + revalidateTeamsList(); }, onError: (error) => { showToast(error.message, "error"); diff --git a/packages/lib/server/repository/team.ts b/packages/lib/server/repository/team.ts index 75eb87959b..cf4a9e8836 100644 --- a/packages/lib/server/repository/team.ts +++ b/packages/lib/server/repository/team.ts @@ -318,4 +318,46 @@ export class TeamRepository { }, }); } + + static async findTeamsByUserId({ userId, includeOrgs }: { userId: number; includeOrgs?: boolean }) { + const memberships = await prisma.membership.findMany({ + where: { + // Show all the teams this user belongs to regardless of the team being part of the user's org or not + // We don't want to restrict in the listing here. If we need to restrict a situation where a user is part of the org along with being part of a non-org team, we should do that instead of filtering out from here + // This became necessary when we started migrating user to Org, without migrating some teams of the user to the org + // Also, we would allow a user to be part of multiple orgs, then also it would be necessary. + userId: userId, + }, + include: { + team: { + select: { + id: true, + name: true, + slug: true, + logoUrl: true, + isOrganization: true, + metadata: true, + inviteTokens: true, + parent: true, + parentId: true, + }, + }, + }, + orderBy: { role: "desc" }, + }); + + return memberships + .filter((mmship) => { + if (includeOrgs) return true; + return !mmship.team.isOrganization; + }) + .map(({ team: { inviteTokens, ...team }, ...membership }) => ({ + role: membership.role, + accepted: membership.accepted, + ...team, + metadata: teamMetadataSchema.parse(team.metadata), + /** 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-${team.id}`), + })); + } } diff --git a/packages/trpc/server/routers/viewer/teams/list.handler.ts b/packages/trpc/server/routers/viewer/teams/list.handler.ts index 66c72cc91e..c50dd1df52 100644 --- a/packages/trpc/server/routers/viewer/teams/list.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/list.handler.ts @@ -1,5 +1,4 @@ -import { prisma } from "@calcom/prisma"; -import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; +import { TeamRepository } from "@calcom/lib/server/repository/team"; import type { TrpcSessionUser } from "../../../types"; import type { TGetListSchema } from "./list.schema"; @@ -12,45 +11,10 @@ type ListOptions = { }; export const listHandler = async ({ ctx, input }: ListOptions) => { - const memberships = await prisma.membership.findMany({ - where: { - // Show all the teams this user belongs to regardless of the team being part of the user's org or not - // We don't want to restrict in the listing here. If we need to restrict a situation where a user is part of the org along with being part of a non-org team, we should do that instead of filtering out from here - // This became necessary when we started migrating user to Org, without migrating some teams of the user to the org - // Also, we would allow a user to be part of multiple orgs, then also it would be necessary. - userId: ctx.user.id, - }, - include: { - team: { - select: { - id: true, - name: true, - slug: true, - logoUrl: true, - isOrganization: true, - metadata: true, - inviteTokens: true, - parent: true, - parentId: true, - }, - }, - }, - orderBy: { role: "desc" }, + return TeamRepository.findTeamsByUserId({ + userId: ctx.user.id, + includeOrgs: input?.includeOrgs, }); - - return memberships - .filter((mmship) => { - if (input?.includeOrgs) return true; - return !mmship.team.isOrganization; - }) - .map(({ team: { inviteTokens, ...team }, ...membership }) => ({ - role: membership.role, - accepted: membership.accepted, - ...team, - metadata: teamMetadataSchema.parse(team.metadata), - /** 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-${team.id}`), - })); }; export default listHandler;