mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-07 22:31:35 -05:00
feat: move response filtering server-side (#1844)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
@@ -10,10 +10,13 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TPerson } from "@formbricks/types/people";
|
||||
import {
|
||||
TResponse,
|
||||
TResponseFilterCriteria,
|
||||
TResponseInput,
|
||||
TResponseLegacyInput,
|
||||
TResponseUpdateInput,
|
||||
TSurveyPersonAttributes,
|
||||
ZResponse,
|
||||
ZResponseFilterCriteria,
|
||||
ZResponseInput,
|
||||
ZResponseLegacyInput,
|
||||
ZResponseNote,
|
||||
@@ -24,7 +27,7 @@ import { TTag } from "@formbricks/types/tags";
|
||||
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
|
||||
import { deleteDisplayByResponseId } from "../display/service";
|
||||
import { createPerson, getPerson, getPersonByUserId, transformPrismaPerson } from "../person/service";
|
||||
import { calculateTtcTotal } from "../response/util";
|
||||
import { buildWhereClause, calculateTtcTotal } from "../response/util";
|
||||
import { responseNoteCache } from "../responseNote/cache";
|
||||
import { getResponseNotes } from "../responseNote/service";
|
||||
import { getSurvey } from "../survey/service";
|
||||
@@ -392,19 +395,76 @@ export const getResponse = async (responseId: string): Promise<TResponse | null>
|
||||
: null;
|
||||
};
|
||||
|
||||
export const getResponsePersonAttributes = async (surveyId: string): Promise<TSurveyPersonAttributes> => {
|
||||
const responses = await unstable_cache(
|
||||
async () => {
|
||||
validateInputs([surveyId, ZId]);
|
||||
|
||||
try {
|
||||
let attributes: TSurveyPersonAttributes = {};
|
||||
const responseAttributes = await prisma.response.findMany({
|
||||
where: {
|
||||
surveyId: surveyId,
|
||||
},
|
||||
select: {
|
||||
personAttributes: true,
|
||||
},
|
||||
});
|
||||
|
||||
responseAttributes.forEach((response) => {
|
||||
Object.keys(response.personAttributes ?? {}).forEach((key) => {
|
||||
if (response.personAttributes && attributes[key]) {
|
||||
attributes[key].push(response.personAttributes[key].toString());
|
||||
} else if (response.personAttributes) {
|
||||
attributes[key] = [response.personAttributes[key].toString()];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Object.keys(attributes).forEach((key) => {
|
||||
attributes[key] = Array.from(new Set(attributes[key]));
|
||||
});
|
||||
|
||||
return attributes;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getAttributesFromResponses-${surveyId}`],
|
||||
{
|
||||
tags: [responseCache.tag.bySurveyId(surveyId)],
|
||||
revalidate: SERVICES_REVALIDATION_INTERVAL,
|
||||
}
|
||||
)();
|
||||
|
||||
return responses;
|
||||
};
|
||||
|
||||
export const getResponses = async (
|
||||
surveyId: string,
|
||||
page?: number,
|
||||
batchSize?: number
|
||||
batchSize?: number,
|
||||
filterCriteria?: TResponseFilterCriteria
|
||||
): Promise<TResponse[]> => {
|
||||
const responses = await unstable_cache(
|
||||
async () => {
|
||||
validateInputs([surveyId, ZId], [page, ZOptionalNumber]);
|
||||
validateInputs(
|
||||
[surveyId, ZId],
|
||||
[page, ZOptionalNumber],
|
||||
[batchSize, ZOptionalNumber],
|
||||
[filterCriteria, ZResponseFilterCriteria.optional()]
|
||||
);
|
||||
batchSize = batchSize ?? RESPONSES_PER_PAGE;
|
||||
|
||||
try {
|
||||
const responses = await prisma.response.findMany({
|
||||
where: {
|
||||
surveyId,
|
||||
...buildWhereClause(filterCriteria),
|
||||
},
|
||||
select: responseSelection,
|
||||
orderBy: [
|
||||
@@ -417,7 +477,7 @@ export const getResponses = async (
|
||||
});
|
||||
|
||||
const transformedResponses: TResponse[] = await Promise.all(
|
||||
responses.map(async (responsePrisma) => {
|
||||
responses.map((responsePrisma) => {
|
||||
return {
|
||||
...responsePrisma,
|
||||
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
|
||||
@@ -435,13 +495,12 @@ export const getResponses = async (
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getResponses-${surveyId}-${page}-${batchSize}`],
|
||||
[`getResponses-${surveyId}-${page}-${batchSize}-${JSON.stringify(filterCriteria)}`],
|
||||
{
|
||||
tags: [responseCache.tag.bySurveyId(surveyId)],
|
||||
revalidate: SERVICES_REVALIDATION_INTERVAL,
|
||||
}
|
||||
)();
|
||||
|
||||
return responses.map((response) => ({
|
||||
...formatDateFields(response, ZResponse),
|
||||
notes: response.notes.map((note) => formatDateFields(note, ZResponseNote)),
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { isAfter, isBefore, isSameDay } from "date-fns";
|
||||
|
||||
import { TDisplay } from "@formbricks/types/displays";
|
||||
import { TResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import {
|
||||
TResponse,
|
||||
TResponseFilterCriteria,
|
||||
TResponseUpdateInput,
|
||||
TSurveyPersonAttributes,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
|
||||
import { transformPrismaPerson } from "../../../person/service";
|
||||
import { responseNoteSelect } from "../../../responseNote/service";
|
||||
import { responseSelection } from "../../service";
|
||||
import { constantsForTests } from "../constants";
|
||||
@@ -109,6 +117,326 @@ export const mockResponse: ResponseMock = {
|
||||
ttc: {},
|
||||
};
|
||||
|
||||
export const mockResponsePersonAttributes: ResponseMock[] = [
|
||||
{
|
||||
id: mockResponseId,
|
||||
surveyId: mockSurveyId,
|
||||
singleUseId: mockSingleUseId,
|
||||
data: {},
|
||||
createdAt: new Date(),
|
||||
finished: constantsForTests.boolean,
|
||||
meta: mockMeta,
|
||||
notes: [mockResponseNote],
|
||||
tags: mockTags,
|
||||
personId: mockPersonId,
|
||||
updatedAt: new Date(),
|
||||
ttc: {},
|
||||
person: null,
|
||||
personAttributes: { Plan: "Paid", "Init Attribute 1": "one", "Init Attribute 2": "two" },
|
||||
},
|
||||
{
|
||||
id: mockResponseId,
|
||||
surveyId: mockSurveyId,
|
||||
singleUseId: mockSingleUseId,
|
||||
data: {},
|
||||
createdAt: new Date(),
|
||||
finished: constantsForTests.boolean,
|
||||
meta: mockMeta,
|
||||
notes: [mockResponseNote],
|
||||
tags: mockTags,
|
||||
personId: mockPersonId,
|
||||
updatedAt: new Date(),
|
||||
ttc: {},
|
||||
person: null,
|
||||
personAttributes: {
|
||||
Plan: "Paid",
|
||||
"Init Attribute 1": "three",
|
||||
"Init Attribute 2": "four",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: mockResponseId,
|
||||
surveyId: mockSurveyId,
|
||||
singleUseId: mockSingleUseId,
|
||||
data: {},
|
||||
createdAt: new Date(),
|
||||
finished: constantsForTests.boolean,
|
||||
meta: mockMeta,
|
||||
notes: [mockResponseNote],
|
||||
tags: mockTags,
|
||||
personId: mockPersonId,
|
||||
updatedAt: new Date(),
|
||||
ttc: {},
|
||||
person: null,
|
||||
personAttributes: { Plan: "Paid", "Init Attribute 1": "five", "Init Attribute 2": "six" },
|
||||
},
|
||||
{
|
||||
id: mockResponseId,
|
||||
surveyId: mockSurveyId,
|
||||
singleUseId: mockSingleUseId,
|
||||
data: {},
|
||||
createdAt: new Date(),
|
||||
finished: constantsForTests.boolean,
|
||||
meta: mockMeta,
|
||||
notes: [mockResponseNote],
|
||||
tags: mockTags,
|
||||
personId: mockPersonId,
|
||||
updatedAt: new Date(),
|
||||
ttc: {},
|
||||
person: null,
|
||||
personAttributes: { Plan: "Paid", "Init Attribute 1": "five", "Init Attribute 2": "four" },
|
||||
},
|
||||
{
|
||||
id: mockResponseId,
|
||||
surveyId: mockSurveyId,
|
||||
singleUseId: mockSingleUseId,
|
||||
data: {},
|
||||
createdAt: new Date(),
|
||||
finished: constantsForTests.boolean,
|
||||
meta: mockMeta,
|
||||
notes: [mockResponseNote],
|
||||
tags: mockTags,
|
||||
personId: mockPersonId,
|
||||
updatedAt: new Date(),
|
||||
ttc: {},
|
||||
person: null,
|
||||
personAttributes: { Plan: "Paid", "Init Attribute 1": "three", "Init Attribute 2": "two" },
|
||||
},
|
||||
];
|
||||
|
||||
const getMockTags = (tags: string[]): { tag: TTag }[] => {
|
||||
return tags.map((tag) => ({
|
||||
tag: {
|
||||
id: constantsForTests.uuid,
|
||||
name: tag,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: mockEnvironmentId,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
export const mockResponses: ResponseMock[] = [
|
||||
{
|
||||
id: "clsk98dpd001qk8iuqllv486a",
|
||||
createdAt: new Date("2024-02-13T11:00:00.000Z"),
|
||||
updatedAt: new Date("2024-02-13T11:00:00.000Z"),
|
||||
surveyId: mockSurveyId,
|
||||
finished: false,
|
||||
data: {
|
||||
hagrboqlnynmxh3obl1wvmtl: "Google Search",
|
||||
uvy0fa96e1xpd10nrj1je662: ["Sun ☀️"],
|
||||
},
|
||||
meta: mockMeta,
|
||||
ttc: {},
|
||||
personAttributes: {
|
||||
Plan: "Paid",
|
||||
"Init Attribute 1": "six",
|
||||
"Init Attribute 2": "five",
|
||||
},
|
||||
singleUseId: mockSingleUseId,
|
||||
personId: mockPersonId,
|
||||
person: null,
|
||||
tags: getMockTags(["tag1", "tag3"]),
|
||||
notes: [],
|
||||
},
|
||||
{
|
||||
id: "clsk8db0r001kk8iujkn32q8g",
|
||||
createdAt: new Date("2024-02-13T11:00:00.000Z"),
|
||||
updatedAt: new Date("2024-02-13T11:00:00.000Z"),
|
||||
surveyId: mockSurveyId,
|
||||
finished: false,
|
||||
data: {
|
||||
hagrboqlnynmxh3obl1wvmtl: "Google Search",
|
||||
uvy0fa96e1xpd10nrj1je662: ["Sun ☀️"],
|
||||
},
|
||||
meta: mockMeta,
|
||||
ttc: {},
|
||||
personAttributes: {
|
||||
Plan: "Paid",
|
||||
"Init Attribute 1": "six",
|
||||
"Init Attribute 2": "four",
|
||||
},
|
||||
singleUseId: mockSingleUseId,
|
||||
personId: mockPersonId,
|
||||
person: null,
|
||||
tags: getMockTags(["tag1", "tag2"]),
|
||||
notes: [],
|
||||
},
|
||||
{
|
||||
id: "clsk7b15p001fk8iu04qpvo2f",
|
||||
createdAt: new Date("2024-02-13T11:00:00.000Z"),
|
||||
updatedAt: new Date("2024-02-13T11:00:00.000Z"),
|
||||
surveyId: mockSurveyId,
|
||||
finished: false,
|
||||
data: {
|
||||
hagrboqlnynmxh3obl1wvmtl: "Google Search",
|
||||
},
|
||||
meta: mockMeta,
|
||||
ttc: {},
|
||||
personAttributes: {
|
||||
Plan: "Paid",
|
||||
"Init Attribute 1": "six",
|
||||
"Init Attribute 2": "four",
|
||||
},
|
||||
singleUseId: mockSingleUseId,
|
||||
personId: mockPersonId,
|
||||
person: null,
|
||||
tags: getMockTags(["tag2", "tag3"]),
|
||||
notes: [],
|
||||
},
|
||||
{
|
||||
id: "clsk6bk1l0017k8iut9dp0uxt",
|
||||
createdAt: new Date("2024-02-13T11:00:00.000Z"),
|
||||
updatedAt: new Date("2024-02-13T11:00:00.000Z"),
|
||||
surveyId: mockSurveyId,
|
||||
finished: false,
|
||||
data: {
|
||||
hagrboqlnynmxh3obl1wvmtl: "Recommendation",
|
||||
},
|
||||
meta: mockMeta,
|
||||
ttc: {},
|
||||
personAttributes: {
|
||||
Plan: "Paid",
|
||||
"Init Attribute 1": "eight",
|
||||
"Init Attribute 2": "two",
|
||||
},
|
||||
singleUseId: mockSingleUseId,
|
||||
personId: mockPersonId,
|
||||
person: null,
|
||||
tags: getMockTags(["tag1", "tag4"]),
|
||||
notes: [],
|
||||
},
|
||||
{
|
||||
id: "clsk5tgkm000uk8iueqoficwc",
|
||||
createdAt: new Date("2024-02-13T11:00:00.000Z"),
|
||||
updatedAt: new Date("2024-02-13T11:00:00.000Z"),
|
||||
surveyId: mockSurveyId,
|
||||
finished: true,
|
||||
data: {
|
||||
hagrboqlnynmxh3obl1wvmtl: "Social Media",
|
||||
},
|
||||
meta: mockMeta,
|
||||
ttc: {},
|
||||
personAttributes: {
|
||||
Plan: "Paid",
|
||||
"Init Attribute 1": "eight",
|
||||
"Init Attribute 2": "two",
|
||||
},
|
||||
singleUseId: mockSingleUseId,
|
||||
personId: mockPersonId,
|
||||
person: null,
|
||||
tags: getMockTags(["tag4", "tag5"]),
|
||||
notes: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const getFilteredMockResponses = (
|
||||
fitlerCritera: TResponseFilterCriteria,
|
||||
format: boolean = true
|
||||
): (ResponseMock | TResponse)[] => {
|
||||
let result = mockResponses;
|
||||
|
||||
if (fitlerCritera.finished !== undefined) {
|
||||
result = result.filter((response) => response.finished === fitlerCritera.finished);
|
||||
}
|
||||
|
||||
if (fitlerCritera.createdAt !== undefined) {
|
||||
if (fitlerCritera.createdAt.min !== undefined) {
|
||||
result = result.filter(
|
||||
(response) =>
|
||||
isAfter(response.createdAt, fitlerCritera.createdAt?.min || "") ||
|
||||
isSameDay(response.createdAt, fitlerCritera.createdAt?.min || "")
|
||||
);
|
||||
}
|
||||
|
||||
if (fitlerCritera.createdAt.max !== undefined) {
|
||||
result = result.filter(
|
||||
(response) =>
|
||||
isBefore(response.createdAt, fitlerCritera.createdAt?.max || "") ||
|
||||
isSameDay(response.createdAt, fitlerCritera.createdAt?.min || "")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (fitlerCritera.personAttributes !== undefined) {
|
||||
result = result.filter((response) => {
|
||||
for (const [key, value] of Object.entries(fitlerCritera.personAttributes || {})) {
|
||||
if (value.op === "equals" && response.personAttributes?.[key] !== value.value) {
|
||||
return false;
|
||||
} else if (value.op === "notEquals" && response.personAttributes?.[key] === value.value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
if (fitlerCritera.tags !== undefined) {
|
||||
result = result.filter((response) => {
|
||||
// response should contain all the tags in applied and none of the tags in notApplied
|
||||
return (
|
||||
fitlerCritera.tags?.applied?.every((tag) => {
|
||||
return response.tags?.some((responseTag) => responseTag.tag.name === tag);
|
||||
}) &&
|
||||
fitlerCritera.tags?.notApplied?.every((tag) => {
|
||||
return !response.tags?.some((responseTag) => responseTag.tag.name === tag);
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (fitlerCritera.data !== undefined) {
|
||||
result = result.filter((response) => {
|
||||
for (const [key, value] of Object.entries(fitlerCritera.data || {})) {
|
||||
switch (value.op) {
|
||||
case "booked":
|
||||
case "accepted":
|
||||
case "clicked":
|
||||
return response.data?.[key] === value.op;
|
||||
case "equals":
|
||||
return response.data?.[key] === value.value;
|
||||
case "greaterThan":
|
||||
return Number(response.data?.[key]) > value.value;
|
||||
case "lessThan":
|
||||
return Number(response.data?.[key]) < value.value;
|
||||
case "greaterEqual":
|
||||
return Number(response.data?.[key]) >= value.value;
|
||||
case "lessEqual":
|
||||
return Number(response.data?.[key]) <= value.value;
|
||||
case "includesAll":
|
||||
return value.value.every((val: string) => (response.data?.[key] as string[])?.includes(val));
|
||||
case "includesOne":
|
||||
return value.value.some((val: string) => {
|
||||
if (Array.isArray(response.data?.[key]))
|
||||
return (response.data?.[key] as string[])?.includes(val);
|
||||
return response.data?.[key] === val;
|
||||
});
|
||||
case "notEquals":
|
||||
return response.data?.[key] !== value.value;
|
||||
case "notUploaded":
|
||||
return response.data?.[key] === undefined || response.data?.[key] === "skipped";
|
||||
case "skipped":
|
||||
return response.data?.[key] === undefined;
|
||||
case "submitted":
|
||||
return response.data?.[key] !== undefined;
|
||||
case "uploaded":
|
||||
return response.data?.[key] !== "skipped";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (format) {
|
||||
return result.map((response) => ({
|
||||
...response,
|
||||
person: response.person ? transformPrismaPerson(response.person) : null,
|
||||
tags: response.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const mockResponseWithMockPerson: ResponseMock = {
|
||||
...mockResponse,
|
||||
person: mockPerson,
|
||||
@@ -120,6 +448,12 @@ export const mockResponseData: TResponseUpdateInput["data"] = {
|
||||
key3: 20,
|
||||
};
|
||||
|
||||
export const mockPersonAttributesData: TSurveyPersonAttributes = {
|
||||
Plan: ["Paid"],
|
||||
"Init Attribute 1": ["one", "three", "five"],
|
||||
"Init Attribute 2": ["two", "four", "six"],
|
||||
};
|
||||
|
||||
export const getMockUpdateResponseInput = (finished: boolean = false): TResponseUpdateInput => ({
|
||||
data: mockResponseData,
|
||||
finished,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import {
|
||||
getFilteredMockResponses,
|
||||
getMockUpdateResponseInput,
|
||||
mockDisplay,
|
||||
mockEnvironmentId,
|
||||
mockMeta,
|
||||
mockPerson,
|
||||
mockPersonAttributesData,
|
||||
mockPersonId,
|
||||
mockResponse,
|
||||
mockResponseData,
|
||||
mockResponseNote,
|
||||
mockResponsePersonAttributes,
|
||||
mockResponseWithMockPerson,
|
||||
mockSingleUseId,
|
||||
mockSurveyId,
|
||||
@@ -19,7 +22,12 @@ import { Prisma } from "@prisma/client";
|
||||
|
||||
import { prismaMock } from "@formbricks/database/src/jestClient";
|
||||
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
|
||||
import { TResponse, TResponseInput, TResponseLegacyInput } from "@formbricks/types/responses";
|
||||
import {
|
||||
TResponse,
|
||||
TResponseFilterCriteria,
|
||||
TResponseInput,
|
||||
TResponseLegacyInput,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
|
||||
import { selectPerson, transformPrismaPerson } from "../../person/service";
|
||||
@@ -30,11 +38,13 @@ import {
|
||||
getResponse,
|
||||
getResponseBySingleUseId,
|
||||
getResponseCountBySurveyId,
|
||||
getResponsePersonAttributes,
|
||||
getResponses,
|
||||
getResponsesByEnvironmentId,
|
||||
getResponsesByPersonId,
|
||||
updateResponse,
|
||||
} from "../service";
|
||||
import { buildWhereClause } from "../util";
|
||||
import { constantsForTests } from "./constants";
|
||||
|
||||
const expectedResponseWithoutPerson: TResponse = {
|
||||
@@ -305,14 +315,132 @@ describe("Tests for getResponse service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for getAttributesFromResponses service", () => {
|
||||
describe("Happy Path", () => {
|
||||
it("Retrieves all attributes from responses for a given survey ID", async () => {
|
||||
prismaMock.response.findMany.mockResolvedValue(mockResponsePersonAttributes);
|
||||
const attributes = await getResponsePersonAttributes(mockSurveyId);
|
||||
expect(attributes).toEqual(mockPersonAttributesData);
|
||||
});
|
||||
|
||||
it("Returns an empty Object when no responses with attributes are found for the given survey ID", async () => {
|
||||
prismaMock.response.findMany.mockResolvedValue([]);
|
||||
|
||||
const responses = await getResponsePersonAttributes(mockSurveyId);
|
||||
expect(responses).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getResponsePersonAttributes, "1");
|
||||
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
prismaMock.response.findMany.mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(getResponsePersonAttributes(mockSurveyId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
it("Throws a generic Error for unexpected problems", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
prismaMock.response.findMany.mockRejectedValue(new Error(mockErrorMessage));
|
||||
|
||||
await expect(getResponsePersonAttributes(mockSurveyId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for getResponses service", () => {
|
||||
describe("Happy Path", () => {
|
||||
it("Fetches all responses for a given survey ID", async () => {
|
||||
const response = await getResponses(mockSurveyId);
|
||||
it("Fetches first 10 responses for a given survey ID", async () => {
|
||||
const response = await getResponses(mockSurveyId, 1, 10);
|
||||
expect(response).toEqual([expectedResponseWithoutPerson]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for getResponses service with filters", () => {
|
||||
describe("Happy Path", () => {
|
||||
it("Fetches all responses for a given survey ID with basic filters", async () => {
|
||||
const whereClause = buildWhereClause({ finished: true });
|
||||
let expectedWhereClause: Prisma.ResponseWhereInput | undefined = {};
|
||||
|
||||
// @ts-expect-error
|
||||
prismaMock.response.findMany.mockImplementation(async (args) => {
|
||||
expectedWhereClause = args?.where;
|
||||
return getFilteredMockResponses({ finished: true }, false);
|
||||
});
|
||||
|
||||
const response = await getResponses(mockSurveyId, 1, undefined, { finished: true });
|
||||
|
||||
expect(expectedWhereClause).toEqual({ surveyId: mockSurveyId, ...whereClause });
|
||||
expect(response).toEqual(getFilteredMockResponses({ finished: true }));
|
||||
});
|
||||
|
||||
it("Fetches all responses for a given survey ID with complex filters", async () => {
|
||||
const criteria: TResponseFilterCriteria = {
|
||||
finished: false,
|
||||
data: {
|
||||
hagrboqlnynmxh3obl1wvmtl: {
|
||||
op: "equals",
|
||||
value: "Google Search",
|
||||
},
|
||||
uvy0fa96e1xpd10nrj1je662: {
|
||||
op: "includesOne",
|
||||
value: ["Sun ☀️"],
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
applied: ["tag1"],
|
||||
notApplied: ["tag4"],
|
||||
},
|
||||
personAttributes: {
|
||||
"Init Attribute 2": {
|
||||
op: "equals",
|
||||
value: "four",
|
||||
},
|
||||
},
|
||||
};
|
||||
const whereClause = buildWhereClause(criteria);
|
||||
let expectedWhereClause: Prisma.ResponseWhereInput | undefined = {};
|
||||
|
||||
// @ts-expect-error
|
||||
prismaMock.response.findMany.mockImplementation(async (args) => {
|
||||
expectedWhereClause = args?.where;
|
||||
return getFilteredMockResponses(criteria, false);
|
||||
});
|
||||
|
||||
const response = await getResponses(mockSurveyId, 1, undefined, criteria);
|
||||
|
||||
expect(expectedWhereClause).toEqual({ surveyId: mockSurveyId, ...whereClause });
|
||||
expect(response).toEqual(getFilteredMockResponses(criteria));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
it("Throws an error when the where clause is different and the data is matched when filters are different.", async () => {
|
||||
const whereClause = buildWhereClause({ finished: true });
|
||||
let expectedWhereClause: Prisma.ResponseWhereInput | undefined = {};
|
||||
|
||||
// @ts-expect-error
|
||||
prismaMock.response.findMany.mockImplementation(async (args) => {
|
||||
expectedWhereClause = args?.where;
|
||||
|
||||
return getFilteredMockResponses({ finished: true });
|
||||
});
|
||||
|
||||
const response = await getResponses(mockSurveyId, 1, undefined, { finished: true });
|
||||
|
||||
expect(expectedWhereClause).not.toEqual(whereClause);
|
||||
expect(response).not.toEqual(getFilteredMockResponses({ finished: false }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getResponses, mockSurveyId, "1");
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import "server-only";
|
||||
|
||||
import { TResponseTtc } from "@formbricks/types/responses";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { TResponseFilterCriteria, TResponseTtc } from "@formbricks/types/responses";
|
||||
|
||||
export function calculateTtcTotal(ttc: TResponseTtc) {
|
||||
const result = { ...ttc };
|
||||
@@ -8,3 +10,281 @@ export function calculateTtcTotal(ttc: TResponseTtc) {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export const buildWhereClause = (filterCriteria?: TResponseFilterCriteria) => {
|
||||
const whereClause: Record<string, any>[] = [];
|
||||
|
||||
// For finished
|
||||
if (filterCriteria?.finished !== undefined) {
|
||||
whereClause.push({
|
||||
finished: filterCriteria?.finished,
|
||||
});
|
||||
}
|
||||
|
||||
// For Date range
|
||||
if (filterCriteria?.createdAt) {
|
||||
const createdAt: { lte?: Date; gte?: Date } = {};
|
||||
if (filterCriteria?.createdAt?.max) {
|
||||
createdAt.lte = filterCriteria?.createdAt?.max;
|
||||
}
|
||||
if (filterCriteria?.createdAt?.min) {
|
||||
createdAt.gte = filterCriteria?.createdAt?.min;
|
||||
}
|
||||
|
||||
whereClause.push({
|
||||
createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
// For Tags
|
||||
if (filterCriteria?.tags) {
|
||||
const tags: Record<string, any>[] = [];
|
||||
|
||||
if (filterCriteria?.tags?.applied) {
|
||||
const appliedTags = filterCriteria.tags.applied.map((name) => ({
|
||||
tags: {
|
||||
some: {
|
||||
tag: {
|
||||
name,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
tags.push(appliedTags);
|
||||
}
|
||||
|
||||
if (filterCriteria?.tags?.notApplied) {
|
||||
const notAppliedTags = {
|
||||
tags: {
|
||||
every: {
|
||||
tag: {
|
||||
name: {
|
||||
notIn: filterCriteria.tags.notApplied,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
tags.push(notAppliedTags);
|
||||
}
|
||||
|
||||
whereClause.push({
|
||||
AND: tags.flat(),
|
||||
});
|
||||
}
|
||||
|
||||
// For Person Attributes
|
||||
if (filterCriteria?.personAttributes) {
|
||||
const personAttributes: Prisma.ResponseWhereInput[] = [];
|
||||
|
||||
Object.entries(filterCriteria.personAttributes).forEach(([key, val]) => {
|
||||
switch (val.op) {
|
||||
case "equals":
|
||||
personAttributes.push({
|
||||
personAttributes: {
|
||||
path: [key],
|
||||
equals: val.value,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "notEquals":
|
||||
personAttributes.push({
|
||||
personAttributes: {
|
||||
path: [key],
|
||||
not: val.value,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
whereClause.push({
|
||||
AND: personAttributes,
|
||||
});
|
||||
}
|
||||
|
||||
// For Questions Data
|
||||
if (filterCriteria?.data) {
|
||||
const data: Prisma.ResponseWhereInput[] = [];
|
||||
|
||||
Object.entries(filterCriteria.data).forEach(([key, val]) => {
|
||||
switch (val.op) {
|
||||
case "submitted":
|
||||
data.push({
|
||||
data: {
|
||||
path: [key],
|
||||
not: Prisma.DbNull,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "skipped": // need to handle dismissed case for CTA type question, that would hinder other ques(eg open text)
|
||||
data.push({
|
||||
OR: [
|
||||
{
|
||||
data: {
|
||||
path: [key],
|
||||
equals: Prisma.DbNull,
|
||||
},
|
||||
},
|
||||
{
|
||||
data: {
|
||||
path: [key],
|
||||
equals: "dismissed",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
break;
|
||||
case "equals":
|
||||
data.push({
|
||||
data: {
|
||||
path: [key],
|
||||
equals: val.value,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "notEquals":
|
||||
data.push({
|
||||
OR: [
|
||||
{
|
||||
// for value not equal to val.value
|
||||
data: {
|
||||
path: [key],
|
||||
not: val.value,
|
||||
},
|
||||
},
|
||||
{
|
||||
// for not answered
|
||||
data: {
|
||||
path: [key],
|
||||
equals: Prisma.DbNull,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
break;
|
||||
case "lessThan":
|
||||
data.push({
|
||||
data: {
|
||||
path: [key],
|
||||
lt: val.value,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "lessEqual":
|
||||
data.push({
|
||||
data: {
|
||||
path: [key],
|
||||
lte: val.value,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "greaterThan":
|
||||
data.push({
|
||||
data: {
|
||||
path: [key],
|
||||
gt: val.value,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "greaterEqual":
|
||||
data.push({
|
||||
data: {
|
||||
path: [key],
|
||||
gte: val.value,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "includesAll":
|
||||
data.push({
|
||||
data: {
|
||||
path: [key],
|
||||
array_contains: val.value,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "includesOne":
|
||||
data.push({
|
||||
OR: val.value.map((value: string) => ({
|
||||
OR: [
|
||||
// for MultipleChoiceMulti
|
||||
{
|
||||
data: {
|
||||
path: [key],
|
||||
array_contains: [value],
|
||||
},
|
||||
},
|
||||
// for MultipleChoiceSingle
|
||||
{
|
||||
data: {
|
||||
path: [key],
|
||||
equals: value,
|
||||
},
|
||||
},
|
||||
],
|
||||
})),
|
||||
});
|
||||
break;
|
||||
case "uploaded":
|
||||
data.push({
|
||||
data: {
|
||||
path: [key],
|
||||
not: "skipped",
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "notUploaded":
|
||||
data.push({
|
||||
OR: [
|
||||
{
|
||||
// for skipped
|
||||
data: {
|
||||
path: [key],
|
||||
equals: "skipped",
|
||||
},
|
||||
},
|
||||
{
|
||||
// for not answered
|
||||
data: {
|
||||
path: [key],
|
||||
equals: Prisma.DbNull,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
break;
|
||||
case "clicked":
|
||||
data.push({
|
||||
data: {
|
||||
path: [key],
|
||||
equals: "clicked",
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "accepted":
|
||||
data.push({
|
||||
data: {
|
||||
path: [key],
|
||||
equals: "accepted",
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "booked":
|
||||
data.push({
|
||||
data: {
|
||||
path: [key],
|
||||
equals: "booked",
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
whereClause.push({
|
||||
AND: data,
|
||||
});
|
||||
}
|
||||
|
||||
return { AND: whereClause };
|
||||
};
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"include": ["."],
|
||||
"exclude": ["dist", "build", "node_modules"],
|
||||
"compilerOptions": {
|
||||
"downlevelIteration": true,
|
||||
},
|
||||
"downlevelIteration": true
|
||||
}
|
||||
}
|
||||
|
||||
+123
-1
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { ZPerson, ZPersonAttributes } from "./people";
|
||||
import { ZSurvey } from "./surveys";
|
||||
import { ZSurvey, ZSurveyLogicCondition } from "./surveys";
|
||||
import { ZTag } from "./tags";
|
||||
|
||||
export const ZResponseData = z.record(z.union([z.string(), z.number(), z.array(z.string())]));
|
||||
@@ -16,6 +16,128 @@ export const ZResponsePersonAttributes = ZPersonAttributes.nullable();
|
||||
|
||||
export type TResponsePersonAttributes = z.infer<typeof ZResponsePersonAttributes>;
|
||||
|
||||
export const ZSurveyPersonAttributes = z.record(z.array(z.string()));
|
||||
|
||||
export type TSurveyPersonAttributes = z.infer<typeof ZSurveyPersonAttributes>;
|
||||
|
||||
const ZResponseFilterCriteriaDataLessThan = z.object({
|
||||
op: z.literal(ZSurveyLogicCondition.Values.lessThan),
|
||||
value: z.number(),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaDataLessEqual = z.object({
|
||||
op: z.literal(ZSurveyLogicCondition.Values.lessEqual),
|
||||
value: z.number(),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaDataGreaterEqual = z.object({
|
||||
op: z.literal(ZSurveyLogicCondition.Values.greaterEqual),
|
||||
value: z.number(),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaDataGreaterThan = z.object({
|
||||
op: z.literal(ZSurveyLogicCondition.Values.greaterThan),
|
||||
value: z.number(),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaDataIncludesOne = z.object({
|
||||
op: z.literal(ZSurveyLogicCondition.Values.includesOne),
|
||||
value: z.array(z.string()),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaDataIncludesAll = z.object({
|
||||
op: z.literal(ZSurveyLogicCondition.Values.includesAll),
|
||||
value: z.array(z.string()),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaDataEquals = z.object({
|
||||
op: z.literal(ZSurveyLogicCondition.Values.equals),
|
||||
value: z.union([z.string(), z.number()]),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaDataNotEquals = z.object({
|
||||
op: z.literal(ZSurveyLogicCondition.Values.notEquals),
|
||||
value: z.union([z.string(), z.number()]),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaDataAccepted = z.object({
|
||||
op: z.literal(ZSurveyLogicCondition.Values.accepted),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaDataClicked = z.object({
|
||||
op: z.literal(ZSurveyLogicCondition.Values.clicked),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaDataSubmitted = z.object({
|
||||
op: z.literal(ZSurveyLogicCondition.Values.submitted),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaDataSkipped = z.object({
|
||||
op: z.literal(ZSurveyLogicCondition.Values.skipped),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaDataUploaded = z.object({
|
||||
op: z.literal(ZSurveyLogicCondition.Values.uploaded),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaDataNotUploaded = z.object({
|
||||
op: z.literal(ZSurveyLogicCondition.Values.notUploaded),
|
||||
});
|
||||
|
||||
const ZResponseFilterCriteriaDataBooked = z.object({
|
||||
op: z.literal(ZSurveyLogicCondition.Values.booked),
|
||||
});
|
||||
|
||||
export const ZResponseFilterCriteria = z.object({
|
||||
finished: z.boolean().optional(),
|
||||
createdAt: z
|
||||
.object({
|
||||
min: z.date().optional(),
|
||||
max: z.date().optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
personAttributes: z
|
||||
.record(
|
||||
z.object({
|
||||
op: z.enum(["equals", "notEquals"]),
|
||||
value: z.union([z.string(), z.number()]),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
|
||||
data: z
|
||||
.record(
|
||||
z.union([
|
||||
ZResponseFilterCriteriaDataLessThan,
|
||||
ZResponseFilterCriteriaDataLessEqual,
|
||||
ZResponseFilterCriteriaDataGreaterEqual,
|
||||
ZResponseFilterCriteriaDataGreaterThan,
|
||||
ZResponseFilterCriteriaDataIncludesOne,
|
||||
ZResponseFilterCriteriaDataIncludesAll,
|
||||
ZResponseFilterCriteriaDataEquals,
|
||||
ZResponseFilterCriteriaDataNotEquals,
|
||||
ZResponseFilterCriteriaDataAccepted,
|
||||
ZResponseFilterCriteriaDataClicked,
|
||||
ZResponseFilterCriteriaDataSubmitted,
|
||||
ZResponseFilterCriteriaDataSkipped,
|
||||
ZResponseFilterCriteriaDataUploaded,
|
||||
ZResponseFilterCriteriaDataNotUploaded,
|
||||
ZResponseFilterCriteriaDataBooked,
|
||||
])
|
||||
)
|
||||
.optional(),
|
||||
|
||||
tags: z
|
||||
.object({
|
||||
applied: z.array(z.string()).optional(),
|
||||
notApplied: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type TResponseFilterCriteria = z.infer<typeof ZResponseFilterCriteria>;
|
||||
|
||||
export const ZResponseNoteUser = z.object({
|
||||
id: z.string().cuid2(),
|
||||
name: z.string().nullable(),
|
||||
|
||||
@@ -41,7 +41,8 @@ export interface SingleResponseCardProps {
|
||||
pageType: "people" | "response";
|
||||
environmentTags: TTag[];
|
||||
environment: TEnvironment;
|
||||
setFetchedResponses?: React.Dispatch<React.SetStateAction<TResponse[]>>;
|
||||
updateResponse?: (responseId: string, responses: TResponse) => void;
|
||||
deleteResponse?: (responseId: string) => void;
|
||||
}
|
||||
|
||||
interface TooltipRendererProps {
|
||||
@@ -80,7 +81,8 @@ export default function SingleResponseCard({
|
||||
pageType,
|
||||
environmentTags,
|
||||
environment,
|
||||
setFetchedResponses,
|
||||
updateResponse,
|
||||
deleteResponse,
|
||||
}: SingleResponseCardProps) {
|
||||
const environmentId = survey.environmentId;
|
||||
const router = useRouter();
|
||||
@@ -154,9 +156,8 @@ export default function SingleResponseCard({
|
||||
throw new Error("You are not authorized to perform this action.");
|
||||
}
|
||||
await deleteResponseAction(response.id);
|
||||
if (setFetchedResponses) {
|
||||
setFetchedResponses((prevResponses) => prevResponses.filter((r) => r.id !== response.id));
|
||||
}
|
||||
deleteResponse?.(response.id);
|
||||
|
||||
router.refresh();
|
||||
toast.success("Response deleted successfully.");
|
||||
setDeleteDialogOpen(false);
|
||||
@@ -226,10 +227,8 @@ export default function SingleResponseCard({
|
||||
|
||||
const updateFetchedResponses = async () => {
|
||||
const updatedResponse = await getResponseAction(response.id);
|
||||
if (updatedResponse !== null && setFetchedResponses) {
|
||||
setFetchedResponses((prevResponses) =>
|
||||
prevResponses.map((response) => (response.id === updatedResponse.id ? updatedResponse : response))
|
||||
);
|
||||
if (updatedResponse !== null && updateResponse) {
|
||||
updateResponse(response.id, updatedResponse);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"include": [".", "../types/*.d.ts"],
|
||||
"exclude": ["build", "node_modules"],
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2021.String"],
|
||||
},
|
||||
"lib": ["ES2021.String"]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user