Compare commits

...

1 Commits

Author SHA1 Message Date
Johannes
8ce3bae78b feat: add expand parameter to getResponses API for enriched data 2026-01-16 10:54:18 +00:00
6 changed files with 278 additions and 5 deletions

View 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;
};

View File

@@ -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(),
},

View File

@@ -93,4 +93,5 @@ export const responseFilter: TGetResponsesFilter = {
skip: 0,
sortBy: "createdAt",
order: "asc",
expand: [],
};

View File

@@ -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;
};

View File

@@ -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 });
},
});

View File

@@ -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) {