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 <mail@matthiasnannt.com>
This commit is contained in:
Anshuman Pandey
2023-09-30 17:52:42 +05:30
committed by GitHub
parent 48d8fc6aca
commit 0225362a92
18 changed files with 586 additions and 388 deletions

View File

@@ -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;
}

View File

@@ -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]);

View File

@@ -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;

View File

@@ -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 <SurveyStarter environmentId={environmentId} environment={environment} product={product} />;
@@ -45,7 +44,7 @@ export default async function SurveysList({ environmentId }: { environmentId: st
</li>
</Link>
{surveys
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
.sort((a, b) => b.updatedAt?.getTime() - a.updatedAt?.getTime())
.map((survey) => (
<li key={survey.id} className="relative col-span-1 h-56">
<div className="delay-50 flex h-full flex-col justify-between rounded-md bg-white shadow transition ease-in-out hover:scale-105">
@@ -82,9 +81,6 @@ export default async function SurveysList({ environmentId }: { environmentId: st
tooltip
environmentId={environmentId}
/>
<p className="ml-2 text-xs text-slate-400 ">
{survey.analytics.numResponses} responses
</p>
</>
)}
{survey.status === "draft" && (
@@ -93,7 +89,7 @@ export default async function SurveysList({ environmentId }: { environmentId: st
</div>
<SurveyDropDownMenu
survey={survey}
key={`survey-${survey.id}`}
key={`surveys-${survey.id}`}
environmentId={environmentId}
environment={environment}
otherEnvironment={otherEnvironment!}
@@ -105,7 +101,7 @@ export default async function SurveysList({ environmentId }: { environmentId: st
</li>
))}
</ul>
<UsageAttributesUpdater numSurveys={surveys.length} totalSubmissions={totalSubmissions} />
<UsageAttributesUpdater numSurveys={surveys.length} />
</>
);
}

View File

@@ -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;

View File

@@ -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) =>

View File

@@ -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");

View File

@@ -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: {

View File

@@ -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),
];

View File

@@ -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),

View File

@@ -39,6 +39,8 @@ const selectDisplay = {
status: true,
};
export const getDisplaysCacheTag = (surveyId: string) => `surveys-${surveyId}-displays`;
export const createDisplay = async (displayInput: TDisplayInput): Promise<TDisplay> => {
validateInputs([displayInput, ZDisplayInput]);
try {
@@ -71,6 +73,10 @@ export const createDisplay = async (displayInput: TDisplayInput): Promise<TDispl
revalidateTag(displayInput.personId);
}
if (displayInput.surveyId) {
revalidateTag(getDisplaysCacheTag(displayInput.surveyId));
}
return display;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -7,88 +7,94 @@ import type { TEnvironment, TEnvironmentId, TEnvironmentUpdateInput } from "@for
import { populateEnvironment } from "../utils/createDemoProductHelpers";
import { ZEnvironment, ZEnvironmentUpdateInput, ZId } from "@formbricks/types/v1/environment";
import { validateInputs } from "../utils/validate";
import { cache } from "react";
import { unstable_cache } from "next/cache";
import { unstable_cache, revalidateTag } from "next/cache";
export const getEnvironment = cache(async (environmentId: string): Promise<TEnvironment | null> => {
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<TEnvironment[]> => {
validateInputs([productId, ZId]);
let productPrisma;
try {
productPrisma = await prisma.product.findFirst({
where: {
id: productId,
},
include: {
environments: true,
},
});
export const getEnvironments = async (productId: string): Promise<TEnvironment[]> =>
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) {

View File

@@ -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
}
)();

View File

@@ -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<TProduct[]> => {
validateInputs([teamId, ZId]);
try {
const products = await prisma.product.findMany({
where: {
teamId,
},
select: selectProduct,
});
export const getProducts = async (teamId: string): Promise<TProduct[]> =>
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<TProduct | null> => {
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<TProduct>
});
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));
});
}

View File

@@ -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<TProfile | null> => {
validateInputs([userId, ZId]);
try {
const profile = await prisma.user.findUnique({
where: {
id: userId,
},
select: responseSelection,
});
export const getProfile = async (userId: string): Promise<TProfile | null> =>
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<TProfile | null> =>
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<TProfile> => {
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<void> => {
validateInputs([personId, ZId]);
export const deleteProfile = async (userId: string): Promise<void> => {
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<void> => {
}
}
await deleteUser(personId);
revalidateTag(getProfileCacheTag(userId));
await deleteUser(userId);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");

View File

@@ -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<Array<TResponse> | null> => {
validateInputs([personId, ZId]);
try {
@@ -147,6 +150,10 @@ export const createResponse = async (responseInput: Partial<TResponseInput>): 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) {

View File

@@ -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<TSurveyWithAnalytics | null> => {
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<TSurveyWithAnalytics | null> => {
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<TSurvey | null> => {
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<TSurvey | null> => {
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<TSurvey[]> => {
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<TSurveyWithAnalytics[]> => {
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<TSurvey[]> => {
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<TSurveyWithAnalytics[]> => {
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<TSurvey>): Promise<TSurvey> {
const surveyId = updatedSurvey.id;
@@ -432,6 +512,7 @@ export async function updateSurvey(updatedSurvey: Partial<TSurvey>): Promise<TSu
};
revalidateTag(getSurveysCacheTag(modifiedSurvey.environmentId));
revalidateTag(getSurveyCacheTag(modifiedSurvey.id));
return modifiedSurvey;
} catch (error) {
@@ -452,7 +533,10 @@ export async function deleteSurvey(surveyId: string) {
},
select: selectSurvey,
});
revalidateTag(getSurveysCacheTag(deletedSurvey.environmentId));
revalidateTag(getSurveyCacheTag(surveyId));
return deletedSurvey;
}
@@ -469,7 +553,9 @@ export async function createSurvey(environmentId: string, surveyBody: any) {
},
});
captureTelemetry("survey created");
revalidateTag(getSurveysCacheTag(environmentId));
revalidateTag(getSurveyCacheTag(survey.id));
return survey;
}

View File

@@ -4,7 +4,7 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/v1/error
import { TTeam, TTeamUpdateInput } from "@formbricks/types/v1/teams";
import { createId } from "@paralleldrive/cuid2";
import { Prisma } from "@prisma/client";
import { cache } from "react";
import { revalidateTag, unstable_cache } from "next/cache";
import {
ChurnResponses,
ChurnSurvey,
@@ -34,67 +34,105 @@ export const select = {
stripeCustomerId: true,
};
export const getTeamsByUserId = cache(async (userId: string): Promise<TTeam[]> => {
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<TTeam | null> => {
validateInputs([environmentId, ZId]);
try {
const team = await prisma.team.findFirst({
where: {
products: {
some: {
environments: {
export const getTeamsByUserId = async (userId: string): Promise<TTeam[]> =>
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<TTeam | null> =>
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<TTeam> => {
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<Prisma.ProductArgs>()({
include: {
@@ -300,4 +359,4 @@ export const createDemoProduct = cache(async (teamId: string) => {
);
return demoProduct;
});
};