From 976013512cf6438a91b7f4cd6f895e97e676f8f3 Mon Sep 17 00:00:00 2001 From: Benny Joo Date: Tue, 10 Sep 2024 22:25:59 -0400 Subject: [PATCH] perf: Server-Side Data Fetching in App Router: `/workflows` (#16563) * wip * complete server side fetching for app router * fix type error * fix * address comments --------- Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> --- .../app/future/workflows/[workflow]/page.tsx | 52 +++- apps/web/app/future/workflows/page.tsx | 26 +- .../workflows/[workflow]/getStaticProps.tsx | 2 +- apps/web/pages/workflows/[workflow].tsx | 2 - .../features/ee/workflows/pages/index.tsx | 26 +- .../features/ee/workflows/pages/workflow.tsx | 46 +++- packages/lib/server/repository/workflow.ts | 226 ++++++++++++++++++ .../viewer/workflows/filteredList.handler.tsx | 106 +------- .../workflows/getVerifiedEmails.handler.ts | 56 +---- .../workflows/getVerifiedNumbers.handler.ts | 12 +- 10 files changed, 359 insertions(+), 195 deletions(-) diff --git a/apps/web/app/future/workflows/[workflow]/page.tsx b/apps/web/app/future/workflows/[workflow]/page.tsx index 10c4cd0739..c66b415071 100644 --- a/apps/web/app/future/workflows/[workflow]/page.tsx +++ b/apps/web/app/future/workflows/[workflow]/page.tsx @@ -1,18 +1,25 @@ -import { withAppDirSsg } from "app/WithAppDirSsg"; import type { PageProps } from "app/_types"; import { _generateMetadata } from "app/_utils"; import { WithLayout } from "app/layoutHOC"; -import { headers, cookies } from "next/headers"; +import { getServerSession } from "next-auth"; +import { notFound } from "next/navigation"; +import { z } from "zod"; +import { AUTH_OPTIONS } from "@calcom/features/auth/lib/next-auth-options"; import LegacyPage from "@calcom/features/ee/workflows/pages/workflow"; import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; -import { buildLegacyCtx } from "@lib/buildLegacyCtx"; -import { getStaticProps } from "@lib/workflows/[workflow]/getStaticProps"; +const querySchema = z.object({ + workflow: z.string(), +}); export const generateMetadata = async ({ params, searchParams }: PageProps) => { - const { workflow: id } = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams)); - const workflow = await WorkflowRepository.getById({ id: +id }); + const parsed = querySchema.safeParse({ ...params, ...searchParams }); + if (!parsed.success) { + notFound(); + } + + const workflow = await WorkflowRepository.getById({ id: +parsed.data.workflow }); return await _generateMetadata( () => (workflow && workflow.name ? workflow.name : "Untitled"), @@ -20,11 +27,38 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => { ); }; -const getData = withAppDirSsg(getStaticProps); - export const generateStaticParams = () => []; -export default WithLayout({ getLayout: null, getData, Page: LegacyPage })<"P">; +const Page = async ({ params, searchParams }: PageProps) => { + const session = await getServerSession(AUTH_OPTIONS); + const user = session?.user; + const parsed = querySchema.safeParse({ ...params, ...searchParams }); + if (!parsed.success) { + notFound(); + } + + const workflow = await WorkflowRepository.getById({ id: +parsed.data.workflow }); + let verifiedEmails, verifiedNumbers; + try { + verifiedEmails = await WorkflowRepository.getVerifiedEmails({ + userEmail: user?.email ?? null, + userId: user?.id ?? null, + teamId: workflow?.team?.id, + }); + } catch (err) {} + try { + verifiedNumbers = await WorkflowRepository.getVerifiedNumbers({ + userId: user?.id ?? null, + teamId: workflow?.team?.id, + }); + } catch (err) {} + + return ( + + ); +}; + +export default WithLayout({ getLayout: null, ServerPage: Page }); export const dynamic = "force-static"; // generate segments on demand export const dynamicParams = true; diff --git a/apps/web/app/future/workflows/page.tsx b/apps/web/app/future/workflows/page.tsx index e53c797a9c..416ec0f6e4 100644 --- a/apps/web/app/future/workflows/page.tsx +++ b/apps/web/app/future/workflows/page.tsx @@ -1,7 +1,12 @@ +import type { PageProps } from "app/_types"; import { _generateMetadata } from "app/_utils"; import { WithLayout } from "app/layoutHOC"; +import { getServerSession } from "next-auth"; +import { AUTH_OPTIONS } from "@calcom/features/auth/lib/next-auth-options"; import LegacyPage from "@calcom/features/ee/workflows/pages/index"; +import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery"; +import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; export const generateMetadata = async () => await _generateMetadata( @@ -9,4 +14,23 @@ export const generateMetadata = async () => (t) => t("workflows_to_automate_notifications") ); -export default WithLayout({ getLayout: null, Page: LegacyPage })<"P">; +const Page = async ({ params, searchParams }: PageProps) => { + const session = await getServerSession(AUTH_OPTIONS); + const user = session?.user; + + const filters = getTeamsFiltersFromQuery({ ...searchParams, ...params }); + + let filteredList; + try { + filteredList = await WorkflowRepository.getFilteredList({ + userId: user?.id, + input: { + filters, + }, + }); + } catch (err) {} + + return ; +}; + +export default WithLayout({ getLayout: null, ServerPage: Page })<"P">; diff --git a/apps/web/lib/workflows/[workflow]/getStaticProps.tsx b/apps/web/lib/workflows/[workflow]/getStaticProps.tsx index 0104997452..34f0ad824a 100644 --- a/apps/web/lib/workflows/[workflow]/getStaticProps.tsx +++ b/apps/web/lib/workflows/[workflow]/getStaticProps.tsx @@ -8,7 +8,7 @@ const querySchema = z.object({ export const getStaticProps: GetStaticProps = (ctx) => { const params = querySchema.safeParse(ctx.params); console.log("Built workflow page:", params); - if (!params.success) return { notFound: true }; + if (!params.success) return { notFound: true } as const; return { props: { diff --git a/apps/web/pages/workflows/[workflow].tsx b/apps/web/pages/workflows/[workflow].tsx index 1fb2aef92e..0ecfc1b7eb 100644 --- a/apps/web/pages/workflows/[workflow].tsx +++ b/apps/web/pages/workflows/[workflow].tsx @@ -1,5 +1,3 @@ -"use client"; - import type { GetStaticPaths } from "next"; import Workflow from "@calcom/features/ee/workflows/pages/workflow"; diff --git a/packages/features/ee/workflows/pages/index.tsx b/packages/features/ee/workflows/pages/index.tsx index e1602948db..99d02bf40b 100644 --- a/packages/features/ee/workflows/pages/index.tsx +++ b/packages/features/ee/workflows/pages/index.tsx @@ -11,6 +11,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; import { HttpError } from "@calcom/lib/http-error"; +import type { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; import { trpc } from "@calcom/trpc/react"; import { AnimatedPopover, Avatar, CreateButtonWithTeamsList, showToast } from "@calcom/ui"; @@ -22,16 +23,27 @@ import EmptyScreen from "../components/EmptyScreen"; import SkeletonLoader from "../components/SkeletonLoaderList"; import WorkflowList from "../components/WorkflowListPage"; -function WorkflowsPage() { +type PageProps = { + filteredList?: Awaited>; +}; + +function WorkflowsPage({ filteredList }: PageProps) { const { t } = useLocale(); const session = useSession(); const router = useRouter(); const routerQuery = useRouterQuery(); const filters = getTeamsFiltersFromQuery(routerQuery); - const queryRes = trpc.viewer.workflows.filteredList.useQuery({ - filters, - }); + const { data, isPending: _isPending } = trpc.viewer.workflows.filteredList.useQuery( + { + filters, + }, + { + enabled: !filteredList, + } + ); + const filteredWorkflows = filteredList ?? data; + const isPending = filteredList ? false : _isPending; const createMutation = trpc.viewer.workflows.create.useMutation({ onSuccess: async ({ workflow }) => { @@ -74,7 +86,7 @@ function WorkflowsPage() { ) : null }> <> - {queryRes.data?.totalCount ? ( + {filteredWorkflows?.totalCount ? (
@@ -90,11 +102,11 @@ function WorkflowsPage() {
) : null} } noResultsScreen={} SkeletonLoader={SkeletonLoader}> - + diff --git a/packages/features/ee/workflows/pages/workflow.tsx b/packages/features/ee/workflows/pages/workflow.tsx index 293632cc4b..42dfac1966 100644 --- a/packages/features/ee/workflows/pages/workflow.tsx +++ b/packages/features/ee/workflows/pages/workflow.tsx @@ -12,6 +12,7 @@ import { SENDER_ID } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback"; import { HttpError } from "@calcom/lib/http-error"; +import type { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; import type { TimeUnit, WorkflowTriggerEvents } from "@calcom/prisma/enums"; import { MembershipRole, WorkflowActions } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc/react"; @@ -37,7 +38,17 @@ export type FormValues = { selectAll: boolean; }; -function WorkflowPage() { +type PageProps = { + workflowData?: Awaited>; + verifiedNumbers?: Awaited>; + verifiedEmails?: Awaited>; +}; + +function WorkflowPage({ + workflowData: workflowDataProp, + verifiedNumbers: verifiedNumbersProp, + verifiedEmails: verifiedEmailsProp, +}: PageProps) { const { t, i18n } = useLocale(); const session = useSession(); const params = useParamsWithFallback(); @@ -58,27 +69,36 @@ function WorkflowPage() { const user = userQuery.data; const { - data: workflow, - isError, + data: workflowData, + isError: _isError, error, - isPending: isPendingWorkflow, + isPending: _isPendingWorkflow, } = trpc.viewer.workflows.get.useQuery( { id: +workflowId }, { - enabled: !!workflowId, + enabled: workflowDataProp ? false : !!workflowId, } ); - const { data: verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery( + const workflow = workflowDataProp || workflowData; + const isPendingWorkflow = workflowDataProp ? false : _isPendingWorkflow; + const isError = workflowDataProp ? false : _isError; + + const { data: verifiedNumbersData } = trpc.viewer.workflows.getVerifiedNumbers.useQuery( { teamId: workflow?.team?.id }, { - enabled: !!workflow?.id, + enabled: verifiedNumbersProp ? false : !!workflow?.id, } ); + const verifiedNumbers = verifiedNumbersProp || verifiedNumbersData; - const { data: verifiedEmails } = trpc.viewer.workflows.getVerifiedEmails.useQuery({ - teamId: workflow?.team?.id, - }); + const { data: verifiedEmailsData } = trpc.viewer.workflows.getVerifiedEmails.useQuery( + { + teamId: workflow?.team?.id, + }, + { enabled: !verifiedEmailsProp } + ); + const verifiedEmails = verifiedEmailsProp || verifiedEmailsData; const isOrg = workflow?.team?.isOrganization ?? false; @@ -135,7 +155,7 @@ function WorkflowPage() { setSelectedOptions(activeOn || []); } else { setSelectedOptions( - workflowData.activeOn.flatMap((active) => { + workflowData.activeOn?.flatMap((active) => { if (workflowData.teamId && active.eventType.parentId) return []; return { value: String(active.eventType.id), @@ -152,7 +172,7 @@ function WorkflowPage() { } } //translate dynamic variables into local language - const steps = workflowData.steps.map((step) => { + const steps = workflowData.steps?.map((step) => { const updatedStep = { ...step, senderName: step.sender, @@ -342,7 +362,7 @@ function WorkflowPage() { )} ) : ( - + )} diff --git a/packages/lib/server/repository/workflow.ts b/packages/lib/server/repository/workflow.ts index 29beff25b8..26e43aa62b 100644 --- a/packages/lib/server/repository/workflow.ts +++ b/packages/lib/server/repository/workflow.ts @@ -1,6 +1,13 @@ import { z } from "zod"; +import type { WorkflowType } from "@calcom/ee/workflows/components/WorkflowListPage"; +import { hasFilter } from "@calcom/features/filters/lib/hasFilter"; import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/client"; +import { Prisma } from "@calcom/prisma/client"; +import type { TFilteredListInputSchema } from "@calcom/trpc/server/routers/viewer/workflows/filteredList.schema"; +import type { TGetVerifiedEmailsInputSchema } from "@calcom/trpc/server/routers/viewer/workflows/getVerifiedEmails.schema"; +import type { TGetVerifiedNumbersInputSchema } from "@calcom/trpc/server/routers/viewer/workflows/getVerifiedNumbers.schema"; export const ZGetInputSchema = z.object({ id: z.number(), @@ -8,6 +15,48 @@ export const ZGetInputSchema = z.object({ export type TGetInputSchema = z.infer; +const { include: includedFields } = Prisma.validator()({ + include: { + activeOn: { + select: { + eventType: { + select: { + id: true, + title: true, + parentId: true, + _count: { + select: { + children: true, + }, + }, + }, + }, + }, + }, + activeOnTeams: { + select: { + team: { + select: { + id: true, + name: true, + }, + }, + }, + }, + steps: true, + team: { + select: { + id: true, + slug: true, + name: true, + members: true, + logoUrl: true, + isOrganization: true, + }, + }, + }, +}); + export class WorkflowRepository { static async getById({ id }: TGetInputSchema) { return await prisma.workflow.findFirst({ @@ -50,4 +99,181 @@ export class WorkflowRepository { }, }); } + + static async getVerifiedNumbers({ + userId, + teamId, + }: TGetVerifiedNumbersInputSchema & { userId: number | null }) { + if (!userId) { + throw new Error("User Id not found"); + } + const verifiedNumbers = await prisma.verifiedNumber.findMany({ + where: { + OR: [{ userId }, { teamId }], + }, + }); + + return verifiedNumbers; + } + + static async getVerifiedEmails({ + userEmail, + userId, + teamId, + }: TGetVerifiedEmailsInputSchema & { userEmail: string | null; userId: number | null }) { + if (!userId) { + throw new Error("User Id not found"); + } + if (!userEmail) { + throw new Error("User email not found"); + } + + let verifiedEmails: string[] = [userEmail]; + + if (teamId) { + const team = await prisma.team.findFirst({ + where: { + id: teamId, + }, + select: { + members: { + include: { + user: { + select: { + id: true, + email: true, + }, + }, + }, + }, + }, + }); + + if (!team) { + throw new Error("Team not found"); + } + + const isTeamMember = team.members.some((member) => member.userId === userId); + + if (!isTeamMember) { + throw new Error("You are not a member of this team"); + } + + verifiedEmails = verifiedEmails.concat(team.members.map((member) => member.user.email)); + } + + const emails = ( + await prisma.verifiedEmail.findMany({ + where: { + OR: [{ userId }, { teamId }], + }, + }) + ).map((verifiedEmail) => verifiedEmail.email); + + verifiedEmails = verifiedEmails.concat(emails); + + return verifiedEmails; + } + + static async getFilteredList({ userId, input }: { userId?: number; input: TFilteredListInputSchema }) { + const filters = input?.filters; + + const filtered = filters && hasFilter(filters); + + const allWorkflows = await prisma.workflow.findMany({ + where: { + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + accepted: true, + }, + }, + }, + }, + ], + }, + include: includedFields, + orderBy: [ + { + position: "desc", + }, + { + id: "asc", + }, + ], + }); + + if (!filtered) { + const workflowsWithReadOnly: WorkflowType[] = allWorkflows.map((workflow) => { + const readOnly = !!workflow.team?.members?.find( + (member) => member.userId === userId && member.role === MembershipRole.MEMBER + ); + + return { readOnly, isOrg: workflow.team?.isOrganization ?? false, ...workflow }; + }); + + return { + filtered: workflowsWithReadOnly, + totalCount: allWorkflows.length, + }; + } + + const where = { + OR: [] as Prisma.WorkflowWhereInput[], + }; + + if (filtered) { + if (!!filters.teamIds) { + where.OR.push({ + team: { + id: { + in: filters.teamIds ?? [], + }, + members: { + some: { + userId, + accepted: true, + }, + }, + }, + }); + } + + if (!!filters.userIds) { + where.OR.push({ + userId: { + in: filters.userIds, + }, + teamId: null, + }); + } + + const filteredWorkflows = await prisma.workflow.findMany({ + where, + include: includedFields, + orderBy: { + id: "asc", + }, + }); + + const workflowsWithReadOnly: WorkflowType[] = filteredWorkflows.map((workflow) => { + const readOnly = !!workflow.team?.members?.find( + (member) => member.userId === userId && member.role === MembershipRole.MEMBER + ); + + return { readOnly, isOrg: workflow.team?.isOrganization ?? false, ...workflow }; + }); + + return { + filtered: workflowsWithReadOnly, + totalCount: allWorkflows.length, + }; + } + } } diff --git a/packages/trpc/server/routers/viewer/workflows/filteredList.handler.tsx b/packages/trpc/server/routers/viewer/workflows/filteredList.handler.tsx index 46e850ea57..f9986484d8 100644 --- a/packages/trpc/server/routers/viewer/workflows/filteredList.handler.tsx +++ b/packages/trpc/server/routers/viewer/workflows/filteredList.handler.tsx @@ -1,8 +1,6 @@ -import type { WorkflowType } from "@calcom/ee/workflows/components/WorkflowListPage"; -import { hasFilter } from "@calcom/features/filters/lib/hasFilter"; +import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; import type { PrismaClient } from "@calcom/prisma"; import { Prisma } from "@calcom/prisma/client"; -import { MembershipRole } from "@calcom/prisma/client"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; import type { TFilteredListInputSchema } from "./filteredList.schema"; @@ -58,105 +56,5 @@ const { include: includedFields } = Prisma.validator }); export const filteredListHandler = async ({ ctx, input }: FilteredListOptions) => { - const { prisma, user } = ctx; - - const filters = input?.filters; - - const filtered = filters && hasFilter(filters); - - const allWorkflows = await prisma.workflow.findMany({ - where: { - OR: [ - { - userId: user.id, - }, - { - team: { - members: { - some: { - userId: user.id, - accepted: true, - }, - }, - }, - }, - ], - }, - include: includedFields, - orderBy: [ - { - position: "desc", - }, - { - id: "asc", - }, - ], - }); - - if (!filtered) { - const workflowsWithReadOnly: WorkflowType[] = allWorkflows.map((workflow) => { - const readOnly = !!workflow.team?.members?.find( - (member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER - ); - - return { readOnly, isOrg: workflow.team?.isOrganization ?? false, ...workflow }; - }); - - return { - filtered: workflowsWithReadOnly, - totalCount: allWorkflows.length, - }; - } - - const where = { - OR: [] as Prisma.WorkflowWhereInput[], - }; - - if (filtered) { - if (!!filters.teamIds) { - where.OR.push({ - team: { - id: { - in: filters.teamIds ?? [], - }, - members: { - some: { - userId: user.id, - accepted: true, - }, - }, - }, - }); - } - - if (!!filters.userIds) { - where.OR.push({ - userId: { - in: filters.userIds, - }, - teamId: null, - }); - } - - const filteredWorkflows = await prisma.workflow.findMany({ - where, - include: includedFields, - orderBy: { - id: "asc", - }, - }); - - const workflowsWithReadOnly: WorkflowType[] = filteredWorkflows.map((workflow) => { - const readOnly = !!workflow.team?.members?.find( - (member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER - ); - - return { readOnly, isOrg: workflow.team?.isOrganization ?? false, ...workflow }; - }); - - return { - filtered: workflowsWithReadOnly, - totalCount: allWorkflows.length, - }; - } + return await WorkflowRepository.getFilteredList({ userId: ctx.user.id, input }); }; diff --git a/packages/trpc/server/routers/viewer/workflows/getVerifiedEmails.handler.ts b/packages/trpc/server/routers/viewer/workflows/getVerifiedEmails.handler.ts index 735cdf1aee..e4fd4b0a71 100644 --- a/packages/trpc/server/routers/viewer/workflows/getVerifiedEmails.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/getVerifiedEmails.handler.ts @@ -1,8 +1,6 @@ -import { prisma } from "@calcom/prisma"; +import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; -import { TRPCError } from "@trpc/server"; - import type { TGetVerifiedEmailsInputSchema } from "./getVerifiedEmails.schema"; type GetVerifiedEmailsOptions = { @@ -13,51 +11,9 @@ type GetVerifiedEmailsOptions = { }; export const getVerifiedEmailsHandler = async ({ ctx, input }: GetVerifiedEmailsOptions) => { - const { user } = ctx; - const { teamId } = input; - let verifiedEmails: string[] = [user.email]; - - if (teamId) { - const team = await prisma.team.findFirst({ - where: { - id: teamId, - }, - select: { - members: { - include: { - user: { - select: { - id: true, - email: true, - }, - }, - }, - }, - }, - }); - - if (!team) { - throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" }); - } - - const isTeamMember = team.members.some((member) => member.userId === user.id); - - if (!isTeamMember) { - throw new TRPCError({ code: "FORBIDDEN", message: "You are not a member of this team" }); - } - - verifiedEmails = verifiedEmails.concat(team.members.map((member) => member.user.email)); - } - - const emails = ( - await prisma.verifiedEmail.findMany({ - where: { - OR: [{ userId: user.id }, { teamId: input.teamId }], - }, - }) - ).map((verifiedEmail) => verifiedEmail.email); - - verifiedEmails = verifiedEmails.concat(emails); - - return verifiedEmails; + return await WorkflowRepository.getVerifiedEmails({ + userId: ctx.user.id, + userEmail: ctx.user.email, + teamId: input.teamId, + }); }; diff --git a/packages/trpc/server/routers/viewer/workflows/getVerifiedNumbers.handler.ts b/packages/trpc/server/routers/viewer/workflows/getVerifiedNumbers.handler.ts index 406fcde53b..b0c71bc82d 100644 --- a/packages/trpc/server/routers/viewer/workflows/getVerifiedNumbers.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/getVerifiedNumbers.handler.ts @@ -1,4 +1,4 @@ -import { prisma } from "@calcom/prisma"; +import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; import type { TGetVerifiedNumbersInputSchema } from "./getVerifiedNumbers.schema"; @@ -11,12 +11,8 @@ type GetVerifiedNumbersOptions = { }; export const getVerifiedNumbersHandler = async ({ ctx, input }: GetVerifiedNumbersOptions) => { - const { user } = ctx; - const verifiedNumbers = await prisma.verifiedNumber.findMany({ - where: { - OR: [{ userId: user.id }, { teamId: input.teamId }], - }, + return await WorkflowRepository.getVerifiedNumbers({ + userId: ctx.user.id, + teamId: input.teamId, }); - - return verifiedNumbers; };