From ec55268b81c1bd3564890d2a33d8bc8d516e2394 Mon Sep 17 00:00:00 2001 From: Tiago <1585571+xernobyl@users.noreply.github.com> Date: Thu, 30 Jul 2026 06:22:12 +0000 Subject: [PATCH 1/4] fix: prevent feedback-source ingestion crash on malformed choice elements (ENG-1939) (#8600) --- apps/web/lib/feedback-source/import.test.ts | 33 ++++++++++ apps/web/lib/feedback-source/import.ts | 19 +++++- .../lib/feedback-source/pipeline-handler.ts | 4 +- .../web/lib/feedback-source/transform.test.ts | 64 +++++++++++++++++++ apps/web/lib/feedback-source/transform.ts | 19 ++++-- apps/web/lib/feedback-source/utils.ts | 3 + .../web/lib/survey/service.scheduling.test.ts | 8 +-- apps/web/lib/survey/service.test.ts | 33 ++++++++-- apps/web/lib/survey/service.ts | 17 ++++- 9 files changed, 180 insertions(+), 20 deletions(-) diff --git a/apps/web/lib/feedback-source/import.test.ts b/apps/web/lib/feedback-source/import.test.ts index a7d66d4bcb81..049f7f9e305a 100644 --- a/apps/web/lib/feedback-source/import.test.ts +++ b/apps/web/lib/feedback-source/import.test.ts @@ -16,9 +16,18 @@ vi.mock("./transform", () => ({ transformResponseToFeedbackRecords: vi.fn(), })); +vi.mock("@formbricks/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + const { getResponses } = vi.mocked(await import("../response/service")); const { createFeedbackRecordsBatch } = vi.mocked(await import("@/modules/hub")); const { transformResponseToFeedbackRecords } = vi.mocked(await import("./transform")); +const { logger } = await import("@formbricks/logger"); const ENV_ID = "clxxxxxxxxxxxxxxxx001"; const FEEDBACK_SOURCE_ID = "clxxxxxxxxxxxxxxxx002"; @@ -168,4 +177,28 @@ describe("importHistoricalResponses", () => { expect(createFeedbackRecordsBatch).not.toHaveBeenCalled(); expect(result).toEqual({ successes: 0, failures: 0, skipped: 2 }); }); + + test("contains a transform failure instead of aborting the whole import (ENG-1939)", async () => { + const mockResponses = [{ id: "r1" }, { id: "r2" }]; + getResponses.mockResolvedValueOnce(mockResponses as never); + getResponses.mockResolvedValueOnce([]); + + // First response throws (e.g. malformed choice element); the second is healthy. + transformResponseToFeedbackRecords + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'some')"); + }) + .mockReturnValueOnce([{ field: "record2" }] as never); + + createFeedbackRecordsBatch.mockResolvedValue({ + results: [{ data: { id: "fb2" }, error: null }], + } as never); + + const result = await importHistoricalResponses(mockFeedbackSource, mockSurvey); + + // The healthy response is still imported; the throwing one is contained and logged. + expect(result.successes).toBe(1); + expect(createFeedbackRecordsBatch).toHaveBeenCalledTimes(1); + expect(vi.mocked(logger.error)).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/web/lib/feedback-source/import.ts b/apps/web/lib/feedback-source/import.ts index 8962d8915712..911b43690621 100644 --- a/apps/web/lib/feedback-source/import.ts +++ b/apps/web/lib/feedback-source/import.ts @@ -1,4 +1,5 @@ import "server-only"; +import { logger } from "@formbricks/logger"; import { InvalidInputError } from "@formbricks/types/errors"; import { TFeedbackSourceFormbricksMapping, @@ -8,6 +9,7 @@ import { TSurvey } from "@formbricks/types/surveys/types"; import { createFeedbackRecordsBatch } from "@/modules/hub"; import { getResponses } from "../response/service"; import { transformResponseToFeedbackRecords } from "./transform"; +import { getErrorMessage } from "./utils"; const IMPORT_BATCH_SIZE = 50; @@ -24,9 +26,20 @@ const processBatch = async ( let duplicates = 0; const expectedRecords = responses.length * mappings.length; - const allRecords = responses.flatMap((response) => - transformResponseToFeedbackRecords(response, survey, mappings, tenantId) - ); + const allRecords = responses.flatMap((response) => { + try { + return transformResponseToFeedbackRecords(response, survey, mappings, tenantId); + } catch (error) { + // Contain a per-response transform failure so one malformed response can't abort the + // entire historical import (the live pipeline path already isolates per response). The + // response yields no records and is counted under `skipped`; the cause is logged. + logger.error( + { surveyId: survey.id, responseId: response.id, error: getErrorMessage(error) }, + "Historical import: failed to transform response, skipping" + ); + return []; + } + }); if (allRecords.length > 0) { const { results } = await createFeedbackRecordsBatch(allRecords); diff --git a/apps/web/lib/feedback-source/pipeline-handler.ts b/apps/web/lib/feedback-source/pipeline-handler.ts index 478cddbd91cd..23eab789dae2 100644 --- a/apps/web/lib/feedback-source/pipeline-handler.ts +++ b/apps/web/lib/feedback-source/pipeline-handler.ts @@ -6,9 +6,7 @@ import { TSurvey } from "@formbricks/types/surveys/types"; import { createFeedbackRecordsBatch } from "@/modules/hub"; import { getFeedbackSourcesBySurveyId, updateFeedbackSource } from "./service"; import { transformResponseToFeedbackRecords } from "./transform"; - -const getErrorMessage = (error: unknown): string => - error instanceof Error ? error.message : "Unknown error"; +import { getErrorMessage } from "./utils"; const logFailedRecords = ( feedbackSourceId: string, diff --git a/apps/web/lib/feedback-source/transform.test.ts b/apps/web/lib/feedback-source/transform.test.ts index 884566dc0c45..1b65fe4b206d 100644 --- a/apps/web/lib/feedback-source/transform.test.ts +++ b/apps/web/lib/feedback-source/transform.test.ts @@ -370,6 +370,32 @@ describe("transformResponseToFeedbackRecords", () => { expect(result[0].value_text).toBe("single-choice"); }); + // ENG-1939: stored survey.blocks JSON is not re-validated on this path, so a choice element + // can arrive with choices absent even though the schema requires min(2). A single string + // answer routes through normalizeElementValue; with no choices and no otherOptionPlaceholder + // it must fall through, not throw on choices.some(...). Covers both types because a + // multipleChoiceMulti with a non-array value falls through the same as multipleChoiceSingle. + test.each(["multipleChoiceSingle", "multipleChoiceMulti"] as const)( + "does not crash when a %s element has no choices array (unmatched free-text answer)", + (type) => { + const surveyNoChoices = { + ...mockSurvey, + blocks: [{ elements: [{ id: "el-choice", type, headline: { default: "Pick one" } }] }], + } as unknown as TSurvey; + const response = { + ...mockResponse, + data: { "el-choice": "free text answer" }, + } as unknown as TResponse; + const mappings = [createMapping({ elementId: "el-choice", hubFieldType: "categorical" })]; + + const result = transformResponseToFeedbackRecords(response, surveyNoChoices, mappings, mockTenantId); + + expect(result).toHaveLength(1); + expect(result[0].value_text).toBe("free text answer"); + expect(result[0].value_id).toBeUndefined(); + } + ); + test("JSON-stringifies object value for categorical field (matrix/ranking responses)", () => { const response = { ...mockResponse, @@ -543,6 +569,25 @@ describe("transformResponseToFeedbackRecords", () => { expect(result).toEqual([]); }); + + // ENG-1939 (same class): a matrix element can reach this path with rows absent even though + // the schema requires it, since stored survey.blocks JSON is not re-validated. Must not throw. + test("does not crash when the matrix element has no rows array", () => { + const surveyNoRows = { + ...matrixSurvey, + blocks: [{ elements: [{ id: "el-matrix", type: "matrix", headline: { default: "Rate" } }] }], + } as unknown as TSurvey; + const response = { + id: "resp-matrix-no-rows", + createdAt: NOW, + data: { "el-matrix": { Speed: "Good" } }, + } as unknown as TResponse; + const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })]; + + const result = transformResponseToFeedbackRecords(response, surveyNoRows, mappings, mockTenantId); + + expect(result).toEqual([]); + }); }); describe("ranking expansion", () => { @@ -616,6 +661,25 @@ describe("transformResponseToFeedbackRecords", () => { expect(result).toEqual([]); }); + // ENG-1939 (same class): a ranking element can reach this path with choices absent even though + // the schema requires it, since stored survey.blocks JSON is not re-validated. Must not throw. + test("does not crash when the ranking element has no choices array", () => { + const surveyNoChoices = { + ...rankingSurvey, + blocks: [{ elements: [{ id: "el-ranking", type: "ranking", headline: { default: "Rank" } }] }], + } as unknown as TSurvey; + const response = { + id: "resp-ranking-no-choices", + createdAt: NOW, + data: { "el-ranking": ["Reports", "Dashboards"] }, + } as unknown as TResponse; + const mappings = [createMapping({ elementId: "el-ranking", hubFieldType: "categorical" })]; + + const result = transformResponseToFeedbackRecords(response, surveyNoChoices, mappings, mockTenantId); + + expect(result).toEqual([]); + }); + test("skips ranking items whose label does not match any choice", () => { const response = { id: "resp-ranking-stale", diff --git a/apps/web/lib/feedback-source/transform.ts b/apps/web/lib/feedback-source/transform.ts index e9425740aa09..741536a2cf01 100644 --- a/apps/web/lib/feedback-source/transform.ts +++ b/apps/web/lib/feedback-source/transform.ts @@ -184,13 +184,17 @@ const expandMatrixToRecords = ( ): FeedbackRecordCreateParams[] => { if (!value || typeof value !== "object" || Array.isArray(value)) return []; + // Guard against malformed/legacy blocks where rows is absent (same class as ENG-1939): the + // schema requires it, but stored survey.blocks JSON is not re-validated here. columns flows + // through normalizeChoiceValue below, which already tolerates an absent array. + const rows = element.rows ?? []; const groupLabel = mapping.customFieldLabel || getHeadlineFromElement(element); const records: FeedbackRecordCreateParams[] = []; for (const [rowLabel, columnLabel] of Object.entries(value)) { if (columnLabel === undefined || columnLabel === null || columnLabel === "") continue; - const row = findChoiceByLabel(element.rows, rowLabel, lookupLanguage); + const row = findChoiceByLabel(rows, rowLabel, lookupLanguage); if (!row) continue; // Column labels are localized like the row labels — store the label as submitted and @@ -227,13 +231,16 @@ const expandRankingToRecords = ( ): FeedbackRecordCreateParams[] => { if (!Array.isArray(value) || value.length === 0) return []; + // Guard against malformed/legacy blocks where choices is absent (same class as ENG-1939): the + // schema requires it, but stored survey.blocks JSON is not re-validated here. + const choices = element.choices ?? []; const groupLabel = mapping.customFieldLabel || getHeadlineFromElement(element); const records: FeedbackRecordCreateParams[] = []; value.forEach((itemLabel, index) => { if (typeof itemLabel !== "string" || itemLabel === "") return; - const choice = findChoiceByLabel(element.choices, itemLabel, lookupLanguage); + const choice = findChoiceByLabel(choices, itemLabel, lookupLanguage); if (!choice) return; records.push({ @@ -324,12 +331,16 @@ const normalizeElementValue = ( element.type === TSurveyElementTypeEnum.MultipleChoiceMulti); if (!isChoiceElement) return { value }; - const normalized = normalizeChoiceValue(element.choices, value, lookupLanguage); + // Elements come from stored survey.blocks JSON that is not re-validated here, so choices may be + // absent at runtime even though the schema requires it (min(2)). Fall back like the siblings + // (normalizeChoiceValue, expandMultiChoiceToRecords) instead of dereferencing it raw. + const choices = element.choices ?? []; + const normalized = normalizeChoiceValue(choices, value, lookupLanguage); if ( !normalized.valueId && typeof value === "string" && - (element.otherOptionPlaceholder !== undefined || element.choices.some((c) => c.id === "other")) + (element.otherOptionPlaceholder !== undefined || choices.some((c) => c.id === "other")) ) { normalized.valueId = "other"; } diff --git a/apps/web/lib/feedback-source/utils.ts b/apps/web/lib/feedback-source/utils.ts index 06df52b0672a..0a1acc9ca086 100644 --- a/apps/web/lib/feedback-source/utils.ts +++ b/apps/web/lib/feedback-source/utils.ts @@ -107,6 +107,9 @@ export const getMissingRequiredCsvFieldMappings = ( return missing; }; +export const getErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : "Unknown error"; + export const routeResponseValueTarget = ( fieldType: THubFieldType ): "value_text" | "value_number" | "value_boolean" | "value_date" => { diff --git a/apps/web/lib/survey/service.scheduling.test.ts b/apps/web/lib/survey/service.scheduling.test.ts index 7457f91ff2b7..9d06d911be99 100644 --- a/apps/web/lib/survey/service.scheduling.test.ts +++ b/apps/web/lib/survey/service.scheduling.test.ts @@ -120,7 +120,7 @@ describe("survey service scheduling", () => { closeOn: scheduledCloseSelection, status: "paused", }, - true + false ); expect(prisma.survey.update).toHaveBeenCalledWith( @@ -196,7 +196,7 @@ describe("survey service scheduling", () => { publishOn: scheduledSelection, status: "completed", }, - true + false ); expect(prisma.survey.update).toHaveBeenCalledWith( @@ -247,7 +247,7 @@ describe("survey service scheduling", () => { publishOn: dueSelection, status: "paused", }, - true + false ); expect(updatedSurvey.status).toBe("inProgress"); @@ -355,7 +355,7 @@ describe("survey service scheduling", () => { publishOn: sameDaySelection, status: "paused", }, - true + false ) ).rejects.toThrow(ValidationError); diff --git a/apps/web/lib/survey/service.test.ts b/apps/web/lib/survey/service.test.ts index 383e5cd3eb50..d9f15a3bb4a1 100644 --- a/apps/web/lib/survey/service.test.ts +++ b/apps/web/lib/survey/service.test.ts @@ -7,6 +7,7 @@ import { TActionClass } from "@formbricks/types/action-classes"; import { DatabaseError, InvalidInputError, + OperationNotAllowedError, ResourceNotFoundError, ValidationError, } from "@formbricks/types/errors"; @@ -481,7 +482,9 @@ describe("Tests for updateSurvey", () => { // ENG-1749/ENG-1920: the app-survey segment block connects segment.surveys by id; a survey from // another workspace must not be connectable (would re-point that survey's targeting). test("rejects connecting a survey from another workspace to the segment (app survey update)", async () => { - prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput); // getSurvey → current survey (own workspace) + // Draft row: this exercises the skip-validation path, which the ENG-1939 guard restricts to + // drafts. The cross-workspace segment check under test is unaffected by the status. + prisma.survey.findUnique.mockResolvedValueOnce({ ...mockSurveyOutput, status: "draft" }); // getSurvey → current survey (own workspace) prisma.segment.findUnique.mockResolvedValueOnce({ workspaceId: updateSurveyInput.workspaceId, } as any); // segment.id belongs to the survey's workspace (passes the segment guard) @@ -1387,10 +1390,15 @@ describe("updateSurveyDraftAction", () => { vi.mocked(getOrganizationByWorkspaceId).mockResolvedValue(mockOrganizationOutput); }); + // The persisted survey must be a draft for the skip-validation path (ENG-1939 guard); these + // cases are about the skipValidation flag, so the stored row is a draft rather than the + // inProgress default of mockSurveyOutput. + const mockDraftSurveyOutput = { ...mockSurveyOutput, status: "draft" as const }; + describe("Happy Path", () => { test("should save draft with missing translations", async () => { - prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput); - prisma.survey.update.mockResolvedValue(mockSurveyOutput); + prisma.survey.findUnique.mockResolvedValue(mockDraftSurveyOutput); + prisma.survey.update.mockResolvedValue(mockDraftSurveyOutput); // Create a survey with incomplete i18n/fields const incompleteSurvey = { @@ -1411,8 +1419,8 @@ describe("updateSurveyDraftAction", () => { }); test("should allow draft with invalid images if gating is applied", async () => { - prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput); - prisma.survey.update.mockResolvedValue(mockSurveyOutput); + prisma.survey.findUnique.mockResolvedValue(mockDraftSurveyOutput); + prisma.survey.update.mockResolvedValue(mockDraftSurveyOutput); const surveyWithInvalidImage = { ...updateSurveyInput, @@ -1453,5 +1461,20 @@ describe("updateSurveyDraftAction", () => { }, SURVEY_SERVICE_TEST_TIMEOUT_MS ); + + // ENG-1939: the lenient draft schema does not validate elements, so skipping validation on an + // already-published survey would let structurally invalid blocks reach the DB and silently + // revert the survey to draft. Gate on the PERSISTED status, not the payload's. + test.each(["inProgress", "paused", "completed"] as const)( + "rejects a skip-validation update when the stored survey is %s", + async (status) => { + prisma.survey.findUnique.mockResolvedValue({ ...mockSurveyOutput, status }); + + await expect(updateSurveyInternal(updateSurveyInput as TSurvey, true)).rejects.toThrow( + OperationNotAllowedError + ); + expect(prisma.survey.update).not.toHaveBeenCalled(); + } + ); }); }); diff --git a/apps/web/lib/survey/service.ts b/apps/web/lib/survey/service.ts index 5e6a45b67603..72e165243331 100644 --- a/apps/web/lib/survey/service.ts +++ b/apps/web/lib/survey/service.ts @@ -4,7 +4,12 @@ import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; import { logger } from "@formbricks/logger"; import { ZId, ZOptionalNumber } from "@formbricks/types/common"; -import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { + DatabaseError, + InvalidInputError, + OperationNotAllowedError, + ResourceNotFoundError, +} from "@formbricks/types/errors"; import { TBaseFilters, ZSegmentFilters } from "@formbricks/types/segment"; import { TSurveyBlock } from "@formbricks/types/surveys/blocks"; import { TSurvey, TSurveyCreateInput, ZSurvey, ZSurveyCreateInput } from "@formbricks/types/surveys/types"; @@ -335,6 +340,16 @@ export const updateSurveyInternal = async ( // tenant's language. Mirrors the create path guard (covers drafts too — runs before validation). await assertSurveyLanguagesBelongToWorkspace(currentSurvey.workspaceId, languages); + // ENG-1939: validation may only be skipped while the survey is still a draft. Gate on the + // PERSISTED status, not the payload's — the lenient draft schema (ZSurveyDraft) does not validate + // elements at all, so without this a caller could push structurally invalid blocks onto a live + // survey (crashing downstream consumers that trust the schema) and silently revert it to draft, + // stopping it from collecting responses. Deliberately placed after the ENG-1749 tenant guards so + // a cross-workspace attempt still reports the authorization failure first. + if (skipValidation && currentSurvey.status !== "draft") { + throw new OperationNotAllowedError("Only draft surveys can be updated without validation"); + } + if (!skipValidation) { checkForInvalidImagesInQuestions(questions); From 327edd1a43e50a75a1f89c31278d5989a97a3fbc Mon Sep 17 00:00:00 2001 From: Javi Aguilar <122741+itsjavi@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:23:12 +0200 Subject: [PATCH 2/4] fix(surveys): return structured 422 when AI generation hits the output token limit (ENG-1891) (#8585) --- .../v3/surveys/generate/error-mapping.test.ts | 124 ++++++++++++++++++ .../api/v3/surveys/generate/error-mapping.ts | 104 +++++++++++++++ apps/web/app/api/v3/surveys/generate/route.ts | 92 ++----------- .../api/v3/surveys/generate/service.test.ts | 1 + .../app/api/v3/surveys/generate/service.ts | 7 +- apps/web/i18n.lock | 1 + apps/web/locales/de-DE.json | 1 + apps/web/locales/en-US.json | 1 + apps/web/locales/es-ES.json | 1 + apps/web/locales/fr-FR.json | 1 + apps/web/locales/hu-HU.json | 1 + apps/web/locales/ja-JP.json | 1 + apps/web/locales/nl-NL.json | 1 + apps/web/locales/pt-BR.json | 1 + apps/web/locales/pt-PT.json | 1 + apps/web/locales/ro-RO.json | 1 + apps/web/locales/ru-RU.json | 1 + apps/web/locales/sv-SE.json | 1 + apps/web/locales/tr-TR.json | 1 + apps/web/locales/zh-Hans-CN.json | 1 + apps/web/locales/zh-Hant-TW.json | 1 + .../hooks/use-create-survey-with-ai.test.ts | 1 + .../hooks/use-create-survey-with-ai.ts | 4 + docs/api-v3-reference/openapi.yml | 11 +- .../src/components/schemas/Problem.yml | 1 + .../src/paths/api_v3_surveys_generate.yml | 14 +- packages/ai/src/errors.ts | 21 +++ packages/ai/src/index.ts | 7 +- packages/ai/src/object.test.ts | 32 +++++ packages/ai/src/object.ts | 13 ++ 30 files changed, 360 insertions(+), 88 deletions(-) create mode 100644 apps/web/app/api/v3/surveys/generate/error-mapping.test.ts create mode 100644 apps/web/app/api/v3/surveys/generate/error-mapping.ts diff --git a/apps/web/app/api/v3/surveys/generate/error-mapping.test.ts b/apps/web/app/api/v3/surveys/generate/error-mapping.test.ts new file mode 100644 index 000000000000..524f7f63b103 --- /dev/null +++ b/apps/web/app/api/v3/surveys/generate/error-mapping.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { AIOutputTokenLimitError } from "@formbricks/ai"; +import { logger } from "@formbricks/logger"; +import { + OperationNotAllowedError, + ResourceNotFoundError, + TooManyRequestsError, +} from "@formbricks/types/errors"; +import { mapV3SurveyGenerateError } from "./error-mapping"; +import { V3SurveyGeneratePromptError, V3SurveyGeneratedPayloadValidationError } from "./service"; + +vi.mock("server-only", () => ({})); + +// The mapper only needs the AI error-code union from this module; the real one pulls in env, +// organization lookups and license checks. +vi.mock("@/lib/ai/service", () => ({ + AI_ERROR_CODES: { + FEATURES_NOT_ENABLED: "ai_features_not_enabled", + SMART_TOOLS_DISABLED: "ai_smart_tools_disabled", + INSTANCE_NOT_CONFIGURED: "ai_instance_not_configured", + QUOTA_EXCEEDED: "ai_quota_exceeded", + }, +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { error: vi.fn() }, +})); + +const context = { + requestId: "req_123", + instance: "/api/v3/surveys/generate", + workspaceId: "clxx1234567890123456789012", + organizationId: "org_123", +}; + +const readProblem = async (response: Response) => + (await response.json()) as { status: number; code?: string; invalid_params?: unknown }; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("mapV3SurveyGenerateError", () => { + test("maps a thin prompt to 400 and passes the invalid params through", async () => { + const invalidParams = [{ name: "prompt", reason: "Prompt needs more detail" }]; + + const response = mapV3SurveyGenerateError(new V3SurveyGeneratePromptError(invalidParams), context); + + expect(response.status).toBe(400); + await expect(readProblem(response)).resolves.toMatchObject({ invalid_params: invalidParams }); + }); + + test("maps provider rate limiting to 429 with Retry-After", () => { + const response = mapV3SurveyGenerateError(new TooManyRequestsError("rate limited", 30), context); + + expect(response.status).toBe(429); + expect(response.headers.get("Retry-After")).toBe("30"); + }); + + test("maps an unconfigured instance to 503 and other AI codes to 403", async () => { + const notConfigured = mapV3SurveyGenerateError( + new OperationNotAllowedError("ai_instance_not_configured"), + context + ); + const disabled = mapV3SurveyGenerateError( + new OperationNotAllowedError("ai_smart_tools_disabled"), + context + ); + + expect(notConfigured.status).toBe(503); + expect(disabled.status).toBe(403); + await expect(readProblem(disabled)).resolves.toMatchObject({ code: "ai_smart_tools_disabled" }); + }); + + test("does not treat an unrelated OperationNotAllowedError as an AI-unavailable problem", () => { + const response = mapV3SurveyGenerateError(new OperationNotAllowedError("something else"), context); + + expect(response.status).toBe(502); + }); + + test("maps an invalid generated payload to 422 with its own code", async () => { + const invalidParams = [{ name: "elements", reason: "unsupported element type" }]; + + const response = mapV3SurveyGenerateError( + new V3SurveyGeneratedPayloadValidationError(invalidParams), + context + ); + + expect(response.status).toBe(422); + await expect(readProblem(response)).resolves.toMatchObject({ + code: "ai_generated_payload_invalid", + invalid_params: invalidParams, + }); + }); + + test("maps hitting the output token limit to 422 ai_output_too_long", async () => { + const response = mapV3SurveyGenerateError( + new AIOutputTokenLimitError({ maxOutputTokens: 8192, outputTokens: 8192 }), + context + ); + + expect(response.status).toBe(422); + await expect(readProblem(response)).resolves.toMatchObject({ code: "ai_output_too_long" }); + }); + + test("maps a missing organization to 404", async () => { + const response = mapV3SurveyGenerateError(new ResourceNotFoundError("Organization", "org_123"), context); + + expect(response.status).toBe(404); + await expect(readProblem(response)).resolves.toMatchObject({ code: "not_found" }); + }); + + test("falls back to 502 and logs for an unrecognized error", () => { + const error = new Error("provider exploded"); + + const response = mapV3SurveyGenerateError(error, context); + + expect(response.status).toBe(502); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ err: error, requestId: context.requestId }), + "Failed to generate v3 survey create payload" + ); + }); +}); diff --git a/apps/web/app/api/v3/surveys/generate/error-mapping.ts b/apps/web/app/api/v3/surveys/generate/error-mapping.ts new file mode 100644 index 000000000000..99654b9e57af --- /dev/null +++ b/apps/web/app/api/v3/surveys/generate/error-mapping.ts @@ -0,0 +1,104 @@ +import { AIOutputTokenLimitError } from "@formbricks/ai"; +import { logger } from "@formbricks/logger"; +import { + OperationNotAllowedError, + ResourceNotFoundError, + TooManyRequestsError, +} from "@formbricks/types/errors"; +import { + problemAIUnavailable, + problemBadGateway, + problemBadRequest, + problemNotFound, + problemTooManyRequests, + problemUnprocessableContent, +} from "@/app/api/v3/lib/response"; +import { AI_ERROR_CODES, type TAIErrorCode } from "@/lib/ai/service"; +import { V3SurveyGeneratePromptError, V3SurveyGeneratedPayloadValidationError } from "./service"; + +const AI_UNAVAILABLE_DETAILS: Record = { + [AI_ERROR_CODES.FEATURES_NOT_ENABLED]: "AI smart tools are not available for this organization.", + [AI_ERROR_CODES.SMART_TOOLS_DISABLED]: "AI smart tools are disabled for this organization.", + [AI_ERROR_CODES.INSTANCE_NOT_CONFIGURED]: "AI is not configured for this Formbricks instance.", + // Quota exhaustion is surfaced as a 429 (see the TooManyRequestsError branch below), not as an + // AI-unavailable 503 — this entry only keeps the code map exhaustive. + [AI_ERROR_CODES.QUOTA_EXCEEDED]: "The AI provider is temporarily rate-limited. Try again shortly.", +}; + +function isAIErrorCode(value: string): value is TAIErrorCode { + return Object.values(AI_ERROR_CODES).includes(value as TAIErrorCode); +} + +interface TGenerateErrorContext { + requestId: string; + instance: string; + workspaceId: string; + organizationId: string; +} + +/** + * Map an error thrown while generating a survey draft to its problem+json Response. Extracted from + * the route handler to keep that handler's cognitive complexity within bounds. + */ +export function mapV3SurveyGenerateError( + error: unknown, + { requestId, instance, workspaceId, organizationId }: TGenerateErrorContext +): Response { + if (error instanceof V3SurveyGeneratePromptError) { + return problemBadRequest(requestId, error.message, { + instance, + invalid_params: error.invalidParams, + }); + } + + if (error instanceof TooManyRequestsError) { + return problemTooManyRequests( + requestId, + "The AI provider is temporarily rate-limited. Try again shortly.", + error.retryAfter + ); + } + + if (error instanceof OperationNotAllowedError && isAIErrorCode(error.message)) { + return problemAIUnavailable(requestId, AI_UNAVAILABLE_DETAILS[error.message], error.message, instance); + } + + if (error instanceof V3SurveyGeneratedPayloadValidationError) { + return problemUnprocessableContent(requestId, error.message, { + instance, + code: "ai_generated_payload_invalid", + invalid_params: error.invalidParams, + }); + } + + if (error instanceof AIOutputTokenLimitError) { + return problemUnprocessableContent( + requestId, + "The generated survey exceeded the AI output token limit. Simplify the prompt or split it into smaller surveys.", + { + instance, + code: "ai_output_too_long", + } + ); + } + + if (error instanceof ResourceNotFoundError) { + return problemNotFound(requestId, "Organization", organizationId, instance); + } + + logger.error( + { + err: error, + requestId, + workspaceId, + organizationId, + }, + "Failed to generate v3 survey create payload" + ); + + return problemBadGateway( + requestId, + "The AI provider could not generate a valid survey draft. Try again or add more detail.", + instance + ); +} diff --git a/apps/web/app/api/v3/surveys/generate/route.ts b/apps/web/app/api/v3/surveys/generate/route.ts index 21a6e7de0260..2d8b196f6cf4 100644 --- a/apps/web/app/api/v3/surveys/generate/route.ts +++ b/apps/web/app/api/v3/surveys/generate/route.ts @@ -1,43 +1,12 @@ -import { logger } from "@formbricks/logger"; -import { - OperationNotAllowedError, - ResourceNotFoundError, - TooManyRequestsError, -} from "@formbricks/types/errors"; import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper"; import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; -import { - problemAIUnavailable, - problemBadGateway, - problemBadRequest, - problemNotFound, - problemTooManyRequests, - problemUnprocessableContent, - successResponse, -} from "@/app/api/v3/lib/response"; -import { AI_ERROR_CODES, type TAIErrorCode } from "@/lib/ai/service"; +import { successResponse } from "@/app/api/v3/lib/response"; import { capturePostHogEvent } from "@/lib/posthog"; import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { getSessionUserId } from "../lib/operations"; +import { mapV3SurveyGenerateError } from "./error-mapping"; import { ZV3SurveyGenerateBody } from "./schemas"; -import { - V3SurveyGeneratePromptError, - V3SurveyGeneratedPayloadValidationError, - generateV3SurveyCreatePayloadFromPrompt, -} from "./service"; - -const AI_UNAVAILABLE_DETAILS: Record = { - [AI_ERROR_CODES.FEATURES_NOT_ENABLED]: "AI smart tools are not available for this organization.", - [AI_ERROR_CODES.SMART_TOOLS_DISABLED]: "AI smart tools are disabled for this organization.", - [AI_ERROR_CODES.INSTANCE_NOT_CONFIGURED]: "AI is not configured for this Formbricks instance.", - // Quota exhaustion is surfaced as a 429 (see the TooManyRequestsError branch below), not as an - // AI-unavailable 503 — this entry only keeps the code map exhaustive. - [AI_ERROR_CODES.QUOTA_EXCEEDED]: "The AI provider is temporarily rate-limited. Try again shortly.", -}; - -function isAIErrorCode(value: string): value is TAIErrorCode { - return Object.values(AI_ERROR_CODES).includes(value as TAIErrorCode); -} +import { generateV3SurveyCreatePayloadFromPrompt } from "./service"; export const POST = withV3ApiWrapper({ auth: "both", @@ -77,57 +46,12 @@ export const POST = withV3ApiWrapper({ return successResponse(result, { requestId }); } catch (error) { - if (error instanceof V3SurveyGeneratePromptError) { - return problemBadRequest(requestId, error.message, { - instance, - invalid_params: error.invalidParams, - }); - } - - if (error instanceof TooManyRequestsError) { - return problemTooManyRequests( - requestId, - "The AI provider is temporarily rate-limited. Try again shortly.", - error.retryAfter - ); - } - - if (error instanceof OperationNotAllowedError && isAIErrorCode(error.message)) { - return problemAIUnavailable( - requestId, - AI_UNAVAILABLE_DETAILS[error.message], - error.message, - instance - ); - } - - if (error instanceof V3SurveyGeneratedPayloadValidationError) { - return problemUnprocessableContent(requestId, error.message, { - instance, - code: "ai_generated_payload_invalid", - invalid_params: error.invalidParams, - }); - } - - if (error instanceof ResourceNotFoundError) { - return problemNotFound(requestId, "Organization", workspaceAccess.organizationId, instance); - } - - logger.error( - { - err: error, - requestId, - workspaceId: body.workspaceId, - organizationId: workspaceAccess.organizationId, - }, - "Failed to generate v3 survey create payload" - ); - - return problemBadGateway( + return mapV3SurveyGenerateError(error, { requestId, - "The AI provider could not generate a valid survey draft. Try again or add more detail.", - instance - ); + instance, + workspaceId: body.workspaceId, + organizationId: workspaceAccess.organizationId, + }); } }, }); diff --git a/apps/web/app/api/v3/surveys/generate/service.test.ts b/apps/web/app/api/v3/surveys/generate/service.test.ts index a2a7bf1e4c54..67e7fa5cf37f 100644 --- a/apps/web/app/api/v3/surveys/generate/service.test.ts +++ b/apps/web/app/api/v3/surveys/generate/service.test.ts @@ -157,6 +157,7 @@ describe("generateV3SurveyCreatePayloadFromPrompt", () => { schema: ZGeneratedSurveyDraftForAI, schemaName: "FormbricksSurveyDraft", temperature: 0.2, + maxOutputTokens: 8192, timeout: 45_000, }) ); diff --git a/apps/web/app/api/v3/surveys/generate/service.ts b/apps/web/app/api/v3/surveys/generate/service.ts index 5a0952415fa0..622833a31aed 100644 --- a/apps/web/app/api/v3/surveys/generate/service.ts +++ b/apps/web/app/api/v3/surveys/generate/service.ts @@ -33,6 +33,11 @@ export type TV3SurveyGenerateResult = { const V3_SURVEY_GENERATION_TIMEOUT_MS = 45_000; +// Gemini 2.5 Flash spends a large share of the output budget on reasoning tokens before emitting +// the survey JSON; 8192 leaves headroom for both so long prompts don't stop with finishReason +// "length" (the previous 3000 budget did). +const V3_SURVEY_GENERATION_MAX_OUTPUT_TOKENS = 8192; + export class V3SurveyGeneratePromptError extends Error { invalidParams: InvalidParam[]; @@ -393,7 +398,7 @@ export async function generateV3SurveyCreatePayloadFromPrompt(params: { V3_SURVEY_GENERATE_ALLOWED_LOCALES ), temperature: 0.2, - maxOutputTokens: 3000, + maxOutputTokens: V3_SURVEY_GENERATION_MAX_OUTPUT_TOKENS, timeout: V3_SURVEY_GENERATION_TIMEOUT_MS, }); diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 3bb65c859b46..e2a46646dfb3 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -2777,6 +2777,7 @@ checksums: workspace/surveys/ai_create/ai_not_available: 840b27ce9b718b487cea62e9768cc904 workspace/surveys/ai_create/ai_not_enabled: 13df84ae47d35dfa6e86ffa62f29c75d workspace/surveys/ai_create/ai_not_in_plan: a059bf0be5d6ef524c1a4212b32a0c8b + workspace/surveys/ai_create/ai_output_too_long: 3be34ddb7af610421d806827ae04f3d7 workspace/surveys/ai_create/card_description: 3917eefc745d2bfec15d25ac343bbb64 workspace/surveys/ai_create/card_title: 5d8c659f559f1343bb23fd191a6556ad workspace/surveys/ai_create/characters: 94b5a8b5c869a7e9bc031f59de56de44 diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 0493fd1ef31b..df51d56cdf5e 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -2892,6 +2892,7 @@ "ai_not_available": "KI-Umfragenerstellung ist nicht verfügbar.", "ai_not_enabled": "KI-Smart-Tools sind für diese Organisation deaktiviert.", "ai_not_in_plan": "KI-Umfragenerstellung ist in deinem aktuellen Tarif nicht verfügbar.", + "ai_output_too_long": "Diese Eingabe fordert mehr an, als die KI auf einmal generieren kann. Vereinfache sie oder teile sie in kleinere Umfragen auf.", "card_description": "Beschreibe, was du herausfinden möchtest, und erstelle einen Umfrage-Entwurf.", "card_title": "Mit KI erstellen", "characters": "{count}/{max}", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 5354d64bd5ab..f4f2e9f60517 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -2892,6 +2892,7 @@ "ai_not_available": "AI survey creation is not available.", "ai_not_enabled": "AI smart tools are disabled for this organization.", "ai_not_in_plan": "AI survey creation is not available on your current plan.", + "ai_output_too_long": "This prompt asks for more than the AI can generate in one go. Simplify it or split it into smaller surveys.", "card_description": "Describe what you want to learn and create a draft survey.", "card_title": "Create with AI", "characters": "{count}/{max}", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 5ff3e76add83..30ffb8cdb3bc 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -2892,6 +2892,7 @@ "ai_not_available": "La creación de encuestas con IA no está disponible.", "ai_not_enabled": "Las herramientas inteligentes de IA están desactivadas para esta organización.", "ai_not_in_plan": "La creación de encuestas con IA no está disponible en tu plan actual.", + "ai_output_too_long": "Este prompt solicita más de lo que la IA puede generar de una sola vez. Simplifícalo o divídelo en encuestas más pequeñas.", "card_description": "Describe lo que quieres aprender y crea un borrador de encuesta.", "card_title": "Crear con IA", "characters": "{count}/{max}", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 94a3e1d90a10..e8d5aade61cf 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -2892,6 +2892,7 @@ "ai_not_available": "La création de questionnaires par IA n'est pas disponible.", "ai_not_enabled": "Les outils intelligents IA sont désactivés pour cette organisation.", "ai_not_in_plan": "La création de questionnaires par IA n'est pas disponible avec ton forfait actuel.", + "ai_output_too_long": "Cette instruction demande plus que ce que l'IA peut générer en une seule fois. Simplifie-la ou divise-la en plusieurs questionnaires.", "card_description": "Décris ce que tu veux apprendre et crée un brouillon de questionnaire.", "card_title": "Créer avec l'IA", "characters": "{count}/{max}", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index ba54df268768..f34ae7e439d6 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -2892,6 +2892,7 @@ "ai_not_available": "Az AI kérdőív-készítés nem elérhető.", "ai_not_enabled": "Az AI intelligens eszközök le vannak tiltva ezen szervezet számára.", "ai_not_in_plan": "Az AI kérdőív-készítés nem elérhető az Ön jelenlegi csomagjában.", + "ai_output_too_long": "Ez a felkérés többet kér, mint amennyit a mesterséges intelligencia egyszerre képes generálni. Kérem, egyszerűsítse le, vagy ossza fel kisebb felmérésekre.", "card_description": "Írja le, hogy mit szeretne megtudni, és hozzon létre egy kérdőívtervezetet.", "card_title": "Készítsen AI-val", "characters": "{count}/{max}", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 37a9a00bc9a6..f9d720d62d4c 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -2892,6 +2892,7 @@ "ai_not_available": "AIによるアンケート作成は利用できません。", "ai_not_enabled": "この組織ではAIスマートツールが無効になっています。", "ai_not_in_plan": "現在のプランではAIによるアンケート作成は利用できません。", + "ai_output_too_long": "このプロンプトは、AIが一度に生成できる量を超えています。プロンプトを簡潔にするか、複数のアンケートに分割してください。", "card_description": "知りたいことを説明するだけで、アンケートの下書きを作成できます。", "card_title": "AIで作成", "characters": "{count}/{max}", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index d3bcff7730c2..98eec844653f 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -2892,6 +2892,7 @@ "ai_not_available": "AI-enquêtecreatie is niet beschikbaar.", "ai_not_enabled": "AI slimme tools zijn uitgeschakeld voor deze organisatie.", "ai_not_in_plan": "AI-enquêtecreatie is niet beschikbaar in je huidige abonnement.", + "ai_output_too_long": "Deze prompt vraagt om meer dan de AI in één keer kan genereren. Vereenvoudig het of splits het op in kleinere enquêtes.", "card_description": "Beschrijf wat je wilt leren en maak een conceptenquête.", "card_title": "Maak met AI", "characters": "{count}/{max}", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 608e7d7ea5d2..34caff480195 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -2892,6 +2892,7 @@ "ai_not_available": "A criação de pesquisas com IA não está disponível.", "ai_not_enabled": "As ferramentas inteligentes de IA estão desabilitadas para esta organização.", "ai_not_in_plan": "A criação de pesquisas com IA não está disponível no seu plano atual.", + "ai_output_too_long": "Este prompt solicita mais do que a IA pode gerar de uma vez. Simplifique-o ou divida em pesquisas menores.", "card_description": "Descreva o que você quer aprender e crie um rascunho de pesquisa.", "card_title": "Criar com IA", "characters": "{count}/{max}", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index f19de62d5582..50e1aede369d 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -2892,6 +2892,7 @@ "ai_not_available": "A criação de inquéritos com IA não está disponível.", "ai_not_enabled": "As ferramentas inteligentes de IA estão desativadas para esta organização.", "ai_not_in_plan": "A criação de inquéritos com IA não está disponível no teu plano atual.", + "ai_output_too_long": "Este prompt pede mais do que a IA consegue gerar de uma só vez. Simplifica-o ou divide-o em inquéritos mais pequenos.", "card_description": "Descreve o que queres aprender e cria um rascunho de inquérito.", "card_title": "Criar com IA", "characters": "{count}/{max}", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 26e2b8bd22c3..3b882ab23c93 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -2892,6 +2892,7 @@ "ai_not_available": "Crearea de chestionare cu AI nu este disponibilă.", "ai_not_enabled": "Instrumentele inteligente AI sunt dezactivate pentru această organizație.", "ai_not_in_plan": "Crearea de chestionare cu AI nu este disponibilă în planul tău actual.", + "ai_output_too_long": "Acest prompt solicită mai mult decât poate genera AI-ul dintr-o dată. Simplifică-l sau împarte-l în sondaje mai mici.", "card_description": "Descrie ce vrei să afli și creează o ciornă de chestionar.", "card_title": "Creează cu AI", "characters": "{count}/{max}", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 6b38f2adee5f..418b3b3762e5 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -2892,6 +2892,7 @@ "ai_not_available": "Создание опросов с помощью ИИ недоступно.", "ai_not_enabled": "Умные инструменты ИИ отключены для этой организации.", "ai_not_in_plan": "Создание опросов с помощью ИИ недоступно в твоём текущем тарифе.", + "ai_output_too_long": "Этот запрос требует больше, чем ИИ может создать за один раз. Упрости его или раздели на несколько опросов.", "card_description": "Опиши, что ты хочешь узнать, и создай черновик опроса.", "card_title": "Создать с помощью ИИ", "characters": "{count}/{max}", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 7f85af83e448..c5e3a117918e 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -2892,6 +2892,7 @@ "ai_not_available": "AI-enkätskapande är inte tillgängligt.", "ai_not_enabled": "AI-smarta verktyg är inaktiverade för den här organisationen.", "ai_not_in_plan": "AI-enkätskapande är inte tillgängligt i din nuvarande plan.", + "ai_output_too_long": "Den här prompten begär mer än vad AI:n kan generera på en gång. Förenkla den eller dela upp den i mindre enkäter.", "card_description": "Beskriv vad du vill lära dig och skapa ett utkast till enkät.", "card_title": "Skapa med AI", "characters": "{count}/{max}", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index 07fed7bc89a7..b8db051af994 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -2892,6 +2892,7 @@ "ai_not_available": "AI ile anket oluşturma özelliği kullanılamıyor.", "ai_not_enabled": "Bu organizasyon için AI akıllı araçlar devre dışı.", "ai_not_in_plan": "AI ile anket oluşturma mevcut planında mevcut değil.", + "ai_output_too_long": "Bu istem, yapay zekanın tek seferde üretebileceğinden daha fazlasını istiyor. Daha basit hale getir veya daha küçük anketlere böl.", "card_description": "Ne öğrenmek istediğini anlat ve bir taslak anket oluştur.", "card_title": "AI ile Oluştur", "characters": "{count}/{max}", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index dbc5bca39ea1..74e2285bfb6a 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -2892,6 +2892,7 @@ "ai_not_available": "AI 问卷创建功能不可用。", "ai_not_enabled": "此组织已禁用 AI 智能工具。", "ai_not_in_plan": "你当前的套餐不支持 AI 问卷创建功能。", + "ai_output_too_long": "此提示要求生成的内容超出了 AI 一次性生成的能力。请简化提示或将其拆分为多个较小的调查问卷。", "card_description": "描述你想了解的内容,创建问卷草稿。", "card_title": "使用 AI 创建", "characters": "{count}/{max}", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 9e8717215171..1333a5452299 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -2892,6 +2892,7 @@ "ai_not_available": "AI 問卷建立功能目前無法使用。", "ai_not_enabled": "此組織已停用 AI 智慧工具。", "ai_not_in_plan": "你目前的方案不包含 AI 問卷建立功能。", + "ai_output_too_long": "這個提示要求的內容超過 AI 一次能生成的範圍。請簡化提示或將其拆分為多個較小的問卷。", "card_description": "描述你想了解的內容,即可建立問卷草稿。", "card_title": "使用 AI 建立", "characters": "{count}/{max}", diff --git a/apps/web/modules/survey/components/template-list/hooks/use-create-survey-with-ai.test.ts b/apps/web/modules/survey/components/template-list/hooks/use-create-survey-with-ai.test.ts index 0b5f1fa93046..8aa3495d2971 100644 --- a/apps/web/modules/survey/components/template-list/hooks/use-create-survey-with-ai.test.ts +++ b/apps/web/modules/survey/components/template-list/hooks/use-create-survey-with-ai.test.ts @@ -149,6 +149,7 @@ describe("useCreateSurveyWithAI", () => { ["ai_smart_tools_disabled", "workspace.surveys.ai_create.ai_not_enabled"], ["ai_instance_not_configured", "workspace.surveys.ai_create.ai_instance_not_configured"], ["ai_generated_payload_invalid", "workspace.surveys.ai_create.generated_payload_invalid"], + ["ai_output_too_long", "workspace.surveys.ai_create.ai_output_too_long"], ])("maps %s errors to the matching message", async (code, expectedMessage) => { vi.mocked(generateSurveyCreatePayload).mockRejectedValueOnce( new V3ApiError({ status: 403, detail: "API error", code }) diff --git a/apps/web/modules/survey/components/template-list/hooks/use-create-survey-with-ai.ts b/apps/web/modules/survey/components/template-list/hooks/use-create-survey-with-ai.ts index 61b5d5c87af6..6245ea8999f8 100644 --- a/apps/web/modules/survey/components/template-list/hooks/use-create-survey-with-ai.ts +++ b/apps/web/modules/survey/components/template-list/hooks/use-create-survey-with-ai.ts @@ -51,6 +51,10 @@ export const useCreateSurveyWithAI = ({ if (error.code === "ai_generated_payload_invalid") { return t("workspace.surveys.ai_create.generated_payload_invalid"); } + + if (error.code === "ai_output_too_long") { + return t("workspace.surveys.ai_create.ai_output_too_long"); + } } return getV3ApiErrorMessage(error, t("common.something_went_wrong_please_try_again")); diff --git a/docs/api-v3-reference/openapi.yml b/docs/api-v3-reference/openapi.yml index cf53483dfe1d..b5b9f4b35c35 100644 --- a/docs/api-v3-reference/openapi.yml +++ b/docs/api-v3-reference/openapi.yml @@ -606,7 +606,7 @@ paths: code: ai_features_not_enabled requestId: req_123 '422': - description: AI generated an invalid payload after schema and v3 create validation + description: AI generated an invalid payload after schema and v3 create validation, or the generation stopped because the output token limit was reached content: application/problem+json: schema: @@ -623,6 +623,14 @@ paths: invalid_params: - name: generatedSurvey.blocks reason: Too small + aiOutputTooLong: + summary: Output token limit reached + value: + title: Unprocessable Content + status: 422 + detail: The generated survey exceeded the AI output token limit. Simplify the prompt or split it into smaller surveys. + code: ai_output_too_long + requestId: req_123 '429': $ref: '#/components/responses/V3TooManyRequests' '500': @@ -1618,6 +1626,7 @@ components: - ai_features_not_enabled - ai_generated_payload_invalid - ai_instance_not_configured + - ai_output_too_long - ai_smart_tools_disabled - bad_gateway - bad_request diff --git a/docs/api-v3-reference/src/components/schemas/Problem.yml b/docs/api-v3-reference/src/components/schemas/Problem.yml index 9828b30890a9..d40931fffd4d 100644 --- a/docs/api-v3-reference/src/components/schemas/Problem.yml +++ b/docs/api-v3-reference/src/components/schemas/Problem.yml @@ -26,6 +26,7 @@ properties: - ai_features_not_enabled - ai_generated_payload_invalid - ai_instance_not_configured + - ai_output_too_long - ai_smart_tools_disabled - bad_gateway - bad_request diff --git a/docs/api-v3-reference/src/paths/api_v3_surveys_generate.yml b/docs/api-v3-reference/src/paths/api_v3_surveys_generate.yml index 5ba112f0b57c..fae11676d37b 100644 --- a/docs/api-v3-reference/src/paths/api_v3_surveys_generate.yml +++ b/docs/api-v3-reference/src/paths/api_v3_surveys_generate.yml @@ -179,7 +179,9 @@ post: code: ai_features_not_enabled requestId: req_123 '422': - description: AI generated an invalid payload after schema and v3 create validation + description: >- + AI generated an invalid payload after schema and v3 create validation, + or the generation stopped because the output token limit was reached content: application/problem+json: schema: @@ -198,6 +200,16 @@ post: invalid_params: - name: generatedSurvey.blocks reason: Too small + aiOutputTooLong: + summary: Output token limit reached + value: + title: Unprocessable Content + status: 422 + detail: >- + The generated survey exceeded the AI output token limit. + Simplify the prompt or split it into smaller surveys. + code: ai_output_too_long + requestId: req_123 '429': $ref: ../components/responses/V3TooManyRequests.yml '500': diff --git a/packages/ai/src/errors.ts b/packages/ai/src/errors.ts index 0d82b2b8b9a7..8dc5f881b0c8 100644 --- a/packages/ai/src/errors.ts +++ b/packages/ai/src/errors.ts @@ -24,6 +24,27 @@ export class AIConfigurationError extends Error { } } +export interface AIOutputTokenLimitErrorDetails { + maxOutputTokens?: number; + outputTokens?: number; + reasoningTokens?: number; +} + +/** + * Thrown when a generation ends because the model hit the output token limit (finish reason + * "length") before completing its response, so no valid structured output exists. Lives here so + * apps can branch on a package-owned error with plain token counts instead of AI SDK internals. + */ +export class AIOutputTokenLimitError extends Error { + details: AIOutputTokenLimitErrorDetails; + + constructor(details: AIOutputTokenLimitErrorDetails = {}) { + super("AI generation stopped because the output token limit was reached"); + this.name = "AIOutputTokenLimitError"; + this.details = details; + } +} + export interface AIProviderErrorInfo { /** Provider returned HTTP 429 — quota / rate limit exhausted. */ isQuotaExhausted: boolean; diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index c5b2b83c808b..91803cdd7fe4 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -7,7 +7,12 @@ export { isAiConfigured, resetLanguageModelCache, } from "./provider"; -export { type AIProviderErrorInfo, classifyAIProviderError } from "./errors"; +export { + AIOutputTokenLimitError, + type AIOutputTokenLimitErrorDetails, + type AIProviderErrorInfo, + classifyAIProviderError, +} from "./errors"; export { generateText } from "./text"; export { generateObject } from "./object"; export type { TAIProvider } from "@formbricks/types/ai"; diff --git a/packages/ai/src/object.test.ts b/packages/ai/src/object.test.ts index 2d2cf57287f7..dab3f6b7f978 100644 --- a/packages/ai/src/object.test.ts +++ b/packages/ai/src/object.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; +import { AIOutputTokenLimitError } from "./errors"; import { generateObject } from "./object"; import type { TGenerateObjectOptions } from "./types"; @@ -96,4 +97,35 @@ describe("generateObject", () => { expect(response.headers.get("content-type")).toBe("application/json; charset=utf-8"); await expect(response.json()).resolves.toEqual(generated.output); }); + + test("throws AIOutputTokenLimitError when the generation stops at the output token limit", async () => { + const schema = { type: "object" } as unknown as TGenerateObjectOptions<{ title: string }>["schema"]; + mocks.generateText.mockResolvedValueOnce({ + finishReason: "length", + usage: { + inputTokens: 1200, + outputTokens: 3000, + totalTokens: 4200, + outputTokenDetails: { textTokens: 336, reasoningTokens: 2664 }, + }, + // Mirrors the AI SDK, whose `output` getter throws when the generation did not finish with + // "stop" — and proves generateObject never reads it on the "length" path. + get output(): never { + throw new Error("result.output must not be read when the output token limit was reached"); + }, + }); + + const promise = generateObject<{ title: string }>({ + schema, + prompt: "Generate a survey", + maxOutputTokens: 3000, + }); + + await expect(promise).rejects.toBeInstanceOf(AIOutputTokenLimitError); + await expect(promise).rejects.toHaveProperty("details", { + maxOutputTokens: 3000, + outputTokens: 3000, + reasoningTokens: 2664, + }); + }); }); diff --git a/packages/ai/src/object.ts b/packages/ai/src/object.ts index 1ea7a81d7eae..5e9c7434ad87 100644 --- a/packages/ai/src/object.ts +++ b/packages/ai/src/object.ts @@ -1,4 +1,5 @@ import { Output, generateText } from "ai"; +import { AIOutputTokenLimitError } from "./errors"; import { getAiModel } from "./provider"; import type { AIEnvironment, TGenerateObjectOptions, TGenerateObjectResult } from "./types"; @@ -18,6 +19,18 @@ export const generateObject = async ( } as Parameters[0]; const result = await generateText(request); + + // With `output: Output.object(...)`, the AI SDK only parses `result.output` when the generation + // finished with "stop"; on "length" the getter throws a bare NoOutputGeneratedError. Throw a + // dedicated error first so callers can tell "output token limit reached" apart from other failures. + if (result.finishReason === "length") { + throw new AIOutputTokenLimitError({ + maxOutputTokens: textOptions.maxOutputTokens, + outputTokens: result.usage.outputTokens, + reasoningTokens: result.usage.outputTokenDetails.reasoningTokens, + }); + } + const object = result.output as T; return { From 6726a555cc1cf75c5cf38c7428b9d5a5cb7cfd71 Mon Sep 17 00:00:00 2001 From: Johannes <72809645+jobenjada@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:47:50 +0200 Subject: [PATCH 3/4] feat(surveys): default link surveys to the cardless card arrangement (#8688) Co-authored-by: Claude Opus 4.8 --- apps/web/lib/styling/constants.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/lib/styling/constants.ts b/apps/web/lib/styling/constants.ts index 9d86888bdd4f..c6d256db66ec 100644 --- a/apps/web/lib/styling/constants.ts +++ b/apps/web/lib/styling/constants.ts @@ -108,7 +108,8 @@ export const STYLE_DEFAULTS: TWorkspaceStyling = { highlightBorderColor: { light: _colors["highlightBorderColor.light"] }, isDarkModeEnabled: false, roundness: 8, - cardArrangement: { linkSurveys: "simple", appSurveys: "simple" }, + // Link surveys default to the cardless layout; "cardless" is link-only, so app surveys keep "simple". + cardArrangement: { linkSurveys: "cardless", appSurveys: "simple" }, linkSurveyCardWidth: "default", // Headlines & Descriptions From 1d521e897074b69b30da1f40605270a10f5e0ad6 Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:43:50 +0530 Subject: [PATCH 4/4] feat(support): migrate in-product chat from Chatwoot to Plain [ENG-1984] (#8654) Co-authored-by: Claude --- .env.example | 7 +- apps/web/app/(app)/layout.tsx | 29 +++- .../chatwoot/components/chatwoot-widget.tsx | 151 ------------------ apps/web/app/chatwoot/lib/actions.ts | 18 --- apps/web/app/plain/components/plain-chat.tsx | 147 +++++++++++++++++ apps/web/app/plain/lib/customer.test.ts | 81 ++++++++++ apps/web/app/plain/lib/customer.ts | 29 ++++ apps/web/app/plain/lib/identity.test.ts | 31 ++++ apps/web/app/plain/lib/identity.ts | 16 ++ .../app/{chatwoot => plain}/lib/utils.test.ts | 0 apps/web/app/{chatwoot => plain}/lib/utils.ts | 0 apps/web/app/posthog/PostHogIdentify.tsx | 2 +- apps/web/lib/constants.ts | 6 +- apps/web/lib/env.ts | 10 +- turbo.json | 5 +- 15 files changed, 342 insertions(+), 190 deletions(-) delete mode 100644 apps/web/app/chatwoot/components/chatwoot-widget.tsx delete mode 100644 apps/web/app/chatwoot/lib/actions.ts create mode 100644 apps/web/app/plain/components/plain-chat.tsx create mode 100644 apps/web/app/plain/lib/customer.test.ts create mode 100644 apps/web/app/plain/lib/customer.ts create mode 100644 apps/web/app/plain/lib/identity.test.ts create mode 100644 apps/web/app/plain/lib/identity.ts rename apps/web/app/{chatwoot => plain}/lib/utils.test.ts (100%) rename apps/web/app/{chatwoot => plain}/lib/utils.ts (100%) diff --git a/.env.example b/.env.example index 568f2d98d0c5..afab45bfa8e6 100644 --- a/.env.example +++ b/.env.example @@ -388,9 +388,10 @@ REDIS_URL=redis://localhost:6379 # The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this) # REDIS_HTTP_URL: -# Chatwoot -# CHATWOOT_BASE_URL= -# CHATWOOT_WEBSITE_TOKEN= +# Plain (in-product support chat) +# PLAIN_APP_ID= +# PLAIN_CHAT_HMAC_SECRET= +# PLAIN_ACTIVE_CUSTOMER_LABEL_TYPE_ID= # Formbricks-in-Formbricks: dogfood in-app surveys inside the app itself. # Set FORMBRICKS_WORKSPACE_ID to the Workspace ID that hosts your surveys to mount diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 274381d05b1b..1b965109a530 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -1,13 +1,15 @@ -import { ChatwootWidget } from "@/app/chatwoot/components/chatwoot-widget"; import { FormbricksProvider } from "@/app/formbricks/components/formbricks-provider"; +import { PlainChat } from "@/app/plain/components/plain-chat"; +import { getIsActiveCustomer } from "@/app/plain/lib/customer"; +import { computePlainEmailHash } from "@/app/plain/lib/identity"; import { PostHogIdentify } from "@/app/posthog/PostHogIdentify"; import { - CHATWOOT_BASE_URL, - CHATWOOT_WEBSITE_TOKEN, FORMBRICKS_APP_URL, FORMBRICKS_WORKSPACE_ID, - IS_CHATWOOT_CONFIGURED, IS_FORMBRICKS_SURVEYS_CONFIGURED, + IS_PLAIN_CHAT_CONFIGURED, + PLAIN_ACTIVE_CUSTOMER_LABEL_TYPE_ID, + PLAIN_APP_ID, POSTHOG_KEY, } from "@/lib/constants"; import { getUser } from "@/lib/user/service"; @@ -25,19 +27,30 @@ const AppLayout = async ({ children }: Readonly<{ children: React.ReactNode }>) return ; } + // Resolve the paying-customer label server-side so Plain applies it to threads + // from init time. Only queried when a label is configured to avoid extra work. + const plainActiveCustomerLabelTypeId = + IS_PLAIN_CHAT_CONFIGURED && + PLAIN_ACTIVE_CUSTOMER_LABEL_TYPE_ID && + user && + (await getIsActiveCustomer(user.id)) + ? PLAIN_ACTIVE_CUSTOMER_LABEL_TYPE_ID + : null; + return ( <> {POSTHOG_KEY && user && ( )} - {IS_CHATWOOT_CONFIGURED && ( - )} {IS_FORMBRICKS_SURVEYS_CONFIGURED && FORMBRICKS_WORKSPACE_ID && ( diff --git a/apps/web/app/chatwoot/components/chatwoot-widget.tsx b/apps/web/app/chatwoot/components/chatwoot-widget.tsx deleted file mode 100644 index 542f8acc145f..000000000000 --- a/apps/web/app/chatwoot/components/chatwoot-widget.tsx +++ /dev/null @@ -1,151 +0,0 @@ -"use client"; - -import { usePathname } from "next/navigation"; -import { useCallback, useEffect, useRef } from "react"; -import { getIsActiveCustomerAction } from "../lib/actions"; -import { isOnboardingPathname } from "../lib/utils"; - -interface ChatwootWidgetProps { - chatwootBaseUrl: string; - chatwootWebsiteToken?: string; - userEmail?: string | null; - userName?: string | null; - userId?: string | null; -} - -const CHATWOOT_SCRIPT_ID = "chatwoot-script"; -const CHATWOOT_ONBOARDING_HIDE_STYLE_ID = "chatwoot-onboarding-hide"; -const CHATWOOT_HIDE_SELECTORS = - "#chatwoot_live_chat_widget, .woot-widget-holder, .woot--bubble-holder, #cw-widget-holder"; - -interface ChatwootInstance { - setUser: ( - userId: string, - userInfo: { - email?: string | null; - name?: string | null; - } - ) => void; - setCustomAttributes: (attributes: Record) => void; - reset: () => void; -} - -export const ChatwootWidget = ({ - userEmail, - userName, - userId, - chatwootWebsiteToken, - chatwootBaseUrl, -}: ChatwootWidgetProps) => { - const pathname = usePathname(); - const isOnboarding = isOnboardingPathname(pathname); - const userSetRef = useRef(false); - const customerStatusSetRef = useRef(false); - - const getChatwoot = useCallback((): ChatwootInstance | null => { - return (globalThis as unknown as { $chatwoot: ChatwootInstance }).$chatwoot ?? null; - }, []); - - const setUserInfo = useCallback(() => { - const $chatwoot = getChatwoot(); - if (userId && $chatwoot && !userSetRef.current) { - $chatwoot.setUser(userId, { - email: userEmail, - name: userName, - }); - userSetRef.current = true; - } - }, [userId, userEmail, userName, getChatwoot]); - - const setCustomerStatus = useCallback(async () => { - if (customerStatusSetRef.current) return; - const $chatwoot = getChatwoot(); - if (!$chatwoot) return; - - const response = await getIsActiveCustomerAction(); - if (response?.data !== undefined) { - $chatwoot.setCustomAttributes({ isActiveCustomer: response.data }); - } - customerStatusSetRef.current = true; - }, [getChatwoot]); - - useEffect(() => { - const hideStyle = document.getElementById(CHATWOOT_ONBOARDING_HIDE_STYLE_ID); - - if (isOnboarding) { - if (!hideStyle) { - const style = document.createElement("style"); - style.id = CHATWOOT_ONBOARDING_HIDE_STYLE_ID; - style.textContent = `${CHATWOOT_HIDE_SELECTORS} { display: none !important; visibility: hidden !important; }`; - document.head.appendChild(style); - } - return; - } - - hideStyle?.remove(); - }, [isOnboarding]); - - useEffect(() => { - if (!chatwootWebsiteToken || isOnboarding) return; - - const existingScript = document.getElementById(CHATWOOT_SCRIPT_ID); - if (existingScript) return; - - const script = document.createElement("script"); - script.src = `${chatwootBaseUrl}/packs/js/sdk.js`; - script.id = CHATWOOT_SCRIPT_ID; - script.async = true; - - script.onload = () => { - ( - globalThis as unknown as { - chatwootSDK: { run: (options: { websiteToken: string; baseUrl: string }) => void }; - } - ).chatwootSDK?.run({ - websiteToken: chatwootWebsiteToken, - baseUrl: chatwootBaseUrl, - }); - }; - - document.head.appendChild(script); - - const handleChatwootReady = () => setUserInfo(); - globalThis.addEventListener("chatwoot:ready", handleChatwootReady); - - const handleChatwootOpen = () => setCustomerStatus(); - globalThis.addEventListener("chatwoot:open", handleChatwootOpen); - - // Check if Chatwoot is already ready - if (getChatwoot()) { - setUserInfo(); - } - - return () => { - globalThis.removeEventListener("chatwoot:ready", handleChatwootReady); - globalThis.removeEventListener("chatwoot:open", handleChatwootOpen); - - const $chatwoot = getChatwoot(); - if ($chatwoot) { - $chatwoot.reset(); - } - - const scriptElement = document.getElementById(CHATWOOT_SCRIPT_ID); - scriptElement?.remove(); - - userSetRef.current = false; - customerStatusSetRef.current = false; - }; - }, [ - chatwootBaseUrl, - chatwootWebsiteToken, - isOnboarding, - userId, - userEmail, - userName, - setUserInfo, - setCustomerStatus, - getChatwoot, - ]); - - return null; -}; diff --git a/apps/web/app/chatwoot/lib/actions.ts b/apps/web/app/chatwoot/lib/actions.ts deleted file mode 100644 index fd33f04d39c4..000000000000 --- a/apps/web/app/chatwoot/lib/actions.ts +++ /dev/null @@ -1,18 +0,0 @@ -"use server"; - -import { TCloudBillingPlan } from "@formbricks/types/organizations"; -import { getOrganizationsByUserId } from "@/lib/organization/service"; -import { authenticatedActionClient } from "@/lib/utils/action-client"; - -export const getIsActiveCustomerAction = authenticatedActionClient.action(async ({ ctx }) => { - const paidBillingPlans = new Set(["pro", "scale", "custom"]); - - const organizations = await getOrganizationsByUserId(ctx.user.id); - return organizations.some((organization) => { - const stripe = organization.billing.stripe; - const isPaidPlan = stripe?.plan ? paidBillingPlans.has(stripe.plan) : false; - const isActiveSubscription = - stripe?.subscriptionStatus === "active" || stripe?.subscriptionStatus === "trialing"; - return isPaidPlan && isActiveSubscription; - }); -}); diff --git a/apps/web/app/plain/components/plain-chat.tsx b/apps/web/app/plain/components/plain-chat.tsx new file mode 100644 index 000000000000..987b852f721b --- /dev/null +++ b/apps/web/app/plain/components/plain-chat.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { useEffect, useRef } from "react"; +import { isOnboardingPathname } from "../lib/utils"; + +interface PlainChatProps { + appId: string; + userEmail?: string | null; + userName?: string | null; + userId?: string | null; + /** HMAC-SHA256 of the user's email, computed server-side. Verifies the customer's identity. */ + emailHash?: string | null; + /** + * Plain label type applied to every thread this user opens. The layout resolves + * this to the paying-customer label id (or null) server-side, so it is attached + * at init time — before the first thread is created. + */ + activeCustomerLabelTypeId?: string | null; +} + +const PLAIN_SCRIPT_ID = "plain-chat-script"; +const PLAIN_SCRIPT_SRC = "https://chat.cdn-plain.com/index.js"; + +interface PlainCustomerDetails { + email: string; + emailHash?: string; + fullName?: string; + shortName?: string; + externalId?: string; +} + +interface PlainInitOptions { + appId: string; + hideLauncher?: boolean; + theme?: "auto" | "light" | "dark"; + customerDetails?: PlainCustomerDetails; + threadDetails?: { labelTypeIds?: string[] }; +} + +interface PlainInstance { + init: (options: PlainInitOptions) => void; + update: (options: Partial) => void; + setCustomerDetails: (details: PlainCustomerDetails) => void; + open: () => void; + close: () => void; + isInitialized: () => boolean; +} + +const getPlain = (): PlainInstance | null => + (globalThis as unknown as { Plain?: PlainInstance }).Plain ?? null; + +const buildCustomerDetails = ( + userEmail?: string | null, + emailHash?: string | null, + userName?: string | null, + userId?: string | null +): PlainCustomerDetails | undefined => { + if (!userEmail) return undefined; + return { + email: userEmail, + emailHash: emailHash ?? undefined, + fullName: userName ?? undefined, + shortName: userName?.split(" ")[0] ?? undefined, + externalId: userId ?? undefined, + }; +}; + +export const PlainChat = ({ + appId, + userEmail, + userName, + userId, + emailHash, + activeCustomerLabelTypeId, +}: Readonly) => { + const pathname = usePathname(); + const isOnboarding = isOnboardingPathname(pathname); + + // Snapshot the latest init inputs so the async script onload closure reads + // current values without re-registering the loader on every prop change. + const initOptionsRef = useRef({ appId }); + useEffect(() => { + initOptionsRef.current = { + appId, + theme: "auto", + hideLauncher: isOnboarding, + customerDetails: buildCustomerDetails(userEmail, emailHash, userName, userId), + threadDetails: activeCustomerLabelTypeId ? { labelTypeIds: [activeCustomerLabelTypeId] } : undefined, + }; + }); + + // Load the widget once and initialize it. Identity, onboarding, and label + // changes are handled by the effects below via Plain's in-place update APIs, + // so the script is never re-injected and no listeners are left dangling. + useEffect(() => { + if (!document.getElementById(PLAIN_SCRIPT_ID)) { + const script = document.createElement("script"); + script.src = PLAIN_SCRIPT_SRC; + script.id = PLAIN_SCRIPT_ID; + script.async = true; + script.onload = () => { + const plain = getPlain(); + if (plain && !plain.isInitialized()) { + plain.init(initOptionsRef.current); + } + }; + document.head.appendChild(script); + } + + return () => { + // Close the widget on unmount (e.g. logout). The identity effect below + // overwrites customer details on account switches so no prior-user data + // persists into the next session. + getPlain()?.close(); + }; + }, []); + + // Push identity changes (login, account switch) into the already-initialized + // widget instead of re-initializing, preventing stale customer data. + useEffect(() => { + const plain = getPlain(); + if (!plain?.isInitialized()) return; + const customerDetails = buildCustomerDetails(userEmail, emailHash, userName, userId); + if (customerDetails) { + plain.setCustomerDetails(customerDetails); + } + }, [userEmail, emailHash, userName, userId]); + + // Keep the active-customer label in sync once resolved. + useEffect(() => { + const plain = getPlain(); + if (!plain?.isInitialized()) return; + plain.update({ + threadDetails: activeCustomerLabelTypeId ? { labelTypeIds: [activeCustomerLabelTypeId] } : undefined, + }); + }, [activeCustomerLabelTypeId]); + + // Toggle the launcher as the user moves in and out of onboarding. + useEffect(() => { + const plain = getPlain(); + if (!plain?.isInitialized()) return; + plain.update({ hideLauncher: isOnboarding }); + }, [isOnboarding]); + + return null; +}; diff --git a/apps/web/app/plain/lib/customer.test.ts b/apps/web/app/plain/lib/customer.test.ts new file mode 100644 index 000000000000..4be259609a4a --- /dev/null +++ b/apps/web/app/plain/lib/customer.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { getOrganizationsByUserId } from "@/lib/organization/service"; +import { getIsActiveCustomer } from "./customer"; + +// A getter lets each test flip the deployment flag at runtime — the helper reads it through a live +// import binding at call time. Cloud is the default since that is where the label is used. +const constantsOverrides = vi.hoisted(() => ({ IS_FORMBRICKS_CLOUD: true })); + +vi.mock("@/lib/constants", () => ({ + get IS_FORMBRICKS_CLOUD() { + return constantsOverrides.IS_FORMBRICKS_CLOUD; + }, +})); + +vi.mock("@/lib/organization/service", () => ({ + getOrganizationsByUserId: vi.fn(), +})); + +const orgWith = (plan: string | null, subscriptionStatus: string | null) => + ({ billing: { stripe: { plan, subscriptionStatus } } }) as unknown as Awaited< + ReturnType + >[number]; + +const mockOrgs = (orgs: unknown[]) => + vi + .mocked(getOrganizationsByUserId) + .mockResolvedValue(orgs as Awaited>); + +describe("getIsActiveCustomer", () => { + beforeEach(() => { + vi.clearAllMocks(); + constantsOverrides.IS_FORMBRICKS_CLOUD = true; + }); + + test("returns true for a paid plan with an active subscription", async () => { + mockOrgs([orgWith("pro", "active")]); + await expect(getIsActiveCustomer("user-1")).resolves.toBe(true); + }); + + test("returns true for a paid plan on trial", async () => { + mockOrgs([orgWith("scale", "trialing")]); + await expect(getIsActiveCustomer("user-1")).resolves.toBe(true); + }); + + test("returns false for a paid plan whose subscription is not active", async () => { + mockOrgs([orgWith("pro", "canceled")]); + await expect(getIsActiveCustomer("user-1")).resolves.toBe(false); + }); + + test("returns false for the hobby plan even when the subscription is active", async () => { + mockOrgs([orgWith("hobby", "active")]); + await expect(getIsActiveCustomer("user-1")).resolves.toBe(false); + }); + + test("returns false for the custom plan (not counted as paying)", async () => { + mockOrgs([orgWith("custom", "active")]); + await expect(getIsActiveCustomer("user-1")).resolves.toBe(false); + }); + + test("returns false when the organization has no stripe billing", async () => { + mockOrgs([{ billing: { stripe: null } }]); + await expect(getIsActiveCustomer("user-1")).resolves.toBe(false); + }); + + test("returns true when at least one of several organizations qualifies", async () => { + mockOrgs([orgWith("hobby", "active"), orgWith("scale", "active")]); + await expect(getIsActiveCustomer("user-1")).resolves.toBe(true); + }); + + test("returns false when the user has no organizations", async () => { + mockOrgs([]); + await expect(getIsActiveCustomer("user-1")).resolves.toBe(false); + }); + + test("returns false on self-hosted instances without querying organizations", async () => { + constantsOverrides.IS_FORMBRICKS_CLOUD = false; + mockOrgs([orgWith("scale", "active")]); + await expect(getIsActiveCustomer("user-1")).resolves.toBe(false); + expect(getOrganizationsByUserId).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/plain/lib/customer.ts b/apps/web/app/plain/lib/customer.ts new file mode 100644 index 000000000000..8957a3107ad1 --- /dev/null +++ b/apps/web/app/plain/lib/customer.ts @@ -0,0 +1,29 @@ +import "server-only"; +import { TCloudBillingPlan } from "@formbricks/types/organizations"; +import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; +import { getOrganizationsByUserId } from "@/lib/organization/service"; + +const PAID_BILLING_PLANS = new Set(["pro", "scale"]); + +/** + * Whether the user belongs to at least one organization on a paid, active plan. + * Resolved server-side so the active-customer label can be attached to Plain + * threads at init time, before the user opens their first thread. + * + * Stripe billing plans only exist on Formbricks Cloud, so self-hosted instances + * short-circuit to false instead of querying the user's organizations. + */ +export const getIsActiveCustomer = async (userId: string): Promise => { + if (!IS_FORMBRICKS_CLOUD) { + return false; + } + + const organizations = await getOrganizationsByUserId(userId); + return organizations.some((organization) => { + const stripe = organization.billing.stripe; + const isPaidPlan = stripe?.plan ? PAID_BILLING_PLANS.has(stripe.plan) : false; + const isActiveSubscription = + stripe?.subscriptionStatus === "active" || stripe?.subscriptionStatus === "trialing"; + return isPaidPlan && isActiveSubscription; + }); +}; diff --git a/apps/web/app/plain/lib/identity.test.ts b/apps/web/app/plain/lib/identity.test.ts new file mode 100644 index 000000000000..6c4c9fb909b5 --- /dev/null +++ b/apps/web/app/plain/lib/identity.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { computePlainEmailHash } from "./identity"; + +const mocks = vi.hoisted(() => ({ + env: { PLAIN_CHAT_HMAC_SECRET: undefined as string | undefined }, +})); + +vi.mock("@/lib/env", () => ({ env: mocks.env })); + +describe("computePlainEmailHash", () => { + beforeEach(() => { + mocks.env.PLAIN_CHAT_HMAC_SECRET = undefined; + }); + + test("returns null when no secret is configured", () => { + expect(computePlainEmailHash("john@example.com")).toBeNull(); + }); + + test("computes the HMAC-SHA256 hex of the email with the secret", () => { + mocks.env.PLAIN_CHAT_HMAC_SECRET = "test-secret"; + expect(computePlainEmailHash("john@example.com")).toBe( + "831412b17524e7d41b2f9085360840f35558068db4efe31cebb6375fbd1ac0a8" + ); + }); + + test("is deterministic and email-specific", () => { + mocks.env.PLAIN_CHAT_HMAC_SECRET = "test-secret"; + expect(computePlainEmailHash("a@example.com")).toBe(computePlainEmailHash("a@example.com")); + expect(computePlainEmailHash("a@example.com")).not.toBe(computePlainEmailHash("b@example.com")); + }); +}); diff --git a/apps/web/app/plain/lib/identity.ts b/apps/web/app/plain/lib/identity.ts new file mode 100644 index 000000000000..7a019a787e53 --- /dev/null +++ b/apps/web/app/plain/lib/identity.ts @@ -0,0 +1,16 @@ +import "server-only"; +import { createHmac } from "node:crypto"; +import { env } from "@/lib/env"; + +/** + * Computes the HMAC-SHA256 hash Plain uses to verify a customer's identity. + * The hash is a bearer credential, so it must only ever be computed server-side + * with the secret from Plain's Chat settings and passed to the client already hashed. + * Returns null when no secret is configured, in which case the chat runs unverified. + */ +export const computePlainEmailHash = (email: string): string | null => { + const secret = env.PLAIN_CHAT_HMAC_SECRET; + if (!secret) return null; + + return createHmac("sha256", secret).update(email).digest("hex"); +}; diff --git a/apps/web/app/chatwoot/lib/utils.test.ts b/apps/web/app/plain/lib/utils.test.ts similarity index 100% rename from apps/web/app/chatwoot/lib/utils.test.ts rename to apps/web/app/plain/lib/utils.test.ts diff --git a/apps/web/app/chatwoot/lib/utils.ts b/apps/web/app/plain/lib/utils.ts similarity index 100% rename from apps/web/app/chatwoot/lib/utils.ts rename to apps/web/app/plain/lib/utils.ts diff --git a/apps/web/app/posthog/PostHogIdentify.tsx b/apps/web/app/posthog/PostHogIdentify.tsx index 141b3069e818..e0a82825aebf 100644 --- a/apps/web/app/posthog/PostHogIdentify.tsx +++ b/apps/web/app/posthog/PostHogIdentify.tsx @@ -22,7 +22,7 @@ export const PostHogIdentify = ({ posthogKey, userId, email, name }: PostHogIden capture_exceptions: true, debug: process.env.NODE_ENV === "development", session_recording: { - blockSelector: "#chatwoot_live_chat_widget", + blockSelector: "iframe[src*='cdn-plain']", }, }); } diff --git a/apps/web/lib/constants.ts b/apps/web/lib/constants.ts index 7cc7a7596a06..6eceda1e8e11 100644 --- a/apps/web/lib/constants.ts +++ b/apps/web/lib/constants.ts @@ -203,9 +203,9 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [ "zh-Hant-TW", ]; -export const CHATWOOT_WEBSITE_TOKEN = env.CHATWOOT_WEBSITE_TOKEN; -export const CHATWOOT_BASE_URL = env.CHATWOOT_BASE_URL || "https://app.chatwoot.com"; -export const IS_CHATWOOT_CONFIGURED = Boolean(env.CHATWOOT_WEBSITE_TOKEN); +export const PLAIN_APP_ID = env.PLAIN_APP_ID; +export const PLAIN_ACTIVE_CUSTOMER_LABEL_TYPE_ID = env.PLAIN_ACTIVE_CUSTOMER_LABEL_TYPE_ID; +export const IS_PLAIN_CHAT_CONFIGURED = Boolean(env.PLAIN_APP_ID); // Formbricks-in-Formbricks: in-app surveys served by a Formbricks instance // (defaults to Formbricks Cloud). The widget only mounts when a workspace id is set. diff --git a/apps/web/lib/env.ts b/apps/web/lib/env.ts index c56b10cca36a..153a33c6f931 100644 --- a/apps/web/lib/env.ts +++ b/apps/web/lib/env.ts @@ -277,8 +277,9 @@ const parsedEnv = createEnv({ .or(z.string().refine((str) => str === "")), IMPRINT_ADDRESS: z.string().optional(), INVITE_DISABLED: z.enum(["1", "0"]).optional(), - CHATWOOT_WEBSITE_TOKEN: z.string().optional(), - CHATWOOT_BASE_URL: z.url().optional(), + PLAIN_APP_ID: z.string().optional(), + PLAIN_CHAT_HMAC_SECRET: z.string().optional(), + PLAIN_ACTIVE_CUSTOMER_LABEL_TYPE_ID: z.string().optional(), // Formbricks-in-Formbricks: dogfood in-app surveys. Points at the Formbricks // instance that hosts the surveys (defaults to Formbricks Cloud). When // FORMBRICKS_WORKSPACE_ID is set, the survey widget is mounted in the app. @@ -452,8 +453,9 @@ const parsedEnv = createEnv({ IMPRINT_URL: process.env.IMPRINT_URL, IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS, INVITE_DISABLED: process.env.INVITE_DISABLED, - CHATWOOT_WEBSITE_TOKEN: process.env.CHATWOOT_WEBSITE_TOKEN, - CHATWOOT_BASE_URL: process.env.CHATWOOT_BASE_URL, + PLAIN_APP_ID: process.env.PLAIN_APP_ID, + PLAIN_CHAT_HMAC_SECRET: process.env.PLAIN_CHAT_HMAC_SECRET, + PLAIN_ACTIVE_CUSTOMER_LABEL_TYPE_ID: process.env.PLAIN_ACTIVE_CUSTOMER_LABEL_TYPE_ID, FORMBRICKS_WORKSPACE_ID: process.env.FORMBRICKS_WORKSPACE_ID, FORMBRICKS_APP_URL: process.env.FORMBRICKS_APP_URL, IS_FORMBRICKS_CLOUD: process.env.IS_FORMBRICKS_CLOUD, diff --git a/turbo.json b/turbo.json index 4baafc25442e..9b7b71e17714 100644 --- a/turbo.json +++ b/turbo.json @@ -282,8 +282,9 @@ "IMPRINT_ADDRESS", "INVITE_DISABLED", "IS_FORMBRICKS_CLOUD", - "CHATWOOT_WEBSITE_TOKEN", - "CHATWOOT_BASE_URL", + "PLAIN_APP_ID", + "PLAIN_CHAT_HMAC_SECRET", + "PLAIN_ACTIVE_CUSTOMER_LABEL_TYPE_ID", "FORMBRICKS_WORKSPACE_ID", "FORMBRICKS_APP_URL", "LOG_LEVEL",