Files
formbricks/packages/lib/survey/service.ts
T
Anshuman Pandey 37ef6be4c3 feat: survey follow ups (#4247)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-11-21 06:50:37 +00:00

1491 lines
45 KiB
TypeScript

import "server-only";
import { createId } from "@paralleldrive/cuid2";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { TActionClass } from "@formbricks/types/action-classes";
import { ZOptionalNumber } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/common";
import { TEnvironment } from "@formbricks/types/environment";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TPerson } from "@formbricks/types/people";
import { TProduct } from "@formbricks/types/product";
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
import {
TSurvey,
TSurveyCreateInput,
TSurveyFilterCriteria,
TSurveyOpenTextQuestion,
TSurveyQuestions,
ZSurvey,
ZSurveyCreateInput,
} from "@formbricks/types/surveys/types";
import { actionClassCache } from "../actionClass/cache";
import { getActionClasses } from "../actionClass/service";
import { attributeCache } from "../attribute/cache";
import { getAttributes } from "../attribute/service";
import { cache } from "../cache";
import { ITEMS_PER_PAGE } from "../constants";
import { displayCache } from "../display/cache";
import { getDisplaysByPersonId } from "../display/service";
import { getEnvironment } from "../environment/service";
import {
getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses,
} from "../organization/service";
import { personCache } from "../person/cache";
import { getPerson } from "../person/service";
import { structuredClone } from "../pollyfills/structuredClone";
import { capturePosthogEnvironmentEvent } from "../posthogServer";
import { productCache } from "../product/cache";
import { getProductByEnvironmentId } from "../product/service";
import { responseCache } from "../response/cache";
import { getResponsesByPersonId } from "../response/service";
import { segmentCache } from "../segment/cache";
import { createSegment, deleteSegment, evaluateSegment, getSegment, updateSegment } from "../segment/service";
import { getIsAIEnabled } from "../utils/ai";
import { diffInDays } from "../utils/datetime";
import { validateInputs } from "../utils/validate";
import { surveyCache } from "./cache";
import {
anySurveyHasFilters,
buildOrderByClause,
buildWhereClause,
doesSurveyHasOpenTextQuestion,
getInsightsEnabled,
transformPrismaSurvey,
} from "./utils";
interface TriggerUpdate {
create?: Array<{ actionClassId: string }>;
deleteMany?: {
actionClassId: {
in: string[];
};
};
}
export const selectSurvey = {
id: true,
createdAt: true,
updatedAt: true,
name: true,
type: true,
environmentId: true,
createdBy: true,
status: true,
welcomeCard: true,
questions: true,
endings: true,
hiddenFields: true,
variables: true,
displayOption: true,
recontactDays: true,
displayLimit: true,
autoClose: true,
runOnDate: true,
closeOnDate: true,
delay: true,
displayPercentage: true,
autoComplete: true,
isVerifyEmailEnabled: true,
isSingleResponsePerEmailEnabled: true,
redirectUrl: true,
productOverwrites: true,
styling: true,
surveyClosedMessage: true,
singleUse: true,
pin: true,
resultShareKey: true,
showLanguageSwitch: true,
languages: {
select: {
default: true,
enabled: true,
language: {
select: {
id: true,
code: true,
alias: true,
createdAt: true,
updatedAt: true,
productId: true,
},
},
},
},
triggers: {
select: {
actionClass: {
select: {
id: true,
createdAt: true,
updatedAt: true,
environmentId: true,
name: true,
description: true,
type: true,
key: true,
noCodeConfig: true,
},
},
},
},
segment: {
include: {
surveys: {
select: {
id: true,
},
},
},
},
followUps: true,
} satisfies Prisma.SurveySelect;
const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: TActionClass[]) => {
if (!triggers) return;
// check if all the triggers are valid
triggers.forEach((trigger) => {
if (!actionClasses.find((actionClass) => actionClass.id === trigger.actionClass.id)) {
throw new InvalidInputError("Invalid trigger id");
}
});
// check if all the triggers are unique
const triggerIds = triggers.map((trigger) => trigger.actionClass.id);
if (new Set(triggerIds).size !== triggerIds.length) {
throw new InvalidInputError("Duplicate trigger id");
}
};
const handleTriggerUpdates = (
updatedTriggers: TSurvey["triggers"],
currentTriggers: TSurvey["triggers"],
actionClasses: TActionClass[]
) => {
if (!updatedTriggers) return {};
checkTriggersValidity(updatedTriggers, actionClasses);
const currentTriggerIds = currentTriggers.map((trigger) => trigger.actionClass.id);
const updatedTriggerIds = updatedTriggers.map((trigger) => trigger.actionClass.id);
// added triggers are triggers that are not in the current triggers and are there in the new triggers
const addedTriggers = updatedTriggers.filter(
(trigger) => !currentTriggerIds.includes(trigger.actionClass.id)
);
// deleted triggers are triggers that are not in the new triggers and are there in the current triggers
const deletedTriggers = currentTriggers.filter(
(trigger) => !updatedTriggerIds.includes(trigger.actionClass.id)
);
// Construct the triggers update object
const triggersUpdate: TriggerUpdate = {};
if (addedTriggers.length > 0) {
triggersUpdate.create = addedTriggers.map((trigger) => ({
actionClassId: trigger.actionClass.id,
}));
}
if (deletedTriggers.length > 0) {
// disconnect the public triggers from the survey
triggersUpdate.deleteMany = {
actionClassId: {
in: deletedTriggers.map((trigger) => trigger.actionClass.id),
},
};
}
[...addedTriggers, ...deletedTriggers].forEach((trigger) => {
surveyCache.revalidate({
actionClassId: trigger.actionClass.id,
});
});
return triggersUpdate;
};
export const getSurvey = reactCache(
async (surveyId: string): Promise<TSurvey | null> =>
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) {
console.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
if (!surveyPrisma) {
return null;
}
return transformPrismaSurvey(surveyPrisma);
},
[`getSurvey-${surveyId}`],
{
tags: [surveyCache.tag.byId(surveyId)],
}
)()
);
export const getSurveysByActionClassId = reactCache(
async (actionClassId: string, page?: number): Promise<TSurvey[]> =>
cache(
async () => {
validateInputs([actionClassId, ZId], [page, ZOptionalNumber]);
let surveysPrisma;
try {
surveysPrisma = await prisma.survey.findMany({
where: {
triggers: {
some: {
actionClass: {
id: actionClassId,
},
},
},
},
select: selectSurvey,
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
const surveys: TSurvey[] = [];
for (const surveyPrisma of surveysPrisma) {
const transformedSurvey = transformPrismaSurvey(surveyPrisma);
surveys.push(transformedSurvey);
}
return surveys;
},
[`getSurveysByActionClassId-${actionClassId}-${page}`],
{
tags: [surveyCache.tag.byActionClassId(actionClassId)],
}
)()
);
export const getSurveys = reactCache(
async (
environmentId: string,
limit?: number,
offset?: number,
filterCriteria?: TSurveyFilterCriteria
): Promise<TSurvey[]> =>
cache(
async () => {
validateInputs([environmentId, ZId], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
try {
const surveysPrisma = await prisma.survey.findMany({
where: {
environmentId,
...buildWhereClause(filterCriteria),
},
select: selectSurvey,
orderBy: buildOrderByClause(filterCriteria?.sortBy),
take: limit,
skip: offset,
});
return surveysPrisma.map(transformPrismaSurvey);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSurveys-${environmentId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`],
{
tags: [surveyCache.tag.byEnvironmentId(environmentId)],
}
)()
);
export const getSurveyCount = reactCache(
async (environmentId: string): Promise<number> =>
cache(
async () => {
validateInputs([environmentId, ZId]);
try {
const surveyCount = await prisma.survey.count({
where: {
environmentId: environmentId,
},
});
return surveyCount;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSurveyCount-${environmentId}`],
{
tags: [surveyCache.tag.byEnvironmentId(environmentId)],
}
)()
);
export const getInProgressSurveyCount = reactCache(
async (environmentId: string, filterCriteria?: TSurveyFilterCriteria): Promise<number> =>
cache(
async () => {
validateInputs([environmentId, ZId]);
try {
const surveyCount = await prisma.survey.count({
where: {
environmentId: environmentId,
status: "inProgress",
...buildWhereClause(filterCriteria),
},
});
return surveyCount;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getInProgressSurveyCount-${environmentId}-${JSON.stringify(filterCriteria)}`],
{
tags: [surveyCache.tag.byEnvironmentId(environmentId)],
}
)()
);
export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
validateInputs([updatedSurvey, ZSurvey]);
try {
const surveyId = updatedSurvey.id;
let data: any = {};
const actionClasses = await getActionClasses(updatedSurvey.environmentId);
const currentSurvey = await getSurvey(surveyId);
if (!currentSurvey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
const { triggers, environmentId, segment, questions, languages, type, followUps, ...surveyData } =
updatedSurvey;
if (languages) {
// Process languages update logic here
// Extract currentLanguageIds and updatedLanguageIds
const currentLanguageIds = currentSurvey.languages
? currentSurvey.languages.map((l) => l.language.id)
: [];
const updatedLanguageIds =
languages.length > 1 ? updatedSurvey.languages.map((l) => l.language.id) : [];
const enabledLanguageIds = languages.map((language) => {
if (language.enabled) return language.language.id;
});
// Determine languages to add and remove
const languagesToAdd = updatedLanguageIds.filter((id) => !currentLanguageIds.includes(id));
const languagesToRemove = currentLanguageIds.filter((id) => !updatedLanguageIds.includes(id));
const defaultLanguageId = updatedSurvey.languages.find((l) => l.default)?.language.id;
// Prepare data for Prisma update
data.languages = {};
// Update existing languages for default value changes
data.languages.updateMany = currentSurvey.languages.map((surveyLanguage) => ({
where: { languageId: surveyLanguage.language.id },
data: {
default: surveyLanguage.language.id === defaultLanguageId,
enabled: enabledLanguageIds.includes(surveyLanguage.language.id),
},
}));
// Add new languages
if (languagesToAdd.length > 0) {
data.languages.create = languagesToAdd.map((languageId) => ({
languageId: languageId,
default: languageId === defaultLanguageId,
enabled: enabledLanguageIds.includes(languageId),
}));
}
// Remove languages no longer associated with the survey
if (languagesToRemove.length > 0) {
data.languages.deleteMany = languagesToRemove.map((languageId) => ({
languageId: languageId,
enabled: enabledLanguageIds.includes(languageId),
}));
}
}
if (triggers) {
data.triggers = handleTriggerUpdates(triggers, currentSurvey.triggers, actionClasses);
}
// if the survey body has type other than "app" but has a private segment, we delete that segment, and if it has a public segment, we disconnect from to the survey
if (segment) {
if (type === "app") {
// parse the segment filters:
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!parsedFilters.success) {
throw new InvalidInputError("Invalid user segment filters");
}
try {
await updateSegment(segment.id, segment);
} catch (error) {
console.error(error);
throw new Error("Error updating survey");
}
} else {
if (segment.isPrivate) {
// disconnect the private segment first and then delete:
await prisma.segment.update({
where: { id: segment.id },
data: {
surveys: {
disconnect: {
id: surveyId,
},
},
},
});
// delete the private segment:
await prisma.segment.delete({
where: {
id: segment.id,
},
});
} else {
await prisma.survey.update({
where: {
id: surveyId,
},
data: {
segment: {
disconnect: true,
},
},
});
}
}
segmentCache.revalidate({
id: segment.id,
environmentId: segment.environmentId,
});
} else if (type === "app") {
if (!currentSurvey.segment) {
await prisma.survey.update({
where: {
id: surveyId,
},
data: {
segment: {
connectOrCreate: {
where: {
environmentId_title: {
environmentId,
title: surveyId,
},
},
create: {
title: surveyId,
isPrivate: true,
filters: [],
environment: {
connect: {
id: environmentId,
},
},
},
},
},
},
});
segmentCache.revalidate({
environmentId,
});
}
}
if (followUps) {
// Separate follow-ups into categories based on deletion flag
const deletedFollowUps = followUps.filter((followUp) => followUp.deleted);
const nonDeletedFollowUps = followUps.filter((followUp) => !followUp.deleted);
// Get set of existing follow-up IDs from currentSurvey
const existingFollowUpIds = new Set(currentSurvey.followUps.map((f) => f.id));
// Separate non-deleted follow-ups into new and existing
const existingFollowUps = nonDeletedFollowUps.filter((followUp) =>
existingFollowUpIds.has(followUp.id)
);
const newFollowUps = nonDeletedFollowUps.filter((followUp) => !existingFollowUpIds.has(followUp.id));
data.followUps = {
// Update existing follow-ups
updateMany: existingFollowUps.map((followUp) => ({
where: {
id: followUp.id,
},
data: {
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
},
})),
// Create new follow-ups
createMany:
newFollowUps.length > 0
? {
data: newFollowUps.map((followUp) => ({
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
}
: undefined,
// Delete follow-ups marked as deleted, regardless of whether they exist in DB
deleteMany:
deletedFollowUps.length > 0
? deletedFollowUps.map((followUp) => ({
id: followUp.id,
}))
: undefined,
};
}
data.questions = questions.map((question) => {
const { isDraft, ...rest } = question;
return rest;
});
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
}
//AI Insights
const isAIEnabled = await getIsAIEnabled(organization);
if (isAIEnabled) {
if (doesSurveyHasOpenTextQuestion(data.questions ?? [])) {
const openTextQuestions = data.questions?.filter((question) => question.type === "openText") ?? [];
const currentSurveyOpenTextQuestions = currentSurvey.questions?.filter(
(question) => question.type === "openText"
);
// find the questions that have been updated or added
const questionsToCheckForInsights: TSurveyQuestions = [];
for (const question of openTextQuestions) {
const existingQuestion = currentSurveyOpenTextQuestions?.find((ques) => ques.id === question.id) as
| TSurveyOpenTextQuestion
| undefined;
const isExistingQuestion = !!existingQuestion;
if (
isExistingQuestion &&
question.headline.default === existingQuestion.headline.default &&
existingQuestion.insightsEnabled !== undefined
) {
continue;
} else {
questionsToCheckForInsights.push(question);
}
}
if (questionsToCheckForInsights.length > 0) {
const insightsEnabledValues = await Promise.all(
questionsToCheckForInsights.map(async (question) => {
const insightsEnabled = await getInsightsEnabled(question);
return { id: question.id, insightsEnabled };
})
);
data.questions = data.questions?.map((question) => {
const index = insightsEnabledValues.findIndex((item) => item.id === question.id);
if (index !== -1) {
return {
...question,
insightsEnabled: insightsEnabledValues[index].insightsEnabled,
};
}
return question;
});
}
}
} else {
// check if an existing question got changed that had insights enabled
const insightsEnabledOpenTextQuestions = currentSurvey.questions?.filter(
(question) => question.type === "openText" && question.insightsEnabled !== undefined
);
// if question headline changed, remove insightsEnabled
for (const question of insightsEnabledOpenTextQuestions) {
const updatedQuestion = data.questions?.find((q) => q.id === question.id);
if (updatedQuestion && updatedQuestion.headline.default !== question.headline.default) {
updatedQuestion.insightsEnabled = undefined;
}
}
}
surveyData.updatedAt = new Date();
data = {
...surveyData,
...data,
type,
};
// Remove scheduled status when runOnDate is not set
if (data.status === "scheduled" && data.runOnDate === null) {
data.status = "inProgress";
}
// Set scheduled status when runOnDate is set and in the future on completed surveys
if (
(data.status === "completed" || data.status === "paused" || data.status === "inProgress") &&
data.runOnDate &&
data.runOnDate > new Date()
) {
data.status = "scheduled";
}
delete data.createdBy;
const prismaSurvey = await prisma.survey.update({
where: { id: surveyId },
data,
select: selectSurvey,
});
let surveySegment: TSegment | null = null;
if (prismaSurvey.segment) {
surveySegment = {
...prismaSurvey.segment,
surveys: prismaSurvey.segment.surveys.map((survey) => survey.id),
};
}
// TODO: Fix this, this happens because the survey type "web" is no longer in the zod types but its required in the schema for migration
// @ts-expect-error
const modifiedSurvey: TSurvey = {
...prismaSurvey, // Properties from prismaSurvey
displayPercentage: Number(prismaSurvey.displayPercentage) || null,
segment: surveySegment,
};
surveyCache.revalidate({
id: modifiedSurvey.id,
environmentId: modifiedSurvey.environmentId,
segmentId: modifiedSurvey.segment?.id,
resultShareKey: currentSurvey.resultShareKey ?? undefined,
});
return modifiedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
};
export const deleteSurvey = async (surveyId: string) => {
validateInputs([surveyId, ZId]);
try {
const deletedSurvey = await prisma.survey.delete({
where: {
id: surveyId,
},
select: selectSurvey,
});
if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) {
const deletedSegment = await prisma.segment.delete({
where: {
id: deletedSurvey.segment.id,
},
});
if (deletedSegment) {
segmentCache.revalidate({
id: deletedSegment.id,
environmentId: deletedSurvey.environmentId,
});
}
}
responseCache.revalidate({
surveyId,
environmentId: deletedSurvey.environmentId,
});
surveyCache.revalidate({
id: deletedSurvey.id,
environmentId: deletedSurvey.environmentId,
resultShareKey: deletedSurvey.resultShareKey ?? undefined,
});
if (deletedSurvey.segment?.id) {
segmentCache.revalidate({
id: deletedSurvey.segment.id,
environmentId: deletedSurvey.environmentId,
});
}
// Revalidate public triggers by actionClassId
deletedSurvey.triggers.forEach((trigger) => {
surveyCache.revalidate({
actionClassId: trigger.actionClass.id,
});
});
return deletedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
};
export const createSurvey = async (
environmentId: string,
surveyBody: TSurveyCreateInput
): Promise<TSurvey> => {
const [parsedEnvironmentId, parsedSurveyBody] = validateInputs(
[environmentId, ZId],
[surveyBody, ZSurveyCreateInput]
);
try {
const { createdBy, ...restSurveyBody } = parsedSurveyBody;
// empty languages array
if (!restSurveyBody.languages?.length) {
delete restSurveyBody.languages;
}
const actionClasses = await getActionClasses(parsedEnvironmentId);
// @ts-expect-error
let data: Omit<Prisma.SurveyCreateInput, "environment"> = {
...restSurveyBody,
// TODO: Create with attributeFilters
triggers: restSurveyBody.triggers
? handleTriggerUpdates(restSurveyBody.triggers, [], actionClasses)
: undefined,
attributeFilters: undefined,
};
if (createdBy) {
data.creator = {
connect: {
id: createdBy,
},
};
}
const organization = await getOrganizationByEnvironmentId(parsedEnvironmentId);
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
}
//AI Insights
const isAIEnabled = await getIsAIEnabled(organization);
if (isAIEnabled) {
if (doesSurveyHasOpenTextQuestion(data.questions ?? [])) {
const openTextQuestions = data.questions?.filter((question) => question.type === "openText") ?? [];
const insightsEnabledValues = await Promise.all(
openTextQuestions.map(async (question) => {
const insightsEnabled = await getInsightsEnabled(question);
return { id: question.id, insightsEnabled };
})
);
data.questions = data.questions?.map((question) => {
const index = insightsEnabledValues.findIndex((item) => item.id === question.id);
if (index !== -1) {
return {
...question,
insightsEnabled: insightsEnabledValues[index].insightsEnabled,
};
}
return question;
});
}
}
// Survey follow-ups
if (restSurveyBody.followUps?.length) {
data.followUps = {
create: restSurveyBody.followUps.map((followUp) => ({
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
};
} else {
delete data.followUps;
}
const survey = await prisma.survey.create({
data: {
...data,
environment: {
connect: {
id: parsedEnvironmentId,
},
},
},
select: selectSurvey,
});
// if the survey created is an "app" survey, we also create a private segment for it.
if (survey.type === "app") {
const newSegment = await createSegment({
environmentId: parsedEnvironmentId,
surveyId: survey.id,
filters: [],
title: survey.id,
isPrivate: true,
});
await prisma.survey.update({
where: {
id: survey.id,
},
data: {
segment: {
connect: {
id: newSegment.id,
},
},
},
});
segmentCache.revalidate({
id: newSegment.id,
environmentId: survey.environmentId,
});
}
// TODO: Fix this, this happens because the survey type "web" is no longer in the zod types but its required in the schema for migration
// @ts-expect-error
const transformedSurvey: TSurvey = {
...survey,
...(survey.segment && {
segment: {
...survey.segment,
surveys: survey.segment.surveys.map((survey) => survey.id),
},
}),
};
surveyCache.revalidate({
id: survey.id,
environmentId: survey.environmentId,
resultShareKey: survey.resultShareKey ?? undefined,
});
if (createdBy) {
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy);
}
await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", {
surveyId: survey.id,
surveyType: survey.type,
});
return transformedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
};
export const copySurveyToOtherEnvironment = async (
environmentId: string,
surveyId: string,
targetEnvironmentId: string,
userId: string
) => {
validateInputs([environmentId, ZId], [surveyId, ZId], [targetEnvironmentId, ZId], [userId, ZId]);
try {
const isSameEnvironment = environmentId === targetEnvironmentId;
// Fetch required resources
const [existingEnvironment, existingProduct, existingSurvey] = await Promise.all([
getEnvironment(environmentId),
getProductByEnvironmentId(environmentId),
getSurvey(surveyId),
]);
if (!existingEnvironment) throw new ResourceNotFoundError("Environment", environmentId);
if (!existingProduct) throw new ResourceNotFoundError("Product", environmentId);
if (!existingSurvey) throw new ResourceNotFoundError("Survey", surveyId);
let targetEnvironment: TEnvironment | null = null;
let targetProduct: TProduct | null = null;
if (isSameEnvironment) {
targetEnvironment = existingEnvironment;
targetProduct = existingProduct;
} else {
[targetEnvironment, targetProduct] = await Promise.all([
getEnvironment(targetEnvironmentId),
getProductByEnvironmentId(targetEnvironmentId),
]);
if (!targetEnvironment) throw new ResourceNotFoundError("Environment", targetEnvironmentId);
if (!targetProduct) throw new ResourceNotFoundError("Product", targetEnvironmentId);
}
const {
environmentId: _,
createdBy,
id: existingSurveyId,
createdAt,
updatedAt,
...restExistingSurvey
} = existingSurvey;
const hasLanguages = existingSurvey.languages && existingSurvey.languages.length > 0;
// Prepare survey data
const surveyData: Prisma.SurveyCreateInput = {
...restExistingSurvey,
id: createId(),
name: `${existingSurvey.name} (copy)`,
type: existingSurvey.type,
status: "draft",
welcomeCard: structuredClone(existingSurvey.welcomeCard),
questions: structuredClone(existingSurvey.questions),
endings: structuredClone(existingSurvey.endings),
variables: structuredClone(existingSurvey.variables),
hiddenFields: structuredClone(existingSurvey.hiddenFields),
languages: hasLanguages
? {
create: existingSurvey.languages.map((surveyLanguage) => ({
language: {
connectOrCreate: {
where: {
productId_code: { code: surveyLanguage.language.code, productId: targetProduct.id },
},
create: {
code: surveyLanguage.language.code,
alias: surveyLanguage.language.alias,
productId: targetProduct.id,
},
},
},
default: surveyLanguage.default,
enabled: surveyLanguage.enabled,
})),
}
: undefined,
triggers: {
create: existingSurvey.triggers.map((trigger): Prisma.SurveyTriggerCreateWithoutSurveyInput => {
const baseActionClassData = {
name: trigger.actionClass.name,
environment: { connect: { id: targetEnvironmentId } },
description: trigger.actionClass.description,
type: trigger.actionClass.type,
};
if (isSameEnvironment) {
return {
actionClass: { connect: { id: trigger.actionClass.id } },
};
} else if (trigger.actionClass.type === "code") {
return {
actionClass: {
connectOrCreate: {
where: {
key_environmentId: { key: trigger.actionClass.key!, environmentId: targetEnvironmentId },
},
create: {
...baseActionClassData,
key: trigger.actionClass.key,
},
},
},
};
} else {
return {
actionClass: {
connectOrCreate: {
where: {
name_environmentId: {
name: trigger.actionClass.name,
environmentId: targetEnvironmentId,
},
},
create: {
...baseActionClassData,
noCodeConfig: trigger.actionClass.noCodeConfig
? structuredClone(trigger.actionClass.noCodeConfig)
: undefined,
},
},
},
};
}
}),
},
environment: {
connect: {
id: targetEnvironmentId,
},
},
creator: {
connect: {
id: userId,
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage
? structuredClone(existingSurvey.surveyClosedMessage)
: Prisma.JsonNull,
singleUse: existingSurvey.singleUse ? structuredClone(existingSurvey.singleUse) : Prisma.JsonNull,
productOverwrites: existingSurvey.productOverwrites
? structuredClone(existingSurvey.productOverwrites)
: Prisma.JsonNull,
styling: existingSurvey.styling ? structuredClone(existingSurvey.styling) : Prisma.JsonNull,
segment: undefined,
followUps: {
createMany: {
data: existingSurvey.followUps.map((followUp) => ({
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
},
},
};
// Handle segment
if (existingSurvey.segment) {
if (existingSurvey.segment.isPrivate) {
surveyData.segment = {
create: {
title: surveyData.id!,
isPrivate: true,
filters: existingSurvey.segment.filters,
environment: { connect: { id: targetEnvironmentId } },
},
};
} else if (isSameEnvironment) {
surveyData.segment = { connect: { id: existingSurvey.segment.id } };
} else {
const existingSegmentInTargetEnvironment = await prisma.segment.findFirst({
where: {
title: existingSurvey.segment.title,
isPrivate: false,
environmentId: targetEnvironmentId,
},
});
surveyData.segment = {
create: {
title: existingSegmentInTargetEnvironment
? `${existingSurvey.segment.title}-${Date.now()}`
: existingSurvey.segment.title,
isPrivate: false,
filters: existingSurvey.segment.filters,
environment: { connect: { id: targetEnvironmentId } },
},
};
}
}
const targetProductLanguageCodes = targetProduct.languages.map((language) => language.code);
const newSurvey = await prisma.survey.create({
data: surveyData,
select: selectSurvey,
});
// Identify newly created action classes
const newActionClasses = newSurvey.triggers.map((trigger) => trigger.actionClass);
// Revalidate cache only for newly created action classes
for (const actionClass of newActionClasses) {
actionClassCache.revalidate({
environmentId: actionClass.environmentId,
name: actionClass.name,
id: actionClass.id,
});
}
let newLanguageCreated = false;
if (existingSurvey.languages && existingSurvey.languages.length > 0) {
const targetLanguageCodes = newSurvey.languages.map((lang) => lang.language.code);
newLanguageCreated = targetLanguageCodes.length > targetProductLanguageCodes.length;
}
// Invalidate caches
if (newLanguageCreated) {
productCache.revalidate({ id: targetProduct.id, environmentId: targetEnvironmentId });
}
surveyCache.revalidate({
id: newSurvey.id,
environmentId: newSurvey.environmentId,
resultShareKey: newSurvey.resultShareKey ?? undefined,
});
existingSurvey.triggers.forEach((trigger) => {
surveyCache.revalidate({
actionClassId: trigger.actionClass.id,
});
});
if (newSurvey.segment) {
segmentCache.revalidate({
id: newSurvey.segment.id,
environmentId: newSurvey.environmentId,
});
}
return newSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getSyncSurveys = reactCache(
async (
environmentId: string,
personId: string,
deviceType: "phone" | "desktop" = "desktop"
): Promise<TSurvey[]> =>
cache(
async () => {
validateInputs([environmentId, ZId]);
try {
const product = await getProductByEnvironmentId(environmentId);
if (!product) {
throw new Error("Product not found");
}
const person = personId === "legacy" ? ({ id: "legacy" } as TPerson) : await getPerson(personId);
if (!person) {
throw new Error("Person not found");
}
let surveys = await getSurveys(environmentId);
// filtered surveys for running and web
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "app");
// if no surveys are left, return an empty array
if (surveys.length === 0) {
return [];
}
const displays = await getDisplaysByPersonId(person.id);
const responses = await getResponsesByPersonId(person.id);
// filter surveys that meet the displayOption criteria
surveys = surveys.filter((survey) => {
switch (survey.displayOption) {
case "respondMultiple":
return true;
case "displayOnce":
return displays.filter((display) => display.surveyId === survey.id).length === 0;
case "displayMultiple":
if (!responses) return true;
else {
return responses.filter((response) => response.surveyId === survey.id).length === 0;
}
case "displaySome":
if (survey.displayLimit === null) {
return true;
}
if (
responses &&
responses.filter((response) => response.surveyId === survey.id).length !== 0
) {
return false;
}
return (
displays.filter((display) => display.surveyId === survey.id).length < survey.displayLimit
);
default:
throw Error("Invalid displayOption");
}
});
const latestDisplay = displays[0];
// filter surveys that meet the recontactDays criteria
surveys = surveys.filter((survey) => {
if (!latestDisplay) {
return true;
} else if (survey.recontactDays !== null) {
const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0];
if (!lastDisplaySurvey) {
return true;
}
return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays;
} else if (product.recontactDays !== null) {
return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= product.recontactDays;
} else {
return true;
}
});
// if no surveys are left, return an empty array
if (surveys.length === 0) {
return [];
}
// if no surveys have segment filters, return the surveys
if (!anySurveyHasFilters(surveys)) {
return surveys;
}
const attributes = await getAttributes(person.id);
const personUserId = person.userId;
// the surveys now have segment filters, so we need to evaluate them
const surveyPromises = surveys.map(async (survey) => {
const { segment } = survey;
// if the survey has no segment, or the segment has no filters, we return the survey
if (!segment || !segment.filters?.length) {
return survey;
}
// Evaluate the segment filters
const result = await evaluateSegment(
{
attributes: attributes ?? {},
deviceType,
environmentId,
personId: person.id,
userId: personUserId,
},
segment.filters
);
return result ? survey : null;
});
const resolvedSurveys = await Promise.all(surveyPromises);
surveys = resolvedSurveys.filter((survey) => !!survey) as TSurvey[];
if (!surveys) {
throw new ResourceNotFoundError("Survey", environmentId);
}
return surveys;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSyncSurveys-${environmentId}-${personId}`],
{
tags: [
personCache.tag.byEnvironmentId(environmentId),
personCache.tag.byId(personId),
displayCache.tag.byPersonId(personId),
surveyCache.tag.byEnvironmentId(environmentId),
productCache.tag.byEnvironmentId(environmentId),
attributeCache.tag.byPersonId(personId),
],
}
)()
);
export const getSurveyIdByResultShareKey = reactCache(
async (resultShareKey: string): Promise<string | null> =>
cache(
async () => {
try {
const survey = await prisma.survey.findFirst({
where: {
resultShareKey,
},
select: {
id: true,
},
});
if (!survey) {
return null;
}
return survey.id;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSurveyIdByResultShareKey-${resultShareKey}`],
{
tags: [surveyCache.tag.byResultShareKey(resultShareKey)],
}
)()
);
export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: string): Promise<TSurvey> => {
validateInputs([surveyId, ZId], [newSegmentId, ZId]);
try {
const currentSurvey = await getSurvey(surveyId);
if (!currentSurvey) {
throw new ResourceNotFoundError("survey", surveyId);
}
const currentSurveySegment = currentSurvey.segment;
const newSegment = await getSegment(newSegmentId);
if (!newSegment) {
throw new ResourceNotFoundError("segment", newSegmentId);
}
const prismaSurvey = await prisma.survey.update({
where: {
id: surveyId,
},
select: selectSurvey,
data: {
segment: {
connect: {
id: newSegmentId,
},
},
},
});
if (
currentSurveySegment &&
currentSurveySegment.isPrivate &&
currentSurveySegment.title === currentSurvey.id
) {
await deleteSegment(currentSurveySegment.id);
}
segmentCache.revalidate({ id: newSegmentId });
surveyCache.revalidate({ id: surveyId });
let surveySegment: TSegment | null = null;
if (prismaSurvey.segment) {
surveySegment = {
...prismaSurvey.segment,
surveys: prismaSurvey.segment.surveys.map((survey) => survey.id),
};
}
// TODO: Fix this, this happens because the survey type "web" is no longer in the zod types but its required in the schema for migration
// @ts-expect-error
const modifiedSurvey: TSurvey = {
...prismaSurvey, // Properties from prismaSurvey
segment: surveySegment,
};
return modifiedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getSurveysBySegmentId = reactCache(
async (segmentId: string): Promise<TSurvey[]> =>
cache(
async () => {
try {
const surveysPrisma = await prisma.survey.findMany({
where: { segmentId },
select: selectSurvey,
});
const surveys: TSurvey[] = [];
for (const surveyPrisma of surveysPrisma) {
const transformedSurvey = transformPrismaSurvey(surveyPrisma);
surveys.push(transformedSurvey);
}
return surveys;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSurveysBySegmentId-${segmentId}`],
{
tags: [surveyCache.tag.bySegmentId(segmentId), segmentCache.tag.byId(segmentId)],
}
)()
);