mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-08 06:41:45 -05:00
feat: Move Response Summary Server-side (#2160)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
TResponseLegacyInput,
|
||||
TResponseUpdateInput,
|
||||
TSurveyPersonAttributes,
|
||||
TSurveySummary,
|
||||
ZResponse,
|
||||
ZResponseFilterCriteria,
|
||||
ZResponseInput,
|
||||
@@ -31,8 +32,11 @@ import {
|
||||
buildWhereClause,
|
||||
calculateTtcTotal,
|
||||
extractSurveyDetails,
|
||||
getQuestionWiseSummary,
|
||||
getResponsesFileName,
|
||||
getResponsesJson,
|
||||
getSurveySummaryDropOff,
|
||||
getSurveySummaryMeta,
|
||||
} from "../response/util";
|
||||
import { responseNoteCache } from "../responseNote/cache";
|
||||
import { getResponseNotes } from "../responseNote/service";
|
||||
@@ -515,6 +519,53 @@ export const getResponses = async (
|
||||
}));
|
||||
};
|
||||
|
||||
export const getSurveySummary = (
|
||||
surveyId: string,
|
||||
filterCriteria?: TResponseFilterCriteria
|
||||
): Promise<TSurveySummary> => {
|
||||
const summary = unstable_cache(
|
||||
async () => {
|
||||
validateInputs([surveyId, ZId], [filterCriteria, ZResponseFilterCriteria.optional()]);
|
||||
|
||||
const survey = await getSurvey(surveyId);
|
||||
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
const batchSize = 3000;
|
||||
const responseCount = await getResponseCountBySurveyId(surveyId);
|
||||
const pages = Math.ceil(responseCount / batchSize);
|
||||
|
||||
const responsesArray = await Promise.all(
|
||||
Array.from({ length: pages }, (_, i) => {
|
||||
return getResponses(surveyId, i + 1, batchSize, filterCriteria);
|
||||
})
|
||||
);
|
||||
const responses = responsesArray.flat();
|
||||
|
||||
const displayCount = await prisma.display.count({
|
||||
where: {
|
||||
surveyId,
|
||||
},
|
||||
});
|
||||
|
||||
const meta = getSurveySummaryMeta(responses, displayCount);
|
||||
const dropOff = getSurveySummaryDropOff(survey, responses, displayCount);
|
||||
const questionWiseSummary = getQuestionWiseSummary(survey, responses);
|
||||
|
||||
return { meta, dropOff, summary: questionWiseSummary };
|
||||
},
|
||||
[`getSurveySummary-${surveyId}-${JSON.stringify(filterCriteria)}`],
|
||||
{
|
||||
tags: [responseCache.tag.bySurveyId(surveyId)],
|
||||
revalidate: SERVICES_REVALIDATION_INTERVAL,
|
||||
}
|
||||
)();
|
||||
|
||||
return summary;
|
||||
};
|
||||
|
||||
export const getResponseDownloadUrl = async (
|
||||
surveyId: string,
|
||||
format: "csv" | "xlsx",
|
||||
@@ -755,15 +806,19 @@ export const deleteResponse = async (responseId: string): Promise<TResponse> =>
|
||||
}
|
||||
};
|
||||
|
||||
export const getResponseCountBySurveyId = async (surveyId: string): Promise<number> =>
|
||||
export const getResponseCountBySurveyId = async (
|
||||
surveyId: string,
|
||||
filterCriteria?: TResponseFilterCriteria
|
||||
): Promise<number> =>
|
||||
unstable_cache(
|
||||
async () => {
|
||||
validateInputs([surveyId, ZId]);
|
||||
validateInputs([surveyId, ZId], [filterCriteria, ZResponseFilterCriteria.optional()]);
|
||||
|
||||
try {
|
||||
const responseCount = await prisma.response.count({
|
||||
where: {
|
||||
surveyId: surveyId,
|
||||
...buildWhereClause(filterCriteria),
|
||||
},
|
||||
});
|
||||
return responseCount;
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
TResponseFilterCriteria,
|
||||
TResponseUpdateInput,
|
||||
TSurveyPersonAttributes,
|
||||
TSurveySummary,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
|
||||
import { transformPrismaPerson } from "../../../person/service";
|
||||
@@ -458,3 +460,40 @@ export const getMockUpdateResponseInput = (finished: boolean = false): TResponse
|
||||
data: mockResponseData,
|
||||
finished,
|
||||
});
|
||||
|
||||
export const mockSurveySummaryOutput: TSurveySummary = {
|
||||
dropOff: [
|
||||
{
|
||||
dropOffCount: 0,
|
||||
dropOffPercentage: 0,
|
||||
headline: "Question Text",
|
||||
questionId: "ars2tjk8hsi8oqk1uac00mo8",
|
||||
ttc: 0,
|
||||
views: 0,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
completedPercentage: 0,
|
||||
completedResponses: 1,
|
||||
displayCount: 0,
|
||||
dropOffPercentage: 0,
|
||||
dropOffCount: 0,
|
||||
startsPercentage: 0,
|
||||
totalResponses: 1,
|
||||
ttcAverage: 0,
|
||||
},
|
||||
summary: [
|
||||
{
|
||||
question: {
|
||||
headline: "Question Text",
|
||||
id: "ars2tjk8hsi8oqk1uac00mo8",
|
||||
inputType: "text",
|
||||
required: false,
|
||||
type: TSurveyQuestionType.OpenText,
|
||||
},
|
||||
responseCount: 0,
|
||||
samples: [],
|
||||
type: "openText",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
mockResponseWithMockPerson,
|
||||
mockSingleUseId,
|
||||
mockSurveyId,
|
||||
mockSurveySummaryOutput,
|
||||
mockTags,
|
||||
mockUserId,
|
||||
} from "./__mocks__/data.mock";
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
getResponses,
|
||||
getResponsesByEnvironmentId,
|
||||
getResponsesByPersonId,
|
||||
getSurveySummary,
|
||||
updateResponse,
|
||||
} from "../service";
|
||||
import { buildWhereClause } from "../util";
|
||||
@@ -468,6 +470,44 @@ describe("Tests for getResponses service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for getSurveySummary service", () => {
|
||||
describe("Happy Path", () => {
|
||||
it("Returns a summary of the survey responses", async () => {
|
||||
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
|
||||
prisma.response.findMany.mockResolvedValue([mockResponse]);
|
||||
|
||||
const summary = await getSurveySummary(mockSurveyId);
|
||||
expect(summary).toEqual(mockSurveySummaryOutput);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getSurveySummary, 1);
|
||||
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
|
||||
prisma.response.findMany.mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(getSurveySummary(mockSurveyId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
it("Throws a generic Error for unexpected problems", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
|
||||
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
|
||||
prisma.response.findMany.mockRejectedValue(new Error(mockErrorMessage));
|
||||
|
||||
await expect(getSurveySummary(mockSurveyId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for getResponseDownloadUrl service", () => {
|
||||
describe("Happy Path", () => {
|
||||
it("Returns a download URL for the csv response file", async () => {
|
||||
|
||||
@@ -2,10 +2,24 @@ import "server-only";
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { TResponse, TResponseFilterCriteria, TResponseTtc } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { TPerson } from "@formbricks/types/people";
|
||||
import {
|
||||
TResponse,
|
||||
TResponseFilterCriteria,
|
||||
TResponseTtc,
|
||||
TSurveySummary,
|
||||
TSurveySummaryDate,
|
||||
TSurveySummaryFileUpload,
|
||||
TSurveySummaryHiddenField,
|
||||
TSurveySummaryMultipleChoice,
|
||||
TSurveySummaryOpenText,
|
||||
TSurveySummaryPictureSelection,
|
||||
TSurveySummaryRating,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
|
||||
import { getTodaysDateTimeFormatted } from "../time";
|
||||
import { evaluateCondition } from "../utils/evaluateLogic";
|
||||
|
||||
export function calculateTtcTotal(ttc: TResponseTtc) {
|
||||
const result = { ...ttc };
|
||||
@@ -382,3 +396,603 @@ export const getResponsesJson = (
|
||||
|
||||
return jsonData;
|
||||
};
|
||||
|
||||
const convertFloatTo2Decimal = (num: number) => {
|
||||
return Math.round(num * 100) / 100;
|
||||
};
|
||||
|
||||
export const getSurveySummaryMeta = (
|
||||
responses: TResponse[],
|
||||
displayCount: number
|
||||
): TSurveySummary["meta"] => {
|
||||
const completedResponses = responses.filter((response) => response.finished).length;
|
||||
|
||||
let ttcResponseCount = 0;
|
||||
const ttcSum = responses.reduce((acc, response) => {
|
||||
if (response.ttc?._total) {
|
||||
ttcResponseCount++;
|
||||
return acc + response.ttc._total;
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
const responseCount = responses.length;
|
||||
|
||||
const startsPercentage = displayCount > 0 ? (responseCount / displayCount) * 100 : 0;
|
||||
const completedPercentage = displayCount > 0 ? (completedResponses / displayCount) * 100 : 0;
|
||||
const dropOffCount = responseCount - completedResponses;
|
||||
const dropOffPercentage = responseCount > 0 ? (dropOffCount / responseCount) * 100 : 0;
|
||||
const ttcAverage = ttcResponseCount > 0 ? ttcSum / ttcResponseCount : 0;
|
||||
|
||||
return {
|
||||
displayCount: displayCount || 0,
|
||||
totalResponses: responseCount,
|
||||
startsPercentage: convertFloatTo2Decimal(startsPercentage),
|
||||
completedResponses,
|
||||
completedPercentage: convertFloatTo2Decimal(completedPercentage),
|
||||
dropOffCount,
|
||||
dropOffPercentage: convertFloatTo2Decimal(dropOffPercentage),
|
||||
ttcAverage: convertFloatTo2Decimal(ttcAverage),
|
||||
};
|
||||
};
|
||||
|
||||
export const getSurveySummaryDropOff = (
|
||||
survey: TSurvey,
|
||||
responses: TResponse[],
|
||||
displayCount: number
|
||||
): TSurveySummary["dropOff"] => {
|
||||
const initialTtc = survey.questions.reduce((acc: Record<string, number>, question) => {
|
||||
acc[question.id] = 0;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
let totalTtc = { ...initialTtc };
|
||||
let responseCounts = { ...initialTtc };
|
||||
|
||||
let dropOffArr = new Array(survey.questions.length).fill(0) as number[];
|
||||
let viewsArr = new Array(survey.questions.length).fill(0) as number[];
|
||||
let dropOffPercentageArr = new Array(survey.questions.length).fill(0) as number[];
|
||||
|
||||
responses.forEach((response) => {
|
||||
// Calculate total time-to-completion
|
||||
Object.keys(totalTtc).forEach((questionId) => {
|
||||
if (response.ttc && response.ttc[questionId]) {
|
||||
totalTtc[questionId] += response.ttc[questionId];
|
||||
responseCounts[questionId]++;
|
||||
}
|
||||
});
|
||||
|
||||
let currQuesIdx = 0;
|
||||
|
||||
while (currQuesIdx < survey.questions.length) {
|
||||
const currQues = survey.questions[currQuesIdx];
|
||||
if (!currQues) break;
|
||||
|
||||
if (!currQues.required) {
|
||||
if (!response.data[currQues.id]) {
|
||||
viewsArr[currQuesIdx]++;
|
||||
|
||||
if (currQuesIdx === survey.questions.length - 1 && !response.finished) {
|
||||
dropOffArr[currQuesIdx]++;
|
||||
break;
|
||||
}
|
||||
|
||||
const questionHasCustomLogic = currQues.logic;
|
||||
if (questionHasCustomLogic) {
|
||||
let didLogicPass = false;
|
||||
for (let logic of questionHasCustomLogic) {
|
||||
if (!logic.destination) continue;
|
||||
if (evaluateCondition(logic, response.data[currQues.id] ?? null)) {
|
||||
didLogicPass = true;
|
||||
currQuesIdx = survey.questions.findIndex((q) => q.id === logic.destination);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!didLogicPass) currQuesIdx++;
|
||||
} else {
|
||||
currQuesIdx++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(response.data[currQues.id] === undefined && !response.finished) ||
|
||||
(currQues.required && !response.data[currQues.id])
|
||||
) {
|
||||
dropOffArr[currQuesIdx]++;
|
||||
viewsArr[currQuesIdx]++;
|
||||
break;
|
||||
}
|
||||
|
||||
viewsArr[currQuesIdx]++;
|
||||
|
||||
let nextQuesIdx = currQuesIdx + 1;
|
||||
const questionHasCustomLogic = currQues.logic;
|
||||
|
||||
if (questionHasCustomLogic) {
|
||||
for (let logic of questionHasCustomLogic) {
|
||||
if (!logic.destination) continue;
|
||||
if (evaluateCondition(logic, response.data[currQues.id])) {
|
||||
nextQuesIdx = survey.questions.findIndex((q) => q.id === logic.destination);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.data[survey.questions[nextQuesIdx]?.id] && !response.finished) {
|
||||
dropOffArr[nextQuesIdx]++;
|
||||
viewsArr[nextQuesIdx]++;
|
||||
break;
|
||||
}
|
||||
|
||||
currQuesIdx = nextQuesIdx;
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate the average time for each question
|
||||
Object.keys(totalTtc).forEach((questionId) => {
|
||||
totalTtc[questionId] =
|
||||
responseCounts[questionId] > 0 ? totalTtc[questionId] / responseCounts[questionId] : 0;
|
||||
});
|
||||
|
||||
if (!survey.welcomeCard.enabled) {
|
||||
dropOffArr[0] = displayCount - viewsArr[0];
|
||||
if (viewsArr[0] > displayCount) dropOffPercentageArr[0] = 0;
|
||||
|
||||
dropOffPercentageArr[0] =
|
||||
viewsArr[0] - displayCount >= 0 ? 0 : ((displayCount - viewsArr[0]) / displayCount) * 100 || 0;
|
||||
|
||||
viewsArr[0] = displayCount;
|
||||
} else {
|
||||
dropOffPercentageArr[0] = (dropOffArr[0] / viewsArr[0]) * 100;
|
||||
}
|
||||
|
||||
for (let i = 1; i < survey.questions.length; i++) {
|
||||
if (viewsArr[i] !== 0) {
|
||||
dropOffPercentageArr[i] = (dropOffArr[i] / viewsArr[i]) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
const dropOff = survey.questions.map((question, index) => {
|
||||
return {
|
||||
questionId: question.id,
|
||||
headline: question.headline,
|
||||
ttc: convertFloatTo2Decimal(totalTtc[question.id]) || 0,
|
||||
views: viewsArr[index] || 0,
|
||||
dropOffCount: dropOffArr[index] || 0,
|
||||
dropOffPercentage: convertFloatTo2Decimal(dropOffPercentageArr[index]) || 0,
|
||||
};
|
||||
});
|
||||
|
||||
return dropOff;
|
||||
};
|
||||
|
||||
export const getQuestionWiseSummary = (
|
||||
survey: TSurvey,
|
||||
responses: TResponse[]
|
||||
): TSurveySummary["summary"] => {
|
||||
const VALUES_LIMIT = 10;
|
||||
let summary: TSurveySummary["summary"] = [];
|
||||
|
||||
survey.questions.forEach((question) => {
|
||||
switch (question.type) {
|
||||
case TSurveyQuestionType.OpenText: {
|
||||
let values: TSurveySummaryOpenText["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (answer && typeof answer === "string") {
|
||||
values.push({
|
||||
id: response.id,
|
||||
updatedAt: response.updatedAt,
|
||||
value: answer,
|
||||
person: response.person,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: values.length,
|
||||
samples: values.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionType.MultipleChoiceSingle:
|
||||
case TSurveyQuestionType.MultipleChoiceMulti: {
|
||||
let values: TSurveySummaryMultipleChoice["choices"] = [];
|
||||
// check last choice is others or not
|
||||
const lastChoice = question.choices[question.choices.length - 1];
|
||||
const isOthersEnabled = lastChoice.id === "other";
|
||||
|
||||
const questionChoices = question.choices.map((choice) => choice.label);
|
||||
if (isOthersEnabled) {
|
||||
questionChoices.pop();
|
||||
}
|
||||
|
||||
let totalResponseCount = 0;
|
||||
const choiceCountMap = questionChoices.reduce((acc: Record<string, number>, choice) => {
|
||||
acc[choice] = 0;
|
||||
return acc;
|
||||
}, {});
|
||||
const otherValues: { value: string; person: TPerson | null }[] = [];
|
||||
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
|
||||
if (Array.isArray(answer)) {
|
||||
answer.forEach((value) => {
|
||||
totalResponseCount++;
|
||||
if (questionChoices.includes(value)) {
|
||||
choiceCountMap[value]++;
|
||||
} else {
|
||||
otherValues.push({
|
||||
value,
|
||||
person: response.person,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (typeof answer === "string") {
|
||||
totalResponseCount++;
|
||||
if (questionChoices.includes(answer)) {
|
||||
choiceCountMap[answer]++;
|
||||
} else {
|
||||
otherValues.push({
|
||||
value: answer,
|
||||
person: response.person,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(choiceCountMap).map(([label, count]) => {
|
||||
values.push({
|
||||
value: label,
|
||||
count,
|
||||
percentage:
|
||||
totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
|
||||
});
|
||||
});
|
||||
|
||||
if (isOthersEnabled) {
|
||||
values.push({
|
||||
value: lastChoice.label || "Other",
|
||||
count: otherValues.length,
|
||||
percentage: convertFloatTo2Decimal((otherValues.length / totalResponseCount) * 100),
|
||||
others: otherValues.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
}
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: totalResponseCount,
|
||||
choices: values,
|
||||
});
|
||||
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionType.PictureSelection: {
|
||||
let values: TSurveySummaryPictureSelection["choices"] = [];
|
||||
const choiceCountMap: Record<string, number> = {};
|
||||
|
||||
question.choices.forEach((choice) => {
|
||||
choiceCountMap[choice.id] = 0;
|
||||
});
|
||||
let totalResponseCount = 0;
|
||||
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (Array.isArray(answer)) {
|
||||
answer.forEach((value) => {
|
||||
totalResponseCount++;
|
||||
choiceCountMap[value]++;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
question.choices.forEach((choice) => {
|
||||
values.push({
|
||||
id: choice.id,
|
||||
imageUrl: choice.imageUrl,
|
||||
count: choiceCountMap[choice.id],
|
||||
percentage:
|
||||
totalResponseCount > 0
|
||||
? convertFloatTo2Decimal((choiceCountMap[choice.id] / totalResponseCount) * 100)
|
||||
: 0,
|
||||
});
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: totalResponseCount,
|
||||
choices: values,
|
||||
});
|
||||
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionType.Rating: {
|
||||
let values: TSurveySummaryRating["choices"] = [];
|
||||
const choiceCountMap: Record<number, number> = {};
|
||||
const range = question.range;
|
||||
|
||||
for (let i = 1; i <= range; i++) {
|
||||
choiceCountMap[i] = 0;
|
||||
}
|
||||
|
||||
let totalResponseCount = 0;
|
||||
let totalRating = 0;
|
||||
let dismissed = 0;
|
||||
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (typeof answer === "number") {
|
||||
totalResponseCount++;
|
||||
choiceCountMap[answer]++;
|
||||
totalRating += answer;
|
||||
} else if (response.ttc && response.ttc[question.id] > 0) {
|
||||
totalResponseCount++;
|
||||
dismissed++;
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(choiceCountMap).map(([label, count]) => {
|
||||
values.push({
|
||||
rating: parseInt(label),
|
||||
count,
|
||||
percentage:
|
||||
totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
|
||||
});
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
average: convertFloatTo2Decimal(totalRating / (totalResponseCount - dismissed)) || 0,
|
||||
responseCount: totalResponseCount,
|
||||
choices: values,
|
||||
dismissed: {
|
||||
count: dismissed,
|
||||
percentage:
|
||||
totalResponseCount > 0 ? convertFloatTo2Decimal((dismissed / totalResponseCount) * 100) : 0,
|
||||
},
|
||||
});
|
||||
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionType.NPS: {
|
||||
const data = {
|
||||
promoters: 0,
|
||||
passives: 0,
|
||||
detractors: 0,
|
||||
dismissed: 0,
|
||||
total: 0,
|
||||
score: 0,
|
||||
};
|
||||
|
||||
responses.forEach((response) => {
|
||||
const value = response.data[question.id];
|
||||
if (typeof value === "number") {
|
||||
data.total++;
|
||||
if (value >= 9) {
|
||||
data.promoters++;
|
||||
} else if (value >= 7) {
|
||||
data.passives++;
|
||||
} else {
|
||||
data.detractors++;
|
||||
}
|
||||
} else if (response.ttc && response.ttc[question.id] > 0) {
|
||||
data.total++;
|
||||
data.dismissed++;
|
||||
}
|
||||
});
|
||||
|
||||
data.score =
|
||||
data.total > 0
|
||||
? convertFloatTo2Decimal(((data.promoters - data.detractors) / data.total) * 100)
|
||||
: 0;
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: data.total,
|
||||
total: data.total,
|
||||
score: data.score,
|
||||
promoters: {
|
||||
count: data.promoters,
|
||||
percentage: data.total > 0 ? convertFloatTo2Decimal((data.promoters / data.total) * 100) : 0,
|
||||
},
|
||||
passives: {
|
||||
count: data.passives,
|
||||
percentage: data.total > 0 ? convertFloatTo2Decimal((data.passives / data.total) * 100) : 0,
|
||||
},
|
||||
detractors: {
|
||||
count: data.detractors,
|
||||
percentage: data.total > 0 ? convertFloatTo2Decimal((data.detractors / data.total) * 100) : 0,
|
||||
},
|
||||
dismissed: {
|
||||
count: data.dismissed,
|
||||
percentage: data.total > 0 ? convertFloatTo2Decimal((data.dismissed / data.total) * 100) : 0,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionType.CTA: {
|
||||
const data = {
|
||||
clicked: 0,
|
||||
dismissed: 0,
|
||||
};
|
||||
|
||||
responses.forEach((response) => {
|
||||
const value = response.data[question.id];
|
||||
if (value === "clicked") {
|
||||
data.clicked++;
|
||||
} else if (value === "dismissed") {
|
||||
data.dismissed++;
|
||||
}
|
||||
});
|
||||
|
||||
const totalResponses = data.clicked + data.dismissed;
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: totalResponses,
|
||||
ctr: {
|
||||
count: data.clicked,
|
||||
percentage:
|
||||
totalResponses > 0 ? convertFloatTo2Decimal((data.clicked / totalResponses) * 100) : 0,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionType.Consent: {
|
||||
const data = {
|
||||
accepted: 0,
|
||||
dismissed: 0,
|
||||
};
|
||||
|
||||
responses.forEach((response) => {
|
||||
const value = response.data[question.id];
|
||||
if (value === "accepted") {
|
||||
data.accepted++;
|
||||
} else if (response.ttc && response.ttc[question.id] > 0) {
|
||||
data.dismissed++;
|
||||
}
|
||||
});
|
||||
|
||||
const totalResponses = data.accepted + data.dismissed;
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: totalResponses,
|
||||
accepted: {
|
||||
count: data.accepted,
|
||||
percentage:
|
||||
totalResponses > 0 ? convertFloatTo2Decimal((data.accepted / totalResponses) * 100) : 0,
|
||||
},
|
||||
dismissed: {
|
||||
count: data.dismissed,
|
||||
percentage:
|
||||
totalResponses > 0 ? convertFloatTo2Decimal((data.dismissed / totalResponses) * 100) : 0,
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionType.Date: {
|
||||
let values: TSurveySummaryDate["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (answer && typeof answer === "string") {
|
||||
values.push({
|
||||
id: response.id,
|
||||
updatedAt: response.updatedAt,
|
||||
value: answer,
|
||||
person: response.person,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: values.length,
|
||||
samples: values.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionType.FileUpload: {
|
||||
let values: TSurveySummaryFileUpload["files"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (Array.isArray(answer)) {
|
||||
values.push({
|
||||
id: response.id,
|
||||
updatedAt: response.updatedAt,
|
||||
value: answer,
|
||||
person: response.person,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: values.length,
|
||||
files: values.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionType.Cal: {
|
||||
const data = {
|
||||
booked: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
responses.forEach((response) => {
|
||||
const value = response.data[question.id];
|
||||
if (value === "booked") {
|
||||
data.booked++;
|
||||
} else if (response.ttc && response.ttc[question.id] > 0) {
|
||||
data.skipped++;
|
||||
}
|
||||
});
|
||||
const totalResponses = data.booked + data.skipped;
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: totalResponses,
|
||||
booked: {
|
||||
count: data.booked,
|
||||
percentage: totalResponses > 0 ? convertFloatTo2Decimal((data.booked / totalResponses) * 100) : 0,
|
||||
},
|
||||
skipped: {
|
||||
count: data.skipped,
|
||||
percentage:
|
||||
totalResponses > 0 ? convertFloatTo2Decimal((data.skipped / totalResponses) * 100) : 0,
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
survey.hiddenFields?.fieldIds?.forEach((question) => {
|
||||
let values: TSurveySummaryHiddenField["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question];
|
||||
if (answer && typeof answer === "string") {
|
||||
values.push({
|
||||
updatedAt: response.updatedAt,
|
||||
value: answer,
|
||||
person: response.person,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: "hiddenField",
|
||||
question,
|
||||
responseCount: values.length,
|
||||
samples: values.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
|
||||
values = [];
|
||||
});
|
||||
|
||||
return summary;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { TSurveyLogic } from "@formbricks/types/surveys";
|
||||
|
||||
export function evaluateCondition(logic: TSurveyLogic, responseValue: any): boolean {
|
||||
switch (logic.condition) {
|
||||
case "equals":
|
||||
return (
|
||||
(Array.isArray(responseValue) && responseValue.length === 1 && responseValue.includes(logic.value)) ||
|
||||
responseValue?.toString() === logic.value
|
||||
);
|
||||
case "notEquals":
|
||||
return responseValue !== logic.value;
|
||||
case "lessThan":
|
||||
return logic.value !== undefined && responseValue < logic.value;
|
||||
case "lessEqual":
|
||||
return logic.value !== undefined && responseValue <= logic.value;
|
||||
case "greaterThan":
|
||||
return logic.value !== undefined && responseValue > logic.value;
|
||||
case "greaterEqual":
|
||||
return logic.value !== undefined && responseValue >= logic.value;
|
||||
case "includesAll":
|
||||
return (
|
||||
Array.isArray(responseValue) &&
|
||||
Array.isArray(logic.value) &&
|
||||
logic.value.every((v) => responseValue.includes(v))
|
||||
);
|
||||
case "includesOne":
|
||||
return (
|
||||
Array.isArray(responseValue) &&
|
||||
Array.isArray(logic.value) &&
|
||||
logic.value.some((v) => responseValue.includes(v))
|
||||
);
|
||||
case "accepted":
|
||||
return responseValue === "accepted";
|
||||
case "clicked":
|
||||
return responseValue === "clicked";
|
||||
case "submitted":
|
||||
if (typeof responseValue === "string") {
|
||||
return responseValue !== "dismissed" && responseValue !== "" && responseValue !== null;
|
||||
} else if (Array.isArray(responseValue)) {
|
||||
return responseValue.length > 0;
|
||||
} else if (typeof responseValue === "number") {
|
||||
return responseValue !== null;
|
||||
}
|
||||
return false;
|
||||
case "skipped":
|
||||
return (
|
||||
(Array.isArray(responseValue) && responseValue.length === 0) ||
|
||||
responseValue === "" ||
|
||||
responseValue === null ||
|
||||
responseValue === "dismissed"
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user