From 0225362a92a8adfff8e1eabbd7072a9e69dc97f1 Mon Sep 17 00:00:00 2001 From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com> Date: Sat, 30 Sep 2023 17:52:42 +0530 Subject: [PATCH] feat: Add caching to more services (survey, environment, team, profile) (#835) * feat: caching in surveys * fix: fixes unstable_cache date parsing error * fix: fixes survey revalidation in displays and responses * fix: fixes survey cache * fix: adds comments * fix: response cache tag * fix cache validation and tag naming * move TSurveysWithAnalytics to TSurveys * add caching to more services --------- Co-authored-by: Matthias Nannt --- apps/web/app/(app)/FormbricksClient.tsx | 17 +- apps/web/app/(app)/PosthogIdentify.tsx | 3 - .../surveys/SurveyDropDownMenu.tsx | 4 +- .../[environmentId]/surveys/SurveyList.tsx | 18 +- .../app/api/auth/[...nextauth]/authOptions.ts | 32 +- apps/web/app/api/v1/js/surveys.ts | 6 +- apps/web/app/api/v1/js/sync/lib/sync.ts | 4 +- .../shared/WidgetStatusIndicator.tsx | 2 +- packages/lib/services/actionClass.ts | 5 +- packages/lib/services/attributeClass.ts | 3 +- packages/lib/services/displays.ts | 6 + packages/lib/services/environment.ts | 146 ++++--- packages/lib/services/person.ts | 4 +- packages/lib/services/product.ts | 51 ++- packages/lib/services/profile.ts | 105 +++-- packages/lib/services/response.ts | 11 + packages/lib/services/survey.ts | 402 +++++++++++------- packages/lib/services/team.ts | 155 ++++--- 18 files changed, 586 insertions(+), 388 deletions(-) diff --git a/apps/web/app/(app)/FormbricksClient.tsx b/apps/web/app/(app)/FormbricksClient.tsx index ebc694e2da..6289105b52 100644 --- a/apps/web/app/(app)/FormbricksClient.tsx +++ b/apps/web/app/(app)/FormbricksClient.tsx @@ -7,7 +7,6 @@ import { useEffect } from "react"; type UsageAttributesUpdaterProps = { numSurveys: number; - totalSubmissions: number; }; export default function FormbricksClient({ session }) { @@ -19,31 +18,23 @@ export default function FormbricksClient({ session }) { }); formbricks.setUserId(session.user.id); formbricks.setEmail(session.user.email); - if (session.user.teams?.length > 0) { - formbricks.setAttribute("Plan", session.user.teams[0].plan); - formbricks.setAttribute("Name", session?.user?.name); - } } }, [session]); return null; } -const updateUsageAttributes = (numSurveys, totalSubmissions) => { +const updateUsageAttributes = (numSurveys) => { if (!formbricksEnabled) return; if (numSurveys >= 3) { formbricks.setAttribute("HasThreeSurveys", "true"); } - - if (totalSubmissions >= 20) { - formbricks.setAttribute("HasTwentySubmissions", "true"); - } }; -export function UsageAttributesUpdater({ numSurveys, totalSubmissions }: UsageAttributesUpdaterProps) { +export function UsageAttributesUpdater({ numSurveys }: UsageAttributesUpdaterProps) { useEffect(() => { - updateUsageAttributes(numSurveys, totalSubmissions); - }, [numSurveys, totalSubmissions]); + updateUsageAttributes(numSurveys); + }, [numSurveys]); return null; } diff --git a/apps/web/app/(app)/PosthogIdentify.tsx b/apps/web/app/(app)/PosthogIdentify.tsx index b022fb4056..dc055535d5 100644 --- a/apps/web/app/(app)/PosthogIdentify.tsx +++ b/apps/web/app/(app)/PosthogIdentify.tsx @@ -12,9 +12,6 @@ export default function PosthogIdentify({ session }: { session: Session }) { useEffect(() => { if (posthogEnabled && session.user && posthog) { posthog.identify(session.user.id); - if (session.user.teams?.length > 0) { - posthog?.group("team", session.user.teams[0].id); - } } }, [session, posthog]); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu.tsx index 0e711af727..3df34eca02 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu.tsx @@ -15,7 +15,7 @@ import { } from "@/components/shared/DropdownMenu"; import LoadingSpinner from "@/components/shared/LoadingSpinner"; import type { TEnvironment } from "@formbricks/types/v1/environment"; -import type { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys"; +import type { TSurvey } from "@formbricks/types/v1/surveys"; import { ArrowUpOnSquareStackIcon, DocumentDuplicateIcon, @@ -32,7 +32,7 @@ import toast from "react-hot-toast"; interface SurveyDropDownMenuProps { environmentId: string; - survey: TSurveyWithAnalytics; + survey: TSurvey; environment: TEnvironment; otherEnvironment: TEnvironment; surveyBaseUrl: string; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyList.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyList.tsx index 5058fbb62a..5665ec320b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyList.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyList.tsx @@ -2,15 +2,14 @@ import { UsageAttributesUpdater } from "@/app/(app)/FormbricksClient"; import SurveyDropDownMenu from "@/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu"; import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/SurveyStarter"; import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator"; +import { SURVEY_BASE_URL } from "@formbricks/lib/constants"; import { getEnvironment, getEnvironments } from "@formbricks/lib/services/environment"; import { getProductByEnvironmentId } from "@formbricks/lib/services/product"; -import { getSurveysWithAnalytics } from "@formbricks/lib/services/survey"; +import { getSurveys } from "@formbricks/lib/services/survey"; import type { TEnvironment } from "@formbricks/types/v1/environment"; -import type { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys"; import { Badge } from "@formbricks/ui"; import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid"; import Link from "next/link"; -import { SURVEY_BASE_URL } from "@formbricks/lib/constants"; export default async function SurveysList({ environmentId }: { environmentId: string }) { const product = await getProductByEnvironmentId(environmentId); @@ -22,10 +21,10 @@ export default async function SurveysList({ environmentId }: { environmentId: st if (!environment) { throw new Error("Environment not found"); } - const surveys: TSurveyWithAnalytics[] = await getSurveysWithAnalytics(environmentId); + const surveys = await getSurveys(environmentId); + const environments: TEnvironment[] = await getEnvironments(product.id); const otherEnvironment = environments.find((e) => e.type !== environment.type)!; - const totalSubmissions = surveys.reduce((acc, survey) => acc + (survey.analytics?.numResponses || 0), 0); if (surveys.length === 0) { return ; @@ -45,7 +44,7 @@ export default async function SurveysList({ environmentId }: { environmentId: st {surveys - .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) + .sort((a, b) => b.updatedAt?.getTime() - a.updatedAt?.getTime()) .map((survey) => (
  • @@ -82,9 +81,6 @@ export default async function SurveysList({ environmentId }: { environmentId: st tooltip environmentId={environmentId} /> -

    - {survey.analytics.numResponses} responses -

    )} {survey.status === "draft" && ( @@ -93,7 +89,7 @@ export default async function SurveysList({ environmentId }: { environmentId: st
    ))} - + ); } diff --git a/apps/web/app/api/auth/[...nextauth]/authOptions.ts b/apps/web/app/api/auth/[...nextauth]/authOptions.ts index 3c61f999d4..0351fb872a 100644 --- a/apps/web/app/api/auth/[...nextauth]/authOptions.ts +++ b/apps/web/app/api/auth/[...nextauth]/authOptions.ts @@ -1,8 +1,9 @@ import { env } from "@/env.mjs"; import { verifyPassword } from "@/lib/auth"; -import { verifyToken } from "@formbricks/lib/jwt"; import { prisma } from "@formbricks/database"; import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants"; +import { verifyToken } from "@formbricks/lib/jwt"; +import { getProfileByEmail } from "@formbricks/lib/services/profile"; import type { IdentityProvider } from "@prisma/client"; import type { NextAuthOptions } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; @@ -133,42 +134,16 @@ export const authOptions: NextAuthOptions = { ], callbacks: { async jwt({ token }) { - const existingUser = await prisma.user.findFirst({ - where: { email: token.email! }, - select: { - id: true, - createdAt: true, - onboardingCompleted: true, - memberships: { - select: { - teamId: true, - role: true, - team: { - select: { - plan: true, - }, - }, - }, - }, - name: true, - }, - }); + const existingUser = await getProfileByEmail(token?.email!); if (!existingUser) { return token; } - const teams = existingUser.memberships.map((membership) => ({ - id: membership.teamId, - role: membership.role, - plan: membership.team.plan, - })); - const additionalAttributs = { id: existingUser.id, createdAt: existingUser.createdAt, onboardingCompleted: existingUser.onboardingCompleted, - teams, name: existingUser.name, }; @@ -185,7 +160,6 @@ export const authOptions: NextAuthOptions = { // @ts-ignore session.user.onboardingCompleted = token?.onboardingCompleted; // @ts-ignore - session.user.teams = token?.teams; session.user.name = token.name || ""; return session; diff --git a/apps/web/app/api/v1/js/surveys.ts b/apps/web/app/api/v1/js/surveys.ts index 8b53d212ce..21095d928a 100644 --- a/apps/web/app/api/v1/js/surveys.ts +++ b/apps/web/app/api/v1/js/surveys.ts @@ -5,13 +5,13 @@ import { TSurvey } from "@formbricks/types/v1/surveys"; import { unstable_cache } from "next/cache"; const getSurveysCacheTags = (environmentId: string, personId: string): string[] => [ - `env-${environmentId}-surveys`, - `env-${environmentId}-product`, + `environments-${environmentId}-surveys`, + `environments-${environmentId}-product`, personId, ]; const getSurveysCacheKey = (environmentId: string, personId: string): string[] => [ - `env-${environmentId}-person-${personId}-syncSurveys`, + `environments-${environmentId}-person-${personId}-syncSurveys`, ]; export const getSurveysCached = (environmentId: string, person: TPerson) => diff --git a/apps/web/app/api/v1/js/sync/lib/sync.ts b/apps/web/app/api/v1/js/sync/lib/sync.ts index e1b4385c31..a9ee6f7ec2 100644 --- a/apps/web/app/api/v1/js/sync/lib/sync.ts +++ b/apps/web/app/api/v1/js/sync/lib/sync.ts @@ -1,7 +1,7 @@ import { getSurveysCached } from "@/app/api/v1/js/surveys"; import { MAU_LIMIT } from "@formbricks/lib/constants"; import { getActionClassesCached } from "@formbricks/lib/services/actionClass"; -import { getEnvironmentCached } from "@formbricks/lib/services/environment"; +import { getEnvironment } from "@formbricks/lib/services/environment"; import { createPerson, getMonthlyActivePeopleCount, getPersonCached } from "@formbricks/lib/services/person"; import { getProductByEnvironmentIdCached } from "@formbricks/lib/services/product"; import { createSession, extendSession, getSessionCached } from "@formbricks/lib/services/session"; @@ -26,7 +26,7 @@ export const getUpdatedState = async ( let session: TSession | null; // check if environment exists - environment = await getEnvironmentCached(environmentId); + environment = await getEnvironment(environmentId); if (!environment) { throw new Error("Environment does not exist"); diff --git a/apps/web/components/shared/WidgetStatusIndicator.tsx b/apps/web/components/shared/WidgetStatusIndicator.tsx index 1f2bb68785..4f0fa8eb04 100644 --- a/apps/web/components/shared/WidgetStatusIndicator.tsx +++ b/apps/web/components/shared/WidgetStatusIndicator.tsx @@ -31,7 +31,7 @@ export default function WidgetStatusIndicator({ if (!environment?.widgetSetupCompleted && actions && actions.length > 0) { updateEnvironmentAction(environment.id, { widgetSetupCompleted: true }); } - }, [environment, actions]); + }, [environment, actions, updateEnvironmentAction]); const stati = { notImplemented: { diff --git a/packages/lib/services/actionClass.ts b/packages/lib/services/actionClass.ts index c0595b81a1..f6e02c7db5 100644 --- a/packages/lib/services/actionClass.ts +++ b/packages/lib/services/actionClass.ts @@ -12,12 +12,13 @@ import { validateInputs } from "../utils/validate"; const halfHourInSeconds = 60 * 30; export const getActionClassCacheTag = (name: string, environmentId: string): string => - `env-${environmentId}-actionClass-${name}`; + `environments-${environmentId}-actionClass-${name}`; const getActionClassCacheKey = (name: string, environmentId: string): string[] => [ getActionClassCacheTag(name, environmentId), ]; -const getActionClassesCacheTag = (environmentId: string): string => `env-${environmentId}-actionClasses`; +const getActionClassesCacheTag = (environmentId: string): string => + `environments-${environmentId}-actionClasses`; const getActionClassesCacheKey = (environmentId: string): string[] => [ getActionClassesCacheTag(environmentId), ]; diff --git a/packages/lib/services/attributeClass.ts b/packages/lib/services/attributeClass.ts index 672481b804..28774ceec8 100644 --- a/packages/lib/services/attributeClass.ts +++ b/packages/lib/services/attributeClass.ts @@ -13,7 +13,8 @@ import { DatabaseError } from "@formbricks/types/v1/errors"; import { cache } from "react"; import { revalidateTag, unstable_cache } from "next/cache"; -const attributeClassesCacheTag = (environmentId: string): string => `env-${environmentId}-attributeClasses`; +const attributeClassesCacheTag = (environmentId: string): string => + `environments-${environmentId}-attributeClasses`; const getAttributeClassesCacheKey = (environmentId: string): string[] => [ attributeClassesCacheTag(environmentId), diff --git a/packages/lib/services/displays.ts b/packages/lib/services/displays.ts index db0ff1b74b..e07d0f54a5 100644 --- a/packages/lib/services/displays.ts +++ b/packages/lib/services/displays.ts @@ -39,6 +39,8 @@ const selectDisplay = { status: true, }; +export const getDisplaysCacheTag = (surveyId: string) => `surveys-${surveyId}-displays`; + export const createDisplay = async (displayInput: TDisplayInput): Promise => { validateInputs([displayInput, ZDisplayInput]); try { @@ -71,6 +73,10 @@ export const createDisplay = async (displayInput: TDisplayInput): Promise => { - validateInputs([environmentId, ZId]); - let environmentPrisma; +export const getEnvironmentCacheTag = (environmentId: string) => `environments-${environmentId}`; +export const getEnvironmentsCacheTag = (productId: string) => `products-${productId}-environments`; - try { - environmentPrisma = await prisma.environment.findUnique({ - where: { - id: environmentId, - }, - }); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError("Database operation failed"); - } - - throw error; - } - - try { - const environment = ZEnvironment.parse(environmentPrisma); - return environment; - } catch (error) { - if (error instanceof z.ZodError) { - console.error(JSON.stringify(error.errors, null, 2)); - } - throw new ValidationError("Data validation of environment failed"); - } -}); - -export const getEnvironmentCached = (environmentId: string) => +export const getEnvironment = (environmentId: string) => unstable_cache( async () => { - return await getEnvironment(environmentId); + validateInputs([environmentId, ZId]); + let environmentPrisma; + + try { + environmentPrisma = await prisma.environment.findUnique({ + where: { + id: environmentId, + }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } + + try { + const environment = ZEnvironment.parse(environmentPrisma); + return environment; + } catch (error) { + if (error instanceof z.ZodError) { + console.error(JSON.stringify(error.errors, null, 2)); + } + throw new ValidationError("Data validation of environment failed"); + } }, - [environmentId], + [`environments-${environmentId}`], { - tags: [environmentId], + tags: [getEnvironmentCacheTag(environmentId)], revalidate: 30 * 60, // 30 minutes } )(); -export const getEnvironments = cache(async (productId: string): Promise => { - validateInputs([productId, ZId]); - let productPrisma; - try { - productPrisma = await prisma.product.findFirst({ - where: { - id: productId, - }, - include: { - environments: true, - }, - }); +export const getEnvironments = async (productId: string): Promise => + unstable_cache( + async () => { + validateInputs([productId, ZId]); + let productPrisma; + try { + productPrisma = await prisma.product.findFirst({ + where: { + id: productId, + }, + include: { + environments: true, + }, + }); - if (!productPrisma) { - throw new ResourceNotFoundError("Product", productId); - } - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError("Database operation failed"); - } - throw error; - } + if (!productPrisma) { + throw new ResourceNotFoundError("Product", productId); + } + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + throw error; + } - const environments: TEnvironment[] = []; - for (let environment of productPrisma.environments) { - let targetEnvironment: TEnvironment = ZEnvironment.parse(environment); - environments.push(targetEnvironment); - } + const environments: TEnvironment[] = []; + for (let environment of productPrisma.environments) { + let targetEnvironment: TEnvironment = ZEnvironment.parse(environment); + environments.push(targetEnvironment); + } - try { - return environments; - } catch (error) { - if (error instanceof z.ZodError) { - console.error(JSON.stringify(error.errors, null, 2)); + try { + return environments; + } catch (error) { + if (error instanceof z.ZodError) { + console.error(JSON.stringify(error.errors, null, 2)); + } + throw new ValidationError("Data validation of environments array failed"); + } + }, + [`products-${productId}-environments`], + { + tags: [getEnvironmentsCacheTag(productId)], + revalidate: 30 * 60, // 30 minutes } - throw new ValidationError("Data validation of environments array failed"); - } -}); + )(); export const updateEnvironment = async ( environmentId: string, @@ -104,6 +110,10 @@ export const updateEnvironment = async ( }, data: newData, }); + + revalidateTag(getEnvironmentsCacheTag(updatedEnvironment.productId)); + revalidateTag(getEnvironmentCacheTag(environmentId)); + return updatedEnvironment; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { diff --git a/packages/lib/services/person.ts b/packages/lib/services/person.ts index 61dd47a489..c8e80598ae 100644 --- a/packages/lib/services/person.ts +++ b/packages/lib/services/person.ts @@ -308,9 +308,9 @@ export const getMonthlyActivePeopleCount = async (environmentId: string): Promis return aggregations._count.id; }, - [`env-${environmentId}-mau`], + [`environments-${environmentId}-mau`], { - tags: [`env-${environmentId}-mau`], + tags: [`environments-${environmentId}-mau`], revalidate: 60 * 60 * 6, // 6 hours } )(); diff --git a/packages/lib/services/product.ts b/packages/lib/services/product.ts index daeb672076..f7179928dd 100644 --- a/packages/lib/services/product.ts +++ b/packages/lib/services/product.ts @@ -9,8 +9,10 @@ import { cache } from "react"; import "server-only"; import { z } from "zod"; import { validateInputs } from "../utils/validate"; +import { getEnvironmentCacheTag, getEnvironmentsCacheTag } from "./environment"; -const getProductCacheTag = (environmentId: string): string => `env-${environmentId}-product`; +export const getProductsCacheTag = (teamId: string): string => `teams-${teamId}-products`; +const getProductCacheTag = (environmentId: string): string => `environments-${environmentId}-product`; const getProductCacheKey = (environmentId: string): string[] => [getProductCacheTag(environmentId)]; const selectProduct = { @@ -29,25 +31,33 @@ const selectProduct = { environments: true, }; -export const getProducts = cache(async (teamId: string): Promise => { - validateInputs([teamId, ZId]); - try { - const products = await prisma.product.findMany({ - where: { - teamId, - }, - select: selectProduct, - }); +export const getProducts = async (teamId: string): Promise => + unstable_cache( + async () => { + validateInputs([teamId, ZId]); + try { + const products = await prisma.product.findMany({ + where: { + teamId, + }, + select: selectProduct, + }); - return products; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError("Database operation failed"); + return products; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } + }, + [`teams-${teamId}-products`], + { + tags: [getProductsCacheTag(teamId)], + revalidate: 30 * 60, // 30 minutes } - - throw error; - } -}); + )(); export const getProductByEnvironmentId = cache(async (environmentId: string): Promise => { if (!environmentId) { @@ -113,6 +123,7 @@ export const updateProduct = async ( try { const product = ZProduct.parse(updatedProduct); + revalidateTag(getProductsCacheTag(product.teamId)); product.environments.forEach((environment) => { // revalidate environment cache revalidateTag(getProductCacheTag(environment.id)); @@ -155,10 +166,12 @@ export const deleteProduct = cache(async (productId: string): Promise }); if (product) { + revalidateTag(getProductsCacheTag(product.teamId)); + revalidateTag(getEnvironmentsCacheTag(product.id)); product.environments.forEach((environment) => { // revalidate product cache revalidateTag(getProductCacheTag(environment.id)); - revalidateTag(environment.id); + revalidateTag(getEnvironmentCacheTag(environment.id)); }); } diff --git a/packages/lib/services/profile.ts b/packages/lib/services/profile.ts index 8380ae3b74..f11cbc4efe 100644 --- a/packages/lib/services/profile.ts +++ b/packages/lib/services/profile.ts @@ -4,9 +4,10 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/v1/error import { TMembership, TMembershipRole, ZMembershipRole } from "@formbricks/types/v1/memberships"; import { TProfile, TProfileUpdateInput, ZProfileUpdateInput } from "@formbricks/types/v1/profile"; import { MembershipRole, Prisma } from "@prisma/client"; -import { cache } from "react"; +import { unstable_cache, revalidateTag } from "next/cache"; import { validateInputs } from "../utils/validate"; import { deleteTeam } from "./team"; +import { z } from "zod"; const responseSelection = { id: true, @@ -17,30 +18,73 @@ const responseSelection = { onboardingCompleted: true, }; +export const getProfileCacheTag = (userId: string): string => `profiles-${userId}`; +export const getProfileByEmailCacheTag = (email: string): string => `profiles-${email}`; + // function to retrive basic information about a user's profile -export const getProfile = cache(async (userId: string): Promise => { - validateInputs([userId, ZId]); - try { - const profile = await prisma.user.findUnique({ - where: { - id: userId, - }, - select: responseSelection, - }); +export const getProfile = async (userId: string): Promise => + unstable_cache( + async () => { + validateInputs([userId, ZId]); + try { + const profile = await prisma.user.findUnique({ + where: { + id: userId, + }, + select: responseSelection, + }); - if (!profile) { - return null; + if (!profile) { + return null; + } + + return profile; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } + }, + [`profiles-${userId}`], + { + tags: [getProfileByEmailCacheTag(userId)], + revalidate: 30 * 60, // 30 minutes } + )(); - return profile; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError("Database operation failed"); +export const getProfileByEmail = async (email: string): Promise => + unstable_cache( + async () => { + validateInputs([email, z.string().email()]); + try { + const profile = await prisma.user.findFirst({ + where: { + email, + }, + select: responseSelection, + }); + + if (!profile) { + return null; + } + + return profile; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } + }, + [`profiles-${email}`], + { + tags: [getProfileCacheTag(email)], + revalidate: 30 * 60, // 30 minutes } - - throw error; - } -}); + )(); const updateUserMembership = async (teamId: string, userId: string, role: TMembershipRole) => { validateInputs([teamId, ZId], [userId, ZId], [role, ZMembershipRole]); @@ -74,6 +118,9 @@ export const updateProfile = async ( data: data, }); + revalidateTag(getProfileByEmailCacheTag(updatedProfile.email)); + revalidateTag(getProfileCacheTag(personId)); + return updatedProfile; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") { @@ -83,22 +130,27 @@ export const updateProfile = async ( } } }; -const deleteUser = async (userId: string) => { + +const deleteUser = async (userId: string): Promise => { validateInputs([userId, ZId]); - await prisma.user.delete({ + const profile = await prisma.user.delete({ where: { id: userId, }, }); + revalidateTag(getProfileByEmailCacheTag(profile.email)); + revalidateTag(getProfileCacheTag(userId)); + + return profile; }; // function to delete a user's profile including teams -export const deleteProfile = async (personId: string): Promise => { - validateInputs([personId, ZId]); +export const deleteProfile = async (userId: string): Promise => { + validateInputs([userId, ZId]); try { const currentUserMemberships = await prisma.membership.findMany({ where: { - userId: personId, + userId: userId, }, include: { team: { @@ -131,7 +183,8 @@ export const deleteProfile = async (personId: string): Promise => { } } - await deleteUser(personId); + revalidateTag(getProfileCacheTag(userId)); + await deleteUser(userId); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseError("Database operation failed"); diff --git a/packages/lib/services/response.ts b/packages/lib/services/response.ts index 4267dcd98f..008a7e3e32 100644 --- a/packages/lib/services/response.ts +++ b/packages/lib/services/response.ts @@ -16,6 +16,7 @@ import { getPerson, transformPrismaPerson } from "./person"; import { captureTelemetry } from "../telemetry"; import { validateInputs } from "../utils/validate"; import { ZId } from "@formbricks/types/v1/environment"; +import { revalidateTag } from "next/cache"; const responseSelection = { id: true, @@ -75,6 +76,8 @@ const responseSelection = { }, }; +export const getResponsesCacheTag = (surveyId: string) => `surveys-${surveyId}-responses`; + export const getResponsesByPersonId = async (personId: string): Promise | null> => { validateInputs([personId, ZId]); try { @@ -147,6 +150,10 @@ export const createResponse = async (responseInput: Partial): Pr tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), }; + if (response.surveyId) { + revalidateTag(getResponsesCacheTag(response.surveyId)); + } + return response; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { @@ -296,6 +303,10 @@ export const updateResponse = async ( tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), }; + if (response.surveyId) { + revalidateTag(getResponsesCacheTag(response.surveyId)); + } + return response; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { diff --git a/packages/lib/services/survey.ts b/packages/lib/services/survey.ts index 495799b675..93cec78f89 100644 --- a/packages/lib/services/survey.ts +++ b/packages/lib/services/survey.ts @@ -9,14 +9,26 @@ import { ZSurveyWithAnalytics, } from "@formbricks/types/v1/surveys"; import { Prisma } from "@prisma/client"; -import { revalidateTag } from "next/cache"; -import { cache } from "react"; +import { revalidateTag, unstable_cache } from "next/cache"; import "server-only"; import { z } from "zod"; import { captureTelemetry } from "../telemetry"; import { validateInputs } from "../utils/validate"; +import { getDisplaysCacheTag } from "./displays"; +import { getResponsesCacheTag } from "./response"; -const getSurveysCacheTag = (environmentId: string): string => `env-${environmentId}-surveys`; +// surveys cache key and tags +const getSurveysCacheKey = (environmentId: string): string => `environments-${environmentId}-surveys`; +const getSurveysCacheTag = (environmentId: string): string => `environments-${environmentId}-surveys`; + +// survey cache key and tags +export const getSurveyCacheKey = (surveyId: string): string => `surveys-${surveyId}`; +export const getSurveyCacheTag = (surveyId: string): string => `surveys-${surveyId}`; + +// survey with analytics cache key +const getSurveysWithAnalyticsCacheKey = (environmentId: string): string => + `environments-${environmentId}-surveysWithAnalytics`; +const getSurveyWithAnalyticsCacheKey = (surveyId: string): string => `surveyWithAnalytics-${surveyId}`; export const selectSurvey = { id: true, @@ -78,187 +90,255 @@ export const selectSurveyWithAnalytics = { }, }; -export const preloadSurveyWithAnalytics = (surveyId: string) => { - validateInputs([surveyId, ZId]); - void getSurveyWithAnalytics(surveyId); -}; +export const getSurveyWithAnalytics = async (surveyId: string): Promise => { + const survey = await unstable_cache( + async () => { + validateInputs([surveyId, ZId]); + let surveyPrisma; + try { + surveyPrisma = await prisma.survey.findUnique({ + where: { + id: surveyId, + }, + select: selectSurveyWithAnalytics, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } -export const getSurveyWithAnalytics = cache( - async (surveyId: string): Promise => { - validateInputs([surveyId, ZId]); - let surveyPrisma; - try { - surveyPrisma = await prisma.survey.findUnique({ - where: { - id: surveyId, + throw error; + } + + if (!surveyPrisma) { + throw new ResourceNotFoundError("Survey", surveyId); + } + + let { _count, displays, ...surveyPrismaFields } = surveyPrisma; + + const numDisplays = displays.length; + const numDisplaysResponded = displays.filter((item) => item.status === "responded").length; + const numResponses = _count.responses; + // responseRate, rounded to 2 decimal places + const responseRate = numDisplays ? Math.round((numDisplaysResponded / numDisplays) * 100) / 100 : 0; + + const transformedSurvey = { + ...surveyPrismaFields, + triggers: surveyPrismaFields.triggers.map((trigger) => trigger.eventClass), + analytics: { + numDisplays, + responseRate, + numResponses, }, - select: selectSurveyWithAnalytics, - }); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError("Database operation failed"); + }; + + try { + const survey = ZSurveyWithAnalytics.parse(transformedSurvey); + return survey; + } catch (error) { + console.log(error); + if (error instanceof z.ZodError) { + console.error(JSON.stringify(error.errors, null, 2)); // log the detailed error information + } + throw new ValidationError("Data validation of survey failed"); } - - throw error; + }, + [getSurveyWithAnalyticsCacheKey(surveyId)], + { + tags: [getSurveyCacheTag(surveyId), getDisplaysCacheTag(surveyId), getResponsesCacheTag(surveyId)], + revalidate: 60 * 30, } + )(); - if (!surveyPrisma) { - throw new ResourceNotFoundError("Survey", surveyId); - } - - let { _count, displays, ...surveyPrismaFields } = surveyPrisma; - - const numDisplays = displays.length; - const numDisplaysResponded = displays.filter((item) => item.status === "responded").length; - const numResponses = _count.responses; - // responseRate, rounded to 2 decimal places - const responseRate = numDisplays ? Math.round((numDisplaysResponded / numDisplays) * 100) / 100 : 0; - - const transformedSurvey = { - ...surveyPrismaFields, - triggers: surveyPrismaFields.triggers.map((trigger) => trigger.eventClass), - analytics: { - numDisplays, - responseRate, - numResponses, - }, - }; - - try { - const survey = ZSurveyWithAnalytics.parse(transformedSurvey); - return survey; - } catch (error) { - if (error instanceof z.ZodError) { - console.error(JSON.stringify(error.errors, null, 2)); // log the detailed error information - } - throw new ValidationError("Data validation of survey failed"); - } - } -); - -export const getSurvey = cache(async (surveyId: string): Promise => { - validateInputs([surveyId, ZId]); - let surveyPrisma; - try { - surveyPrisma = await prisma.survey.findUnique({ - where: { - id: surveyId, - }, - select: selectSurvey, - }); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError("Database operation failed"); - } - - throw error; - } - - if (!surveyPrisma) { + if (!survey) { return null; } - const transformedSurvey = { - ...surveyPrisma, - triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass), + // since the unstable_cache function does not support deserialization of dates, we need to manually deserialize them + // https://github.com/vercel/next.js/issues/51613 + return { + ...survey, + createdAt: new Date(survey.createdAt), + updatedAt: new Date(survey.updatedAt), }; +}; - try { - const survey = ZSurvey.parse(transformedSurvey); - return survey; - } catch (error) { - if (error instanceof z.ZodError) { - console.error(JSON.stringify(error.errors, null, 2)); // log the detailed error information - } - throw new ValidationError("Data validation of survey failed"); - } -}); +export const getSurvey = async (surveyId: string): Promise => { + const survey = await unstable_cache( + async () => { + validateInputs([surveyId, ZId]); + let surveyPrisma; + try { + surveyPrisma = await prisma.survey.findUnique({ + where: { + id: surveyId, + }, + select: selectSurvey, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } -export const getSurveys = cache(async (environmentId: string): Promise => { - validateInputs([environmentId, ZId]); - let surveysPrisma; - try { - surveysPrisma = await prisma.survey.findMany({ - where: { - environmentId, - }, - select: selectSurvey, - }); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError("Database operation failed"); - } + throw error; + } - throw error; - } + if (!surveyPrisma) { + return null; + } - const surveys: TSurvey[] = []; - - try { - for (const surveyPrisma of surveysPrisma) { const transformedSurvey = { ...surveyPrisma, triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass), }; - const survey = ZSurvey.parse(transformedSurvey); - surveys.push(survey); - } - return surveys; - } catch (error) { - if (error instanceof z.ZodError) { - console.error(JSON.stringify(error.errors, null, 2)); // log the detailed error information - } - throw new ValidationError("Data validation of survey failed"); - } -}); -export const getSurveysWithAnalytics = cache( - async (environmentId: string): Promise => { - validateInputs([environmentId, ZId]); - let surveysPrisma; - try { - surveysPrisma = await prisma.survey.findMany({ - where: { - environmentId, - }, - select: selectSurveyWithAnalytics, - }); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError("Database operation failed"); + try { + const survey = ZSurvey.parse(transformedSurvey); + return survey; + } catch (error) { + if (error instanceof z.ZodError) { + console.error(JSON.stringify(error.errors, null, 2)); // log the detailed error information + } + throw new ValidationError("Data validation of survey failed"); } - - throw error; + }, + [getSurveyCacheKey(surveyId)], + { + tags: [getSurveyCacheTag(surveyId)], + revalidate: 60 * 30, } + )(); - try { - const surveys: TSurveyWithAnalytics[] = []; - for (const { _count, displays, ...surveyPrisma } of surveysPrisma) { - const numDisplays = displays.length; - const numDisplaysResponded = displays.filter((item) => item.status === "responded").length; - const responseRate = numDisplays ? Math.round((numDisplaysResponded / numDisplays) * 100) / 100 : 0; + if (!survey) { + return null; + } - const transformedSurvey = { - ...surveyPrisma, - triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass), - analytics: { - numDisplays, - responseRate, - numResponses: _count.responses, + // since the unstable_cache function does not support deserialization of dates, we need to manually deserialize them + // https://github.com/vercel/next.js/issues/51613 + return { + ...survey, + createdAt: new Date(survey.createdAt), + updatedAt: new Date(survey.updatedAt), + }; +}; + +export const getSurveys = async (environmentId: string): Promise => { + const surveys = await unstable_cache( + async () => { + validateInputs([environmentId, ZId]); + let surveysPrisma; + try { + surveysPrisma = await prisma.survey.findMany({ + where: { + environmentId, }, - }; - const survey = ZSurveyWithAnalytics.parse(transformedSurvey); - surveys.push(survey); + select: selectSurvey, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; } - return surveys; - } catch (error) { - if (error instanceof z.ZodError) { - console.error(JSON.stringify(error.errors, null, 2)); // log the detailed error information + + const surveys: TSurvey[] = []; + + try { + for (const surveyPrisma of surveysPrisma) { + const transformedSurvey = { + ...surveyPrisma, + triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass), + }; + const survey = ZSurvey.parse(transformedSurvey); + surveys.push(survey); + } + return surveys; + } catch (error) { + if (error instanceof z.ZodError) { + console.error(JSON.stringify(error.errors, null, 2)); // log the detailed error information + } + throw new ValidationError("Data validation of survey failed"); } - throw new ValidationError("Data validation of survey failed"); + }, + [getSurveysCacheKey(environmentId)], + { + tags: [getSurveysCacheTag(environmentId)], + revalidate: 60 * 30, } - } -); + )(); + + // since the unstable_cache function does not support deserialization of dates, we need to manually deserialize them + // https://github.com/vercel/next.js/issues/51613 + return surveys.map((survey) => ({ + ...survey, + createdAt: new Date(survey.createdAt), + updatedAt: new Date(survey.updatedAt), + })); +}; + +// TODO: Cache doesn't work for updated displays & responses +export const getSurveysWithAnalytics = async (environmentId: string): Promise => { + const surveysWithAnalytics = await unstable_cache( + async () => { + validateInputs([environmentId, ZId]); + let surveysPrisma; + try { + surveysPrisma = await prisma.survey.findMany({ + where: { + environmentId, + }, + select: selectSurveyWithAnalytics, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } + + try { + const surveys: TSurveyWithAnalytics[] = []; + for (const { _count, displays, ...surveyPrisma } of surveysPrisma) { + const numDisplays = displays.length; + const numDisplaysResponded = displays.filter((item) => item.status === "responded").length; + const responseRate = numDisplays ? Math.round((numDisplaysResponded / numDisplays) * 100) / 100 : 0; + + const transformedSurvey = { + ...surveyPrisma, + triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass), + analytics: { + numDisplays, + responseRate, + numResponses: _count.responses, + }, + }; + const survey = ZSurveyWithAnalytics.parse(transformedSurvey); + surveys.push(survey); + } + return surveys; + } catch (error) { + if (error instanceof z.ZodError) { + console.error(JSON.stringify(error.errors, null, 2)); // log the detailed error information + } + throw new ValidationError("Data validation of survey failed"); + } + }, + [getSurveysWithAnalyticsCacheKey(environmentId)], + { + tags: [getSurveysCacheTag(environmentId)], // TODO: add tags for displays and responses + } + )(); + + // since the unstable_cache function does not support deserialization of dates, we need to manually deserialize them + // https://github.com/vercel/next.js/issues/51613 + return surveysWithAnalytics.map((survey) => ({ + ...survey, + createdAt: new Date(survey.createdAt), + updatedAt: new Date(survey.updatedAt), + })); +}; export async function updateSurvey(updatedSurvey: Partial): Promise { const surveyId = updatedSurvey.id; @@ -432,6 +512,7 @@ export async function updateSurvey(updatedSurvey: Partial): Promise => { - try { - const teams = await prisma.team.findMany({ - where: { - memberships: { - some: { - userId, - }, - }, - }, - select, - }); +export const getTeamsByUserIdCacheTag = (userId: string) => `users-${userId}-teams`; +export const getTeamByEnvironmentIdCacheTag = (environmentId: string) => `environments-${environmentId}-team`; - return teams; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError("Database operation failed"); - } - - throw error; - } -}); - -export const getTeamByEnvironmentId = cache(async (environmentId: string): Promise => { - validateInputs([environmentId, ZId]); - try { - const team = await prisma.team.findFirst({ - where: { - products: { - some: { - environments: { +export const getTeamsByUserId = async (userId: string): Promise => + unstable_cache( + async () => { + try { + const teams = await prisma.team.findMany({ + where: { + memberships: { some: { - id: environmentId, + userId, }, }, }, - }, - }, - select, - }); + select, + }); - return team; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError("Database operation failed"); + return teams; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } + }, + [`users-${userId}-teams`], + { + tags: [getTeamsByUserIdCacheTag(userId)], + revalidate: 30 * 60, // 30 minutes } + )(); - throw error; - } -}); +export const getTeamByEnvironmentId = async (environmentId: string): Promise => + unstable_cache( + async () => { + validateInputs([environmentId, ZId]); + try { + const team = await prisma.team.findFirst({ + where: { + products: { + some: { + environments: { + some: { + id: environmentId, + }, + }, + }, + }, + }, + select: { ...select, memberships: true }, // include memberships + }); -export const updateTeam = async (teamId: string, data: TTeamUpdateInput) => { + return team; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } + }, + [`environments-${environmentId}-team`], + { + tags: [getTeamByEnvironmentIdCacheTag(environmentId)], + revalidate: 30 * 60, // 30 minutes + } + )(); + +export const updateTeam = async (teamId: string, data: TTeamUpdateInput): Promise => { try { const updatedTeam = await prisma.team.update({ where: { id: teamId, }, data, + select: { ...select, memberships: true, products: { select: { environments: true } } }, // include memberships & environments }); - return updatedTeam; + // revalidate cache for members + updatedTeam?.memberships.forEach((membership) => { + revalidateTag(getTeamsByUserIdCacheTag(membership.userId)); + }); + + // revalidate cache for environments + updatedTeam?.products.forEach((product) => { + product.environments.forEach((environment) => { + revalidateTag(getTeamByEnvironmentIdCacheTag(environment.id)); + }); + }); + + const team = { + ...updatedTeam, + memberships: undefined, + products: undefined, + }; + + return team; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") { throw new ResourceNotFoundError("Team", teamId); @@ -107,11 +145,32 @@ export const updateTeam = async (teamId: string, data: TTeamUpdateInput) => { export const deleteTeam = async (teamId: string) => { validateInputs([teamId, ZId]); try { - await prisma.team.delete({ + const deletedTeam = await prisma.team.delete({ where: { id: teamId, }, + select: { ...select, memberships: true, products: { select: { environments: true } } }, // include memberships & environments }); + + // revalidate cache for members + deletedTeam?.memberships.forEach((membership) => { + revalidateTag(getTeamsByUserIdCacheTag(membership.userId)); + }); + + // revalidate cache for environments + deletedTeam?.products.forEach((product) => { + product.environments.forEach((environment) => { + revalidateTag(getTeamByEnvironmentIdCacheTag(environment.id)); + }); + }); + + const team = { + ...deletedTeam, + memberships: undefined, + products: undefined, + }; + + return team; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseError("Database operation failed"); @@ -121,7 +180,7 @@ export const deleteTeam = async (teamId: string) => { } }; -export const createDemoProduct = cache(async (teamId: string) => { +export const createDemoProduct = async (teamId: string) => { validateInputs([teamId, ZId]); const productWithEnvironment = Prisma.validator()({ include: { @@ -300,4 +359,4 @@ export const createDemoProduct = cache(async (teamId: string) => { ); return demoProduct; -}); +};