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/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/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/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/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/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/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
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);
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 {
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",