Files
formbricks/packages/lib/segment/service.ts
2024-04-26 15:18:10 +00:00

701 lines
19 KiB
TypeScript

import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { ZString } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import {
TActionMetric,
TAllOperators,
TBaseFilters,
TEvaluateSegmentUserAttributeData,
TEvaluateSegmentUserData,
TSegment,
TSegmentActionFilter,
TSegmentAttributeFilter,
TSegmentConnector,
TSegmentCreateInput,
TSegmentDeviceFilter,
TSegmentPersonFilter,
TSegmentSegmentFilter,
TSegmentUpdateInput,
ZSegmentCreateInput,
ZSegmentFilters,
ZSegmentUpdateInput,
} from "@formbricks/types/segment";
import {
getActionCountInLastMonth,
getActionCountInLastQuarter,
getActionCountInLastWeek,
getFirstOccurrenceDaysAgo,
getLastOccurrenceDaysAgo,
getTotalOccurrencesForAction,
} from "../action/service";
import { cache } from "../cache";
import { structuredClone } from "../pollyfills/structuredClone";
import { surveyCache } from "../survey/cache";
import { getSurvey } from "../survey/service";
import { validateInputs } from "../utils/validate";
import { segmentCache } from "./cache";
import { isResourceFilter, searchForAttributeClassNameInSegment } from "./utils";
type PrismaSegment = Prisma.SegmentGetPayload<{
include: {
surveys: {
select: {
id: true;
};
};
};
}>;
export const selectSegment: Prisma.SegmentDefaultArgs["select"] = {
id: true,
createdAt: true,
updatedAt: true,
title: true,
description: true,
environmentId: true,
filters: true,
isPrivate: true,
surveys: {
select: {
id: true,
},
},
};
export const transformPrismaSegment = (segment: PrismaSegment): TSegment => {
return {
...segment,
surveys: segment.surveys.map((survey) => survey.id),
};
};
export const createSegment = async (segmentCreateInput: TSegmentCreateInput): Promise<TSegment> => {
validateInputs([segmentCreateInput, ZSegmentCreateInput]);
const { description, environmentId, filters, isPrivate, surveyId, title } = segmentCreateInput;
let data: Prisma.SegmentCreateArgs["data"] = {
environmentId,
title,
description,
isPrivate,
filters,
};
if (surveyId) {
data = {
...data,
surveys: {
connect: {
id: surveyId,
},
},
};
}
try {
const segment = await prisma.segment.create({
data,
select: selectSegment,
});
segmentCache.revalidate({ id: segment.id, environmentId });
surveyCache.revalidate({ id: surveyId });
return transformPrismaSegment(segment);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getSegments = (environmentId: string): Promise<TSegment[]> =>
cache(
async () => {
validateInputs([environmentId, ZId]);
try {
const segments = await prisma.segment.findMany({
where: {
environmentId,
},
select: selectSegment,
});
if (!segments) {
return [];
}
return segments.map((segment) => transformPrismaSegment(segment));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSegments-${environmentId}`],
{
tags: [segmentCache.tag.byEnvironmentId(environmentId)],
}
)();
export const getSegment = (segmentId: string): Promise<TSegment> =>
cache(
async () => {
validateInputs([segmentId, ZId]);
try {
const segment = await prisma.segment.findUnique({
where: {
id: segmentId,
},
select: selectSegment,
});
if (!segment) {
throw new ResourceNotFoundError("segment", segmentId);
}
return transformPrismaSegment(segment);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSegment-${segmentId}`],
{
tags: [segmentCache.tag.byId(segmentId)],
}
)();
export const updateSegment = async (segmentId: string, data: TSegmentUpdateInput): Promise<TSegment> => {
validateInputs([segmentId, ZId], [data, ZSegmentUpdateInput]);
try {
let updatedInput: Prisma.SegmentUpdateInput = {
...data,
surveys: undefined,
};
if (data.surveys) {
updatedInput = {
...data,
surveys: {
connect: data.surveys.map((surveyId) => ({ id: surveyId })),
},
};
}
const currentSegment = await getSegment(segmentId);
if (!currentSegment) {
throw new ResourceNotFoundError("segment", segmentId);
}
const segment = await prisma.segment.update({
where: {
id: segmentId,
},
data: updatedInput,
select: selectSegment,
});
segmentCache.revalidate({ id: segmentId, environmentId: segment.environmentId });
segment.surveys.map((survey) => surveyCache.revalidate({ id: survey.id }));
return transformPrismaSegment(segment);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const deleteSegment = async (segmentId: string): Promise<TSegment> => {
validateInputs([segmentId, ZId]);
try {
const currentSegment = await getSegment(segmentId);
if (!currentSegment) {
throw new ResourceNotFoundError("segment", segmentId);
}
const segment = await prisma.segment.delete({
where: {
id: segmentId,
},
select: selectSegment,
});
// pause all the running surveys that are using this segment
const surveyIds = segment.surveys.map((survey) => survey.id);
if (!!surveyIds?.length) {
await prisma.survey.updateMany({
where: {
id: { in: surveyIds },
status: "inProgress",
},
data: {
status: "paused",
},
});
}
segmentCache.revalidate({ id: segmentId, environmentId: segment.environmentId });
segment.surveys.map((survey) => surveyCache.revalidate({ id: survey.id }));
surveyCache.revalidate({ environmentId: currentSegment.environmentId });
return transformPrismaSegment(segment);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const cloneSegment = async (segmentId: string, surveyId: string): Promise<TSegment> => {
validateInputs([segmentId, ZId], [surveyId, ZId]);
try {
const segment = await getSegment(segmentId);
if (!segment) {
throw new ResourceNotFoundError("segment", segmentId);
}
const allSegments = await getSegments(segment.environmentId);
// Find the last "Copy of" title and extract the number from it
const lastCopyTitle = allSegments
.map((existingSegment) => existingSegment.title)
.filter((title) => title.startsWith(`Copy of ${segment.title}`))
.pop();
let suffix = 1;
if (lastCopyTitle) {
const match = lastCopyTitle.match(/\((\d+)\)$/);
if (match) {
suffix = parseInt(match[1], 10) + 1;
}
}
// Construct the title for the cloned segment
const clonedTitle = `Copy of ${segment.title} (${suffix})`;
// parse the filters and update the user segment
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!parsedFilters.success) {
throw new ValidationError("Invalid filters");
}
const clonedSegment = await prisma.segment.create({
data: {
title: clonedTitle,
description: segment.description,
isPrivate: segment.isPrivate,
environmentId: segment.environmentId,
filters: segment.filters,
surveys: {
connect: {
id: surveyId,
},
},
},
select: selectSegment,
});
segmentCache.revalidate({ id: clonedSegment.id, environmentId: clonedSegment.environmentId });
surveyCache.revalidate({ id: surveyId });
return transformPrismaSegment(clonedSegment);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getSegmentsByAttributeClassName = (environmentId: string, attributeClassName: string) =>
cache(
async () => {
validateInputs([environmentId, ZId], [attributeClassName, ZString]);
try {
const segments = await prisma.segment.findMany({
where: {
environmentId,
},
select: selectSegment,
});
// search for attributeClassName in the filters
const clonedSegments = structuredClone(segments);
const filteredSegments = clonedSegments.filter((segment) => {
return searchForAttributeClassNameInSegment(segment.filters, attributeClassName);
});
return filteredSegments;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSegmentsByAttributeClassName-${environmentId}-${attributeClassName}`],
{
tags: [
segmentCache.tag.byEnvironmentId(environmentId),
segmentCache.tag.byAttributeClassName(attributeClassName),
],
}
)();
export const resetSegmentInSurvey = async (surveyId: string): Promise<TSegment> => {
validateInputs([surveyId, ZId]);
const survey = await getSurvey(surveyId);
if (!survey) {
throw new ResourceNotFoundError("survey", surveyId);
}
try {
return await prisma.$transaction(async (tx) => {
// for this survey, does a private segment already exist
const segment = await tx.segment.findFirst({
where: {
title: `${surveyId}`,
isPrivate: true,
},
select: selectSegment,
});
// if a private segment already exists, connect it to the survey
if (segment) {
await tx.survey.update({
where: { id: surveyId },
data: { segment: { connect: { id: segment.id } } },
});
// reset the filters
const updatedSegment = await tx.segment.update({
where: { id: segment.id },
data: { filters: [] },
select: selectSegment,
});
surveyCache.revalidate({ id: surveyId });
segmentCache.revalidate({ environmentId: survey.environmentId });
return transformPrismaSegment(updatedSegment);
} else {
// This case should never happen because a private segment with the title of the surveyId
// should always exist. But, handling it just in case.
// if a private segment does not exist, create one
const newPrivateSegment = await tx.segment.create({
data: {
title: `${surveyId}`,
isPrivate: true,
filters: [],
surveys: { connect: { id: surveyId } },
environment: { connect: { id: survey?.environmentId } },
},
select: selectSegment,
});
surveyCache.revalidate({ id: surveyId });
segmentCache.revalidate({ environmentId: survey.environmentId });
return transformPrismaSegment(newPrivateSegment);
}
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
const evaluateAttributeFilter = (
attributes: TEvaluateSegmentUserAttributeData,
filter: TSegmentAttributeFilter
): boolean => {
const { value, qualifier, root } = filter;
const { attributeClassName } = root;
const attributeValue = attributes[attributeClassName];
if (!attributeValue) {
return false;
}
const attResult = compareValues(attributeValue, value, qualifier.operator);
return attResult;
};
const evaluatePersonFilter = (userId: string, filter: TSegmentPersonFilter): boolean => {
const { value, qualifier, root } = filter;
const { personIdentifier } = root;
if (personIdentifier === "userId") {
const attResult = compareValues(userId, value, qualifier.operator);
return attResult;
}
return false;
};
const getResolvedActionValue = async (actionClassId: string, personId: string, metric: TActionMetric) => {
if (metric === "lastQuarterCount") {
const lastQuarterCount = await getActionCountInLastQuarter(actionClassId, personId);
return lastQuarterCount;
}
if (metric === "lastMonthCount") {
const lastMonthCount = await getActionCountInLastMonth(actionClassId, personId);
return lastMonthCount;
}
if (metric === "lastWeekCount") {
const lastWeekCount = await getActionCountInLastWeek(actionClassId, personId);
return lastWeekCount;
}
if (metric === "lastOccurranceDaysAgo") {
const lastOccurranceDaysAgo = await getLastOccurrenceDaysAgo(actionClassId, personId);
return lastOccurranceDaysAgo;
}
if (metric === "firstOccurranceDaysAgo") {
const firstOccurranceDaysAgo = await getFirstOccurrenceDaysAgo(actionClassId, personId);
return firstOccurranceDaysAgo;
}
if (metric === "occuranceCount") {
const occuranceCount = await getTotalOccurrencesForAction(actionClassId, personId);
return occuranceCount;
}
};
const evaluateActionFilter = async (
actionClassIds: string[],
filter: TSegmentActionFilter,
personId: string
): Promise<boolean> => {
const { value, qualifier, root } = filter;
const { actionClassId } = root;
const { metric } = qualifier;
// there could be a case when the actionIds do not have the actionClassId
// in such a case, we return false
const actionClassIdIndex = actionClassIds.findIndex((actionId) => actionId === actionClassId);
if (actionClassIdIndex === -1) {
return false;
}
try {
// we have the action metric and we'll need to find out the values for those metrics from the db
const actionValue = await getResolvedActionValue(actionClassId, personId, metric);
const actionResult =
actionValue !== undefined && compareValues(actionValue ?? 0, value, qualifier.operator);
return actionResult;
} catch (error) {
throw error;
}
};
const evaluateSegmentFilter = async (
userData: TEvaluateSegmentUserData,
filter: TSegmentSegmentFilter
): Promise<boolean> => {
const { qualifier, root } = filter;
const { segmentId } = root;
const { operator } = qualifier;
const segment = await getSegment(segmentId);
if (!segment) {
return false;
}
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!parsedFilters.success) {
return false;
}
const isInSegment = await evaluateSegment(userData, parsedFilters.data);
if (operator === "userIsIn") {
return isInSegment;
}
if (operator === "userIsNotIn") {
return !isInSegment;
}
return false;
};
const evaluateDeviceFilter = (device: "phone" | "desktop", filter: TSegmentDeviceFilter): boolean => {
const { value, qualifier } = filter;
return compareValues(device, value, qualifier.operator);
};
export const compareValues = (
a: string | number | undefined,
b: string | number,
operator: TAllOperators
): boolean => {
switch (operator) {
case "equals":
return a === b;
case "notEquals":
return a !== b;
case "lessThan":
return (a as number) < (b as number);
case "lessEqual":
return (a as number) <= (b as number);
case "greaterThan":
return (a as number) > (b as number);
case "greaterEqual":
return (a as number) >= (b as number);
case "isSet":
return a !== undefined;
case "isNotSet":
return a === "" || a === null || a === undefined;
case "contains":
return (a as string).includes(b as string);
case "doesNotContain":
return !(a as string).includes(b as string);
case "startsWith":
return (a as string).startsWith(b as string);
case "endsWith":
return (a as string).endsWith(b as string);
default:
throw new Error(`Unexpected operator: ${operator}`);
}
};
type ResultConnectorPair = {
result: boolean;
connector: TSegmentConnector;
};
export const evaluateSegment = async (
userData: TEvaluateSegmentUserData,
filters: TBaseFilters
): Promise<boolean> => {
let resultPairs: ResultConnectorPair[] = [];
try {
for (let filterItem of filters) {
const { resource } = filterItem;
let result: boolean;
if (isResourceFilter(resource)) {
const { root } = resource;
const { type } = root;
if (type === "attribute") {
result = evaluateAttributeFilter(userData.attributes, resource as TSegmentAttributeFilter);
resultPairs.push({
result,
connector: filterItem.connector,
});
}
if (type === "person") {
result = evaluatePersonFilter(userData.userId, resource as TSegmentPersonFilter);
resultPairs.push({
result,
connector: filterItem.connector,
});
}
if (type === "action") {
result = await evaluateActionFilter(
userData.actionIds,
resource as TSegmentActionFilter,
userData.personId
);
resultPairs.push({
result,
connector: filterItem.connector,
});
}
if (type === "segment") {
result = await evaluateSegmentFilter(userData, resource as TSegmentSegmentFilter);
resultPairs.push({
result,
connector: filterItem.connector,
});
}
if (type === "device") {
result = evaluateDeviceFilter(userData.deviceType, resource as TSegmentDeviceFilter);
resultPairs.push({
result,
connector: filterItem.connector,
});
}
} else {
result = await evaluateSegment(userData, resource);
// this is a sub-group and we need to evaluate the sub-group
resultPairs.push({
result,
connector: filterItem.connector,
});
}
}
if (!resultPairs.length) {
return false;
}
// Given that the first filter in every group/sub-group always has a connector value of "null",
// we initialize the finalResult with the result of the first filter.
let finalResult = resultPairs[0].result;
for (let i = 1; i < resultPairs.length; i++) {
const { result, connector } = resultPairs[i];
if (connector === "and") {
finalResult = finalResult && result;
} else if (connector === "or") {
finalResult = finalResult || result;
}
}
return finalResult;
} catch (error) {
throw error;
}
};