feat: move response filtering server-side (#1844)

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Piyush Gupta
2024-02-20 21:04:34 +05:30
committed by GitHub
parent f69e8e82e3
commit d2e6238ad7
25 changed files with 1331 additions and 228 deletions
+65 -6
View File
@@ -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,
+131 -3
View File
@@ -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");
+281 -1
View File
@@ -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 };
};
+2 -2
View File
@@ -3,6 +3,6 @@
"include": ["."],
"exclude": ["dist", "build", "node_modules"],
"compilerOptions": {
"downlevelIteration": true,
},
"downlevelIteration": true
}
}
+123 -1
View File
@@ -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(),
+8 -9
View File
@@ -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);
}
};
+2 -2
View File
@@ -3,6 +3,6 @@
"include": [".", "../types/*.d.ts"],
"exclude": ["build", "node_modules"],
"compilerOptions": {
"lib": ["ES2021.String"],
},
"lib": ["ES2021.String"]
}
}