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 <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 <benny@cal.com>
This commit is contained in:
Benny Joo
2025-06-10 20:44:51 -04:00
committed by GitHub
co-authored by benny@cal.com Devin AI
parent 14b9e0c032
commit 13966e0ec8
7 changed files with 79 additions and 61 deletions
@@ -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");
}
@@ -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 (
<ShellMainAppDir CTA={CTA} heading={t("teams")} subtitle={t("create_manage_teams_collaborative")}>
@@ -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: (
@@ -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");
@@ -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");
+42
View File
@@ -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}`),
}));
}
}
@@ -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;