mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-04 18:49:39 -06:00
Compare commits
1 Commits
01-19-demo
...
poc-expand
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ce3bae78b |
176
apps/web/modules/api/v2/management/responses/lib/expand.ts
Normal file
176
apps/web/modules/api/v2/management/responses/lib/expand.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { z } from "zod";
|
||||
import { TResponseData, TResponseDataValue } from "@formbricks/types/responses";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
|
||||
// Supported expansion keys
|
||||
export const ZResponseExpand = z.enum(["choiceIds", "questionHeadlines"]);
|
||||
|
||||
export type TResponseExpand = z.infer<typeof ZResponseExpand>;
|
||||
|
||||
// Schema for the expand query parameter (comma-separated list)
|
||||
export const ZExpandParam = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (!val) return [];
|
||||
return val.split(",").map((s) => s.trim());
|
||||
})
|
||||
.pipe(z.array(ZResponseExpand));
|
||||
|
||||
export type TExpandParam = z.infer<typeof ZExpandParam>;
|
||||
|
||||
// Expanded response data structure for a single answer
|
||||
export type TExpandedValue = {
|
||||
value: TResponseDataValue;
|
||||
choiceIds?: string[];
|
||||
};
|
||||
|
||||
// Expanded response data structure
|
||||
export type TExpandedResponseData = {
|
||||
[questionId: string]: TExpandedValue;
|
||||
};
|
||||
|
||||
// Additional expansions that are added as separate fields
|
||||
export type TResponseExpansions = {
|
||||
questionHeadlines?: Record<string, string>;
|
||||
};
|
||||
|
||||
// Choice element types that support choiceIds expansion
|
||||
const CHOICE_ELEMENT_TYPES = ["multipleChoiceMulti", "multipleChoiceSingle", "ranking", "pictureSelection"];
|
||||
|
||||
/**
|
||||
* Check if an element type supports choice ID expansion
|
||||
*/
|
||||
export const isChoiceElement = (element: TSurveyElement): boolean => {
|
||||
return CHOICE_ELEMENT_TYPES.includes(element.type);
|
||||
};
|
||||
|
||||
/**
|
||||
* Type guard to check if element has choices property
|
||||
*/
|
||||
const hasChoices = (
|
||||
element: TSurveyElement
|
||||
): element is TSurveyElement & { choices: Array<{ id: string; label: Record<string, string> }> } => {
|
||||
return "choices" in element && Array.isArray(element.choices);
|
||||
};
|
||||
|
||||
/**
|
||||
* Type guard to check if element has headline property
|
||||
*/
|
||||
const hasHeadline = (
|
||||
element: TSurveyElement
|
||||
): element is TSurveyElement & { headline: Record<string, string> } => {
|
||||
return "headline" in element && typeof element.headline === "object";
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts choice IDs from a response value for choice-based questions
|
||||
* @param responseValue - The response value (string for single choice, array for multi choice)
|
||||
* @param element - The survey element containing choices
|
||||
* @param language - The language to match against (defaults to "default")
|
||||
* @returns Array of choice IDs
|
||||
*/
|
||||
export const extractChoiceIdsFromResponse = (
|
||||
responseValue: TResponseDataValue,
|
||||
element: TSurveyElement,
|
||||
language: string = "default"
|
||||
): string[] => {
|
||||
if (!isChoiceElement(element) || !responseValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Picture selection already stores IDs directly
|
||||
if (element.type === "pictureSelection") {
|
||||
if (Array.isArray(responseValue)) {
|
||||
return responseValue.filter((id): id is string => typeof id === "string");
|
||||
}
|
||||
return typeof responseValue === "string" ? [responseValue] : [];
|
||||
}
|
||||
|
||||
// For other choice types, we need to map labels to IDs
|
||||
if (!hasChoices(element)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const findChoiceByLabel = (label: string): string => {
|
||||
const choice = element.choices.find((c) => {
|
||||
// Try exact language match first
|
||||
if (c.label[language] === label) {
|
||||
return true;
|
||||
}
|
||||
// Fall back to checking all language values
|
||||
return Object.values(c.label).includes(label);
|
||||
});
|
||||
return choice?.id ?? "other";
|
||||
};
|
||||
|
||||
if (Array.isArray(responseValue)) {
|
||||
return responseValue.filter((v): v is string => typeof v === "string" && v !== "").map(findChoiceByLabel);
|
||||
}
|
||||
|
||||
if (typeof responseValue === "string") {
|
||||
return [findChoiceByLabel(responseValue)];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Expand response data with choice IDs
|
||||
* @param data - The response data object
|
||||
* @param survey - The survey definition
|
||||
* @param language - The language code for label matching
|
||||
* @returns Expanded response data with choice IDs
|
||||
*/
|
||||
export const expandWithChoiceIds = (
|
||||
data: TResponseData,
|
||||
survey: TSurvey,
|
||||
language: string = "default"
|
||||
): TExpandedResponseData => {
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
const expandedData: TExpandedResponseData = {};
|
||||
|
||||
for (const [questionId, value] of Object.entries(data)) {
|
||||
const element = elements.find((e) => e.id === questionId);
|
||||
|
||||
if (element && isChoiceElement(element)) {
|
||||
const choiceIds = extractChoiceIdsFromResponse(value, element, language);
|
||||
expandedData[questionId] = {
|
||||
value,
|
||||
...(choiceIds.length > 0 && { choiceIds }),
|
||||
};
|
||||
} else {
|
||||
expandedData[questionId] = { value };
|
||||
}
|
||||
}
|
||||
|
||||
return expandedData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate question headlines map
|
||||
* @param data - The response data object
|
||||
* @param survey - The survey definition
|
||||
* @param language - The language code for localization
|
||||
* @returns Record mapping question IDs to their headlines
|
||||
*/
|
||||
export const getQuestionHeadlines = (
|
||||
data: TResponseData,
|
||||
survey: TSurvey,
|
||||
language: string = "default"
|
||||
): Record<string, string> => {
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
const headlines: Record<string, string> = {};
|
||||
|
||||
for (const questionId of Object.keys(data)) {
|
||||
const element = elements.find((e) => e.id === questionId);
|
||||
if (element && hasHeadline(element)) {
|
||||
headlines[questionId] = getLocalizedValue(element.headline, language);
|
||||
}
|
||||
}
|
||||
|
||||
return headlines;
|
||||
};
|
||||
@@ -11,7 +11,8 @@ import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/type
|
||||
export const getResponsesEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getResponses",
|
||||
summary: "Get responses",
|
||||
description: "Gets responses from the database.",
|
||||
description:
|
||||
"Gets responses from the database. Use the `expand` parameter to enrich response data with additional information like choice IDs (for language-agnostic processing) or question headlines.",
|
||||
requestParams: {
|
||||
query: ZGetResponsesFilter.sourceType(),
|
||||
},
|
||||
|
||||
@@ -93,4 +93,5 @@ export const responseFilter: TGetResponsesFilter = {
|
||||
skip: 0,
|
||||
sortBy: "createdAt",
|
||||
order: "asc",
|
||||
expand: [],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Response } from "@prisma/client";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TExpandParam,
|
||||
TExpandedResponseData,
|
||||
TResponseExpansions,
|
||||
expandWithChoiceIds,
|
||||
getQuestionHeadlines,
|
||||
} from "./expand";
|
||||
|
||||
export type TTransformedResponse = Omit<Response, "data"> & {
|
||||
data: TResponseData | TExpandedResponseData;
|
||||
expansions?: TResponseExpansions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform a response based on requested expansions
|
||||
* @param response - The raw response from the database
|
||||
* @param survey - The survey definition
|
||||
* @param expand - Array of expansion keys to apply
|
||||
* @returns Transformed response with requested expansions
|
||||
*/
|
||||
export const transformResponse = (
|
||||
response: Response,
|
||||
survey: TSurvey,
|
||||
expand: TExpandParam
|
||||
): TTransformedResponse => {
|
||||
const language = response.language ?? "default";
|
||||
const data = response.data as TResponseData;
|
||||
|
||||
let transformedData: TResponseData | TExpandedResponseData = data;
|
||||
const expansions: TResponseExpansions = {};
|
||||
|
||||
// Apply choiceIds expansion
|
||||
if (expand.includes("choiceIds")) {
|
||||
transformedData = expandWithChoiceIds(data, survey, language);
|
||||
}
|
||||
|
||||
// Apply questionHeadlines expansion
|
||||
if (expand.includes("questionHeadlines")) {
|
||||
expansions.questionHeadlines = getQuestionHeadlines(data, survey, language);
|
||||
}
|
||||
|
||||
return {
|
||||
...response,
|
||||
data: transformedData,
|
||||
...(Object.keys(expansions).length > 0 && { expansions }),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform multiple responses with caching of survey lookups
|
||||
* @param responses - Array of raw responses from the database
|
||||
* @param expand - Array of expansion keys to apply
|
||||
* @param getSurvey - Function to fetch survey by ID
|
||||
* @returns Array of transformed responses
|
||||
*/
|
||||
export const transformResponses = async (
|
||||
responses: Response[],
|
||||
expand: TExpandParam,
|
||||
getSurvey: (surveyId: string) => Promise<TSurvey | null>
|
||||
): Promise<TTransformedResponse[]> => {
|
||||
if (expand.length === 0) {
|
||||
// No expansion requested, return as-is
|
||||
return responses as TTransformedResponse[];
|
||||
}
|
||||
|
||||
// Cache surveys to avoid duplicate lookups
|
||||
const surveyCache = new Map<string, TSurvey | null>();
|
||||
|
||||
const transformed = await Promise.all(
|
||||
responses.map(async (response) => {
|
||||
let survey = surveyCache.get(response.surveyId);
|
||||
|
||||
if (survey === undefined) {
|
||||
survey = await getSurvey(response.surveyId);
|
||||
surveyCache.set(response.surveyId, survey);
|
||||
}
|
||||
|
||||
if (!survey) {
|
||||
// Survey not found, return response unchanged
|
||||
return response as TTransformedResponse;
|
||||
}
|
||||
|
||||
return transformResponse(response, survey, expand);
|
||||
})
|
||||
);
|
||||
|
||||
return transformed;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Response } from "@prisma/client";
|
||||
import { NextRequest } from "next/server";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
@@ -13,6 +13,7 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
|
||||
import { transformResponses } from "./lib/transform";
|
||||
|
||||
export const GET = async (request: NextRequest) =>
|
||||
authenticatedApiClient({
|
||||
@@ -34,16 +35,17 @@ export const GET = async (request: NextRequest) =>
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
|
||||
const environmentResponses: Response[] = [];
|
||||
const res = await getResponses(environmentIds, query);
|
||||
|
||||
if (!res.ok) {
|
||||
return handleApiError(request, res.error);
|
||||
}
|
||||
|
||||
environmentResponses.push(...res.data.data);
|
||||
// Transform responses if expansion is requested
|
||||
const expand = query.expand ?? [];
|
||||
const transformedResponses = await transformResponses(res.data.data, expand, getSurvey);
|
||||
|
||||
return responses.successResponse({ data: environmentResponses });
|
||||
return responses.successResponse({ data: transformedResponses, meta: res.data.meta });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { ZResponse } from "@formbricks/database/zod/responses";
|
||||
import { ZExpandParam } from "@/modules/api/v2/management/responses/lib/expand";
|
||||
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
|
||||
export const ZGetResponsesFilter = ZGetFilter.extend({
|
||||
surveyId: z.string().cuid2().optional(),
|
||||
contactId: z.string().optional(),
|
||||
expand: ZExpandParam,
|
||||
}).refine(
|
||||
(data) => {
|
||||
if (data.startDate && data.endDate && data.startDate > data.endDate) {
|
||||
|
||||
Reference in New Issue
Block a user