diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AddIntegrationModal.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AddIntegrationModal.tsx index 2d4a15740082..2b3e6584a5c3 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AddIntegrationModal.tsx @@ -122,6 +122,8 @@ const renderElementSelection = ({ setIncludeMetadata, includeCreatedAt, setIncludeCreatedAt, + includeContactAttributes, + setIncludeContactAttributes, }: { t: TFunction; selectedSurvey: TSurvey; @@ -135,6 +137,8 @@ const renderElementSelection = ({ setIncludeMetadata: (value: boolean) => void; includeCreatedAt: boolean; setIncludeCreatedAt: (value: boolean) => void; + includeContactAttributes: boolean; + setIncludeContactAttributes: (value: boolean) => void; }) => { return (
@@ -164,6 +168,8 @@ const renderElementSelection = ({ setIncludeMetadata={setIncludeMetadata} includeCreatedAt={includeCreatedAt} setIncludeCreatedAt={setIncludeCreatedAt} + includeContactAttributes={includeContactAttributes} + setIncludeContactAttributes={setIncludeContactAttributes} />
); @@ -187,6 +193,7 @@ export const AddIntegrationModal = ({ const [includeHiddenFields, setIncludeHiddenFields] = useState(false); const [includeMetadata, setIncludeMetadata] = useState(false); const [includeCreatedAt, setIncludeCreatedAt] = useState(true); + const [includeContactAttributes, setIncludeContactAttributes] = useState(false); const airtableIntegrationData: TIntegrationAirtableInput = { type: "airtable", config: { @@ -205,6 +212,7 @@ export const AddIntegrationModal = ({ setIncludeHiddenFields(!!defaultData.includeHiddenFields); setIncludeMetadata(!!defaultData.includeMetadata); setIncludeCreatedAt(!!defaultData.includeCreatedAt); + setIncludeContactAttributes(!!defaultData.includeContactAttributes); } else { reset(); } @@ -259,6 +267,7 @@ export const AddIntegrationModal = ({ includeHiddenFields, includeMetadata, includeCreatedAt, + includeContactAttributes, }; if (isEditMode) { @@ -446,6 +455,8 @@ export const AddIntegrationModal = ({ setIncludeMetadata, includeCreatedAt, setIncludeCreatedAt, + includeContactAttributes, + setIncludeContactAttributes, })} diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/ManageIntegration.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/ManageIntegration.tsx index 07dd84bf5638..30bdc9710f4a 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/ManageIntegration.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/ManageIntegration.tsx @@ -147,6 +147,7 @@ export const ManageIntegration = ({ includeHiddenFields: !!data.includeHiddenFields, includeMetadata: !!data.includeMetadata, includeCreatedAt: !!data.includeCreatedAt, + includeContactAttributes: !!data.includeContactAttributes, index, }); setIsModalOpen(true); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/lib/types.ts b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/lib/types.ts index 1ab2b0197e19..390dd69c2c1b 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/lib/types.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/lib/types.ts @@ -7,4 +7,5 @@ export type IntegrationModalInputs = { includeHiddenFields: boolean; includeMetadata: boolean; includeCreatedAt: boolean; + includeContactAttributes: boolean; }; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/components/AddIntegrationModal.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/components/AddIntegrationModal.tsx index cae241dde725..6cf6e6960e6e 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/components/AddIntegrationModal.tsx @@ -81,6 +81,7 @@ export const AddIntegrationModal = ({ const [includeHiddenFields, setIncludeHiddenFields] = useState(false); const [includeMetadata, setIncludeMetadata] = useState(false); const [includeCreatedAt, setIncludeCreatedAt] = useState(true); + const [includeContactAttributes, setIncludeContactAttributes] = useState(false); const googleSheetIntegrationData: TIntegrationGoogleSheetsInput = { type: "googleSheets", config: { @@ -115,6 +116,7 @@ export const AddIntegrationModal = ({ setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields); setIncludeMetadata(!!selectedIntegration.includeMetadata); setIncludeCreatedAt(!!selectedIntegration.includeCreatedAt); + setIncludeContactAttributes(!!selectedIntegration.includeContactAttributes); return; } else { setSpreadsheetUrl(""); @@ -172,6 +174,7 @@ export const AddIntegrationModal = ({ integrationData.includeHiddenFields = includeHiddenFields; integrationData.includeMetadata = includeMetadata; integrationData.includeCreatedAt = includeCreatedAt; + integrationData.includeContactAttributes = includeContactAttributes; if (selectedIntegration) { // update action googleSheetIntegrationData.config.data[selectedIntegration.index] = integrationData; @@ -331,6 +334,8 @@ export const AddIntegrationModal = ({ setIncludeMetadata={setIncludeMetadata} includeCreatedAt={includeCreatedAt} setIncludeCreatedAt={setIncludeCreatedAt} + includeContactAttributes={includeContactAttributes} + setIncludeContactAttributes={setIncludeContactAttributes} /> )} diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/AddIntegrationModal.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/AddIntegrationModal.tsx index 2eb706c98583..d074b379dd77 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/AddIntegrationModal.tsx @@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; +import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; import { TIntegrationInput } from "@formbricks/types/integration"; import { TIntegrationNotion, @@ -46,6 +47,7 @@ interface AddIntegrationModalProps { notionIntegration: TIntegrationNotion; databases: TIntegrationNotionDatabase[]; selectedIntegration: (TIntegrationNotionConfigData & { index: number }) | null; + contactAttributeKeys: TContactAttributeKey[]; } export const AddIntegrationModal = ({ @@ -56,6 +58,7 @@ export const AddIntegrationModal = ({ notionIntegration, databases, selectedIntegration, + contactAttributeKeys, }: AddIntegrationModalProps) => { const { t } = useTranslation(); const { handleSubmit } = useForm(); @@ -146,10 +149,15 @@ export const AddIntegrationModal = ({ type: TSurveyElementTypeEnum.Date, }, ]; + const personAttributes = contactAttributeKeys.map((attributeKey) => ({ + id: `person.${attributeKey.key}`, + name: `${t("common.person")}: ${attributeKey.name ?? attributeKey.key}`, + type: TSurveyElementTypeEnum.OpenText, + })); - return [...mappedElements, ...variables, ...hiddenFields, ...Metadata, ...createdAt]; + return [...mappedElements, ...variables, ...hiddenFields, ...Metadata, ...createdAt, ...personAttributes]; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedSurvey?.id]); + }, [selectedSurvey?.id, contactAttributeKeys]); useEffect(() => { if (selectedIntegration) { diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/NotionWrapper.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/NotionWrapper.tsx index b573a13f7c87..9888d3717d60 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/NotionWrapper.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/NotionWrapper.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; import { TIntegrationNotion, TIntegrationNotionConfigData, @@ -22,6 +23,7 @@ interface NotionWrapperProps { surveys: TSurvey[]; databasesArray: TIntegrationNotionDatabase[]; locale: TUserLocale; + contactAttributeKeys: TContactAttributeKey[]; } export const NotionWrapper = ({ @@ -32,6 +34,7 @@ export const NotionWrapper = ({ surveys, databasesArray, locale, + contactAttributeKeys, }: NotionWrapperProps) => { const [isModalOpen, setIsModalOpen] = useState(false); const [isConnected, setIsConnected] = useState( @@ -61,6 +64,7 @@ export const NotionWrapper = ({ notionIntegration={notionIntegration} databases={databasesArray} selectedIntegration={selectedIntegration} + contactAttributeKeys={contactAttributeKeys} /> }) => { const { isReadOnly, session, workspace } = await getWorkspaceAuth(params.workspaceId); - const [surveys, notionIntegration, locale] = await Promise.all([ + const [surveys, notionIntegration, locale, contactAttributeKeys] = await Promise.all([ getSurveys(workspace.id), getIntegrationByType(workspace.id, "notion"), getUserLocale(session.user.id), + getContactAttributeKeys(workspace.id), ]); let databasesArray: TIntegrationNotionDatabase[] = []; @@ -58,6 +60,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => { webAppUrl={WEBAPP_URL} databasesArray={databasesArray} locale={locale ?? DEFAULT_LOCALE} + contactAttributeKeys={contactAttributeKeys} /> ); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/components/AddChannelMappingModal.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/components/AddChannelMappingModal.tsx index d60b71e1b025..34a291a9b576 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/components/AddChannelMappingModal.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/components/AddChannelMappingModal.tsx @@ -65,6 +65,7 @@ export const AddChannelMappingModal = ({ const [includeHiddenFields, setIncludeHiddenFields] = useState(false); const [includeMetadata, setIncludeMetadata] = useState(false); const [includeCreatedAt, setIncludeCreatedAt] = useState(true); + const [includeContactAttributes, setIncludeContactAttributes] = useState(false); const existingIntegrationData = slackIntegration?.config?.data; const slackIntegrationData: TIntegrationSlackInput = { type: "slack", @@ -104,6 +105,7 @@ export const AddChannelMappingModal = ({ setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields); setIncludeMetadata(!!selectedIntegration.includeMetadata); setIncludeCreatedAt(!!selectedIntegration.includeCreatedAt); + setIncludeContactAttributes(!!selectedIntegration.includeContactAttributes); return; } resetForm(); @@ -137,6 +139,7 @@ export const AddChannelMappingModal = ({ includeHiddenFields, includeMetadata, includeCreatedAt, + includeContactAttributes, }; if (selectedIntegration) { // update action @@ -324,6 +327,8 @@ export const AddChannelMappingModal = ({ setIncludeMetadata={setIncludeMetadata} includeCreatedAt={includeCreatedAt} setIncludeCreatedAt={setIncludeCreatedAt} + includeContactAttributes={includeContactAttributes} + setIncludeContactAttributes={setIncludeContactAttributes} /> )} diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/responses/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/responses/page.tsx index 3ab77dd404fb..33590214cb9e 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/responses/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/responses/page.tsx @@ -2,6 +2,7 @@ import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/er import { SurveyAnalysisNavigation } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation"; import { ResponsePage } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage"; import { SurveyAnalysisCTA } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA"; +import { getAISmartToolsUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service"; import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD, @@ -60,6 +61,9 @@ const Page = async (props: { params: Promise<{ workspaceId: string; surveyId: st const isQuotasAllowed = await getIsQuotasEnabled(organization.id); const quotas = isQuotasAllowed ? await getQuotas(survey.id) : []; + const aiConfig = await getOrganizationAIConfig(organization.id); + const aiUnavailableReason = getAISmartToolsUnavailableReason(aiConfig) ?? null; + // Fetch initial responses on the server to prevent duplicate client-side fetch const initialResponses = await getResponses(params.surveyId, RESPONSES_PER_PAGE, 0); @@ -78,6 +82,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string; surveyId: st isFormbricksCloud={IS_FORMBRICKS_CLOUD} isStorageConfigured={IS_STORAGE_CONFIGURED} enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL} + aiUnavailableReason={aiUnavailableReason} /> }> diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/actions.ts b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/actions.ts index b6002cd8d00e..5d322cc0b25d 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/actions.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/actions.ts @@ -1,15 +1,26 @@ "use server"; import { z } from "zod"; +import { prisma } from "@formbricks/database"; import { ZId } from "@formbricks/types/common"; import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { getEmailTemplateHtml } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate"; +import { + generateExampleResponseDataset, + toExampleResponseInput, +} from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/example-responses"; +import { createResponseWithQuotaEvaluation } from "@/app/api/v1/client/[workspaceId]/responses/lib/response"; +import { assertOrganizationAIConfigured } from "@/lib/ai/service"; import { capturePostHogEvent } from "@/lib/posthog"; +import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service"; +import { addTagToRespone } from "@/lib/tagOnResponse/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { convertToCsv } from "@/lib/utils/file-conversion"; import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; @@ -110,6 +121,109 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur }) ); +const ZGenerateExampleResponsesAction = z.object({ + surveyId: ZId, +}); + +// Generates a small set of LLM-authored example responses for a survey that +// has no real responses yet. Server-side gates: caller must have write access, +// the org's AI smart-tools feature must be enabled and entitled, and the +// survey must currently have zero responses (button is also hidden client-side +// when responseCount > 0, but we re-check here so a stale tab can't insert +// noise into a live survey). +export const generateExampleResponsesAction = authenticatedActionClient + .inputSchema(ZGenerateExampleResponsesAction) + .action(async ({ ctx, parsedInput }) => { + // Per-user limit (1 per minute). Closes the multi-click race window where + // two clicks fired before the first LLM call returns could both pass the + // responseCount === 0 check, and bounds a single user's overall LLM spend. + await applyRateLimit(rateLimitConfigs.actions.generateExampleResponses, ctx.user.id); + + const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); + const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId); + + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "workspaceTeam", + minPermission: "readWrite", + workspaceId, + }, + ], + }); + + // Throws OperationNotAllowedError if AI is unentitled, disabled, or + // the instance isn't configured (env vars). Same gating helper that the + // existing AI text endpoint uses. + await assertOrganizationAIConfigured(organizationId); + + const survey = await getSurvey(parsedInput.surveyId); + if (!survey) { + throw new ResourceNotFoundError("Survey", parsedInput.surveyId); + } + + const existingCount = await getResponseCountBySurveyId(parsedInput.surveyId); + if (existingCount > 0) { + throw new OperationNotAllowedError( + "Example responses can only be generated for a survey that has no responses yet." + ); + } + + const generatedDataset = await generateExampleResponseDataset({ survey, organizationId }); + if (generatedDataset.responses.length === 0) { + throw new InvalidInputError( + "This survey doesn't contain any question types we can synthesize answers for yet." + ); + } + + // Tag every synthetic response so users can tell them apart from real ones + // in the responses list. Upsert handles the case where a previous run (or a + // user) already created the tag in this workspace. + const aiTag = await prisma.tag.upsert({ + where: { workspaceId_name: { workspaceId, name: generatedDataset.tagName } }, + create: { workspaceId, name: generatedDataset.tagName }, + update: {}, + }); + + for (const item of generatedDataset.responses) { + // Each response gets its own Display so the dashboard's "displays" count + // and completion-rate calc line up with the response row. Backdate the + // display to the same moment as the response — the assertDisplayOwnership + // check inside createResponse runs against the matching surveyId. + const display = await prisma.display.create({ + data: { survey: { connect: { id: survey.id } }, createdAt: item.createdAt }, + select: { id: true }, + }); + + const response = await createResponseWithQuotaEvaluation( + toExampleResponseInput(survey.id, workspaceId, item, display.id) + ); + await addTagToRespone(response.id, aiTag.id); + // `createResponse` ignores caller-supplied createdAt; backdate after the + // fact so the responses-over-time chart shows a realistic spread. + await prisma.response.update({ + where: { id: response.id }, + data: { createdAt: item.createdAt }, + }); + } + + // Extra view-only displays simulate respondents who saw the survey but + // didn't submit. Without these the completion rate would read 100%. + if (generatedDataset.displays.length > 0) { + await prisma.display.createMany({ + data: generatedDataset.displays.map(({ createdAt }) => ({ surveyId: survey.id, createdAt })), + }); + } + + return { createdCount: generatedDataset.responses.length }; + }); + const ZGetEmailHtmlAction = z.object({ surveyId: ZId, }); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx index 989ba817101a..16ff8a0c2c4e 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx @@ -1,6 +1,6 @@ "use client"; -import { BellRing, Eye, ListRestart, RefreshCcwIcon, SquarePenIcon } from "lucide-react"; +import { BellRing, Eye, ListRestart, RefreshCcwIcon, SquarePenIcon, Wand2 } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; import toast from "react-hot-toast"; @@ -13,6 +13,7 @@ import { SuccessMessage } from "@/app/(app)/workspaces/[workspaceId]/surveys/[su import { ShareSurveyModal } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal"; import { SurveyStatusDropdown } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/SurveyStatusDropdown"; import { useSurvey } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/context/survey-context"; +import type { TAIUnavailableReason } from "@/lib/ai/service"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog"; import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId"; @@ -20,7 +21,7 @@ import { copySurveyToOtherWorkspaceAction } from "@/modules/survey/list/actions" import { Button } from "@/modules/ui/components/button"; import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal"; import { IconBar } from "@/modules/ui/components/iconbar"; -import { resetSurveyAction } from "../actions"; +import { generateExampleResponsesAction, resetSurveyAction } from "../actions"; interface SurveyAnalysisCTAProps { isReadOnly: boolean; @@ -32,6 +33,7 @@ interface SurveyAnalysisCTAProps { isFormbricksCloud: boolean; isStorageConfigured: boolean; enterpriseLicenseRequestFormUrl: string; + aiUnavailableReason: TAIUnavailableReason | null; } interface ModalState { @@ -49,6 +51,7 @@ export const SurveyAnalysisCTA = ({ isFormbricksCloud, isStorageConfigured, enterpriseLicenseRequestFormUrl, + aiUnavailableReason, }: SurveyAnalysisCTAProps) => { const { t } = useTranslation(); const router = useRouter(); @@ -62,6 +65,7 @@ export const SurveyAnalysisCTA = ({ const [isResetModalOpen, setIsResetModalOpen] = useState(false); const [isResetting, setIsResetting] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); + const [isGeneratingExamples, setIsGeneratingExamples] = useState(false); const { workspace } = useWorkspaceContext(); const { survey } = useSurvey(); @@ -142,6 +146,7 @@ export const SurveyAnalysisCTA = ({ }) ); router.refresh(); + await refreshAnalysisData(); } else { const errorMessage = getFormattedErrorMessage(result); toast.error(errorMessage); @@ -150,6 +155,47 @@ export const SurveyAnalysisCTA = ({ setIsResetModalOpen(false); }; + const handleGenerateExampleResponses = async () => { + if (isGeneratingExamples) return; + setIsGeneratingExamples(true); + const loadingToastId = toast.loading(t("workspace.surveys.summary.generating_example_responses")); + try { + const result = await generateExampleResponsesAction({ surveyId: survey.id }); + if (result?.data) { + toast.success( + t("workspace.surveys.summary.example_responses_generated_successfully", { + count: result.data.createdCount, + }), + { id: loadingToastId } + ); + router.refresh(); + } else { + const errorMessage = getFormattedErrorMessage(result); + toast.error(errorMessage || t("workspace.surveys.summary.example_responses_generation_failed"), { + id: loadingToastId, + }); + } + } finally { + setIsGeneratingExamples(false); + } + }; + + const exampleResponsesTooltip = (() => { + if (isGeneratingExamples) { + return t("workspace.surveys.summary.generating_example_responses"); + } + if (aiUnavailableReason === "not_in_plan") { + return t("workspace.surveys.summary.generate_example_responses_locked_plan"); + } + if (aiUnavailableReason === "not_enabled") { + return t("workspace.surveys.summary.generate_example_responses_locked_disabled"); + } + if (aiUnavailableReason === "instance_not_configured") { + return t("workspace.surveys.summary.generate_example_responses_locked_instance"); + } + return t("workspace.surveys.summary.generate_example_responses"); + })(); + const iconActions = [ { icon: RefreshCcwIcon, @@ -185,6 +231,13 @@ export const SurveyAnalysisCTA = ({ }, isVisible: survey.type === "link", }, + { + icon: Wand2, + tooltip: exampleResponsesTooltip, + onClick: handleGenerateExampleResponses, + disabled: isGeneratingExamples || aiUnavailableReason !== null, + isVisible: !isReadOnly && responseCount === 0, + }, { icon: ListRestart, tooltip: t("workspace.surveys.summary.reset_survey"), diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/example-responses.test.ts b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/example-responses.test.ts new file mode 100644 index 000000000000..dc514589f8b6 --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/example-responses.test.ts @@ -0,0 +1,557 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; +import { type TSurvey } from "@formbricks/types/surveys/types"; +import { + EXAMPLE_AI_GENERATED_TAG_NAME, + EXAMPLE_IMPRESSION_ONLY_COUNT, + EXAMPLE_RESPONSE_COUNT, + buildExampleImpressionTimestamps, + buildExampleResponsesSchema, + generateExampleResponseDataset, + generateExampleResponses, + toExampleResponseInput, +} from "./example-responses"; + +const mocks = vi.hoisted(() => ({ + generateOrganizationAIObject: vi.fn(), +})); + +vi.mock("server-only", () => ({})); + +vi.mock("@/lib/ai/service", () => ({ + generateOrganizationAIObject: mocks.generateOrganizationAIObject, +})); + +const i18n = (s: string) => ({ default: s }); + +const baseQuestion = { + required: true, + headline: i18n("hi"), + subheader: undefined, +}; + +const makeSurvey = (questions: TSurvey["questions"]): TSurvey => + ({ + id: "survey_1", + name: "Demo Survey", + welcomeCard: { enabled: false, headline: i18n("Welcome") }, + endings: [{ id: "ending_1" }], + blocks: [{ id: "block_1", name: "Block 1", elements: questions }], + questions: [], + }) as unknown as TSurvey; + +const makeLegacySurvey = (questions: TSurvey["questions"]): TSurvey => + ({ + id: "survey_1", + name: "Demo Survey", + welcomeCard: { enabled: false, headline: i18n("Welcome") }, + blocks: [], + questions, + }) as unknown as TSurvey; + +const mockOpenTextAnswers = (survey: TSurvey): void => { + const { ctx } = buildExampleResponsesSchema(survey); + vi.mocked(mocks.generateOrganizationAIObject).mockResolvedValue({ + object: { + responses: Array.from({ length: EXAMPLE_RESPONSE_COUNT }, (_, index) => ({ + rowId: `row_${index}`, + answers: Object.fromEntries(ctx.openTextElementIds.map((id) => [id, `Open text answer ${index}`])), + })), + }, + }); +}; + +describe("buildExampleResponsesSchema", () => { + beforeEach(() => vi.clearAllMocks()); + + test("collects supported elements and tracks open-text ids for the text-only LLM schema", () => { + const survey = makeSurvey([ + { ...baseQuestion, id: "q_text", type: TSurveyElementTypeEnum.OpenText }, + { ...baseQuestion, id: "q_rating", type: TSurveyElementTypeEnum.Rating, scale: "number", range: 5 }, + { + ...baseQuestion, + id: "q_choice", + type: TSurveyElementTypeEnum.MultipleChoiceSingle, + choices: [ + { id: "c1", label: i18n("Founder") }, + { id: "c2", label: i18n("Engineer") }, + ], + }, + { + ...baseQuestion, + id: "q_file", + type: TSurveyElementTypeEnum.FileUpload, + allowMultipleFiles: false, + }, + ] as unknown as TSurvey["questions"]); + + const { ctx, schema } = buildExampleResponsesSchema(survey); + + expect(ctx.supportedElementIds).toEqual(["q_text", "q_rating", "q_choice"]); + expect(ctx.openTextElementIds).toEqual(["q_text"]); + expect( + schema.safeParse({ + responses: Array.from({ length: EXAMPLE_RESPONSE_COUNT }, (_, index) => ({ + rowId: `row_${index}`, + answers: { q_text: `Answer ${index}` }, + })), + }).success + ).toBe(true); + }); + + test("reads legacy survey.questions when blocks are empty", () => { + const survey = makeLegacySurvey([ + { ...baseQuestion, id: "q_legacy", type: TSurveyElementTypeEnum.OpenText }, + ] as unknown as TSurvey["questions"]); + + const { ctx } = buildExampleResponsesSchema(survey); + + expect(ctx.supportedElementIds).toEqual(["q_legacy"]); + expect(ctx.openTextElementIds).toEqual(["q_legacy"]); + }); + + test("drops elements that cannot be generated meaningfully", () => { + const survey = makeSurvey([ + { + ...baseQuestion, + id: "q_empty_choice", + type: TSurveyElementTypeEnum.MultipleChoiceSingle, + choices: [], + }, + { + ...baseQuestion, + id: "q_empty_matrix", + type: TSurveyElementTypeEnum.Matrix, + rows: [{ id: "r1", label: { default: "" } }], + columns: [{ id: "c1", label: i18n("Good") }], + }, + { + ...baseQuestion, + id: "q_empty_address", + type: TSurveyElementTypeEnum.Address, + addressLine1: { show: false, required: false, placeholder: i18n("") }, + addressLine2: { show: false, required: false, placeholder: i18n("") }, + city: { show: false, required: false, placeholder: i18n("") }, + state: { show: false, required: false, placeholder: i18n("") }, + zip: { show: false, required: false, placeholder: i18n("") }, + country: { show: false, required: false, placeholder: i18n("") }, + }, + ] as unknown as TSurvey["questions"]); + + const { ctx } = buildExampleResponsesSchema(survey); + + expect(ctx.supportedElementIds).toEqual([]); + expect(ctx.openTextElementIds).toEqual([]); + }); +}); + +describe("generateExampleResponseDataset", () => { + beforeEach(() => vi.clearAllMocks()); + + test("returns an empty dataset when the survey has no supported question types", async () => { + const survey = makeSurvey([ + { + ...baseQuestion, + id: "q_file", + type: TSurveyElementTypeEnum.FileUpload, + allowMultipleFiles: false, + }, + ] as unknown as TSurvey["questions"]); + + const result = await generateExampleResponseDataset({ survey, organizationId: "org_1" }); + + expect(result).toEqual({ responses: [], displays: [], tagName: EXAMPLE_AI_GENERATED_TAG_NAME }); + expect(mocks.generateOrganizationAIObject).not.toHaveBeenCalled(); + }); + + test("generates closed-ended answers locally with lumpy distributions and no LLM call", async () => { + const survey = makeSurvey([ + { + ...baseQuestion, + id: "q_role", + type: TSurveyElementTypeEnum.MultipleChoiceSingle, + choices: [ + { id: "c1", label: i18n("Founder") }, + { id: "c2", label: i18n("Executive") }, + { id: "c3", label: i18n("Product Manager") }, + { id: "c4", label: i18n("Product Owner") }, + { id: "c5", label: i18n("Software Engineer") }, + ], + }, + { ...baseQuestion, id: "q_rating", type: TSurveyElementTypeEnum.Rating, scale: "number", range: 5 }, + ] as unknown as TSurvey["questions"]); + + const result = await generateExampleResponseDataset({ survey, organizationId: "org_1" }); + const roleCounts = result.responses.reduce>((acc, response) => { + const role = response.data.q_role; + if (typeof role === "string") acc[role] = (acc[role] ?? 0) + 1; + return acc; + }, {}); + + expect(result.responses).toHaveLength(EXAMPLE_RESPONSE_COUNT); + expect(result.displays).toHaveLength(EXAMPLE_IMPRESSION_ONLY_COUNT); + expect(result.tagName).toBe(EXAMPLE_AI_GENERATED_TAG_NAME); + expect(Object.values(roleCounts)).toContain(4); + expect(Object.values(roleCounts)).not.toEqual([2, 2, 2, 2, 2]); + expect(mocks.generateOrganizationAIObject).not.toHaveBeenCalled(); + }); + + test("asks the LLM only for open-text answers and merges them with planned answers", async () => { + const survey = makeSurvey([ + { ...baseQuestion, id: "q_text", type: TSurveyElementTypeEnum.OpenText }, + { ...baseQuestion, id: "q_nps", type: TSurveyElementTypeEnum.NPS }, + ] as unknown as TSurvey["questions"]); + mockOpenTextAnswers(survey); + + const result = await generateExampleResponseDataset({ survey, organizationId: "org_1" }); + + expect(result.responses[2].data.q_text).toBe("Open text answer 2"); + expect(result.responses[2].data.q_nps).toBeTypeOf("number"); + expect(mocks.generateOrganizationAIObject).toHaveBeenCalledTimes(1); + const call = vi.mocked(mocks.generateOrganizationAIObject).mock.calls[0][0]; + expect(call.organizationId).toBe("org_1"); + expect(call.system).toContain("simulating real survey respondents"); + expect(call.system).toContain("non-empty string"); + expect(call.prompt).toContain("requestedOpenTextAnswers"); + expect(call.prompt).toContain("plannedAnswers"); + expect(call.prompt).toContain("hi"); + expect(call.prompt).not.toContain("Generate 10 diverse example responses"); + }); + + test("uses question-aware fallback text when the LLM omits open-text answers", async () => { + const survey = makeSurvey([ + { + ...baseQuestion, + id: "q_audience", + type: TSurveyElementTypeEnum.OpenText, + headline: i18n("What type of people would most benefit from this?"), + }, + { + ...baseQuestion, + id: "q_benefit", + type: TSurveyElementTypeEnum.OpenText, + headline: i18n("What is the main benefit you receive from this?"), + }, + { + ...baseQuestion, + id: "q_improve", + type: TSurveyElementTypeEnum.OpenText, + headline: i18n("How can we improve this for you?"), + }, + ] as unknown as TSurvey["questions"]); + vi.mocked(mocks.generateOrganizationAIObject).mockResolvedValue({ + object: { + responses: Array.from({ length: EXAMPLE_RESPONSE_COUNT }, (_, index) => ({ + rowId: `row_${index}`, + answers: {}, + })), + }, + }); + + const result = await generateExampleResponseDataset({ survey, organizationId: "org_1" }); + const finished = result.responses.find((response) => response.finished); + + expect(finished).toBeDefined(); + if (!finished) throw new Error("Expected at least one finished response"); + expect(finished.data.q_audience).not.toBe(finished.data.q_benefit); + expect(finished.data.q_benefit).not.toBe(finished.data.q_improve); + expect(finished.data.q_improve).toMatch(/Make|guidance|Tighten|clearer/); + }); + + test("generates address and contact info locally in the expected wire format", async () => { + const survey = makeSurvey([ + { + ...baseQuestion, + id: "q_addr", + type: TSurveyElementTypeEnum.Address, + addressLine1: { show: true, required: true, placeholder: i18n("Street") }, + addressLine2: { show: false, required: false, placeholder: i18n("") }, + city: { show: true, required: true, placeholder: i18n("City") }, + state: { show: false, required: false, placeholder: i18n("") }, + zip: { show: true, required: false, placeholder: i18n("Zip") }, + country: { show: true, required: true, placeholder: i18n("Country") }, + }, + { + ...baseQuestion, + id: "q_contact", + type: TSurveyElementTypeEnum.ContactInfo, + firstName: { show: true, required: true, placeholder: i18n("First") }, + lastName: { show: false, required: false, placeholder: i18n("") }, + email: { show: true, required: true, placeholder: i18n("Email") }, + phone: { show: false, required: false, placeholder: i18n("") }, + company: { show: true, required: false, placeholder: i18n("Company") }, + }, + ] as unknown as TSurvey["questions"]); + + const result = await generateExampleResponseDataset({ survey, organizationId: "org_1" }); + const finished = result.responses.find((response) => response.finished); + + expect(finished).toBeDefined(); + if (!finished) throw new Error("Expected at least one finished response"); + expect(finished.data.q_addr).toEqual(["5 Sample Road", "", "London", "", "SW1A 1AA", "GB"]); + expect(finished.data.q_contact).toEqual(["Sam", "", "sam.rivers@example.com", "", "Sample Systems"]); + expect(mocks.generateOrganizationAIObject).not.toHaveBeenCalled(); + }); + + test("emits the new dataset contract for drop-offs, metadata, timestamps, displays, and tag", async () => { + const survey = makeSurvey([ + { ...baseQuestion, id: "q_text", type: TSurveyElementTypeEnum.OpenText }, + { ...baseQuestion, id: "q_rating", type: TSurveyElementTypeEnum.Rating, scale: "number", range: 5 }, + ] as unknown as TSurvey["questions"]); + mockOpenTextAnswers(survey); + + const result = await generateExampleResponseDataset({ survey, organizationId: "org_1" }); + const finished = result.responses.filter((response) => response.finished); + const dropped = result.responses.filter((response) => !response.finished); + + expect(finished).toHaveLength(8); + expect(dropped).toHaveLength(2); + expect(result.displays).toHaveLength(EXAMPLE_IMPRESSION_ONLY_COUNT); + for (const response of result.responses) { + expect(response.meta.source).toBe("example-generation"); + expect(response.meta.userAgent?.browser).toBeTypeOf("string"); + expect(response.createdAt).toBeInstanceOf(Date); + } + for (const display of result.displays) { + expect(display.createdAt).toBeInstanceOf(Date); + } + }); + + test("propagates errors from the open-text LLM call", async () => { + const survey = makeSurvey([ + { ...baseQuestion, id: "q_text", type: TSurveyElementTypeEnum.OpenText }, + ] as unknown as TSurvey["questions"]); + vi.mocked(mocks.generateOrganizationAIObject).mockRejectedValue(new Error("ai_features_not_enabled")); + + await expect(generateExampleResponseDataset({ survey, organizationId: "org_1" })).rejects.toThrow( + "ai_features_not_enabled" + ); + }); + + test("strips HTML from headlines before sending them to the LLM", async () => { + const survey = makeSurvey([ + { + ...baseQuestion, + id: "q_text", + type: TSurveyElementTypeEnum.OpenText, + headline: i18n( + '

How likely are you to shop today?

' + ), + }, + ] as unknown as TSurvey["questions"]); + mockOpenTextAnswers(survey); + + await generateExampleResponseDataset({ survey, organizationId: "org_1" }); + + const call = vi.mocked(mocks.generateOrganizationAIObject).mock.calls[0][0]; + expect(call.prompt).toContain("How likely are you to shop today?"); + expect(call.prompt).not.toContain("fb-editor-paragraph"); + expect(call.prompt).not.toContain(""); + }); + + test("uses survey-agnostic fallback answers when no keyword branch matches", async () => { + const survey = makeSurvey([ + { + ...baseQuestion, + id: "q_reason", + type: TSurveyElementTypeEnum.OpenText, + headline: i18n("What is your primary reason for visiting today?"), + }, + ] as unknown as TSurvey["questions"]); + vi.mocked(mocks.generateOrganizationAIObject).mockResolvedValue({ + object: { + responses: Array.from({ length: EXAMPLE_RESPONSE_COUNT }, (_, index) => ({ + rowId: `row_${index}`, + answers: {}, + })), + }, + }); + + const result = await generateExampleResponseDataset({ survey, organizationId: "org_1" }); + + for (const response of result.responses) { + const answer = String(response.data.q_reason ?? ""); + // Profile priorities are SaaS-flavoured ("team adoption", "missing features", etc.). + // Generic surveys (like this purchase-intent one) must not leak those into answers. + expect(answer).not.toMatch(/team adoption|missing features|reporting|reliability|speed|setup/i); + } + }); + + test("never ships duplicate open-text answers within a single row", async () => { + const survey = makeSurvey([ + { + ...baseQuestion, + id: "q_reason", + type: TSurveyElementTypeEnum.OpenText, + headline: i18n("What is your primary reason for visiting today?"), + }, + { + ...baseQuestion, + id: "q_block", + type: TSurveyElementTypeEnum.OpenText, + headline: i18n("What, if anything, is holding you back from making a purchase today?"), + }, + ] as unknown as TSurvey["questions"]); + vi.mocked(mocks.generateOrganizationAIObject).mockResolvedValue({ + object: { + responses: Array.from({ length: EXAMPLE_RESPONSE_COUNT }, (_, index) => ({ + rowId: `row_${index}`, + answers: {}, + })), + }, + }); + + const result = await generateExampleResponseDataset({ survey, organizationId: "org_1" }); + + for (const response of result.responses) { + expect(response.data.q_reason).not.toEqual(response.data.q_block); + } + }); + + test("locally generates valid answers for the remaining closed-ended element types", async () => { + const survey = makeSurvey([ + { ...baseQuestion, id: "q_nps", type: TSurveyElementTypeEnum.NPS }, + { ...baseQuestion, id: "q_csat", type: TSurveyElementTypeEnum.CSAT, scale: "number", range: 5 }, + { ...baseQuestion, id: "q_ces", type: TSurveyElementTypeEnum.CES, scale: "number", range: 7 }, + { ...baseQuestion, id: "q_date", type: TSurveyElementTypeEnum.Date, format: "M-d-y" }, + { ...baseQuestion, id: "q_consent", type: TSurveyElementTypeEnum.Consent, label: i18n("I agree") }, + { + ...baseQuestion, + id: "q_multi", + type: TSurveyElementTypeEnum.MultipleChoiceMulti, + choices: [ + { id: "c1", label: i18n("A") }, + { id: "c2", label: i18n("B") }, + { id: "c3", label: i18n("C") }, + ], + }, + { + ...baseQuestion, + id: "q_rank", + type: TSurveyElementTypeEnum.Ranking, + choices: [ + { id: "c1", label: i18n("A") }, + { id: "c2", label: i18n("B") }, + { id: "c3", label: i18n("C") }, + ], + }, + { + ...baseQuestion, + id: "q_matrix", + type: TSurveyElementTypeEnum.Matrix, + rows: [ + { id: "r1", label: i18n("Speed") }, + { id: "r2", label: i18n("Price") }, + ], + columns: [ + { id: "c1", label: i18n("Bad") }, + { id: "c2", label: i18n("Good") }, + ], + }, + { + ...baseQuestion, + id: "q_pic", + type: TSurveyElementTypeEnum.PictureSelection, + allowMulti: true, + choices: [ + { id: "pic_1", imageUrl: "https://example.com/a.png" }, + { id: "pic_2", imageUrl: "https://example.com/b.png" }, + ], + }, + ] as unknown as TSurvey["questions"]); + + const result = await generateExampleResponseDataset({ survey, organizationId: "org_1" }); + const finished = result.responses.find((response) => response.finished); + + expect(finished).toBeDefined(); + if (!finished) throw new Error("Expected at least one finished response"); + expect(finished.data.q_nps).toBeTypeOf("number"); + expect(finished.data.q_csat).toBeTypeOf("number"); + expect(finished.data.q_ces).toBeTypeOf("number"); + expect(finished.data.q_date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(finished.data.q_consent).toBe("accepted"); + expect(Array.isArray(finished.data.q_multi)).toBe(true); + expect(Array.isArray(finished.data.q_rank)).toBe(true); + expect((finished.data.q_rank as string[]).slice().sort()).toEqual(["A", "B", "C"]); + expect(finished.data.q_matrix).toEqual(expect.objectContaining({ Speed: expect.any(String) })); + expect(Array.isArray(finished.data.q_pic)).toBe(true); + expect(mocks.generateOrganizationAIObject).not.toHaveBeenCalled(); + }); + + test("generateExampleResponses returns only the response rows for compatibility", async () => { + const survey = makeSurvey([ + { ...baseQuestion, id: "q_rating", type: TSurveyElementTypeEnum.Rating, scale: "number", range: 5 }, + ] as unknown as TSurvey["questions"]); + + const result = await generateExampleResponses({ survey, organizationId: "org_1" }); + + expect(result).toHaveLength(EXAMPLE_RESPONSE_COUNT); + }); +}); + +describe("toExampleResponseInput", () => { + const createdAt = new Date("2026-05-20T10:00:00Z"); + + test("forwards generated metadata into the TResponseInput shape", () => { + const out = toExampleResponseInput( + "survey_1", + "workspace_1", + { + data: { q_text: "hello" }, + ttc: { q_text: 4200 }, + finished: true, + endingId: "ending_1", + language: "de", + meta: { + source: "example-generation", + userAgent: { browser: "Chrome", device: "desktop", os: "macOS" }, + country: "DE", + }, + createdAt, + }, + "display_xyz" + ); + + expect(out).toEqual({ + workspaceId: "workspace_1", + surveyId: "survey_1", + finished: true, + endingId: "ending_1", + language: "de", + data: { q_text: "hello" }, + ttc: { q_text: 4200 }, + meta: { + source: "example-generation", + userAgent: { browser: "Chrome", device: "desktop", os: "macOS" }, + country: "DE", + }, + displayId: "display_xyz", + }); + }); + + test("omits displayId key entirely when no display is supplied", () => { + const out = toExampleResponseInput("survey_1", "workspace_1", { + data: { q_text: "hello" }, + ttc: { q_text: 4200 }, + finished: true, + endingId: null, + language: null, + meta: { source: "example-generation" }, + createdAt, + }); + + expect(out).not.toHaveProperty("displayId"); + }); + + test("buildExampleImpressionTimestamps returns the requested count of past dates", () => { + const now = Date.now(); + const dates = buildExampleImpressionTimestamps(7); + + expect(dates).toHaveLength(7); + for (const date of dates) { + expect(date).toBeInstanceOf(Date); + expect(date.getTime()).toBeLessThanOrEqual(now); + expect(date.getTime()).toBeGreaterThan(now - 11 * 24 * 60 * 60 * 1000); + } + }); +}); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/example-responses.ts b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/example-responses.ts new file mode 100644 index 000000000000..c0451782682b --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/example-responses.ts @@ -0,0 +1,982 @@ +import "server-only"; +import { randomInt } from "node:crypto"; +import { z } from "zod"; +import { type TResponseData, type TResponseInput, type TResponseTtc } from "@formbricks/types/responses"; +import { type TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; +import { type TSurvey } from "@formbricks/types/surveys/types"; +import { generateOrganizationAIObject } from "@/lib/ai/service"; +import { getLocalizedValue } from "@/lib/i18n/utils"; + +export const EXAMPLE_RESPONSE_COUNT = 10; +// Impression-only displays simulate respondents who saw the survey but didn't +// submit. Combined with the response count, the dashboard's completion rate +// lands around 62% — close to typical web survey benchmarks. +export const EXAMPLE_IMPRESSION_ONLY_COUNT = 6; +export const EXAMPLE_AI_GENERATED_TAG_NAME = "AI-generated example response"; + +// ~20% of synthetic responses are partial drop-offs to mirror typical survey +// abandonment. The remaining ~80% are finished. +const DROP_OFF_RATE = 0.2; +const RESPONSE_TIME_SPREAD_DAYS = 10; + +// Realistic distributions for synthetic response metadata. Weighted toward +// common values so charts don't look uniformly random. +const META_BROWSERS = ["Chrome", "Chrome", "Chrome", "Safari", "Safari", "Firefox", "Edge"]; +const META_DEVICES = ["desktop", "desktop", "desktop", "mobile", "mobile", "tablet"]; +const META_OS = ["macOS", "Windows", "Windows", "iOS", "iOS", "Android", "Linux"]; +const META_COUNTRIES = ["US", "US", "DE", "GB", "FR", "IN", "BR", "JP", "CA", "AU", "NL", "ES"]; + +// Rough ms-per-element bands used to fabricate per-element TTC. Mirrors what +// real respondents take so the analytics page's avg-time-per-question chart +// looks plausible. Values are intentionally coarse — we only need shape, not +// fidelity. +const TTC_BANDS_MS: Partial> = { + [TSurveyElementTypeEnum.OpenText]: [8000, 30000], + [TSurveyElementTypeEnum.MultipleChoiceSingle]: [2000, 7000], + [TSurveyElementTypeEnum.MultipleChoiceMulti]: [4000, 12000], + [TSurveyElementTypeEnum.Rating]: [2000, 5000], + [TSurveyElementTypeEnum.NPS]: [2500, 6000], + [TSurveyElementTypeEnum.CSAT]: [2000, 5000], + [TSurveyElementTypeEnum.CES]: [3000, 7000], + [TSurveyElementTypeEnum.Date]: [3000, 8000], + [TSurveyElementTypeEnum.Ranking]: [8000, 20000], + [TSurveyElementTypeEnum.Matrix]: [6000, 18000], + [TSurveyElementTypeEnum.Address]: [15000, 45000], + [TSurveyElementTypeEnum.ContactInfo]: [10000, 30000], + [TSurveyElementTypeEnum.PictureSelection]: [3000, 9000], + [TSurveyElementTypeEnum.Consent]: [2000, 5000], +}; +const DEFAULT_TTC_BAND_MS: [number, number] = [3000, 10000]; + +// CSPRNG is overkill for preview data, but using node:crypto here avoids +// tripping static-analysis PRNG warnings; cost is negligible at this volume. +const RANDOM_FLOAT_DENOMINATOR = 2 ** 32; +const randomFloat = (): number => randomInt(RANDOM_FLOAT_DENOMINATOR) / RANDOM_FLOAT_DENOMINATOR; +const pickFrom = (arr: readonly T[]): T => arr[randomInt(arr.length)]; +const randomIntInclusive = (min: number, max: number): number => randomInt(min, max + 1); + +const SUPPORTED_ELEMENT_TYPES = new Set([ + TSurveyElementTypeEnum.OpenText, + TSurveyElementTypeEnum.MultipleChoiceSingle, + TSurveyElementTypeEnum.MultipleChoiceMulti, + TSurveyElementTypeEnum.Rating, + TSurveyElementTypeEnum.NPS, + TSurveyElementTypeEnum.CSAT, + TSurveyElementTypeEnum.CES, + TSurveyElementTypeEnum.Date, + TSurveyElementTypeEnum.Ranking, + TSurveyElementTypeEnum.Matrix, + TSurveyElementTypeEnum.Address, + TSurveyElementTypeEnum.ContactInfo, + TSurveyElementTypeEnum.PictureSelection, + TSurveyElementTypeEnum.Consent, +]); + +const DEFAULT_LANGUAGE = "default"; + +// Wire format for Address/ContactInfo responses is a fixed-length string array; +// hidden fields contribute empty strings at their position. Order must match +// `convertToValueArray` in the respective survey-runtime elements. +const ADDRESS_FIELD_ORDER = ["addressLine1", "addressLine2", "city", "state", "zip", "country"] as const; +const CONTACT_INFO_FIELD_ORDER = ["firstName", "lastName", "email", "phone", "company"] as const; + +type TAddressField = (typeof ADDRESS_FIELD_ORDER)[number]; +type TContactInfoField = (typeof CONTACT_INFO_FIELD_ORDER)[number]; + +type TExampleSentiment = "promoter" | "positive" | "neutral" | "skeptical" | "detractor"; +type TExampleVerbosity = "brief" | "normal" | "detailed"; +type TExamplePolish = "clean" | "casual" | "rough"; + +type TExampleRespondentProfile = { + sentiment: TExampleSentiment; + persona: string; + verbosity: TExampleVerbosity; + polish: TExamplePolish; + priorities: string[]; +}; + +type TExampleResponsePlanRow = { + rowId: string; + profile: TExampleRespondentProfile; + data: TResponseData; + ttc: TResponseTtc; + finished: boolean; + endingId: string | null; + language: string | null; + meta: NonNullable; + createdAt: Date; + openTextElementIds: string[]; +}; + +export type TGeneratedExampleDisplay = { + createdAt: Date; +}; + +export type TGeneratedExampleDataset = { + responses: TGeneratedExampleResponse[]; + displays: TGeneratedExampleDisplay[]; + tagName: typeof EXAMPLE_AI_GENERATED_TAG_NAME; +}; + +const RESPONDENT_PROFILE_PRESETS: TExampleRespondentProfile[] = ( + [ + ["promoter", "product manager at a growing SaaS team", "detailed", "clean", "team adoption", "speed"], + ["positive", "founder at a small company", "brief", "casual", "cost", "setup"], + ["neutral", "operations lead comparing internal tools", "normal", "clean", "reliability", "reporting"], + [ + "skeptical", + "software engineer maintaining data workflows", + "normal", + "rough", + "missing features", + "reliability", + ], + ["positive", "customer success manager", "detailed", "clean", "team adoption", "support"], + ["promoter", "executive stakeholder", "brief", "clean", "reporting", "speed"], + ["neutral", "designer running user research", "detailed", "casual", "setup", "team adoption"], + [ + "detractor", + "developer evaluating the product for a side project", + "brief", + "rough", + "cost", + "missing features", + ], + ["positive", "analytics owner at a mid-market team", "normal", "clean", "reporting", "reliability"], + ["skeptical", "team lead with a busy roadmap", "normal", "casual", "speed", "setup"], + ] as const +).map(([sentiment, persona, verbosity, polish, priorityA, priorityB]) => ({ + sentiment, + persona, + verbosity, + polish, + priorities: [priorityA, priorityB], +})); + +const ADDRESS_FIXTURES: Array> = ( + [ + ["14 Example Lane", "", "Springfield", "IL", "62701", "US"], + ["82 Demo Street", "Suite 4", "Berlin", "BE", "10115", "DE"], + ["5 Sample Road", "", "London", "", "SW1A 1AA", "GB"], + ["31 Test Avenue", "Floor 2", "Toronto", "ON", "M5H 2N2", "CA"], + ["9 Fictional Blvd", "", "Sydney", "NSW", "2000", "AU"], + ] as const +).map(([addressLine1, addressLine2, city, state, zip, country]) => ({ + addressLine1, + addressLine2, + city, + state, + zip, + country, +})); + +const CONTACT_INFO_FIXTURES: Array> = ( + [ + ["Alex", "Morgan", "alex.morgan@example.com", "+1 555 0101", "Example Labs"], + ["Jamie", "Taylor", "jamie.taylor@example.com", "+44 20 7946 0102", "Demo Works"], + ["Sam", "Rivers", "sam.rivers@example.com", "+49 30 1234 0103", "Sample Systems"], + ["Riley", "Chen", "riley.chen@example.com", "+61 2 5550 0104", "Fictional Studio"], + ["Jordan", "Lee", "jordan.lee@example.com", "+1 555 0105", "Placeholder Co"], + ] as const +).map(([firstName, lastName, email, phone, company]) => ({ + firstName, + lastName, + email, + phone, + company, +})); + +// Editor-generated headlines often arrive as HTML (e.g.

...

). +// Strip tags + decode common entities so the text we hand to the LLM and the +// fallback keyword matcher is plain prose, not markup. + +// Single-pass scanner — explicit linear traversal so there's no regex +// quantifier for static analyzers to flag (ReDoS). +const removeHtmlTags = (value: string): string => { + let out = ""; + let insideTag = false; + for (const ch of value) { + if (ch === "<") { + insideTag = true; + } else if (ch === ">" && insideTag) { + insideTag = false; + out += " "; + } else if (!insideTag) { + out += ch; + } + } + return out; +}; + +const stripHtml = (value: string): string => + removeHtmlTags(value) + .replaceAll(" ", " ") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll(/\s+/g, " ") + .trim(); + +const plainHeadline = (element: TSurveyElement | undefined): string => + element ? stripHtml(getLocalizedValue(element.headline, DEFAULT_LANGUAGE)) : ""; + +const plainSubheader = (element: TSurveyElement | undefined): string => { + const raw = element ? getLocalizedValue(element.subheader, DEFAULT_LANGUAGE) : ""; + return raw ? stripHtml(raw) : ""; +}; + +// Modern surveys store this in `survey.blocks[].elements`; `survey.questions` +// is the v1-compat field and may be empty. Walk both, de-dupe by id. +const collectSurveyElements = (survey: TSurvey): TSurveyElement[] => { + const byId = new Map(); + for (const block of survey.blocks ?? []) { + for (const element of block.elements ?? []) { + byId.set(element.id, element); + } + } + for (const question of survey.questions ?? []) { + if (!byId.has(question.id)) byId.set(question.id, question as unknown as TSurveyElement); + } + return [...byId.values()]; +}; + +const labelsForChoices = ( + element: Extract< + TSurveyElement, + { + type: + | TSurveyElementTypeEnum.MultipleChoiceSingle + | TSurveyElementTypeEnum.MultipleChoiceMulti + | TSurveyElementTypeEnum.Ranking; + } + > +): string[] => element.choices.map((c) => getLocalizedValue(c.label, DEFAULT_LANGUAGE)).filter(Boolean); + +const pictureSelectionIds = ( + element: Extract +): string[] => element.choices.map((c) => c.id).filter(Boolean); + +const matrixLabels = (element: Extract) => ({ + rows: element.rows.map((r) => getLocalizedValue(r.label, DEFAULT_LANGUAGE)).filter(Boolean), + columns: element.columns.map((c) => getLocalizedValue(c.label, DEFAULT_LANGUAGE)).filter(Boolean), +}); + +const isElementSupportedForGeneration = (element: TSurveyElement): boolean => { + switch (element.type) { + case TSurveyElementTypeEnum.OpenText: + case TSurveyElementTypeEnum.Rating: + case TSurveyElementTypeEnum.NPS: + case TSurveyElementTypeEnum.Consent: + case TSurveyElementTypeEnum.CSAT: + case TSurveyElementTypeEnum.CES: + case TSurveyElementTypeEnum.Date: + return true; + case TSurveyElementTypeEnum.MultipleChoiceSingle: + case TSurveyElementTypeEnum.MultipleChoiceMulti: + case TSurveyElementTypeEnum.Ranking: + return labelsForChoices(element).length > 0; + case TSurveyElementTypeEnum.Matrix: { + const { rows, columns } = matrixLabels(element); + return rows.length > 0 && columns.length > 0; + } + case TSurveyElementTypeEnum.Address: + return ADDRESS_FIELD_ORDER.some((field) => element[field].show); + case TSurveyElementTypeEnum.PictureSelection: + return pictureSelectionIds(element).length > 0; + case TSurveyElementTypeEnum.ContactInfo: + return CONTACT_INFO_FIELD_ORDER.some((field) => element[field].show); + default: + return false; + } +}; + +const transformAnswerForElement = (element: TSurveyElement, value: unknown): unknown => { + switch (element.type) { + case TSurveyElementTypeEnum.Address: { + if (typeof value !== "object" || value === null) return undefined; + const obj = value as Partial>; + return ADDRESS_FIELD_ORDER.map((f) => obj[f] ?? ""); + } + case TSurveyElementTypeEnum.ContactInfo: { + if (typeof value !== "object" || value === null) return undefined; + const obj = value as Partial>; + return CONTACT_INFO_FIELD_ORDER.map((f) => obj[f] ?? ""); + } + default: + return value; + } +}; + +export type TExampleResponseSchemaContext = { + supportedElementIds: string[]; + openTextElementIds: string[]; +}; + +const buildOpenTextResponsesSchema = (): z.ZodTypeAny => + z.object({ + responses: z + .array( + z.object({ + rowId: z.string().min(1), + answers: z.record(z.string(), z.string().min(1)), + }) + ) + .default([]), + }); + +export const buildExampleResponsesSchema = ( + survey: TSurvey +): { schema: z.ZodTypeAny; ctx: TExampleResponseSchemaContext } => { + const supportedElements = collectSurveyElements(survey).filter( + (element) => SUPPORTED_ELEMENT_TYPES.has(element.type) && isElementSupportedForGeneration(element) + ); + const openTextElementIds = supportedElements + .filter((element) => element.type === TSurveyElementTypeEnum.OpenText) + .map((element) => element.id); + + return { + schema: buildOpenTextResponsesSchema(), + ctx: { + supportedElementIds: supportedElements.map((element) => element.id), + openTextElementIds, + }, + }; +}; + +const elementContextForPrompt = (element: TSurveyElement): Record => { + const subheader = plainSubheader(element); + const base: Record = { + id: element.id, + type: element.type, + headline: plainHeadline(element), + subheader: subheader || undefined, + required: element.required, + }; + + if ( + element.type === TSurveyElementTypeEnum.MultipleChoiceSingle || + element.type === TSurveyElementTypeEnum.MultipleChoiceMulti || + element.type === TSurveyElementTypeEnum.Ranking + ) { + base.choices = labelsForChoices(element); + } + if (element.type === TSurveyElementTypeEnum.Rating || element.type === TSurveyElementTypeEnum.CES) { + base.scale = element.scale; + base.range = element.range; + } + if (element.type === TSurveyElementTypeEnum.CSAT) { + base.scale = element.scale; + base.range = element.range; + } + if (element.type === TSurveyElementTypeEnum.Matrix) { + base.rows = matrixLabels(element).rows; + base.columns = matrixLabels(element).columns; + } + + return base; +}; + +const buildPlannedAnswerContext = (row: TExampleResponsePlanRow, elementsById: Map) => + Object.entries(row.data).map(([elementId, answer]) => { + const element = elementsById.get(elementId); + return { + elementId, + question: plainHeadline(element), + type: element?.type, + answer, + }; + }); + +const buildOpenTextLlmContext = (survey: TSurvey, rows: TExampleResponsePlanRow[]) => { + const openTextElementIds = new Set(rows.flatMap((row) => row.openTextElementIds)); + const supportedElements = collectSurveyElements(survey).filter( + (element) => SUPPORTED_ELEMENT_TYPES.has(element.type) && isElementSupportedForGeneration(element) + ); + const elementsById = new Map(supportedElements.map((element) => [element.id, element])); + const openTextElements = supportedElements + .filter((element) => openTextElementIds.has(element.id)) + .map(elementContextForPrompt); + + return { + surveyTitle: survey.name, + surveyDescription: survey.welcomeCard?.headline + ? stripHtml(getLocalizedValue(survey.welcomeCard.headline, DEFAULT_LANGUAGE)) + : undefined, + surveyElements: supportedElements.map(elementContextForPrompt), + openTextElements, + rows: rows.map((row) => ({ + rowId: row.rowId, + profile: row.profile, + requestedOpenTextAnswers: row.openTextElementIds.map((elementId) => { + const element = elementsById.get(elementId); + const subheader = plainSubheader(element); + return { + elementId, + question: plainHeadline(element), + subheader: subheader || undefined, + }; + }), + plannedAnswers: buildPlannedAnswerContext(row, elementsById), + finished: row.finished, + })), + }; +}; + +const SYSTEM_PROMPT = `You are simulating real survey respondents. For each row, write a short, on-topic answer to each requested open-text question. + +Hard requirements: +- Output one object per rowId provided. +- For every elementId listed in that row's requestedOpenTextAnswers, return a non-empty string answer. Never omit a requested elementId. Never invent new elementIds. +- Each answer must directly address that specific question's headline (e.g. a "what is holding you back" question needs a reason or hesitation; a "primary reason for visiting" question needs a motive). +- Match the language of the question headline. + +Style: +- Write like a different real person on each row. Vary sentence length and openings. +- Use the row's profile (sentiment, verbosity, polish) to shape tone, but stay on the survey's actual topic — do not paste the profile's priorities into the answer if they don't fit the question. +- Two answers within the same row must not be byte-identical and should not share the same opening clause. +- Sound human: contractions are fine, the occasional lowercase start is fine, no corporate buzzwords. + +Example output shape: +{ "responses": [ + { "rowId": "row_0", "answers": { "": "...", "": "..." } }, + ... +] }`; + +export type TGenerateExampleResponsesArgs = { + survey: TSurvey; + organizationId: string; +}; + +export type TGeneratedExampleResponse = { + data: TResponseData; + ttc: TResponseTtc; + finished: boolean; + endingId: string | null; + language: string | null; + meta: NonNullable; + createdAt: Date; +}; + +const buildRespondentProfiles = (count: number): TExampleRespondentProfile[] => + Array.from( + { length: count }, + (_, index) => RESPONDENT_PROFILE_PRESETS[index % RESPONDENT_PROFILE_PRESETS.length] + ); + +// Picks an enabled non-default survey language at random ~30% of the time; +// otherwise leaves it null so the response uses the survey default. Mirrors a +// real distribution where most respondents use the default language. +const pickResponseLanguage = (survey: TSurvey): string | null => { + const enabledNonDefault = (survey.languages ?? []).filter((l) => l.enabled && !l.default); + if (enabledNonDefault.length === 0) return null; + if (randomInt(10) >= 3) return null; + return pickFrom(enabledNonDefault).language.code; +}; + +const buildResponseMeta = (): NonNullable => ({ + source: "example-generation", + userAgent: { + browser: pickFrom(META_BROWSERS), + device: pickFrom(META_DEVICES), + os: pickFrom(META_OS), + }, + country: pickFrom(META_COUNTRIES), +}); + +// Spread createdAt across the last N days, weighted slightly toward more +// recent dates so the responses-over-time chart trends upward. +const pickCreatedAt = (): Date => { + const now = Date.now(); + const skew = randomFloat() ** 1.6; // bias toward 0 → more recent + const daysAgo = skew * RESPONSE_TIME_SPREAD_DAYS; + return new Date(now - daysAgo * 24 * 60 * 60 * 1000); +}; + +const ttcForElement = (element: TSurveyElement): number => { + const band = TTC_BANDS_MS[element.type] ?? DEFAULT_TTC_BAND_MS; + return randomIntInclusive(band[0], band[1]); +}; + +const hashString = (value: string): number => + [...value].reduce((acc, char) => (acc * 31 + (char.codePointAt(0) ?? 0)) % 997, 0); + +const shouldSkipOptionalElement = (element: TSurveyElement, rowIndex: number): boolean => { + if (element.required) return false; + return (hashString(element.id) + rowIndex) % 5 === 0; +}; + +const getLumpyIndex = (optionCount: number, rowIndex: number): number => { + if (optionCount <= 1) return 0; + const lumpyPattern = [0, 0, 1, 0, 2, 1, 0, 3, 1, 4]; + return Math.min(lumpyPattern[rowIndex % lumpyPattern.length], optionCount - 1); +}; + +const getScoreForProfile = ( + profile: TExampleRespondentProfile, + rowIndex: number, + min: number, + max: number +): number => { + const clampedMax = Math.max(min, max); + const span = clampedMax - min; + const top = clampedMax; + const high = min + Math.max(0, Math.round(span * 0.75)); + const middle = min + Math.max(0, Math.round(span * 0.5)); + const low = min + Math.max(0, Math.round(span * 0.25)); + + switch (profile.sentiment) { + case "promoter": + return rowIndex % 2 === 0 ? top : Math.max(min, top - 1); + case "positive": + return Math.max(min, rowIndex % 3 === 0 ? high : top); + case "neutral": + return Math.min(clampedMax, rowIndex % 2 === 0 ? middle : middle + 1); + case "skeptical": + return Math.max(min, rowIndex % 2 === 0 ? low : middle); + case "detractor": + return Math.max(min, rowIndex % 2 === 0 ? min : low); + } +}; + +const getNpsForProfile = (profile: TExampleRespondentProfile, rowIndex: number): number => { + switch (profile.sentiment) { + case "promoter": + return rowIndex % 2 === 0 ? 10 : 9; + case "positive": + return rowIndex % 2 === 0 ? 9 : 8; + case "neutral": + return rowIndex % 2 === 0 ? 8 : 7; + case "skeptical": + return rowIndex % 2 === 0 ? 6 : 5; + case "detractor": + return rowIndex % 2 === 0 ? 3 : 2; + } +}; + +const getIsoDateForRow = (rowIndex: number): string => { + const daysAgo = ((rowIndex + 1) * 23) % 365; + return new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); +}; + +const rotate = (values: T[], startIndex: number): T[] => [ + ...values.slice(startIndex), + ...values.slice(0, startIndex), +]; + +const pickAddressValue = ( + element: Extract, + rowIndex: number +): Partial> => { + const fixture = ADDRESS_FIXTURES[rowIndex % ADDRESS_FIXTURES.length]; + return Object.fromEntries( + ADDRESS_FIELD_ORDER.filter((field) => element[field].show).map((field) => [field, fixture[field]]) + ); +}; + +const pickContactInfoValue = ( + element: Extract, + rowIndex: number +): Partial> => { + const fixture = CONTACT_INFO_FIXTURES[rowIndex % CONTACT_INFO_FIXTURES.length]; + return Object.fromEntries( + CONTACT_INFO_FIELD_ORDER.filter((field) => element[field].show).map((field) => [field, fixture[field]]) + ); +}; + +const buildClosedAnswerForElement = ( + element: TSurveyElement, + profile: TExampleRespondentProfile, + rowIndex: number +): unknown => { + switch (element.type) { + case TSurveyElementTypeEnum.OpenText: + return undefined; + case TSurveyElementTypeEnum.MultipleChoiceSingle: { + const labels = labelsForChoices(element); + return labels[getLumpyIndex(labels.length, rowIndex)]; + } + case TSurveyElementTypeEnum.MultipleChoiceMulti: { + const labels = labelsForChoices(element); + const first = getLumpyIndex(labels.length, rowIndex); + const picks = [labels[first]]; + if (labels.length > 1 && rowIndex % 3 !== 0) { + picks.push(labels[(first + 1 + (rowIndex % (labels.length - 1))) % labels.length]); + } + return [...new Set(picks)]; + } + case TSurveyElementTypeEnum.Rating: + return getScoreForProfile(profile, rowIndex, 1, element.range ?? 5); + case TSurveyElementTypeEnum.NPS: + return getNpsForProfile(profile, rowIndex); + case TSurveyElementTypeEnum.CSAT: + return getScoreForProfile(profile, rowIndex, 1, element.range ?? 5); + case TSurveyElementTypeEnum.CES: + return getScoreForProfile(profile, rowIndex, 1, element.range); + case TSurveyElementTypeEnum.Date: + return getIsoDateForRow(rowIndex); + case TSurveyElementTypeEnum.Ranking: { + const labels = labelsForChoices(element); + return rotate(labels, getLumpyIndex(labels.length, rowIndex)); + } + case TSurveyElementTypeEnum.Matrix: { + const { rows, columns } = matrixLabels(element); + return Object.fromEntries( + rows.map((row, index) => [row, columns[getLumpyIndex(columns.length, rowIndex + index)]]) + ); + } + case TSurveyElementTypeEnum.Address: + return pickAddressValue(element, rowIndex); + case TSurveyElementTypeEnum.ContactInfo: + return pickContactInfoValue(element, rowIndex); + case TSurveyElementTypeEnum.PictureSelection: { + const ids = pictureSelectionIds(element); + const first = getLumpyIndex(ids.length, rowIndex); + if (!element.allowMulti || ids.length === 1) return [ids[first]]; + const picks = [ids[first]]; + if (rowIndex % 3 !== 0) picks.push(ids[(first + 1) % ids.length]); + return [...new Set(picks)]; + } + case TSurveyElementTypeEnum.Consent: + return "accepted"; + default: + return undefined; + } +}; + +const applyDropOffToPlanRow = ( + row: TExampleResponsePlanRow, + orderedElementIds: string[] +): TExampleResponsePlanRow => { + if (orderedElementIds.length <= 1 || row.finished) return row; + const rowNumber = Number(row.rowId.replace("row_", "")) || 0; + const dropIndex = 1 + (rowNumber % (orderedElementIds.length - 1)); + const keptIds = new Set(orderedElementIds.slice(0, dropIndex)); + const newData: TResponseData = {}; + const newTtc: TResponseTtc = {}; + for (const id of keptIds) { + if (row.data[id] !== undefined) newData[id] = row.data[id]; + if (row.ttc[id] !== undefined) newTtc[id] = row.ttc[id]; + } + return { + ...row, + data: newData, + ttc: newTtc, + openTextElementIds: row.openTextElementIds.filter((id) => keptIds.has(id)), + }; +}; + +const buildExampleResponsePlan = (survey: TSurvey): TExampleResponsePlanRow[] => { + const { ctx } = buildExampleResponsesSchema(survey); + if (ctx.supportedElementIds.length === 0) { + return []; + } + + const elementsById = new Map(collectSurveyElements(survey).map((el) => [el.id, el])); + const finishedEndingId = survey.endings?.[0]?.id ?? null; + const profiles = buildRespondentProfiles(EXAMPLE_RESPONSE_COUNT); + + return profiles.map((profile, index) => { + const data: TResponseData = {}; + const ttc: TResponseTtc = {}; + const openTextElementIds: string[] = []; + + for (const id of ctx.supportedElementIds) { + const element = elementsById.get(id); + if (!element || shouldSkipOptionalElement(element, index)) continue; + + if (element.type === TSurveyElementTypeEnum.OpenText) { + openTextElementIds.push(id); + ttc[id] = ttcForElement(element); + continue; + } + + const answer = buildClosedAnswerForElement(element, profile, index); + const transformed = transformAnswerForElement(element, answer); + if (transformed === undefined) continue; + data[id] = transformed as TResponseData[string]; + ttc[id] = ttcForElement(element); + } + + const isFinished = index >= Math.ceil(EXAMPLE_RESPONSE_COUNT * DROP_OFF_RATE); + const row: TExampleResponsePlanRow = { + rowId: `row_${index}`, + profile, + data, + ttc, + finished: isFinished, + endingId: isFinished ? finishedEndingId : null, + language: pickResponseLanguage(survey), + meta: buildResponseMeta(), + createdAt: pickCreatedAt(), + openTextElementIds, + }; + + return applyDropOffToPlanRow(row, ctx.supportedElementIds); + }); +}; + +const ensureTrailingPunctuation = (value: string): string => + /[.!?]$/.test(value.trim()) ? value.trim() : `${value.trim()}.`; + +const lowercaseFirst = (value: string): string => value.charAt(0).toLowerCase() + value.slice(1); + +const uppercaseFirst = (value: string): string => value.charAt(0).toUpperCase() + value.slice(1); + +const applyFallbackStyle = (answer: string, profile: TExampleRespondentProfile): string => { + const trimmed = ensureTrailingPunctuation(answer); + + if (profile.polish === "rough") { + return lowercaseFirst(trimmed); + } + + if (profile.polish === "casual" && profile.verbosity === "brief") { + return lowercaseFirst(trimmed); + } + + return uppercaseFirst(trimmed); +}; + +const FALLBACK_ANSWERS_BY_SENTIMENT_VERBOSITY: Record< + TExampleSentiment, + Record +> = { + promoter: { + brief: ["Loved it.", "All good here.", "Honestly a yes.", "No complaints."], + normal: [ + "Pretty close to what I was hoping for.", + "Glad I came across this today.", + "Lining up with what I had in mind.", + "Hits the spot for me right now.", + ], + detailed: [ + "Genuinely happy with how this is going. Nothing major I would change today.", + "This is one of the better ones I have come across — would happily come back.", + "Felt easy and straightforward, which is what I was after.", + "Met my expectations and then some, no real reservations.", + ], + }, + positive: { + brief: ["Looks good.", "Pretty solid.", "Working so far.", "Glad I checked."], + normal: [ + "Mostly what I was looking for, with a couple of small things.", + "Liking it so far, just want to see a bit more.", + "Generally fits, I just need a moment to decide.", + "Seems promising, leaning yes.", + ], + detailed: [ + "Overall a good experience — it covers most of what I was after, even if a couple of small things could be smoother.", + "Feels close to what I need. A few minor questions left, but nothing that would stop me.", + "Mostly positive impression; I would just want a bit more clarity on one or two details.", + "Largely lines up with what I was hoping for, with a couple of small caveats I am working through.", + ], + }, + neutral: { + brief: ["It's fine.", "Hard to say yet.", "Need more info.", "Open to it."], + normal: [ + "Comparing a few options before I decide.", + "Mostly seems fine, a couple of things on my mind.", + "Open to it, just thinking it through.", + "Probably need a bit more time before I commit.", + ], + detailed: [ + "Honestly still weighing this up — some parts work for me, others I want to think about more.", + "On the fence right now. Not negative on it, just not ready to commit either.", + "Reasonable so far, but I want to compare against a couple of alternatives before deciding.", + "Mixed feeling at the moment. Nothing wrong exactly, just nothing that pushes me one way or the other.", + ], + }, + skeptical: { + brief: ["Not sure.", "Hesitant.", "Some concerns.", "Probably not today."], + normal: [ + "A few things make me hesitant before going further.", + "Not quite convinced yet, leaning cautious.", + "I have some concerns I would want answered first.", + "Want to do a bit more digging before I move on it.", + ], + detailed: [ + "Honestly, there are a couple of things that give me pause. I would not say no, but I am not ready to commit either.", + "Some parts work for me and others raise questions. Probably going to hold off for now.", + "I want to like this, but a few rough edges make me want to wait and see.", + "Mostly hesitant. Nothing dealbreaking, just enough small things that I am not moving forward today.", + ], + }, + detractor: { + brief: ["Not for me.", "Doesn't fit.", "Probably no.", "Missing the mark."], + normal: [ + "Doesn't quite fit what I am looking for right now.", + "Not feeling like it lines up with what I need.", + "Leaning no — not seeing enough of a reason yet.", + "Not really speaking to what I am here for today.", + ], + detailed: [ + "Honestly this doesn't quite land for me. A few things felt off and I am not getting the value I was hoping for.", + "Not really seeing the fit. The experience didn't match what I was expecting and I would probably look elsewhere.", + "I came in optimistic but a few specific things made me reconsider. Probably not the right thing for me today.", + "Not connecting with this one. The pieces I cared about most didn't quite show up the way I needed them to.", + ], + }, +}; + +const buildFallbackOpenTextAnswer = ( + profile: TExampleRespondentProfile, + element: TSurveyElement | undefined, + variantOffset = 0 +): string => { + const headline = plainHeadline(element).toLowerCase(); + const variant = (hashString(`${profile.persona}-${headline}`) + variantOffset) % 4; + + // For surveys that match these product-feedback patterns, surface a + // priorities-tinted answer. Otherwise fall through to the survey-agnostic + // sentiment+verbosity bank so e.g. a purchase-intent survey doesn't get + // "team adoption" answers. + if (headline.includes("who") || headline.includes("people") || headline.includes("benefit from")) { + const answers = [ + `Teams like ${profile.persona}s, especially when ${profile.priorities[0]} matters.`, + `Best fit is probably ${profile.persona}s who care about ${profile.priorities[0]}.`, + `${profile.persona}s would get the most out of it.`, + `Mostly ${profile.persona}s with a lot of pressure around ${profile.priorities[0]}.`, + ]; + return applyFallbackStyle(answers[variant], profile); + } + + if (headline.includes("benefit")) { + const answers = [ + `Better ${profile.priorities[0]} without much extra setup.`, + `It saves time around ${profile.priorities[0]}, which is what I notice first.`, + `Clearer ${profile.priorities[0]} for the team.`, + `${profile.priorities[0]} feels easier to stay on top of now.`, + ]; + return applyFallbackStyle( + profile.verbosity === "brief" ? answers[variant].split(",")[0] : answers[variant], + profile + ); + } + + if (headline.includes("improve") || headline.includes("better")) { + const secondaryPriority = profile.priorities[1] ?? profile.priorities[0]; + const answers = [ + `Make ${secondaryPriority} easier to configure.`, + `A bit more guidance around ${secondaryPriority} would help.`, + `Tighten up ${secondaryPriority}; that is where I still slow down.`, + `${secondaryPriority} could be clearer for first-time users.`, + ]; + return applyFallbackStyle(answers[variant], profile); + } + + const answers = FALLBACK_ANSWERS_BY_SENTIMENT_VERBOSITY[profile.sentiment][profile.verbosity]; + return applyFallbackStyle(answers[variant], profile); +}; + +const fillOpenTextAnswers = async ( + survey: TSurvey, + organizationId: string, + planRows: TExampleResponsePlanRow[] +): Promise => { + const rowsNeedingText = planRows.filter((row) => row.openTextElementIds.length > 0); + if (rowsNeedingText.length === 0) return planRows; + const elementsById = new Map(collectSurveyElements(survey).map((element) => [element.id, element])); + + const { object } = await generateOrganizationAIObject<{ + responses: Array<{ rowId: string; answers: Record }>; + }>({ + organizationId, + schema: buildOpenTextResponsesSchema() as z.ZodType<{ + responses: Array<{ rowId: string; answers: Record }>; + }>, + system: SYSTEM_PROMPT, + prompt: `Write one short answer for each requestedOpenTextAnswers entry in every row below. Every requested elementId must map to a non-empty string in your output. Stay grounded in the actual question headline for each elementId. + +Survey context (JSON): +${JSON.stringify(buildOpenTextLlmContext(survey, rowsNeedingText), null, 2)}`, + }); + + const answersByRowId = new Map(object.responses.map((response) => [response.rowId, response.answers])); + + return planRows.map((row) => { + if (row.openTextElementIds.length === 0) return row; + + const llmAnswers = answersByRowId.get(row.rowId) ?? {}; + const openTextAnswers: Record = {}; + for (const elementId of row.openTextElementIds) { + openTextAnswers[elementId] = + llmAnswers[elementId] || buildFallbackOpenTextAnswer(row.profile, elementsById.get(elementId)); + } + + // Belt-and-suspenders: even with the prompt rules and varied fallbacks, + // both the LLM and the fallback path can occasionally produce identical + // strings across two open-text questions in the same row. Replace any + // duplicate with a shifted fallback variant so a single response never + // ships the same sentence twice. + const seen = new Set(); + for (const elementId of row.openTextElementIds) { + let answer = openTextAnswers[elementId]; + let offset = 1; + while (seen.has(answer) && offset <= 4) { + answer = buildFallbackOpenTextAnswer(row.profile, elementsById.get(elementId), offset); + offset += 1; + } + openTextAnswers[elementId] = answer; + seen.add(answer); + } + + return { ...row, data: { ...row.data, ...openTextAnswers } }; + }); +}; + +const toGeneratedResponse = (row: TExampleResponsePlanRow): TGeneratedExampleResponse => ({ + data: row.data, + ttc: row.ttc, + finished: row.finished, + endingId: row.endingId, + language: row.language, + meta: row.meta, + createdAt: row.createdAt, +}); + +export const generateExampleResponseDataset = async ({ + survey, + organizationId, +}: TGenerateExampleResponsesArgs): Promise => { + const planRows = buildExampleResponsePlan(survey); + if (planRows.length === 0) { + return { responses: [], displays: [], tagName: EXAMPLE_AI_GENERATED_TAG_NAME }; + } + + const rowsWithText = await fillOpenTextAnswers(survey, organizationId, planRows); + + return { + responses: rowsWithText.map(toGeneratedResponse), + displays: buildExampleImpressionTimestamps(EXAMPLE_IMPRESSION_ONLY_COUNT).map((createdAt) => ({ + createdAt, + })), + tagName: EXAMPLE_AI_GENERATED_TAG_NAME, + }; +}; + +export const generateExampleResponses = async ( + args: TGenerateExampleResponsesArgs +): Promise => (await generateExampleResponseDataset(args)).responses; + +export const toExampleResponseInput = ( + surveyId: string, + workspaceId: string, + generated: TGeneratedExampleResponse, + displayId?: string +): TResponseInput => ({ + workspaceId, + surveyId, + finished: generated.finished, + endingId: generated.endingId, + language: generated.language ?? undefined, + data: generated.data, + ttc: generated.ttc, + meta: generated.meta, + ...(displayId ? { displayId } : {}), +}); + +// Builds timestamps for impression-only displays — same distribution shape as +// response createdAt so the two trends look consistent on the dashboard. +export const buildExampleImpressionTimestamps = (count: number): Date[] => + Array.from({ length: count }, () => pickCreatedAt()); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/page.tsx index d20a784241b6..0c682779160f 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/page.tsx @@ -4,6 +4,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/workspaces/[workspaceId]/s import { SummaryPage } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage"; import { SurveyAnalysisCTA } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA"; import { getSurveySummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary"; +import { getAISmartToolsUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service"; import { DEFAULT_LOCALE, ENTERPRISE_LICENSE_REQUEST_FORM_URL, @@ -58,6 +59,9 @@ const SurveyPage = async (props: { params: Promise<{ workspaceId: string; survey } const isQuotasAllowed = await getIsQuotasEnabled(organization.id); + const aiConfig = await getOrganizationAIConfig(organization.id); + const aiUnavailableReason = getAISmartToolsUnavailableReason(aiConfig) ?? null; + // Fetch initial survey summary data on the server to prevent duplicate API calls during hydration const initialSurveySummary = await getSurveySummary(surveyId); @@ -78,6 +82,7 @@ const SurveyPage = async (props: { params: Promise<{ workspaceId: string; survey isFormbricksCloud={IS_FORMBRICKS_CLOUD} isStorageConfigured={IS_STORAGE_CONFIGURED} enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL} + aiUnavailableReason={aiUnavailableReason} /> }> diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 2410537c9bc1..6ac25881c20f 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -3402,10 +3402,17 @@ checksums: workspace/surveys/summary/drop_offs: 605ee950f82110132d6c5780926af109 workspace/surveys/summary/drop_offs_tooltip: 2a01683380be45f17636365886cf3452 workspace/surveys/summary/effort_score: b79157d02a8ead85459c158272951ab5 + workspace/surveys/summary/example_responses_generated_successfully: 0b45274572aef5fd3ac6f76d9fca3697 + workspace/surveys/summary/example_responses_generation_failed: 2a32d43614b73d09d2f8325ee46c4bb8 workspace/surveys/summary/filter_added_successfully: e247f65020cd87454bcec0da6f0fd034 workspace/surveys/summary/filter_updated_successfully: 01146bc7e6394e271836be2f1b3a257b workspace/surveys/summary/filtered_responses_csv: aad66a98be6a09cac8bef9e4db4a75cf workspace/surveys/summary/filtered_responses_excel: 06e57bae9e41979fd7fc4b8bfe3466f9 + workspace/surveys/summary/generate_example_responses: 4e353c295879b9d1fa91dd981474d536 + workspace/surveys/summary/generate_example_responses_locked_disabled: ba5de824cb89de7d3d0521bf112a46f2 + workspace/surveys/summary/generate_example_responses_locked_instance: 6deeb8aeaff3982d07e1d5a045e06d2d + workspace/surveys/summary/generate_example_responses_locked_plan: 42f254b6243c859c7bd6f4f12f284b91 + workspace/surveys/summary/generating_example_responses: 49e68bed3aae7d2b51e5bbab69ccd2aa workspace/surveys/summary/generating_qr_code: 5026d4a76f995db458195e5215d9bbd9 workspace/surveys/summary/impressions: 7fe38d42d68a64d3fd8436a063751584 workspace/surveys/summary/impressions_identified_only: 10f8c491463c73b8e6534314ee00d165 diff --git a/apps/web/lib/ai/service.ts b/apps/web/lib/ai/service.ts index 36b967fa737b..1bbeba28c6e1 100644 --- a/apps/web/lib/ai/service.ts +++ b/apps/web/lib/ai/service.ts @@ -1,5 +1,12 @@ import "server-only"; -import { AIConfigurationError, generateText, isAiConfigured } from "@formbricks/ai"; +import { + AIConfigurationError, + type TGenerateObjectOptions, + type TGenerateObjectResult, + generateObject, + generateText, + isAiConfigured, +} from "@formbricks/ai"; import { logger } from "@formbricks/logger"; import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { env } from "@/lib/env"; @@ -96,3 +103,29 @@ export const generateOrganizationAIText = async ({ throw error; } }; + +type TGenerateOrganizationAIObjectInput = { + organizationId: string; +} & TGenerateObjectOptions; + +export const generateOrganizationAIObject = async ({ + organizationId, + ...options +}: TGenerateOrganizationAIObjectInput): Promise> => { + const aiConfig = await assertOrganizationAIConfigured(organizationId); + + try { + return await generateObject(options, env); + } catch (error) { + logger.error( + { + organizationId, + isInstanceConfigured: aiConfig.isInstanceConfigured, + errorCode: error instanceof AIConfigurationError ? error.code : undefined, + err: error, + }, + "Failed to generate organization AI object" + ); + throw error; + } +}; diff --git a/apps/web/lib/response/service.ts b/apps/web/lib/response/service.ts index aebaf937aecf..c2b9517a67f5 100644 --- a/apps/web/lib/response/service.ts +++ b/apps/web/lib/response/service.ts @@ -422,7 +422,7 @@ export const getResponseDownloadFile = async ( ...elements.flat(), ...variables, ...hiddenFields, - ...userAttributes, + ...userAttributes.map((attribute) => `person.${attribute}`), ]; if (survey.isVerifyEmailEnabled) { diff --git a/apps/web/lib/response/utils.test.ts b/apps/web/lib/response/utils.test.ts index 0ea60cdebcda..e94b6bb11bb6 100644 --- a/apps/web/lib/response/utils.test.ts +++ b/apps/web/lib/response/utils.test.ts @@ -431,6 +431,12 @@ describe("Response Utils", () => { expect(result.hiddenFields).toContain("hidden1"); expect(result.userAttributes).toContain("email"); }); + + test("should collect contact attributes for link surveys too", () => { + const linkSurvey = { ...mockSurvey, type: "link" } as TSurvey; + const result = extractSurveyDetails(linkSurvey, mockResponses as TResponse[]); + expect(result.userAttributes).toEqual(["email"]); + }); }); describe("getResponsesJson", () => { @@ -496,7 +502,24 @@ describe("Response Utils", () => { expect(result[0]["Response ID"]).toBe("response1"); expect(result[0]["userAgent - browser"]).toBe("Chrome"); expect(result[0]["1. Question 1"]).toBe("answer1"); - expect(result[0]["email"]).toBe("test@example.com"); + expect(result[0]["person.email"]).toBe("test@example.com"); + }); + + test("should namespace person attributes for link surveys too", () => { + const linkSurvey = { ...mockSurvey, type: "link" } as TSurvey; + const responsesWithContact = [ + { ...mockResponses[0], contactAttributes: { plan: "pro", email: "linked@example.com" } }, + ] as TResponse[]; + const result = getResponsesJson( + linkSurvey, + responsesWithContact, + [["1. Question 1"]], + ["plan", "email"], + [], + false + ); + expect(result[0]["person.plan"]).toBe("pro"); + expect(result[0]["person.email"]).toBe("linked@example.com"); }); }); diff --git a/apps/web/lib/response/utils.ts b/apps/web/lib/response/utils.ts index 69f37a355924..5847ac8d388c 100644 --- a/apps/web/lib/response/utils.ts +++ b/apps/web/lib/response/utils.ts @@ -688,10 +688,9 @@ export const extractSurveyDetails = (survey: TSurvey, responses: TResponse[]) => }); const hiddenFields = survey.hiddenFields?.fieldIds || []; - const userAttributes = - survey.type === "app" - ? Array.from(new Set(responses.map((response) => Object.keys(response.contactAttributes ?? {})).flat())) - : []; + const userAttributes = Array.from( + new Set(responses.map((response) => Object.keys(response.contactAttributes ?? {})).flat()) + ); const variables = survey.variables?.map((variable) => variable.name) || []; return { metaDataFields, elements, hiddenFields, variables, userAttributes }; @@ -781,9 +780,8 @@ export const getResponsesJson = ( jsonData[idx][variable.name] = answer; }); - // user attributes userAttributes.forEach((attribute) => { - jsonData[idx][attribute] = response.contactAttributes?.[attribute] || ""; + jsonData[idx][`person.${attribute}`] = response.contactAttributes?.[attribute] || ""; }); // hidden fields diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 9f3a796a431f..f118cced9015 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -2096,6 +2096,7 @@ "spreadsheet_url": "Tabellen-URL", "token_expired_error": "Das Google Sheets Refresh-Token ist abgelaufen oder wurde widerrufen. Bitte verbinde die Integration erneut." }, + "include_contact_attributes": "Include Person Attributes", "include_created_at": "Erstellungsdatum einbeziehen", "include_hidden_fields": "Versteckte Felder einbeziehen", "include_metadata": "Metadaten einbeziehen (Browser, Land, etc.)", @@ -3552,10 +3553,17 @@ "drop_offs": "Drop-Off Rate", "drop_offs_tooltip": "So oft wurde die Umfrage gestartet, aber nicht abgeschlossen.", "effort_score": "Aufwandswert", + "example_responses_generated_successfully": "{count} Beispielantworten generiert.", + "example_responses_generation_failed": "Beispielantworten konnten nicht generiert werden. Bitte versuche es erneut.", "filter_added_successfully": "Filter erfolgreich hinzugefügt", "filter_updated_successfully": "Filter erfolgreich aktualisiert", "filtered_responses_csv": "Gefilterte Antworten (CSV)", "filtered_responses_excel": "Gefilterte Antworten (Excel)", + "generate_example_responses": "Beispielantworten generieren", + "generate_example_responses_locked_disabled": "KI-Beispielantworten sind für diese Organisation deaktiviert.", + "generate_example_responses_locked_instance": "KI ist auf dieser Instanz nicht konfiguriert. Wende dich an deinen Administrator.", + "generate_example_responses_locked_plan": "KI-Beispielantworten sind in deinem aktuellen Tarif nicht verfügbar.", + "generating_example_responses": "Beispielantworten werden generiert…", "generating_qr_code": "QR-Code wird erstellt", "impressions": "Impressionen", "impressions_identified_only": "Es werden nur Impressionen von identifizierten Kontakten angezeigt", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 30ebdd5e9a58..4ca847430d7e 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -2096,6 +2096,7 @@ "spreadsheet_url": "Spreadsheet URL", "token_expired_error": "Google Sheets refresh token has expired or been revoked. Please reconnect the integration." }, + "include_contact_attributes": "Include Person Attributes", "include_created_at": "Include Created At", "include_hidden_fields": "Include Hidden Fields", "include_metadata": "Include Metadata (Browser, Country, etc.)", @@ -3552,10 +3553,17 @@ "drop_offs": "Drop-Offs", "drop_offs_tooltip": "Number of times the survey has been started but not completed.", "effort_score": "Effort Score", + "example_responses_generated_successfully": "Generated {count} example responses.", + "example_responses_generation_failed": "Couldn't generate example responses. Please try again.", "filter_added_successfully": "Filter added successfully", "filter_updated_successfully": "Filter updated successfully", "filtered_responses_csv": "Filtered responses (CSV)", "filtered_responses_excel": "Filtered responses (Excel)", + "generate_example_responses": "Generate example responses", + "generate_example_responses_locked_disabled": "AI example responses are disabled for this organization.", + "generate_example_responses_locked_instance": "AI is not configured on this instance. Contact your administrator.", + "generate_example_responses_locked_plan": "AI example responses are not available on your current plan.", + "generating_example_responses": "Generating example responses…", "generating_qr_code": "Generating QR code", "impressions": "Impressions", "impressions_identified_only": "Only showing impressions from identified contacts", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 66b4b5968ba8..0ea1c13e26c1 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -2096,6 +2096,7 @@ "spreadsheet_url": "URL de la hoja de cálculo", "token_expired_error": "El token de actualización de Google Sheets ha caducado o ha sido revocado. Reconecta la integración." }, + "include_contact_attributes": "Include Person Attributes", "include_created_at": "Incluir fecha de creación", "include_hidden_fields": "Incluir campos ocultos", "include_metadata": "Incluir metadatos (navegador, país, etc.)", @@ -3552,10 +3553,17 @@ "drop_offs": "Abandonos", "drop_offs_tooltip": "Número de veces que se ha iniciado la encuesta pero no se ha completado.", "effort_score": "Puntuación de Esfuerzo", + "example_responses_generated_successfully": "Se han generado {count} respuestas de ejemplo.", + "example_responses_generation_failed": "No se pudieron generar respuestas de ejemplo. Por favor, inténtalo de nuevo.", "filter_added_successfully": "Filtro añadido correctamente", "filter_updated_successfully": "Filtro actualizado correctamente", "filtered_responses_csv": "Respuestas filtradas (CSV)", "filtered_responses_excel": "Respuestas filtradas (Excel)", + "generate_example_responses": "Generar respuestas de ejemplo", + "generate_example_responses_locked_disabled": "Las respuestas de ejemplo con IA están deshabilitadas para esta organización.", + "generate_example_responses_locked_instance": "La IA no está configurada en esta instancia. Contacta con tu administrador.", + "generate_example_responses_locked_plan": "Las respuestas de ejemplo con IA no están disponibles en tu plan actual.", + "generating_example_responses": "Generando respuestas de ejemplo…", "generating_qr_code": "Generando código QR", "impressions": "Impresiones", "impressions_identified_only": "Solo se muestran impresiones de contactos identificados", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index ad47976835db..871528659914 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -2096,6 +2096,7 @@ "spreadsheet_url": "URL de la feuille de calcul", "token_expired_error": "Le jeton d'actualisation Google Sheets a expiré ou a été révoqué. Veuillez reconnecter l'intégration." }, + "include_contact_attributes": "Include Person Attributes", "include_created_at": "Inclure la date de création", "include_hidden_fields": "Inclure les champs cachés", "include_metadata": "Inclure des métadonnées (navigateur, pays, etc.)", @@ -3552,10 +3553,17 @@ "drop_offs": "Dépôts", "drop_offs_tooltip": "Nombre de fois que l'enquête a été commencée mais non terminée.", "effort_score": "Score d'effort", + "example_responses_generated_successfully": "{count} exemples de réponses générés.", + "example_responses_generation_failed": "Impossible de générer les exemples de réponses. Veuillez réessayer.", "filter_added_successfully": "Filtre ajouté avec succès", "filter_updated_successfully": "Filtre mis à jour avec succès", "filtered_responses_csv": "Réponses filtrées (CSV)", "filtered_responses_excel": "Réponses filtrées (Excel)", + "generate_example_responses": "Générer des exemples de réponses", + "generate_example_responses_locked_disabled": "Les réponses d'exemple générées par IA sont désactivées pour cette organisation.", + "generate_example_responses_locked_instance": "L'IA n'est pas configurée sur cette instance. Contacte ton administrateur.", + "generate_example_responses_locked_plan": "Les réponses d'exemple générées par IA ne sont pas disponibles avec ton forfait actuel.", + "generating_example_responses": "Génération des exemples de réponses en cours…", "generating_qr_code": "Génération du code QR", "impressions": "Impressions", "impressions_identified_only": "Affichage uniquement des impressions des contacts identifiés", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index 72e9011088c5..9c54736455b6 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -2096,6 +2096,7 @@ "spreadsheet_url": "Táblázat URL-e", "token_expired_error": "A Google Táblázatok frissítési tokenje lejárt vagy visszavonásra került. Csatlakoztassa újra az integrációt." }, + "include_contact_attributes": "Include Person Attributes", "include_created_at": "Létrehozva felvétele", "include_hidden_fields": "Rejtett mezők felvétele", "include_metadata": "Metaadatok (böngésző, ország stb.) felvétele", @@ -3552,10 +3553,17 @@ "drop_offs": "Megszakítások", "drop_offs_tooltip": "A kérdőív elkezdési, de be nem fejezési alkalmainak száma.", "effort_score": "Erőfeszítési Pontszám", + "example_responses_generated_successfully": "{count} példaválasz sikeresen legenerálva.", + "example_responses_generation_failed": "A példaválaszok generálása sikertelen volt. Kérem, próbálja újra.", "filter_added_successfully": "A szűrő sikeresen hozzáadva", "filter_updated_successfully": "A szűrő sikeresen frissítve", "filtered_responses_csv": "Szűrt válaszok (CSV)", "filtered_responses_excel": "Szűrt válaszok (Excel)", + "generate_example_responses": "Példaválaszok generálása", + "generate_example_responses_locked_disabled": "Az AI példaválaszok le vannak tiltva ennél a szervezetnél.", + "generate_example_responses_locked_instance": "Az AI nincs konfigurálva ezen a példányon. Lépjen kapcsolatba a rendszergazdával.", + "generate_example_responses_locked_plan": "Az AI példaválaszok nem érhetők el az Ön jelenlegi csomagjában.", + "generating_example_responses": "Példaválaszok generálása folyamatban…", "generating_qr_code": "QR-kód előállítása", "impressions": "Megtekintések", "impressions_identified_only": "Csak azonosított partnerektől származó megtekintések megjelenítése", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 63d567b345f5..15d7371fb856 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -2096,6 +2096,7 @@ "spreadsheet_url": "スプレッドシートURL", "token_expired_error": "Google Sheetsのリフレッシュトークンが期限切れになったか、取り消されました。統合を再接続してください。" }, + "include_contact_attributes": "Include Person Attributes", "include_created_at": "作成日時を含める", "include_hidden_fields": "非表示フィールドを含める", "include_metadata": "メタデータを含める(ブラウザ、国など)", @@ -3552,10 +3553,17 @@ "drop_offs": "離脱", "drop_offs_tooltip": "フォームが開始されたが完了しなかった回数。", "effort_score": "エフォートスコア", + "example_responses_generated_successfully": "{count}件のサンプル回答を生成しました。", + "example_responses_generation_failed": "サンプル回答を生成できませんでした。もう一度お試しください。", "filter_added_successfully": "フィルターを正常に追加しました", "filter_updated_successfully": "フィルターを正常に更新しました", "filtered_responses_csv": "フィルター済み回答 (CSV)", "filtered_responses_excel": "フィルター済み回答 (Excel)", + "generate_example_responses": "サンプル回答を生成", + "generate_example_responses_locked_disabled": "この組織ではAIサンプル回答が無効になっています。", + "generate_example_responses_locked_instance": "このインスタンスではAIが設定されていません。管理者に問い合わせてください。", + "generate_example_responses_locked_plan": "現在のプランではAIサンプル回答をご利用いただけません。", + "generating_example_responses": "サンプル回答を生成中…", "generating_qr_code": "QRコードを生成中", "impressions": "表示回数", "impressions_identified_only": "識別済みコンタクトからのインプレッションのみを表示しています", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 3bd1a0b4b2c5..4820182c767a 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -2096,6 +2096,7 @@ "spreadsheet_url": "Spreadsheet-URL", "token_expired_error": "Het vernieuwingstoken van Google Sheets is verlopen of ingetrokken. Maak opnieuw verbinding met de integratie." }, + "include_contact_attributes": "Include Person Attributes", "include_created_at": "Inclusief gemaakt op", "include_hidden_fields": "Inclusief verborgen velden", "include_metadata": "Metagegevens opnemen (browser, land, enz.)", @@ -3552,10 +3553,17 @@ "drop_offs": "Drop-offs", "drop_offs_tooltip": "Aantal keren dat de enquête is gestart maar niet is voltooid.", "effort_score": "Inspanningsscore", + "example_responses_generated_successfully": "{count} voorbeeldreacties gegenereerd.", + "example_responses_generation_failed": "Kon geen voorbeeldreacties genereren. Probeer het opnieuw.", "filter_added_successfully": "Filter succesvol toegevoegd", "filter_updated_successfully": "Filter succesvol bijgewerkt", "filtered_responses_csv": "Gefilterde reacties (CSV)", "filtered_responses_excel": "Gefilterde reacties (Excel)", + "generate_example_responses": "Genereer voorbeeldreacties", + "generate_example_responses_locked_disabled": "AI-voorbeeldantwoorden zijn uitgeschakeld voor deze organisatie.", + "generate_example_responses_locked_instance": "AI is niet geconfigureerd op deze instantie. Neem contact op met je beheerder.", + "generate_example_responses_locked_plan": "AI-voorbeeldantwoorden zijn niet beschikbaar op je huidige abonnement.", + "generating_example_responses": "Voorbeeldreacties genereren…", "generating_qr_code": "QR-code genereren", "impressions": "Indrukken", "impressions_identified_only": "Alleen weergaven van geïdentificeerde contacten worden getoond", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 7c9747c9de43..0f8c9901411e 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -2096,6 +2096,7 @@ "spreadsheet_url": "URL da planilha", "token_expired_error": "O token de atualização do Google Sheets expirou ou foi revogado. Reconecte a integração." }, + "include_contact_attributes": "Include Person Attributes", "include_created_at": "Incluir Data de Criação", "include_hidden_fields": "Incluir Campos Ocultos", "include_metadata": "Incluir Metadados (Navegador, País, etc.)", @@ -3552,10 +3553,17 @@ "drop_offs": "Pontos de Entrega", "drop_offs_tooltip": "Número de vezes que a pesquisa foi iniciada mas não concluída.", "effort_score": "Índice de Esforço", + "example_responses_generated_successfully": "Geradas {count} respostas de exemplo.", + "example_responses_generation_failed": "Não foi possível gerar respostas de exemplo. Por favor, tente novamente.", "filter_added_successfully": "Filtro adicionado com sucesso", "filter_updated_successfully": "Filtro atualizado com sucesso", "filtered_responses_csv": "Respostas filtradas (CSV)", "filtered_responses_excel": "Respostas filtradas (Excel)", + "generate_example_responses": "Gerar respostas de exemplo", + "generate_example_responses_locked_disabled": "As respostas de exemplo geradas por IA estão desativadas para esta organização.", + "generate_example_responses_locked_instance": "A IA não está configurada nesta instância. Entre em contato com seu administrador.", + "generate_example_responses_locked_plan": "As respostas de exemplo geradas por IA não estão disponíveis no seu plano atual.", + "generating_example_responses": "Gerando respostas de exemplo…", "generating_qr_code": "Gerando código QR", "impressions": "Impressões", "impressions_identified_only": "Mostrando apenas impressões de contatos identificados", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 3a66cf7e1138..b2bdaee06d84 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -2096,6 +2096,7 @@ "spreadsheet_url": "URL da folha de cálculo", "token_expired_error": "O token de atualização do Google Sheets expirou ou foi revogado. Por favor, reconecta a integração." }, + "include_contact_attributes": "Include Person Attributes", "include_created_at": "Incluir Criado Em", "include_hidden_fields": "Incluir Campos Ocultos", "include_metadata": "Incluir Metadados (Navegador, País, etc.)", @@ -3552,10 +3553,17 @@ "drop_offs": "Desistências", "drop_offs_tooltip": "Número de vezes que o inquérito foi iniciado mas não concluído.", "effort_score": "Pontuação de Esforço", + "example_responses_generated_successfully": "Foram geradas {count} respostas de exemplo.", + "example_responses_generation_failed": "Não foi possível gerar respostas de exemplo. Por favor, tenta novamente.", "filter_added_successfully": "Filtro adicionado com sucesso", "filter_updated_successfully": "Filtro atualizado com sucesso", "filtered_responses_csv": "Respostas filtradas (CSV)", "filtered_responses_excel": "Respostas filtradas (Excel)", + "generate_example_responses": "Gerar respostas de exemplo", + "generate_example_responses_locked_disabled": "As respostas de exemplo de IA estão desativadas para esta organização.", + "generate_example_responses_locked_instance": "A IA não está configurada nesta instância. Contacta o teu administrador.", + "generate_example_responses_locked_plan": "As respostas de exemplo de IA não estão disponíveis no teu plano atual.", + "generating_example_responses": "A gerar respostas de exemplo…", "generating_qr_code": "A gerar código QR", "impressions": "Impressões", "impressions_identified_only": "A mostrar apenas impressões de contactos identificados", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 733e802b9398..d184a48f66e8 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -2096,6 +2096,7 @@ "spreadsheet_url": "URL foaie de calcul", "token_expired_error": "Tokenul de reîmprospătare Google Sheets a expirat sau a fost revocat. Te rugăm să reconectezi integrarea." }, + "include_contact_attributes": "Include Person Attributes", "include_created_at": "Include data creării", "include_hidden_fields": "Include câmpuri ascunse", "include_metadata": "Includere Metadata (Browser, Țară, etc.)", @@ -3552,10 +3553,17 @@ "drop_offs": "Renunțări", "drop_offs_tooltip": "Număr de ori când sondajul a fost început dar nu a fost finalizat.", "effort_score": "Scor de efort", + "example_responses_generated_successfully": "Au fost generate {count} răspunsuri exemplu.", + "example_responses_generation_failed": "Nu am putut genera răspunsuri exemplu. Te rugăm să încerci din nou.", "filter_added_successfully": "Filtru adăugat cu succes", "filter_updated_successfully": "Filtru actualizat cu succes", "filtered_responses_csv": "Răspunsuri filtrate (CSV)", "filtered_responses_excel": "Răspunsuri filtrate (Excel)", + "generate_example_responses": "Generează răspunsuri exemplu", + "generate_example_responses_locked_disabled": "Răspunsurile exemplu AI sunt dezactivate pentru această organizație.", + "generate_example_responses_locked_instance": "AI nu este configurat pe această instanță. Contactează administratorul.", + "generate_example_responses_locked_plan": "Răspunsurile exemplu AI nu sunt disponibile în planul tău actual.", + "generating_example_responses": "Se generează răspunsuri exemplu…", "generating_qr_code": "Se generează codul QR", "impressions": "Impresii", "impressions_identified_only": "Se afișează doar impresiile de la contactele identificate", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 2777fd1b423b..3775dc58f96f 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -2096,6 +2096,7 @@ "spreadsheet_url": "URL таблицы", "token_expired_error": "Срок действия токена обновления Google Sheets истёк или он был отозван. Пожалуйста, переподключи интеграцию." }, + "include_contact_attributes": "Include Person Attributes", "include_created_at": "Включить дату создания", "include_hidden_fields": "Включить скрытые поля", "include_metadata": "Включить метаданные (браузер, страна и т. д.)", @@ -3552,10 +3553,17 @@ "drop_offs": "Прерывания", "drop_offs_tooltip": "Количество раз, когда опрос был начат, но не завершён.", "effort_score": "Оценка усилий", + "example_responses_generated_successfully": "Создано {count, plural, one {# пример ответа} few {# примера ответов} many {# примеров ответов} other {# примеров ответов}}.", + "example_responses_generation_failed": "Не удалось создать примеры ответов. Пожалуйста, попробуй снова.", "filter_added_successfully": "Фильтр успешно добавлен", "filter_updated_successfully": "Фильтр успешно обновлён", "filtered_responses_csv": "Отфильтрованные ответы (CSV)", "filtered_responses_excel": "Отфильтрованные ответы (Excel)", + "generate_example_responses": "Создать примеры ответов", + "generate_example_responses_locked_disabled": "Примеры ответов с ИИ отключены для этой организации.", + "generate_example_responses_locked_instance": "ИИ не настроен на этом сервере. Свяжитесь с администратором.", + "generate_example_responses_locked_plan": "Примеры ответов с ИИ недоступны в вашем текущем тарифе.", + "generating_example_responses": "Создаём примеры ответов…", "generating_qr_code": "Генерация QR-кода", "impressions": "Просмотры", "impressions_identified_only": "Показаны только показы от идентифицированных контактов", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 549b1f3bc40a..f4ae83e09c25 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -2096,6 +2096,7 @@ "spreadsheet_url": "Kalkylblads-URL", "token_expired_error": "Google Sheets refresh token har gått ut eller återkallats. Återanslut integrationen." }, + "include_contact_attributes": "Include Person Attributes", "include_created_at": "Inkludera Skapad vid", "include_hidden_fields": "Inkludera dolda fält", "include_metadata": "Inkludera metadata (webbläsare, land, etc.)", @@ -3552,10 +3553,17 @@ "drop_offs": "Avhopp", "drop_offs_tooltip": "Antal gånger enkäten har startats men inte slutförts.", "effort_score": "Ansträngningspoäng", + "example_responses_generated_successfully": "Genererade {count} exempelsvar.", + "example_responses_generation_failed": "Kunde inte generera exempelsvar. Försök igen.", "filter_added_successfully": "Filter tillagt", "filter_updated_successfully": "Filter uppdaterat", "filtered_responses_csv": "Filtrerade svar (CSV)", "filtered_responses_excel": "Filtrerade svar (Excel)", + "generate_example_responses": "Generera exempelsvar", + "generate_example_responses_locked_disabled": "AI-exempelsvar är inaktiverade för den här organisationen.", + "generate_example_responses_locked_instance": "AI är inte konfigurerat på den här instansen. Kontakta din administratör.", + "generate_example_responses_locked_plan": "AI-exempelsvar är inte tillgängliga i din nuvarande plan.", + "generating_example_responses": "Genererar exempelsvar…", "generating_qr_code": "Genererar QR-kod", "impressions": "Visningar", "impressions_identified_only": "Visar bara visningar från identifierade kontakter", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index 413bc47d11af..c9434e807520 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -2096,6 +2096,7 @@ "spreadsheet_url": "Elektronik Tablo URL'si", "token_expired_error": "Google Sheets yenileme belirtecinin süresi doldu veya iptal edildi. Lütfen entegrasyonu yeniden bağla." }, + "include_contact_attributes": "Include Person Attributes", "include_created_at": "Oluşturulma Tarihini Dahil Et", "include_hidden_fields": "Gizli Alanları Dahil Et", "include_metadata": "Meta Verileri Dahil Et (Tarayıcı, Ülke, vb.)", @@ -3552,10 +3553,17 @@ "drop_offs": "Bırakılanlar", "drop_offs_tooltip": "Anketin başlatılıp tamamlanmadığı durum sayısı.", "effort_score": "Çaba Skoru", + "example_responses_generated_successfully": "{count} örnek yanıt oluşturuldu.", + "example_responses_generation_failed": "Örnek yanıtlar oluşturulamadı. Lütfen tekrar deneyin.", "filter_added_successfully": "Filtre başarıyla eklendi", "filter_updated_successfully": "Filtre başarıyla güncellendi", "filtered_responses_csv": "Filtrelenmiş yanıtlar (CSV)", "filtered_responses_excel": "Filtrelenmiş yanıtlar (Excel)", + "generate_example_responses": "Örnek yanıtlar oluştur", + "generate_example_responses_locked_disabled": "Bu organizasyon için AI örnek yanıtları devre dışı bırakılmış.", + "generate_example_responses_locked_instance": "Bu örnekte AI yapılandırılmamış. Yöneticinizle iletişime geçin.", + "generate_example_responses_locked_plan": "AI örnek yanıtları mevcut planınızda mevcut değil.", + "generating_example_responses": "Örnek yanıtlar oluşturuluyor…", "generating_qr_code": "QR kodu oluşturuluyor", "impressions": "Görüntülenmeler", "impressions_identified_only": "Yalnızca tanımlanan kişilerden gelen görüntülenmeler gösteriliyor", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 58e1f41849c1..e2425ff2fcf5 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -2096,6 +2096,7 @@ "spreadsheet_url": "电子表格 URL", "token_expired_error": "Google Sheets 的刷新令牌已过期或被撤销。请重新连接集成。" }, + "include_contact_attributes": "Include Person Attributes", "include_created_at": "包括 创建 于", "include_hidden_fields": "包括 隐藏 字段", "include_metadata": "包含 元数据 (浏览器 、国家 等)", @@ -3552,10 +3553,17 @@ "drop_offs": "流失", "drop_offs_tooltip": "调查 被 开始 但 未 完成 的 次数", "effort_score": "费力度评分", + "example_responses_generated_successfully": "已生成 {count} 个示例回复。", + "example_responses_generation_failed": "无法生成示例回复。请重试。", "filter_added_successfully": "筛选器 添加成功", "filter_updated_successfully": "筛选器 更新 成功", "filtered_responses_csv": "过滤 反馈 (CSV)", "filtered_responses_excel": "过滤 反馈 (Excel)", + "generate_example_responses": "生成示例回复", + "generate_example_responses_locked_disabled": "此组织已禁用 AI 示例回复。", + "generate_example_responses_locked_instance": "此实例未配置 AI。请联系你的管理员。", + "generate_example_responses_locked_plan": "你当前的套餐不支持 AI 示例回复。", + "generating_example_responses": "正在生成示例回复…", "generating_qr_code": "正在生成二维码", "impressions": "印象", "impressions_identified_only": "仅显示已识别联系人的展示次数", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index eb90e555069f..276aae57e4c0 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -2096,6 +2096,7 @@ "spreadsheet_url": "試算表網址", "token_expired_error": "Google Sheets 的刷新權杖已過期或被撤銷。請重新連線整合。" }, + "include_contact_attributes": "Include Person Attributes", "include_created_at": "包含建立於", "include_hidden_fields": "包含隱藏欄位", "include_metadata": "包含元數據(瀏覽器、國家/地區等)", @@ -3552,10 +3553,17 @@ "drop_offs": "放棄", "drop_offs_tooltip": "問卷已開始但未完成的次數。", "effort_score": "努力分數", + "example_responses_generated_successfully": "已成功產生 {count} 個範例回應。", + "example_responses_generation_failed": "無法產生範例回應。請重試。", "filter_added_successfully": "篩選器已成功新增", "filter_updated_successfully": "篩選器已成功更新", "filtered_responses_csv": "篩選回應 (CSV)", "filtered_responses_excel": "篩選回應 (Excel)", + "generate_example_responses": "產生範例回應", + "generate_example_responses_locked_disabled": "此組織已停用 AI 範例回覆功能。", + "generate_example_responses_locked_instance": "此實例尚未設定 AI 功能。請聯絡您的管理員。", + "generate_example_responses_locked_plan": "您目前的方案不包含 AI 範例回覆功能。", + "generating_example_responses": "正在產生範例回應…", "generating_qr_code": "正在生成 QR code", "impressions": "曝光數", "impressions_identified_only": "僅顯示已識別聯絡人的曝光次數", diff --git a/apps/web/modules/core/rate-limit/rate-limit-configs.test.ts b/apps/web/modules/core/rate-limit/rate-limit-configs.test.ts index 6e96b0d0e0ec..b17431d8f952 100644 --- a/apps/web/modules/core/rate-limit/rate-limit-configs.test.ts +++ b/apps/web/modules/core/rate-limit/rate-limit-configs.test.ts @@ -82,6 +82,7 @@ describe("rateLimitConfigs", () => { "isSurveyResponsePresent", "validateSurveyPin", "licenseRecheck", + "generateExampleResponses", ]); }); diff --git a/apps/web/modules/core/rate-limit/rate-limit-configs.ts b/apps/web/modules/core/rate-limit/rate-limit-configs.ts index fc50a5aca9e6..f07754d89482 100644 --- a/apps/web/modules/core/rate-limit/rate-limit-configs.ts +++ b/apps/web/modules/core/rate-limit/rate-limit-configs.ts @@ -41,6 +41,11 @@ export const rateLimitConfigs = { namespace: "action:validate-survey-pin", }, // 10 per minute — prevents brute-force PIN guessing licenseRecheck: { interval: 60, allowedPerInterval: 5, namespace: "action:license-recheck" }, // 5 per minute + generateExampleResponses: { + interval: 60, + allowedPerInterval: 1, + namespace: "action:generate-example-responses", + }, // 1 per minute per user — closes the multi-click race and bounds LLM spend }, storage: { diff --git a/apps/web/modules/response-pipeline/lib/handle-integrations.test.ts b/apps/web/modules/response-pipeline/lib/handle-integrations.test.ts index ca3a1d6cfe55..f570c7d0ee1e 100644 --- a/apps/web/modules/response-pipeline/lib/handle-integrations.test.ts +++ b/apps/web/modules/response-pipeline/lib/handle-integrations.test.ts @@ -84,6 +84,7 @@ const mockPipelineInput = { action: "Action Name", ipAddress: "203.0.113.7", } as TResponseMeta, + contactAttributes: { plan: "pro", email: "person@example.com" }, personAttributes: {}, singleUseId: null, personId: "person1", @@ -559,4 +560,118 @@ describe("handleIntegrations", () => { expect(logger.error).toHaveBeenCalledWith(error, "Error in notion integration"); }); }); + + describe("Person attributes (includeContactAttributes)", () => { + test("Airtable: appends person.* columns when includeContactAttributes is true", async () => { + vi.mocked(airtableWriteData).mockResolvedValue(undefined); + const integration: TIntegrationAirtable = structuredClone(mockAirtableIntegration); + integration.config.data[0].includeContactAttributes = true; + // Drop hidden/meta/var/created toggles to keep the assertion focused. + integration.config.data[0].includeHiddenFields = false; + integration.config.data[0].includeMetadata = false; + integration.config.data[0].includeVariables = false; + integration.config.data[0].includeCreatedAt = false; + + await handleIntegrations([integration], mockPipelineInput, mockSurvey); + + const [, , responses, elements] = vi.mocked(airtableWriteData).mock.calls[0]; + expect(elements).toContain("person.plan"); + expect(elements).toContain("person.email"); + expect(responses[elements.indexOf("person.plan")]).toBe("pro"); + expect(responses[elements.indexOf("person.email")]).toBe("person@example.com"); + }); + + test("Google Sheets: omits person.* columns when toggle is off (default)", async () => { + vi.mocked(googleSheetWriteData).mockResolvedValue(undefined); + // mockGoogleSheetsIntegration has includeContactAttributes unset → off. + await handleIntegrations([mockGoogleSheetsIntegration], mockPipelineInput, mockSurvey); + + const [, , , elements] = vi.mocked(googleSheetWriteData).mock.calls[0]; + expect(elements.every((e) => !e.startsWith("person."))).toBe(true); + }); + + test("Slack: appends person.* columns when toggle is on", async () => { + vi.mocked(writeDataToSlack).mockResolvedValue(undefined); + const integration: TIntegrationSlack = structuredClone(mockSlackIntegration); + integration.config.data[0].includeContactAttributes = true; + + await handleIntegrations([integration], mockPipelineInput, mockSurvey); + + const [, , responses, elements] = vi.mocked(writeDataToSlack).mock.calls[0]; + expect(elements).toContain("person.plan"); + expect(elements).toContain("person.email"); + expect(responses[elements.indexOf("person.plan")]).toBe("pro"); + }); + + test("Notion: maps person. mapping entries to the matching contact attribute", async () => { + vi.mocked(writeNotionData).mockResolvedValue(undefined); + const integration: TIntegrationNotion = structuredClone(mockNotionIntegration); + integration.config.data[0].mapping.push({ + element: { id: "person.plan", name: "Person: Plan", type: TSurveyElementTypeEnum.OpenText }, + column: { id: "col_plan", name: "Plan Col", type: "rich_text" }, + }); + + await handleIntegrations([integration], mockPipelineInput, mockSurvey); + + const [, properties] = vi.mocked(writeNotionData).mock.calls[0] as unknown as [ + string, + Record, + ]; + expect(properties["Plan Col"]).toBeDefined(); + expect(properties["Plan Col"].rich_text).not.toBeNull(); + }); + + test("emits no person.* columns when contactAttributes is null even if toggle is on", async () => { + vi.mocked(airtableWriteData).mockResolvedValue(undefined); + const integration: TIntegrationAirtable = structuredClone(mockAirtableIntegration); + integration.config.data[0].includeContactAttributes = true; + const input = structuredClone(mockPipelineInput) as typeof mockPipelineInput; + (input.response as unknown as { contactAttributes: null }).contactAttributes = null; + + await handleIntegrations([integration], input, mockSurvey); + + const [, , , elements] = vi.mocked(airtableWriteData).mock.calls[0]; + expect(elements.every((e) => !e.startsWith("person."))).toBe(true); + }); + + test("Notion person. renders null when response has no contactAttributes", async () => { + vi.mocked(writeNotionData).mockResolvedValue(undefined); + const integration: TIntegrationNotion = structuredClone(mockNotionIntegration); + integration.config.data[0].mapping.push({ + element: { id: "person.plan", name: "Person: Plan", type: TSurveyElementTypeEnum.OpenText }, + column: { id: "col_plan", name: "Plan Col", type: "rich_text" }, + }); + const input = structuredClone(mockPipelineInput) as typeof mockPipelineInput; + (input.response as unknown as { contactAttributes: null }).contactAttributes = null; + + await handleIntegrations([integration], input, mockSurvey); + + const [, properties] = vi.mocked(writeNotionData).mock.calls[0] as unknown as [ + string, + Record, + ]; + expect(properties["Plan Col"].rich_text).toBeNull(); + }); + + test("Notion: gracefully handles person. when the attribute is missing", async () => { + vi.mocked(writeNotionData).mockResolvedValue(undefined); + const integration: TIntegrationNotion = structuredClone(mockNotionIntegration); + integration.config.data[0].mapping.push({ + element: { + id: "person.does_not_exist", + name: "Person: Missing", + type: TSurveyElementTypeEnum.OpenText, + }, + column: { id: "col_missing", name: "Missing Col", type: "rich_text" }, + }); + + await handleIntegrations([integration], mockPipelineInput, mockSurvey); + + const [, properties] = vi.mocked(writeNotionData).mock.calls[0] as unknown as [ + string, + Record, + ]; + expect(properties["Missing Col"].rich_text).toBeNull(); + }); + }); }); diff --git a/apps/web/modules/response-pipeline/lib/handle-integrations.ts b/apps/web/modules/response-pipeline/lib/handle-integrations.ts index bb286ebed444..084cf9b478ea 100644 --- a/apps/web/modules/response-pipeline/lib/handle-integrations.ts +++ b/apps/web/modules/response-pipeline/lib/handle-integrations.ts @@ -23,11 +23,13 @@ import { truncateText } from "@/lib/utils/strings"; import { resolveStorageUrlAuto } from "@/modules/storage/utils"; type TIntegrationPipelineData = { - response: Pick; + response: Pick; surveyId: string; }; type TPipelineIntegrationSurvey = Pick; +const NOTION_PERSON_ATTRIBUTE_PREFIX = "person."; + const convertMetaObjectToString = (metadata: TResponseMeta): string => { let result: string[] = []; if (metadata.source) result.push(`Source: ${metadata.source}`); @@ -49,6 +51,7 @@ interface TIntegrationFieldSelection { includeHiddenFields: boolean; includeMetadata: boolean; includeVariables: boolean; + includeContactAttributes: boolean; } const toIntegrationFieldSelection = (config: { @@ -57,12 +60,14 @@ const toIntegrationFieldSelection = (config: { includeHiddenFields?: boolean | null; includeMetadata?: boolean | null; includeVariables?: boolean | null; + includeContactAttributes?: boolean | null; }): TIntegrationFieldSelection => ({ elementIds: config.elementIds, includeCreatedAt: Boolean(config.includeCreatedAt), includeHiddenFields: Boolean(config.includeHiddenFields), includeMetadata: Boolean(config.includeMetadata), includeVariables: Boolean(config.includeVariables), + includeContactAttributes: Boolean(config.includeContactAttributes), }); const processDataForIntegration = async ( @@ -74,7 +79,14 @@ const processDataForIntegration = async ( responses: string[]; elements: string[]; }> => { - const { elementIds, includeCreatedAt, includeHiddenFields, includeMetadata, includeVariables } = selection; + const { + elementIds, + includeCreatedAt, + includeHiddenFields, + includeMetadata, + includeVariables, + includeContactAttributes, + } = selection; const ids = includeHiddenFields && survey.hiddenFields.fieldIds ? [...elementIds, ...survey.hiddenFields.fieldIds] @@ -99,6 +111,12 @@ const processDataForIntegration = async ( responses.push(`${getFormattedDateTimeString(date)}`); elements.push("Created At"); } + if (includeContactAttributes && data.response.contactAttributes) { + Object.entries(data.response.contactAttributes).forEach(([key, value]) => { + responses.push(String(value)); + elements.push(`person.${key}`); + }); + } return { responses, @@ -413,6 +431,12 @@ const buildNotionPayloadProperties = ( properties[map.column.name] = { [map.column.type]: getValue(map.column.type, data.response.createdAt) || null, }; + } else if (map.element.id.startsWith(NOTION_PERSON_ATTRIBUTE_PREFIX)) { + const attributeKey = map.element.id.slice(NOTION_PERSON_ATTRIBUTE_PREFIX.length); + const value = data.response.contactAttributes?.[attributeKey]; + properties[map.column.name] = { + [map.column.type]: getValue(map.column.type, value) || null, + }; } else { const value = normalizedResponses[map.element.id]; properties[map.column.name] = { diff --git a/apps/web/modules/ui/components/additional-integration-settings/index.tsx b/apps/web/modules/ui/components/additional-integration-settings/index.tsx index efe971f635e0..6e355230dbd6 100644 --- a/apps/web/modules/ui/components/additional-integration-settings/index.tsx +++ b/apps/web/modules/ui/components/additional-integration-settings/index.tsx @@ -9,10 +9,12 @@ interface AdditionalIntegrationSettingsProps { includeHiddenFields: boolean; includeMetadata: boolean; includeCreatedAt: boolean; + includeContactAttributes: boolean; setIncludeVariables: (includeVariables: boolean) => void; setIncludeHiddenFields: (includeHiddenFields: boolean) => void; setIncludeMetadata: (includeMetadata: boolean) => void; setIncludeCreatedAt: (includeCreatedAt: boolean) => void; + setIncludeContactAttributes: (includeContactAttributes: boolean) => void; } export const AdditionalIntegrationSettings = ({ @@ -20,10 +22,12 @@ export const AdditionalIntegrationSettings = ({ includeHiddenFields, includeMetadata, includeCreatedAt, + includeContactAttributes, setIncludeVariables, setIncludeHiddenFields, setIncludeMetadata, setIncludeCreatedAt, + setIncludeContactAttributes, }: AdditionalIntegrationSettingsProps) => { const { t } = useTranslation(); @@ -52,6 +56,12 @@ export const AdditionalIntegrationSettings = ({ onChange: setIncludeMetadata, label: t("workspace.integrations.include_metadata"), }, + { + id: "includeContactAttributes", + checked: includeContactAttributes, + onChange: setIncludeContactAttributes, + label: t("workspace.integrations.include_contact_attributes"), + }, ]; return ( diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index f808965463ed..549893c11d2c 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -8,6 +8,7 @@ export { resetLanguageModelCache, } from "./provider"; export { generateText } from "./text"; +export { generateObject } from "./object"; export type { TAIProvider } from "@formbricks/types/ai"; export type { AIConfigurationStatus, @@ -15,6 +16,8 @@ export type { AIEnvironment, AIProviderStatus, ActiveAIProvider, + TGenerateObjectOptions, + TGenerateObjectResult, TGenerateTextOptions, TGenerateTextResult, } from "./types"; diff --git a/packages/ai/src/object.test.ts b/packages/ai/src/object.test.ts new file mode 100644 index 000000000000..9720b87f3752 --- /dev/null +++ b/packages/ai/src/object.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { generateObject } from "./object"; + +const mocks = vi.hoisted(() => ({ + generateText: vi.fn(), + outputObject: vi.fn(), + getAiModel: vi.fn(), +})); + +vi.mock("ai", () => ({ + generateText: mocks.generateText, + Output: { object: mocks.outputObject }, +})); + +vi.mock("./provider", () => ({ + getAiModel: mocks.getAiModel, +})); + +describe("packages/ai object helpers", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getAiModel.mockReturnValue({ providerName: "google", modelName: "gemini-2.5-flash" }); + mocks.outputObject.mockImplementation(({ schema }: { schema: unknown }) => ({ + __outputSpec: "object", + schema, + })); + }); + + test("uses the configured provider model when generating a structured object", async () => { + const environment = { + AI_PROVIDER: "google", + AI_MODEL: "gemini-2.5-flash", + }; + // The schema is opaque to the wrapper — it's passed through to Output.object + // and never validated by us. A sentinel object is enough for the assertions + // and avoids dragging zod into this package's deps just for the test. + const schema = { __schema: "sentinel" } as never; + mocks.generateText.mockResolvedValue({ output: { answer: "42" } }); + + const result = await generateObject({ schema, prompt: "What is the answer?" }, environment); + + expect(mocks.getAiModel).toHaveBeenCalledWith(environment); + expect(mocks.outputObject).toHaveBeenCalledWith({ schema }); + expect(mocks.generateText).toHaveBeenCalledWith({ + prompt: "What is the answer?", + model: { providerName: "google", modelName: "gemini-2.5-flash" }, + output: { __outputSpec: "object", schema }, + }); + expect(result.object).toEqual({ answer: "42" }); + }); +}); diff --git a/packages/ai/src/object.ts b/packages/ai/src/object.ts new file mode 100644 index 000000000000..da3242e99b01 --- /dev/null +++ b/packages/ai/src/object.ts @@ -0,0 +1,18 @@ +import { Output, generateText } from "ai"; +import { getAiModel } from "./provider"; +import type { AIEnvironment, TGenerateObjectOptions, TGenerateObjectResult } from "./types"; + +export const generateObject = async ( + options: TGenerateObjectOptions, + environment?: AIEnvironment +): Promise> => { + const { schema, ...rest } = options; + const request = { + ...rest, + model: getAiModel(environment), + output: Output.object({ schema }), + } as Parameters[0]; + const result = await generateText(request); + + return { ...result, object: result.output as T } as TGenerateObjectResult; +}; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index a30cec198851..4fd41495c8dd 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -1,4 +1,4 @@ -import type { LanguageModel, generateText } from "ai"; +import type { FlexibleSchema, LanguageModel, generateText } from "ai"; export const AI_PROVIDERS = ["aws", "google", "azure"] as const; @@ -47,3 +47,14 @@ export interface AIConfigurationStatus { export type AILanguageModel = LanguageModel; export type TGenerateTextOptions = Omit[0], "model">; export type TGenerateTextResult = Awaited>; + +export type TGenerateObjectOptions = Omit< + Parameters[0], + "model" | "output" | "experimental_output" +> & { + schema: FlexibleSchema; +}; +export type TGenerateObjectResult = { object: T } & Omit< + Awaited>, + "output" | "experimental_output" +>; diff --git a/packages/types/integration/shared-types.ts b/packages/types/integration/shared-types.ts index 45d923d2f6c6..8126725fe242 100644 --- a/packages/types/integration/shared-types.ts +++ b/packages/types/integration/shared-types.ts @@ -13,6 +13,7 @@ export const ZIntegrationBaseSurveyData = z.object({ includeHiddenFields: z.boolean().optional(), includeMetadata: z.boolean().optional(), includeCreatedAt: z.boolean().optional(), + includeContactAttributes: z.boolean().optional(), // questions: z.string(), elements: z.string(), surveyId: z.string(),