Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 21 additions & 8 deletions apps/web/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -25,19 +27,30 @@ const AppLayout = async ({ children }: Readonly<{ children: React.ReactNode }>)
return <ClientLogout />;
}

// 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 (
<>
<NoMobileOverlay />
{POSTHOG_KEY && user && (
<PostHogIdentify posthogKey={POSTHOG_KEY} userId={user.id} email={user.email} name={user.name} />
)}
{IS_CHATWOOT_CONFIGURED && (
<ChatwootWidget
{IS_PLAIN_CHAT_CONFIGURED && PLAIN_APP_ID && (
<PlainChat
appId={PLAIN_APP_ID}
userEmail={user?.email}
userName={user?.name}
userId={user?.id}
chatwootWebsiteToken={CHATWOOT_WEBSITE_TOKEN}
chatwootBaseUrl={CHATWOOT_BASE_URL}
emailHash={user?.email ? computePlainEmailHash(user.email) : null}
activeCustomerLabelTypeId={plainActiveCustomerLabelTypeId}
/>
)}
{IS_FORMBRICKS_SURVEYS_CONFIGURED && FORMBRICKS_WORKSPACE_ID && (
Expand Down
124 changes: 124 additions & 0 deletions apps/web/app/api/v3/surveys/generate/error-mapping.test.ts
Original file line number Diff line number Diff line change
@@ -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"
);
});
});
104 changes: 104 additions & 0 deletions apps/web/app/api/v3/surveys/generate/error-mapping.ts
Original file line number Diff line number Diff line change
@@ -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<TAIErrorCode, string> = {
[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
);
}
Loading
Loading