feat: Move Response Summary Server-side (#2160)

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Piyush Gupta
2024-03-06 17:23:21 +05:30
committed by GitHub
parent a9f5289672
commit d01b293a27
38 changed files with 1691 additions and 1749 deletions
+57 -2
View File
@@ -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 () => {
+616 -2
View File
@@ -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;
};
+55
View File
@@ -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;
}
}