From ff60ff9e0ba74821fcb55c56f2b28eee4bb09bc1 Mon Sep 17 00:00:00 2001 From: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com> Date: Tue, 9 Apr 2024 16:07:58 +0530 Subject: [PATCH] feat: Add response filtering for meta data (#2363) Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Co-authored-by: Dhruwang --- .../responses/components/ResponsePage.tsx | 6 +- .../[surveyId]/(analysis)/responses/page.tsx | 4 +- .../summary/components/SummaryPage.tsx | 9 +-- .../[surveyId]/(analysis)/summary/page.tsx | 8 +- .../surveys/[surveyId]/actions.ts | 23 +++++- .../[surveyId]/components/CustomFilter.tsx | 26 +----- .../components/QuestionFilterComboBox.tsx | 8 +- .../components/QuestionsComboBox.tsx | 42 +++++++--- .../[surveyId]/components/ResponseFilter.tsx | 39 ++++++++- apps/web/app/lib/surveys/surveys.ts | 81 +++++++++++++++---- .../(analysis)/responses/page.tsx | 4 +- .../[sharingKey]/(analysis)/summary/page.tsx | 7 +- apps/web/app/share/[sharingKey]/action.ts | 25 +++++- packages/lib/response/service.ts | 65 +++++++++++++++ packages/lib/response/util.ts | 52 ++++++++++-- packages/types/responses.ts | 15 +++- packages/ui/Command/index.tsx | 2 +- 17 files changed, 320 insertions(+), 96 deletions(-) diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx index a820fa3712..1ca7c187e8 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx @@ -21,7 +21,7 @@ import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall"; import { TEnvironment } from "@formbricks/types/environment"; import { TMembershipRole } from "@formbricks/types/memberships"; import { TProduct } from "@formbricks/types/product"; -import { TResponse, TSurveyPersonAttributes } from "@formbricks/types/responses"; +import { TResponse } from "@formbricks/types/responses"; import { TSurvey } from "@formbricks/types/surveys"; import { TTag } from "@formbricks/types/tags"; import { TUser } from "@formbricks/types/user"; @@ -37,7 +37,6 @@ interface ResponsePageProps { product: TProduct; user?: TUser; environmentTags: TTag[]; - attributes: TSurveyPersonAttributes; responsesPerPage: number; membershipRole?: TMembershipRole; totalResponseCount: number; @@ -51,7 +50,6 @@ const ResponsePage = ({ product, user, environmentTags, - attributes, responsesPerPage, membershipRole, totalResponseCount, @@ -176,7 +174,7 @@ const ResponsePage = ({ membershipRole={membershipRole} />
- + {!isSharingPage && }
{ @@ -142,7 +137,7 @@ const SummaryPage = ({ membershipRole={membershipRole} />
- + {!isSharingPage && }
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions.ts index 8da855d431..6227944320 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions.ts @@ -3,8 +3,13 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@formbricks/lib/authOptions"; -import { getResponseDownloadUrl } from "@formbricks/lib/response/service"; +import { + getResponseDownloadUrl, + getResponseMeta, + getResponsePersonAttributes, +} from "@formbricks/lib/response/service"; import { canUserAccessSurvey } from "@formbricks/lib/survey/auth"; +import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service"; import { AuthorizationError } from "@formbricks/types/errors"; import { TResponseFilterCriteria } from "@formbricks/types/responses"; @@ -21,3 +26,19 @@ export async function getResponsesDownloadUrlAction( return getResponseDownloadUrl(surveyId, format, filterCritera); } + +export async function getSurveyFilterDataAction(surveyId: string, environmentId: string) { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); + + const [tags, attributes, meta] = await Promise.all([ + getTagsByEnvironmentId(environmentId), + getResponsePersonAttributes(surveyId), + getResponseMeta(surveyId), + ]); + + return { environmentTags: tags, attributes, meta }; +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx index 030322a548..6f1006c912 100755 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx @@ -5,11 +5,7 @@ import { useResponseFilter, } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions"; -import { - generateQuestionAndFilterOptions, - getFormattedFilters, - getTodayDate, -} from "@/app/lib/surveys/surveys"; +import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys"; import { differenceInDays, format, startOfDay, subDays } from "date-fns"; import { ChevronDown, ChevronUp, DownloadIcon } from "lucide-react"; import { useParams } from "next/navigation"; @@ -17,9 +13,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import toast from "react-hot-toast"; import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside"; -import { TSurveyPersonAttributes } from "@formbricks/types/responses"; import { TSurvey } from "@formbricks/types/surveys"; -import { TTag } from "@formbricks/types/tags"; import { Calendar } from "@formbricks/ui/Calendar"; import { DropdownMenu, @@ -48,8 +42,6 @@ enum FilterDropDownLabels { } interface CustomFilterProps { - environmentTags: TTag[]; - attributes: TSurveyPersonAttributes; survey: TSurvey; } @@ -64,11 +56,11 @@ const getDifferenceOfDays = (from, to) => { } }; -const CustomFilter = ({ environmentTags, attributes, survey }: CustomFilterProps) => { +const CustomFilter = ({ survey }: CustomFilterProps) => { const params = useParams(); const isSharingPage = !!params.sharingKey; - const { selectedFilter, setSelectedOptions, dateRange, setDateRange, resetState } = useResponseFilter(); + const { selectedFilter, dateRange, setDateRange, resetState } = useResponseFilter(); const [filterRange, setFilterRange] = useState( dateRange.from && dateRange.to ? getDifferenceOfDays(dateRange.from, dateRange.to) @@ -95,16 +87,6 @@ const CustomFilter = ({ environmentTags, attributes, survey }: CustomFilterProps } }, [survey?.id, resetState]); - // when the page loads we get total responses and iterate over the responses and questions, tags and attributes to create the filter options - useEffect(() => { - const { questionFilterOptions, questionOptions } = generateQuestionAndFilterOptions( - survey, - environmentTags, - attributes - ); - setSelectedOptions({ questionFilterOptions, questionOptions }); - }, [survey, setSelectedOptions, environmentTags, attributes]); - const filters = useMemo( () => getFormattedFilters(survey, selectedFilter, dateRange), [survey, selectedFilter, dateRange] @@ -210,7 +192,7 @@ const CustomFilter = ({ environmentTags, attributes, survey }: CustomFilterProps <>
- + { value && handleDatePickerClose(); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx index 89e3b2b0ef..26eb21ba31 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx @@ -23,7 +23,13 @@ type QuestionFilterComboBoxProps = { filterComboBoxValue: string | string[] | undefined; onChangeFilterValue: (o: string) => void; onChangeFilterComboBoxValue: (o: string | string[]) => void; - type: OptionsType.METADATA | TSurveyQuestionType | OptionsType.ATTRIBUTES | OptionsType.TAGS | undefined; + type: + | OptionsType.OTHERS + | TSurveyQuestionType + | OptionsType.ATTRIBUTES + | OptionsType.TAGS + | OptionsType.META + | undefined; handleRemoveMultiSelect: (value: string[]) => void; disabled?: boolean; }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx index a2a43cd547..033aa50f42 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx @@ -2,19 +2,23 @@ import clsx from "clsx"; import { + AirplayIcon, CheckIcon, ChevronDown, ChevronUp, + GlobeIcon, GridIcon, HashIcon, HelpCircleIcon, ImageIcon, LanguagesIcon, ListIcon, + MessageSquareTextIcon, MousePointerClickIcon, Rows3Icon, SmartphoneIcon, StarIcon, + User, } from "lucide-react"; import * as React from "react"; @@ -35,7 +39,8 @@ export enum OptionsType { QUESTIONS = "Questions", TAGS = "Tags", ATTRIBUTES = "Attributes", - METADATA = "Metadata", + OTHERS = "Other Filters", + META = "Meta", } export type QuestionOption = { @@ -60,16 +65,18 @@ const SelectedCommandItem = ({ label, questionType, type }: Partial; - case TSurveyQuestionType.CTA: - return ; case TSurveyQuestionType.OpenText: - return ; + return ; + case TSurveyQuestionType.Rating: + return ; + case TSurveyQuestionType.CTA: + return ; + case TSurveyQuestionType.OpenText: + return ; case TSurveyQuestionType.MultipleChoiceMulti: - return ; + return ; case TSurveyQuestionType.MultipleChoiceSingle: - return ; + return ; case TSurveyQuestionType.NPS: return ; case TSurveyQuestionType.Consent: @@ -80,13 +87,24 @@ const SelectedCommandItem = ({ label, questionType, type }: Partial; } case OptionsType.ATTRIBUTES: - return ; - case OptionsType.METADATA: + return ; + case OptionsType.META: + switch (label) { + case "device": + return ; + case "os": + return ; + case "browser": + return ; + case "source": + return ; + case "action": + return ; + } + case OptionsType.OTHERS: switch (label) { case "Language": return ; - case "Device Type": - return ; } case OptionsType.TAGS: return ; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.tsx index 8c389bd1dd..7196a54414 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.tsx @@ -4,14 +4,18 @@ import { SelectedFilterValue, useResponseFilter, } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; +import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions"; import QuestionFilterComboBox from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox"; +import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys"; +import { getSurveyFilterDataBySurveySharingKeyAction } from "@/app/share/[sharingKey]/action"; import clsx from "clsx"; import { isEqual } from "lodash"; import { TrashIcon } from "lucide-react"; import { ChevronDown, ChevronUp, Plus } from "lucide-react"; +import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; -import { TSurveyTSurveyQuestionType } from "@formbricks/types/surveys"; +import { TSurvey, TSurveyTSurveyQuestionType } from "@formbricks/types/surveys"; import { Button } from "@formbricks/ui/Button"; import { Checkbox } from "@formbricks/ui/Checkbox"; import { Popover, PopoverContent, PopoverTrigger } from "@formbricks/ui/Popover"; @@ -25,11 +29,40 @@ export type QuestionFilterOptions = { id: string; }; -const ResponseFilter = () => { +interface ResponseFilterProps { + survey: TSurvey; +} + +const ResponseFilter = ({ survey }: ResponseFilterProps) => { + const params = useParams(); + const sharingKey = params.sharingKey as string; + const isSharingPage = !!sharingKey; + + const { selectedFilter, setSelectedFilter, selectedOptions, setSelectedOptions } = useResponseFilter(); const [isOpen, setIsOpen] = useState(false); - const { selectedFilter, setSelectedFilter, selectedOptions } = useResponseFilter(); const [filterValue, setFilterValue] = useState(selectedFilter); + useEffect(() => { + // Fetch the initial data for the filter and load it into the state + const handleInitialData = async () => { + if (isOpen) { + const { attributes, meta, environmentTags } = isSharingPage + ? await getSurveyFilterDataBySurveySharingKeyAction(sharingKey, survey.environmentId) + : await getSurveyFilterDataAction(survey.id, survey.environmentId); + + const { questionFilterOptions, questionOptions } = generateQuestionAndFilterOptions( + survey, + environmentTags, + attributes, + meta + ); + setSelectedOptions({ questionFilterOptions, questionOptions }); + } + }; + + handleInitialData(); + }, [isOpen, isSharingPage, setSelectedOptions, sharingKey, survey]); + const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => { if (filterValue.filter[index].questionType) { // Create a new array and copy existing values from SelectedFilter diff --git a/apps/web/app/lib/surveys/surveys.ts b/apps/web/app/lib/surveys/surveys.ts index 833cecf820..f26fd4983c 100644 --- a/apps/web/app/lib/surveys/surveys.ts +++ b/apps/web/app/lib/surveys/surveys.ts @@ -10,7 +10,11 @@ import { } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox"; import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter"; -import { TResponseFilterCriteria, TSurveyPersonAttributes } from "@formbricks/types/responses"; +import { + TResponseFilterCriteria, + TSurveyMetaFieldFilter, + TSurveyPersonAttributes, +} from "@formbricks/types/responses"; import { TSurveyQuestionType } from "@formbricks/types/surveys"; import { TSurvey } from "@formbricks/types/surveys"; import { TTag } from "@formbricks/types/tags"; @@ -42,7 +46,8 @@ const filterOptions = { export const generateQuestionAndFilterOptions = ( survey: TSurvey, environmentTags: TTag[] | undefined, - attributes: TSurveyPersonAttributes + attributes: TSurveyPersonAttributes, + meta: TSurveyMetaFieldFilter ): { questionOptions: QuestionOptions[]; questionFilterOptions: QuestionFilterOptions[]; @@ -137,19 +142,40 @@ export const generateQuestionAndFilterOptions = ( }); } - let metadataOptions: QuestionOption[] = []; + if (meta) { + questionOptions = [ + ...questionOptions, + { + header: OptionsType.META, + option: Object.keys(meta).map((m) => { + return { label: m, type: OptionsType.META, id: m }; + }), + }, + ]; + Object.keys(meta).forEach((m) => { + questionFilterOptions.push({ + type: "Meta", + filterOptions: ["Equals", "Not equals"], + filterComboBoxOptions: meta[m], + id: m, + }); + }); + } + + let languageQuestion: QuestionOption[] = []; + //can be extended to include more properties if (survey.languages?.length > 0) { - metadataOptions.push({ label: "Language", type: OptionsType.METADATA, id: "language" }); + languageQuestion.push({ label: "Language", type: OptionsType.OTHERS, id: "language" }); const languageOptions = survey.languages.map((sl) => sl.language.code); questionFilterOptions.push({ - type: "Metadata", + type: OptionsType.OTHERS, filterOptions: conditionOptions.languages, filterComboBoxOptions: languageOptions, id: "language", }); } - questionOptions = [...questionOptions, { header: OptionsType.METADATA, option: metadataOptions }]; + questionOptions = [...questionOptions, { header: OptionsType.OTHERS, option: languageQuestion }]; return { questionOptions: [...questionOptions], questionFilterOptions: [...questionFilterOptions] }; }; @@ -161,20 +187,22 @@ export const getFormattedFilters = ( dateRange: DateRange ): TResponseFilterCriteria => { const filters: TResponseFilterCriteria = {}; - const [questions, tags, attributes, metadata] = selectedFilter.filter.reduce( - (result: [FilterValue[], FilterValue[], FilterValue[], FilterValue[]], filter) => { + const [questions, tags, attributes, others, meta] = selectedFilter.filter.reduce( + (result: [FilterValue[], FilterValue[], FilterValue[], FilterValue[], FilterValue[]], filter) => { if (filter.questionType?.type === "Questions") { result[0].push(filter); } else if (filter.questionType?.type === "Tags") { result[1].push(filter); } else if (filter.questionType?.type === "Attributes") { result[2].push(filter); - } else if (filter.questionType?.type === "Metadata") { + } else if (filter.questionType?.type === "Other Filters") { result[3].push(filter); + } else if (filter.questionType?.type === "Meta") { + result[4].push(filter); } return result; }, - [[], [], [], []] + [[], [], [], [], []] ); // for completed responses @@ -328,6 +356,7 @@ export const getFormattedFilters = ( }); } + // for attributes if (attributes.length) { attributes.forEach(({ filterType, questionType }) => { if (!filters.personAttributes) filters.personAttributes = {}; @@ -345,24 +374,42 @@ export const getFormattedFilters = ( }); } - // for metadata - if (metadata.length) { - metadata.forEach(({ filterType, questionType }) => { - if (!filters.metadata) filters.metadata = {}; - + // for others + if (others.length) { + others.forEach(({ filterType, questionType }) => { + if (!filters.others) filters.others = {}; if (filterType.filterValue === "Equals") { - filters.metadata[questionType.label ?? ""] = { + filters.others[questionType.label ?? ""] = { op: "equals", value: filterType.filterComboBoxValue as string, }; } else if (filterType.filterValue === "Not equals") { - filters.metadata[questionType.label ?? ""] = { + filters.others[questionType.label ?? ""] = { op: "notEquals", value: filterType.filterComboBoxValue as string, }; } }); } + + // for meta + if (meta.length) { + meta.forEach(({ filterType, questionType }) => { + if (!filters.meta) filters.meta = {}; + if (filterType.filterValue === "Equals") { + filters.meta[questionType.label ?? ""] = { + op: "equals", + value: filterType.filterComboBoxValue as string, + }; + } else if (filterType.filterValue === "Not equals") { + filters.meta[questionType.label ?? ""] = { + op: "notEquals", + value: filterType.filterComboBoxValue as string, + }; + } + }); + } + return filters; }; diff --git a/apps/web/app/share/[sharingKey]/(analysis)/responses/page.tsx b/apps/web/app/share/[sharingKey]/(analysis)/responses/page.tsx index 23dfc7eaa1..e85cc9f07b 100644 --- a/apps/web/app/share/[sharingKey]/(analysis)/responses/page.tsx +++ b/apps/web/app/share/[sharingKey]/(analysis)/responses/page.tsx @@ -4,7 +4,7 @@ import { notFound } from "next/navigation"; import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@formbricks/lib/constants"; import { getEnvironment } from "@formbricks/lib/environment/service"; import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; -import { getResponseCountBySurveyId, getResponsePersonAttributes } from "@formbricks/lib/response/service"; +import { getResponseCountBySurveyId } from "@formbricks/lib/response/service"; import { getSurvey, getSurveyIdByResultShareKey } from "@formbricks/lib/survey/service"; import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service"; @@ -32,7 +32,6 @@ export default async function Page({ params }) { } const tags = await getTagsByEnvironmentId(environment.id); - const attributes = await getResponsePersonAttributes(surveyId); const totalResponseCount = await getResponseCountBySurveyId(surveyId); return ( @@ -44,7 +43,6 @@ export default async function Page({ params }) { webAppUrl={WEBAPP_URL} product={product} environmentTags={tags} - attributes={attributes} responsesPerPage={RESPONSES_PER_PAGE} totalResponseCount={totalResponseCount} /> diff --git a/apps/web/app/share/[sharingKey]/(analysis)/summary/page.tsx b/apps/web/app/share/[sharingKey]/(analysis)/summary/page.tsx index 7c757a17aa..8fc7b98694 100644 --- a/apps/web/app/share/[sharingKey]/(analysis)/summary/page.tsx +++ b/apps/web/app/share/[sharingKey]/(analysis)/summary/page.tsx @@ -4,9 +4,8 @@ import { notFound } from "next/navigation"; import { WEBAPP_URL } from "@formbricks/lib/constants"; import { getEnvironment } from "@formbricks/lib/environment/service"; import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; -import { getResponseCountBySurveyId, getResponsePersonAttributes } from "@formbricks/lib/response/service"; +import { getResponseCountBySurveyId } from "@formbricks/lib/response/service"; import { getSurvey, getSurveyIdByResultShareKey } from "@formbricks/lib/survey/service"; -import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service"; export default async function Page({ params }) { const surveyId = await getSurveyIdByResultShareKey(params.sharingKey); @@ -31,8 +30,6 @@ export default async function Page({ params }) { throw new Error("Product not found"); } - const tags = await getTagsByEnvironmentId(environment.id); - const attributes = await getResponsePersonAttributes(surveyId); const totalResponseCount = await getResponseCountBySurveyId(surveyId); return ( @@ -43,8 +40,6 @@ export default async function Page({ params }) { surveyId={survey.id} webAppUrl={WEBAPP_URL} product={product} - environmentTags={tags} - attributes={attributes} totalResponseCount={totalResponseCount} /> diff --git a/apps/web/app/share/[sharingKey]/action.ts b/apps/web/app/share/[sharingKey]/action.ts index cd04db0f14..70691f7eb8 100644 --- a/apps/web/app/share/[sharingKey]/action.ts +++ b/apps/web/app/share/[sharingKey]/action.ts @@ -1,7 +1,14 @@ "use server"; -import { getResponseCountBySurveyId, getResponses, getSurveySummary } from "@formbricks/lib/response/service"; +import { + getResponseCountBySurveyId, + getResponseMeta, + getResponsePersonAttributes, + getResponses, + getSurveySummary, +} from "@formbricks/lib/response/service"; import { getSurveyIdByResultShareKey } from "@formbricks/lib/survey/service"; +import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service"; import { AuthorizationError } from "@formbricks/types/errors"; import { TResponse, TResponseFilterCriteria, TSurveySummary } from "@formbricks/types/responses"; @@ -38,3 +45,19 @@ export const getResponseCountBySurveySharingKeyAction = async ( return await getResponseCountBySurveyId(surveyId, filterCriteria); }; + +export const getSurveyFilterDataBySurveySharingKeyAction = async ( + sharingKey: string, + environmentId: string +) => { + const surveyId = await getSurveyIdByResultShareKey(sharingKey); + if (!surveyId) throw new AuthorizationError("Not authorized"); + + const [tags, attributes, meta] = await Promise.all([ + getTagsByEnvironmentId(environmentId), + getResponsePersonAttributes(surveyId), + getResponseMeta(surveyId), + ]); + + return { environmentTags: tags, attributes, meta }; +}; diff --git a/packages/lib/response/service.ts b/packages/lib/response/service.ts index 4d133f7414..191b5bc155 100644 --- a/packages/lib/response/service.ts +++ b/packages/lib/response/service.ts @@ -14,6 +14,7 @@ import { TResponseInput, TResponseLegacyInput, TResponseUpdateInput, + TSurveyMetaFieldFilter, TSurveyPersonAttributes, TSurveySummary, ZResponse, @@ -457,6 +458,70 @@ export const getResponsePersonAttributes = async (surveyId: string): Promise => { + const meta = await unstable_cache( + async () => { + validateInputs([surveyId, ZId]); + + try { + const responseMeta = await prisma.response.findMany({ + where: { + surveyId: surveyId, + }, + select: { + meta: true, + }, + }); + + const meta: { [key: string]: Set } = {}; + + responseMeta.forEach((response) => { + Object.entries(response.meta).forEach(([key, value]) => { + // skip url + if (key === "url") return; + + // Handling nested objects (like userAgent) + if (typeof value === "object" && value !== null) { + Object.entries(value).forEach(([nestedKey, nestedValue]) => { + if (typeof nestedValue === "string" && nestedValue) { + if (!meta[nestedKey]) { + meta[nestedKey] = new Set(); + } + meta[nestedKey].add(nestedValue); + } + }); + } else if (typeof value === "string" && value) { + if (!meta[key]) { + meta[key] = new Set(); + } + meta[key].add(value); + } + }); + }); + + // Convert Set to Array + const result = Object.fromEntries( + Object.entries(meta).map(([key, valueSet]) => [key, Array.from(valueSet)]) + ); + + return result; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } + }, + [`getResponseMeta-${surveyId}`], + { + tags: [responseCache.tag.bySurveyId(surveyId)], + revalidate: SERVICES_REVALIDATION_INTERVAL, + } + )(); + + return meta; +}; + export const getResponses = async ( surveyId: string, page?: number, diff --git a/packages/lib/response/util.ts b/packages/lib/response/util.ts index 470309b8d2..13753b1920 100644 --- a/packages/lib/response/util.ts +++ b/packages/lib/response/util.ts @@ -38,6 +38,7 @@ export function calculateTtcTotal(ttc: TResponseTtc) { export const buildWhereClause = (filterCriteria?: TResponseFilterCriteria) => { const whereClause: Record[] = []; + // For finished if (filterCriteria?.finished !== undefined) { whereClause.push({ @@ -128,19 +129,56 @@ export const buildWhereClause = (filterCriteria?: TResponseFilterCriteria) => { }); } - // For Metadata - if (filterCriteria?.metadata) { - const metadata: Prisma.ResponseWhereInput[] = []; + // for meta + if (filterCriteria?.meta) { + const meta: Prisma.ResponseWhereInput[] = []; + + Object.entries(filterCriteria.meta).forEach(([key, val]) => { + let updatedKey: string[] = []; + if (["browser", "os", "device"].includes(key)) { + updatedKey = ["userAgent", key]; + } else { + updatedKey = [key]; + } - Object.entries(filterCriteria.metadata).forEach(([key, val]) => { switch (val.op) { case "equals": - metadata.push({ + meta.push({ + meta: { + path: updatedKey, + equals: val.value, + }, + }); + break; + case "notEquals": + meta.push({ + meta: { + path: updatedKey, + not: val.value, + }, + }); + break; + } + }); + + whereClause.push({ + AND: meta, + }); + } + + // For Language + if (filterCriteria?.others) { + const others: Prisma.ResponseWhereInput[] = []; + + Object.entries(filterCriteria.others).forEach(([key, val]) => { + switch (val.op) { + case "equals": + others.push({ [key.toLocaleLowerCase()]: val.value, }); break; case "notEquals": - metadata.push({ + others.push({ [key.toLocaleLowerCase()]: { not: val.value, }, @@ -149,7 +187,7 @@ export const buildWhereClause = (filterCriteria?: TResponseFilterCriteria) => { } }); whereClause.push({ - AND: metadata, + AND: others, }); } diff --git a/packages/types/responses.ts b/packages/types/responses.ts index 81fc78436b..42edded6f4 100644 --- a/packages/types/responses.ts +++ b/packages/types/responses.ts @@ -37,6 +37,10 @@ export const ZSurveyPersonAttributes = z.record(z.array(z.string())); export type TSurveyPersonAttributes = z.infer; +export const ZSurveyMetaFieldFilter = z.record(z.array(z.string())); + +export type TSurveyMetaFieldFilter = z.infer; + const ZResponseFilterCriteriaDataLessThan = z.object({ op: z.literal(ZSurveyLogicCondition.Values.lessThan), value: z.number(), @@ -158,7 +162,16 @@ export const ZResponseFilterCriteria = z.object({ }) .optional(), - metadata: z + others: z + .record( + z.object({ + op: z.enum(["equals", "notEquals"]), + value: z.union([z.string(), z.number()]), + }) + ) + .optional(), + + meta: z .record( z.object({ op: z.enum(["equals", "notEquals"]), diff --git a/packages/ui/Command/index.tsx b/packages/ui/Command/index.tsx index ad4f173b02..6690d9e4cd 100644 --- a/packages/ui/Command/index.tsx +++ b/packages/ui/Command/index.tsx @@ -61,7 +61,7 @@ const CommandList = React.forwardRef< >(({ className, ...props }, ref) => ( ));