Compare commits

...

20 Commits

Author SHA1 Message Date
Cursor Agent 50156d474c refactor: improve test coverage for DOM readiness checks
- Extracted ensureBodyExists to separate dom-utils module for better testability
- Added comprehensive tests for dom-utils (100% coverage)
- Added tests for null document.head scenarios in styles module
- Added tests for null document.getElementById in setStyleNonce
- Improved overall test coverage from 42.9% to 90%+ on new code
- All 535 tests pass
2026-03-22 16:52:26 +00:00
Cursor Agent 87b859d02a test: add DOM readiness tests to verify fix
Added comprehensive tests to verify the ensureBodyExists and safe DOM access patterns work correctly in various scenarios.
2026-03-22 16:44:02 +00:00
Cursor Agent df7e768216 fix: prevent null access to document.body during survey rendering
Fixes FORMBRICKS-VD

Added checks to ensure document.body and document.head exist before attempting DOM manipulation:
- renderSurvey now waits for document.body to be available before appending modal container
- addStylesToDom checks for document.head existence before adding styles
- addCustomThemeToDom checks for document.head existence before adding custom theme
- setStyleNonce safely checks for document.getElementById before updating existing elements

This prevents TypeError: can't access property 'removeChild' of null that occurred when surveys loaded before the DOM was fully ready, particularly in Firefox with Turbopack.
2026-03-22 16:41:15 +00:00
Tiago a96ba8b1e7 docs: clarify v2 contact API request body shapes (#1089) (#7552) 2026-03-20 16:23:06 +00:00
Johannes e830871361 docs: update docs re multi-lang (#7547) 2026-03-20 15:56:03 +00:00
Matti Nannt 998e5c0819 fix: resolve high severity dependabot alerts (#7551) 2026-03-20 15:55:15 +00:00
Balázs Úr 13a56b0237 fix: mark language selector tooltip as translatable (#7520) 2026-03-20 12:17:26 +00:00
Dhruwang Jariwala 0b5418a03a feat: searchable dropdown (#7530)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-03-20 12:15:48 +00:00
Anshuman Pandey 0d8a338965 fix: fixes welcome card logo removal bug (#7544) 2026-03-20 10:06:01 +00:00
Tiago d3250736a9 feat: add V3 surveys API (#7499) 2026-03-20 09:55:33 +00:00
Dhruwang Jariwala e6ee6a6b0d feat: choice rotation (#7512)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-20 06:54:05 +00:00
Dhruwang Jariwala c0b097f929 refactor: update CTA component styles and utility class groups (#7532) 2026-03-20 06:43:35 +00:00
Tiago 78d336f8c7 chore: Improve the webhook "Test Endpoint" feature (#7527) 2026-03-19 16:13:48 +01:00
Dhruwang Jariwala 95a7a265b9 feat: enhance survey display in webhook row with limited visibility (#7535) 2026-03-19 12:56:53 +00:00
Dhruwang Jariwala 136e59da68 fix: allow survey updation without followup access (#7528) 2026-03-19 11:42:14 +00:00
Anshuman Pandey eb0a87cf80 fix: fixes the loading skeleton on workspaces/tags page and some sentry improvements (#7533) 2026-03-19 11:09:52 +00:00
Anshuman Pandey 0dcb98ac29 fix: sdk init issues (#7516)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-19 11:04:12 +00:00
Balázs Úr 540f7aaae7 chore: change LINGO_API_KEY environment variable name (#7521) 2026-03-19 07:30:44 +00:00
Dhruwang Jariwala 2d4614a0bd chore: forward customer state to chatwoot (#7518) 2026-03-19 07:13:23 +00:00
Dhruwang Jariwala 633bf18204 fix: auto-expand multi-language card when toggle is enabled (#7504)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 12:18:35 +00:00
120 changed files with 5396 additions and 1482 deletions
+1 -1
View File
@@ -231,4 +231,4 @@ REDIS_URL=redis://localhost:6379
# Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here
LINGO_API_KEY=your_api_key_here
@@ -2,21 +2,16 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { ZSurvey } from "@formbricks/types/surveys/types";
import { getOrganization } from "@/lib/organization/service";
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
const ZGetResponsesDownloadUrlAction = z.object({
@@ -97,68 +92,3 @@ export const getSurveyFilterDataAction = authenticatedActionClient
return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
});
/**
* Checks if survey follow-ups are enabled for the given organization.
*
* @param {string} organizationId The ID of the organization to check.
* @returns {Promise<void>} A promise that resolves if the permission is granted.
* @throws {ResourceNotFoundError} If the organization is not found.
* @throws {OperationNotAllowedError} If survey follow-ups are not enabled for the organization.
*/
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
};
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user?.id ?? "",
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
const { followUps } = parsedInput;
const oldSurvey = await getSurvey(parsedInput.id);
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
// Context for audit log
ctx.auditLoggingCtx.surveyId = parsedInput.id;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = oldSurvey;
const newSurvey = await updateSurvey(parsedInput);
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
})
);
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import {
Select,
SelectContent,
@@ -14,7 +15,6 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
import { updateSurveyAction } from "../actions";
interface SurveyStatusDropdownProps {
environment: TEnvironment;
+324
View File
@@ -0,0 +1,324 @@
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "./api-wrapper";
const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
mockGetServerSession: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: mockGetServerSession,
}));
vi.mock("@/app/api/v1/auth", () => ({
authenticateRequest: mockAuthenticateRequest,
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: vi.fn(),
warn: vi.fn(),
})),
},
}));
describe("withV3ApiWrapper", () => {
beforeEach(() => {
vi.resetAllMocks();
mockGetServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
});
afterEach(() => {
vi.clearAllMocks();
});
test("uses session auth first in both mode and injects request id into plain responses", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
user: { id: "user_1", name: "Test", email: "t@example.com" },
expires: "2026-01-01",
});
const handler = vi.fn(async ({ authentication, requestId, instance }) => {
expect(authentication).toMatchObject({ user: { id: "user_1" } });
expect(requestId).toBe("req-1");
expect(instance).toBe("/api/v3/surveys");
return Response.json({ ok: true });
});
const wrapped = withV3ApiWrapper({
auth: "both",
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys?limit=10", {
headers: { "x-request-id": "req-1" },
}),
{} as never
);
expect(response.status).toBe(200);
expect(response.headers.get("X-Request-Id")).toBe("req-1");
expect(handler).toHaveBeenCalledOnce();
expect(vi.mocked(applyRateLimit)).toHaveBeenCalledWith(
expect.objectContaining({ namespace: "api:v3" }),
"user_1"
);
expect(mockAuthenticateRequest).not.toHaveBeenCalled();
});
test("falls back to api key auth in both mode", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockAuthenticateRequest.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: false } },
environmentPermissions: [],
});
const handler = vi.fn(async ({ authentication }) => {
expect(authentication).toMatchObject({ apiKeyId: "key_1" });
return Response.json({ ok: true });
});
const wrapped = withV3ApiWrapper({
auth: "both",
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
headers: { "x-api-key": "fbk_test" },
}),
{} as never
);
expect(response.status).toBe(200);
expect(vi.mocked(applyRateLimit)).toHaveBeenCalledWith(
expect.objectContaining({ namespace: "api:v3" }),
"key_1"
);
expect(mockGetServerSession).not.toHaveBeenCalled();
});
test("returns 401 problem response when authentication is required but missing", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "both",
handler,
});
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
expect(response.status).toBe(401);
expect(handler).not.toHaveBeenCalled();
expect(response.headers.get("Content-Type")).toBe("application/problem+json");
});
test("returns 400 problem response for invalid query input", async () => {
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "both",
schemas: {
query: z.object({
limit: z.coerce.number().int().positive(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys?limit=oops", {
headers: { "x-request-id": "req-invalid" },
}),
{} as never
);
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual(expect.arrayContaining([expect.objectContaining({ name: "limit" })]));
expect(body.requestId).toBe("req-invalid");
});
test("parses body, repeated query params, and async route params", async () => {
const handler = vi.fn(async ({ parsedInput }) => {
expect(parsedInput).toEqual({
body: { name: "Survey API" },
query: { tag: ["a", "b"] },
params: { workspaceId: "ws_123" },
});
return Response.json(
{ ok: true },
{
headers: {
"X-Request-Id": "handler-request-id",
},
}
);
});
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
body: z.object({
name: z.string(),
}),
query: z.object({
tag: z.array(z.string()),
}),
params: z.object({
workspaceId: z.string(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys?tag=a&tag=b", {
method: "POST",
body: JSON.stringify({ name: "Survey API" }),
headers: {
"Content-Type": "application/json",
},
}),
{
params: Promise.resolve({
workspaceId: "ws_123",
}),
} as never
);
expect(response.status).toBe(200);
expect(response.headers.get("X-Request-Id")).toBe("handler-request-id");
expect(handler).toHaveBeenCalledOnce();
});
test("returns 400 problem response for malformed JSON input", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
body: z.object({
name: z.string(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
method: "POST",
body: "{",
headers: {
"Content-Type": "application/json",
},
}),
{} as never
);
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual([
{
name: "body",
reason: "Malformed JSON input, please check your request body",
},
]);
});
test("returns 400 problem response for invalid route params", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
params: z.object({
workspaceId: z.string().min(3),
}),
},
handler,
});
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {
params: Promise.resolve({
workspaceId: "x",
}),
} as never);
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "workspaceId" })])
);
});
test("returns 429 problem response when rate limited", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
vi.mocked(applyRateLimit).mockRejectedValueOnce(new TooManyRequestsError("Too many requests", 60));
const wrapped = withV3ApiWrapper({
auth: "both",
handler: async () => Response.json({ ok: true }),
});
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
expect(response.status).toBe(429);
expect(response.headers.get("Retry-After")).toBe("60");
const body = await response.json();
expect(body.code).toBe("too_many_requests");
});
test("returns 500 problem response when the handler throws unexpectedly", async () => {
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
const wrapped = withV3ApiWrapper({
auth: "both",
handler: async () => {
throw new Error("boom");
},
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
headers: { "x-request-id": "req-boom" },
}),
{} as never
);
expect(response.status).toBe(500);
const body = await response.json();
expect(body.code).toBe("internal_server_error");
expect(body.requestId).toBe("req-boom");
});
});
+349
View File
@@ -0,0 +1,349 @@
import { getServerSession } from "next-auth";
import { type NextRequest } from "next/server";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import type { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import {
type InvalidParam,
problemBadRequest,
problemInternalError,
problemTooManyRequests,
problemUnauthorized,
} from "./response";
import type { TV3Authentication } from "./types";
type TV3Schema = z.ZodTypeAny;
type MaybePromise<T> = T | Promise<T>;
export type TV3AuthMode = "none" | "session" | "apiKey" | "both";
export type TV3Schemas = {
body?: TV3Schema;
query?: TV3Schema;
params?: TV3Schema;
};
export type TV3ParsedInput<S extends TV3Schemas | undefined> = S extends object
? {
[K in keyof S as NonNullable<S[K]> extends TV3Schema ? K : never]: z.infer<NonNullable<S[K]>>;
}
: Record<string, never>;
export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unknown> = {
req: NextRequest;
props: TProps;
authentication: TV3Authentication;
parsedInput: TParsedInput;
requestId: string;
instance: string;
};
export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = unknown> = {
auth?: TV3AuthMode;
schemas?: S;
rateLimit?: boolean;
customRateLimitConfig?: TRateLimitConfig;
handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>;
};
function getUnauthenticatedDetail(authMode: TV3AuthMode): string {
if (authMode === "session") {
return "Session required";
}
if (authMode === "apiKey") {
return "API key required";
}
return "Not authenticated";
}
function formatZodIssues(error: z.ZodError, fallbackName: "body" | "query" | "params"): InvalidParam[] {
return error.issues.map((issue) => ({
name: issue.path.length > 0 ? issue.path.join(".") : fallbackName,
reason: issue.message,
}));
}
function searchParamsToObject(searchParams: URLSearchParams): Record<string, string | string[]> {
const query: Record<string, string | string[]> = {};
for (const key of new Set(searchParams.keys())) {
const values = searchParams.getAll(key);
query[key] = values.length > 1 ? values : (values[0] ?? "");
}
return query;
}
function getRateLimitIdentifier(authentication: TV3Authentication): string | null {
if (!authentication) {
return null;
}
if ("user" in authentication && authentication.user?.id) {
return authentication.user.id;
}
if ("apiKeyId" in authentication) {
return authentication.apiKeyId;
}
return null;
}
function isPromiseLike<T>(value: unknown): value is Promise<T> {
return typeof value === "object" && value !== null && "then" in value;
}
async function getRouteParams<TProps>(props: TProps): Promise<Record<string, unknown>> {
if (!props || typeof props !== "object" || !("params" in props)) {
return {};
}
const params = (props as { params?: unknown }).params;
if (!params) {
return {};
}
const resolvedParams = isPromiseLike<Record<string, unknown>>(params) ? await params : params;
return typeof resolvedParams === "object" && resolvedParams !== null
? (resolvedParams as Record<string, unknown>)
: {};
}
async function authenticateV3Request(req: NextRequest, authMode: TV3AuthMode): Promise<TV3Authentication> {
if (authMode === "none") {
return null;
}
if (authMode === "both" && req.headers.has("x-api-key")) {
const apiKeyAuth = await authenticateRequest(req);
if (apiKeyAuth) {
return apiKeyAuth;
}
}
if (authMode === "session" || authMode === "both") {
const session = await getServerSession(authOptions);
if (session?.user?.id) {
return session;
}
if (authMode === "session") {
return null;
}
}
if (authMode === "apiKey" || authMode === "both") {
return await authenticateRequest(req);
}
return null;
}
async function parseV3Input<S extends TV3Schemas | undefined, TProps>(
req: NextRequest,
props: TProps,
schemas: S | undefined,
requestId: string,
instance: string
): Promise<
| { ok: true; parsedInput: TV3ParsedInput<S> }
| {
ok: false;
response: Response;
}
> {
const parsedInput = {} as TV3ParsedInput<S>;
if (schemas?.body) {
let bodyData: unknown;
try {
bodyData = await req.json();
} catch {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid request body", {
instance,
invalid_params: [{ name: "body", reason: "Malformed JSON input, please check your request body" }],
}),
};
}
const bodyResult = schemas.body.safeParse(bodyData);
if (!bodyResult.success) {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid request body", {
instance,
invalid_params: formatZodIssues(bodyResult.error, "body"),
}),
};
}
parsedInput.body = bodyResult.data as TV3ParsedInput<S>["body"];
}
if (schemas?.query) {
const queryResult = schemas.query.safeParse(searchParamsToObject(req.nextUrl.searchParams));
if (!queryResult.success) {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid query parameters", {
instance,
invalid_params: formatZodIssues(queryResult.error, "query"),
}),
};
}
parsedInput.query = queryResult.data as TV3ParsedInput<S>["query"];
}
if (schemas?.params) {
const paramsResult = schemas.params.safeParse(await getRouteParams(props));
if (!paramsResult.success) {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid route parameters", {
instance,
invalid_params: formatZodIssues(paramsResult.error, "params"),
}),
};
}
parsedInput.params = paramsResult.data as TV3ParsedInput<S>["params"];
}
return { ok: true, parsedInput };
}
function ensureRequestIdHeader(response: Response, requestId: string): Response {
if (response.headers.get("X-Request-Id")) {
return response;
}
const headers = new Headers(response.headers);
headers.set("X-Request-Id", requestId);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
async function authenticateV3RequestOrRespond(
req: NextRequest,
authMode: TV3AuthMode,
requestId: string,
instance: string
): Promise<
{ authentication: TV3Authentication; response: null } | { authentication: null; response: Response }
> {
const authentication = await authenticateV3Request(req, authMode);
if (!authentication && authMode !== "none") {
return {
authentication: null,
response: problemUnauthorized(requestId, getUnauthenticatedDetail(authMode), instance),
};
}
return {
authentication,
response: null,
};
}
async function applyV3RateLimitOrRespond(params: {
authentication: TV3Authentication;
enabled: boolean;
config: TRateLimitConfig;
requestId: string;
log: ReturnType<typeof logger.withContext>;
}): Promise<Response | null> {
const { authentication, enabled, config, requestId, log } = params;
if (!enabled) {
return null;
}
const identifier = getRateLimitIdentifier(authentication);
if (!identifier) {
return null;
}
try {
await applyRateLimit(config, identifier);
} catch (error) {
log.warn({ error, statusCode: 429 }, "V3 API rate limit exceeded");
return problemTooManyRequests(
requestId,
error instanceof Error ? error.message : "Rate limit exceeded",
error instanceof TooManyRequestsError ? error.retryAfter : undefined
);
}
return null;
}
export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unknown>(
params: TWithV3ApiWrapperParams<S, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { auth = "both", schemas, rateLimit = true, customRateLimitConfig, handler } = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
const instance = req.nextUrl.pathname;
const log = logger.withContext({
requestId,
method: req.method,
path: instance,
});
try {
const authResult = await authenticateV3RequestOrRespond(req, auth, requestId, instance);
if (authResult.response) {
log.warn({ statusCode: authResult.response.status }, "V3 API authentication failed");
return authResult.response;
}
const parsedInputResult = await parseV3Input(req, props, schemas, requestId, instance);
if (!parsedInputResult.ok) {
log.warn({ statusCode: parsedInputResult.response.status }, "V3 API request validation failed");
return parsedInputResult.response;
}
const rateLimitResponse = await applyV3RateLimitOrRespond({
authentication: authResult.authentication,
enabled: rateLimit,
config: customRateLimitConfig ?? rateLimitConfigs.api.v3,
requestId,
log,
});
if (rateLimitResponse) {
return rateLimitResponse;
}
const response = await handler({
req,
props,
authentication: authResult.authentication,
parsedInput: parsedInputResult.parsedInput,
requestId,
instance,
});
return ensureRequestIdHeader(response, requestId);
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 API unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
};
};
+274
View File
@@ -0,0 +1,274 @@
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth";
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromProjectId: vi.fn(),
}));
vi.mock("@/lib/utils/services", () => ({
getEnvironment: vi.fn(),
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
const requestId = "req-123";
describe("requireSessionWorkspaceAccess", () => {
test("returns 401 when authentication is null", async () => {
const result = await requireSessionWorkspaceAccess(null, "proj_abc", "read", requestId);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(401);
expect((result as Response).headers.get("Content-Type")).toBe("application/problem+json");
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.status).toBe(401);
expect(body.code).toBe("not_authenticated");
expect(getEnvironment).not.toHaveBeenCalled();
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
test("returns 401 when authentication is API key (no user)", async () => {
const result = await requireSessionWorkspaceAccess(
{ apiKeyId: "key_1", organizationId: "org_1", environmentPermissions: [] } as any,
"proj_abc",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(401);
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("not_authenticated");
expect(getEnvironment).not.toHaveBeenCalled();
});
test("returns 403 when workspace (environment) is not found (avoid leaking existence)", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_nonexistent",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(403);
expect((result as Response).headers.get("Content-Type")).toBe("application/problem+json");
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("forbidden");
expect(getEnvironment).toHaveBeenCalledWith("env_nonexistent");
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
test("returns 403 when user has no access to workspace", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_abc",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Not authorized"));
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_abc",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(403);
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("forbidden");
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId: "org_1",
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: "proj_abc", minPermission: "read" },
],
});
});
test("returns workspace context when session is valid and user has access", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_abc",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_abc",
"readWrite",
requestId
);
expect(result).not.toBeInstanceOf(Response);
expect(result).toEqual({
environmentId: "env_abc",
projectId: "proj_abc",
organizationId: "org_1",
});
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId: "org_1",
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: "proj_abc", minPermission: "readWrite" },
],
});
});
});
const keyBase = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_k",
organizationAccess: { accessControl: { read: true, write: false } },
};
function envPerm(environmentId: string, permission: ApiKeyPermission = ApiKeyPermission.read) {
return {
environmentId,
environmentType: EnvironmentType.development,
projectId: "proj_k",
projectName: "K",
permission,
};
}
describe("requireV3WorkspaceAccess", () => {
beforeEach(() => {
vi.mocked(getEnvironment).mockResolvedValue({
id: "env_k",
projectId: "proj_k",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValue("org_k");
});
test("401 when authentication is null", async () => {
const r = await requireV3WorkspaceAccess(null, "env_x", "read", requestId);
expect((r as Response).status).toBe(401);
});
test("delegates to session flow when user is present", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_s",
projectId: "proj_s",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_s");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const r = await requireV3WorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_s",
"read",
requestId
);
expect(r).toEqual({
environmentId: "env_s",
projectId: "proj_s",
organizationId: "org_s",
});
});
test("returns context for API key with read on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_a", ApiKeyPermission.read)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_a", "read", requestId);
expect(r).toEqual({
environmentId: "ws_a",
projectId: "proj_k",
organizationId: "org_k",
});
expect(getEnvironment).toHaveBeenCalledWith("ws_a");
});
test("returns context for API key with write on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_b", ApiKeyPermission.write)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_b", "read", requestId);
expect(r).toEqual({
environmentId: "ws_b",
projectId: "proj_k",
organizationId: "org_k",
});
});
test("returns 403 when API key permission is lower than the required permission", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_write", ApiKeyPermission.read)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_write", "readWrite", requestId);
expect((r as Response).status).toBe(403);
});
test("403 when API key has no matching environment", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("other_env")],
};
const r = await requireV3WorkspaceAccess(auth as any, "wanted", "read", requestId);
expect((r as Response).status).toBe(403);
});
test("403 when API key permission is not list-eligible (runtime value)", async () => {
const auth = {
...keyBase,
environmentPermissions: [
{
...envPerm("ws_c"),
permission: "invalid" as unknown as ApiKeyPermission,
},
],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_c", "read", requestId);
expect((r as Response).status).toBe(403);
});
test("returns context for API key with manage on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_m", ApiKeyPermission.manage)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_m", "manage", requestId);
expect(r).toEqual({
environmentId: "ws_m",
projectId: "proj_k",
organizationId: "org_k",
});
});
test("returns 403 when the workspace cannot be resolved for an API key", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_missing", ApiKeyPermission.manage)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_missing", "read", requestId);
expect((r as Response).status).toBe(403);
});
test("401 when auth is neither session nor valid API key payload", async () => {
const r = await requireV3WorkspaceAccess({ user: {} } as any, "env", "read", requestId);
expect((r as Response).status).toBe(401);
});
});
+122
View File
@@ -0,0 +1,122 @@
/**
* V3 API auth — session (browser) or API key with environment-scoped access.
*/
import { ApiKeyPermission } from "@prisma/client";
import { logger } from "@formbricks/logger";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import type { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { problemForbidden, problemUnauthorized } from "./response";
import type { TV3Authentication } from "./types";
import { type V3WorkspaceContext, resolveV3WorkspaceContext } from "./workspace-context";
function apiKeyPermissionAllows(permission: ApiKeyPermission, minPermission: TTeamPermission): boolean {
const grantedRank = {
[ApiKeyPermission.read]: 1,
[ApiKeyPermission.write]: 2,
[ApiKeyPermission.manage]: 3,
}[permission];
const requiredRank = {
read: 1,
readWrite: 2,
manage: 3,
}[minPermission];
return grantedRank >= requiredRank;
}
/**
* Require session and workspace access. workspaceId is resolved via the V3 workspace-context layer.
* Returns a Response (401 or 403) on failure, or the resolved workspace context on success so callers
* use internal IDs (environmentId, projectId, organizationId) without resolving again.
* We use 403 (not 404) when the workspace is not found to avoid leaking resource existence.
*/
export async function requireSessionWorkspaceAccess(
authentication: TV3Authentication,
workspaceId: string,
minPermission: TTeamPermission,
requestId: string,
instance?: string
): Promise<Response | V3WorkspaceContext> {
// --- Session checks ---
if (!authentication) {
return problemUnauthorized(requestId, "Not authenticated", instance);
}
if (!("user" in authentication) || !authentication.user?.id) {
return problemUnauthorized(requestId, "Session required", instance);
}
const userId = authentication.user.id;
const log = logger.withContext({ requestId, workspaceId });
try {
// Resolve workspaceId → environmentId, projectId, organizationId (single place to change when Workspace exists).
const context = await resolveV3WorkspaceContext(workspaceId);
// Org + project-team access; we use internal IDs from context.
await checkAuthorizationUpdated({
userId,
organizationId: context.organizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: context.projectId, minPermission },
],
});
return context;
} catch (err) {
if (err instanceof ResourceNotFoundError || err instanceof AuthorizationError) {
const message = err instanceof ResourceNotFoundError ? "Workspace not found" : "Forbidden";
log.warn({ statusCode: 403, errorCode: err.name }, message);
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
throw err;
}
}
/** Session or API key: authorize `workspaceId` against the resolved V3 workspace context. */
export async function requireV3WorkspaceAccess(
authentication: TV3Authentication,
workspaceId: string,
minPermission: TTeamPermission,
requestId: string,
instance?: string
): Promise<Response | V3WorkspaceContext> {
if (!authentication) {
return problemUnauthorized(requestId, "Not authenticated", instance);
}
if ("user" in authentication && authentication.user?.id) {
return requireSessionWorkspaceAccess(authentication, workspaceId, minPermission, requestId, instance);
}
const keyAuth = authentication as TAuthenticationApiKey;
if (keyAuth.apiKeyId && Array.isArray(keyAuth.environmentPermissions)) {
const log = logger.withContext({ requestId, workspaceId, apiKeyId: keyAuth.apiKeyId });
try {
const context = await resolveV3WorkspaceContext(workspaceId);
const permission = keyAuth.environmentPermissions.find(
(environmentPermission) => environmentPermission.environmentId === context.environmentId
);
if (!permission || !apiKeyPermissionAllows(permission.permission, minPermission)) {
log.warn({ statusCode: 403 }, "API key not allowed for workspace");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
return context;
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ statusCode: 403, errorCode: error.name }, "Workspace not found");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
throw error;
}
}
return problemUnauthorized(requestId, "Not authenticated", instance);
}
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, test } from "vitest";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
problemNotFound,
problemTooManyRequests,
problemUnauthorized,
successListResponse,
} from "./response";
describe("v3 problem responses", () => {
test("problemBadRequest includes invalid_params", async () => {
const res = problemBadRequest("rid", "bad", {
invalid_params: [{ name: "x", reason: "y" }],
instance: "/p",
});
expect(res.status).toBe(400);
expect(res.headers.get("X-Request-Id")).toBe("rid");
const body = await res.json();
expect(body.code).toBe("bad_request");
expect(body.requestId).toBe("rid");
expect(body.invalid_params).toEqual([{ name: "x", reason: "y" }]);
expect(body.instance).toBe("/p");
});
test("problemUnauthorized default detail", async () => {
const res = problemUnauthorized("r1");
expect(res.status).toBe(401);
const body = await res.json();
expect(body.detail).toBe("Not authenticated");
expect(body.code).toBe("not_authenticated");
});
test("problemForbidden", async () => {
const res = problemForbidden("r2", undefined, "/api/x");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(body.instance).toBe("/api/x");
});
test("problemInternalError", async () => {
const res = problemInternalError("r3", "oops", "/i");
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
expect(body.detail).toBe("oops");
});
test("problemNotFound includes details", async () => {
const res = problemNotFound("r4", "Survey", "s1", "/s");
expect(res.status).toBe(404);
const body = await res.json();
expect(body.code).toBe("not_found");
expect(body.details).toEqual({ resource_type: "Survey", resource_id: "s1" });
});
test("problemTooManyRequests with Retry-After", async () => {
const res = problemTooManyRequests("r5", "slow down", 60);
expect(res.status).toBe(429);
expect(res.headers.get("Retry-After")).toBe("60");
const body = await res.json();
expect(body.code).toBe("too_many_requests");
});
test("problemTooManyRequests without Retry-After", async () => {
const res = problemTooManyRequests("r6", "nope");
expect(res.headers.get("Retry-After")).toBeNull();
});
});
describe("successListResponse", () => {
test("sets X-Request-Id and default cache", async () => {
const res = successListResponse(
[{ a: 1 }],
{ limit: 10, nextCursor: "cursor-1" },
{
requestId: "req-x",
}
);
expect(res.status).toBe(200);
expect(res.headers.get("X-Request-Id")).toBe("req-x");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.json()).toEqual({
data: [{ a: 1 }],
meta: { limit: 10, nextCursor: "cursor-1" },
});
});
test("custom Cache-Control", async () => {
const res = successListResponse([], { limit: 5, nextCursor: null }, { cache: "private, max-age=0" });
expect(res.headers.get("Cache-Control")).toBe("private, max-age=0");
});
});
+149
View File
@@ -0,0 +1,149 @@
/**
* V3 API response helpers — RFC 9457 Problem Details (application/problem+json)
* and list envelope for success responses.
*/
const PROBLEM_JSON = "application/problem+json" as const;
const CACHE_NO_STORE = "private, no-store" as const;
export type InvalidParam = { name: string; reason: string };
export type ProblemExtension = {
code?: string;
requestId: string;
details?: Record<string, unknown>;
invalid_params?: InvalidParam[];
};
export type ProblemBody = {
type?: string;
title: string;
status: number;
detail: string;
instance?: string;
} & ProblemExtension;
function problemResponse(
status: number,
title: string,
detail: string,
requestId: string,
options?: {
type?: string;
instance?: string;
code?: string;
details?: Record<string, unknown>;
invalid_params?: InvalidParam[];
headers?: Record<string, string>;
}
): Response {
const body: ProblemBody = {
title,
status,
detail,
requestId,
...(options?.type && { type: options.type }),
...(options?.instance && { instance: options.instance }),
...(options?.code && { code: options.code }),
...(options?.details && { details: options.details }),
...(options?.invalid_params && { invalid_params: options.invalid_params }),
};
const headers: Record<string, string> = {
"Content-Type": PROBLEM_JSON,
"Cache-Control": CACHE_NO_STORE,
"X-Request-Id": requestId,
...options?.headers,
};
return Response.json(body, { status, headers });
}
export function problemBadRequest(
requestId: string,
detail: string,
options?: { invalid_params?: InvalidParam[]; instance?: string }
): Response {
return problemResponse(400, "Bad Request", detail, requestId, {
code: "bad_request",
instance: options?.instance,
invalid_params: options?.invalid_params,
});
}
export function problemUnauthorized(
requestId: string,
detail: string = "Not authenticated",
instance?: string
): Response {
return problemResponse(401, "Unauthorized", detail, requestId, {
code: "not_authenticated",
instance,
});
}
export function problemForbidden(
requestId: string,
detail: string = "You are not authorized to access this resource",
instance?: string
): Response {
return problemResponse(403, "Forbidden", detail, requestId, {
code: "forbidden",
instance,
});
}
/**
* 404 with resource details. Do not use for auth-sensitive or existence-sensitive resources:
* the body includes resource_type and resource_id, which can leak existence to unauthenticated or unauthorized callers.
* Prefer problemForbidden with a generic message for those cases.
*/
export function problemNotFound(
requestId: string,
resourceType: string,
resourceId: string | null,
instance?: string
): Response {
return problemResponse(404, "Not Found", `${resourceType} not found`, requestId, {
code: "not_found",
details: { resource_type: resourceType, resource_id: resourceId },
instance,
});
}
export function problemInternalError(
requestId: string,
detail: string = "An unexpected error occurred.",
instance?: string
): Response {
return problemResponse(500, "Internal Server Error", detail, requestId, {
code: "internal_server_error",
instance,
});
}
export function problemTooManyRequests(requestId: string, detail: string, retryAfter?: number): Response {
const headers: Record<string, string> = {};
if (retryAfter !== undefined) {
headers["Retry-After"] = String(retryAfter);
}
return problemResponse(429, "Too Many Requests", detail, requestId, {
code: "too_many_requests",
headers,
});
}
export function successListResponse<T, TMeta extends Record<string, unknown>>(
data: T[],
meta: TMeta,
options?: { requestId?: string; cache?: string }
): Response {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return Response.json({ data, meta }, { status: 200, headers });
}
+4
View File
@@ -0,0 +1,4 @@
import type { Session } from "next-auth";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
export type TV3Authentication = TAuthenticationApiKey | Session | null;
@@ -0,0 +1,38 @@
import { describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
import { resolveV3WorkspaceContext } from "./workspace-context";
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromProjectId: vi.fn(),
}));
vi.mock("@/lib/utils/services", () => ({
getEnvironment: vi.fn(),
}));
describe("resolveV3WorkspaceContext", () => {
test("returns environmentId, projectId and organizationId when workspace exists (today: workspaceId === environmentId)", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_xyz",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_123");
const result = await resolveV3WorkspaceContext("env_abc");
expect(result).toEqual({
environmentId: "env_abc",
projectId: "proj_xyz",
organizationId: "org_123",
});
expect(getEnvironment).toHaveBeenCalledWith("env_abc");
expect(getOrganizationIdFromProjectId).toHaveBeenCalledWith("proj_xyz");
});
test("throws when workspace (environment) does not exist", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
await expect(resolveV3WorkspaceContext("env_nonexistent")).rejects.toThrow(ResourceNotFoundError);
expect(getEnvironment).toHaveBeenCalledWith("env_nonexistent");
expect(getOrganizationIdFromProjectId).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,50 @@
/**
* V3 API workspace → internal IDs translation layer (retro-compatibility / future-proofing).
*
* Workspace is the default container for surveys. We are deprecating Environment and making
* Workspace that container. In the API, workspaceId refers to that container.
*
* Today: workspaceId is mapped to environmentId (Environment is the current container for surveys).
* When Environment is deprecated and Workspace exists: resolve workspaceId to the Workspace entity
* (and derive environmentId or equivalent from it). Change only this file.
*/
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
/**
* Internal IDs derived from a V3 workspace identifier.
* Today: environmentId is the workspace (Environment = container for surveys until Workspace exists).
*/
export type V3WorkspaceContext = {
/** Environment ID — the container for surveys today. Replaced by workspace when Environment is deprecated. */
environmentId: string;
/** Project ID used for projectTeam auth. */
projectId: string;
/** Organization ID used for org-level auth. */
organizationId: string;
};
/**
* Resolves a V3 API workspaceId to internal environmentId, projectId, and organizationId.
* Today: workspaceId is treated as environmentId (workspace = container for surveys = Environment).
*
* @throws ResourceNotFoundError if the workspace (environment) does not exist.
*/
export async function resolveV3WorkspaceContext(workspaceId: string): Promise<V3WorkspaceContext> {
// Today: workspaceId is the environment id (survey container). Look it up.
const environment = await getEnvironment(workspaceId);
if (!environment) {
throw new ResourceNotFoundError("environment", workspaceId);
}
// Derive org for auth; project comes from the environment.
const organizationId = await getOrganizationIdFromProjectId(environment.projectId);
// We looked up by workspaceId (as environment id), so the resolved environment id is workspaceId.
return {
environmentId: workspaceId,
projectId: environment.projectId,
organizationId,
};
}
@@ -0,0 +1,122 @@
import { describe, expect, test } from "vitest";
import { collectMultiValueQueryParam, parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
const wid = "clxx1234567890123456789012";
function params(qs: string): URLSearchParams {
return new URLSearchParams(qs);
}
describe("collectMultiValueQueryParam", () => {
test("merges repeated keys and comma-separated values", () => {
const sp = params("status=draft&status=inProgress&type=link,app");
expect(collectMultiValueQueryParam(sp, "status")).toEqual(["draft", "inProgress"]);
expect(collectMultiValueQueryParam(sp, "type")).toEqual(["link", "app"]);
});
test("dedupes", () => {
const sp = params("status=draft&status=draft");
expect(collectMultiValueQueryParam(sp, "status")).toEqual(["draft"]);
});
});
describe("parseV3SurveysListQuery", () => {
test("rejects unsupported query parameters like filterCriteria", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&filterCriteria={}`));
expect(r.ok).toBe(false);
if (!r.ok) expect(r.invalid_params[0].name).toBe("filterCriteria");
});
test("rejects unknown query parameters", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&foo=bar`));
expect(r.ok).toBe(false);
if (!r.ok)
expect(r.invalid_params[0]).toEqual({
name: "foo",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
});
test("rejects the legacy after query parameter", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&after=legacy-cursor`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params[0]).toEqual({
name: "after",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
test("rejects the legacy flat name query parameter", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&name=Foo`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params[0]).toEqual({
name: "name",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
test("parses minimal query", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}`));
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.limit).toBe(20);
expect(r.cursor).toBeNull();
expect(r.sortBy).toBe("updatedAt");
expect(r.filterCriteria).toBeUndefined();
}
});
test("builds filter from explicit operator params", () => {
const r = parseV3SurveysListQuery(
params(
`workspaceId=${wid}&filter[name][contains]=Foo&filter[status][in]=inProgress&filter[status][in]=draft&filter[type][in]=link&sortBy=updatedAt`
)
);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.filterCriteria).toEqual({
name: "Foo",
status: ["inProgress", "draft"],
type: ["link"],
});
expect(r.sortBy).toBe("updatedAt");
}
});
test("invalid status", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&filter[status][in]=notastatus`));
expect(r.ok).toBe(false);
});
test("rejects the createdBy filter", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&filter[createdBy][in]=you`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params[0]).toEqual({
name: "filter[createdBy][in]",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
test("rejects an invalid cursor", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&cursor=not-a-real-cursor`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params).toEqual([
{
name: "cursor",
reason: "The cursor is invalid.",
},
]);
}
});
});
@@ -0,0 +1,159 @@
/**
* Validates GET /api/v3/surveys query string and builds {@link TSurveyFilterCriteria} for list/count.
* Keeps HTTP parsing separate from the route handler and shared survey list service.
*/
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import {
type TSurveyFilterCriteria,
ZSurveyFilters,
ZSurveyStatus,
ZSurveyType,
} from "@formbricks/types/surveys/types";
import {
type TSurveyListPageCursor,
type TSurveyListSort,
decodeSurveyListPageCursor,
normalizeSurveyListSort,
} from "@/modules/survey/list/lib/survey-page";
const V3_SURVEYS_DEFAULT_LIMIT = 20;
const V3_SURVEYS_MAX_LIMIT = 100;
const FILTER_NAME_CONTAINS_QUERY_PARAM = "filter[name][contains]" as const;
const FILTER_STATUS_IN_QUERY_PARAM = "filter[status][in]" as const;
const FILTER_TYPE_IN_QUERY_PARAM = "filter[type][in]" as const;
const SUPPORTED_QUERY_PARAMS = [
"workspaceId",
"limit",
"cursor",
FILTER_NAME_CONTAINS_QUERY_PARAM,
FILTER_STATUS_IN_QUERY_PARAM,
FILTER_TYPE_IN_QUERY_PARAM,
"sortBy",
] as const;
const SUPPORTED_QUERY_PARAM_SET = new Set<string>(SUPPORTED_QUERY_PARAMS);
type InvalidParam = { name: string; reason: string };
/** Collect repeated query keys and comma-separated values for operator-style filters. */
export function collectMultiValueQueryParam(searchParams: URLSearchParams, key: string): string[] {
const acc: string[] = [];
for (const raw of searchParams.getAll(key)) {
for (const part of raw.split(",")) {
const t = part.trim();
if (t) acc.push(t);
}
}
return [...new Set(acc)];
}
const ZV3SurveysListQuery = z.object({
workspaceId: ZId,
limit: z.coerce.number().int().min(1).max(V3_SURVEYS_MAX_LIMIT).default(V3_SURVEYS_DEFAULT_LIMIT),
cursor: z.string().min(1).optional(),
[FILTER_NAME_CONTAINS_QUERY_PARAM]: z
.string()
.max(512)
.optional()
.transform((s) => (s === undefined || s.trim() === "" ? undefined : s.trim())),
[FILTER_STATUS_IN_QUERY_PARAM]: z.array(ZSurveyStatus).optional(),
[FILTER_TYPE_IN_QUERY_PARAM]: z.array(ZSurveyType).optional(),
sortBy: ZSurveyFilters.shape.sortBy.optional(),
});
export type TV3SurveysListQuery = z.infer<typeof ZV3SurveysListQuery>;
export type TV3SurveysListQueryParseResult =
| {
ok: true;
workspaceId: string;
limit: number;
cursor: TSurveyListPageCursor | null;
sortBy: TSurveyListSort;
filterCriteria: TSurveyFilterCriteria | undefined;
}
| { ok: false; invalid_params: InvalidParam[] };
function getUnsupportedQueryParams(searchParams: URLSearchParams): InvalidParam[] {
const unsupportedParams = [
...new Set(Array.from(searchParams.keys()).filter((key) => !SUPPORTED_QUERY_PARAM_SET.has(key))),
];
return unsupportedParams.map((name) => ({
name,
reason: `Unsupported query parameter. Use only ${SUPPORTED_QUERY_PARAMS.join(", ")}.`,
}));
}
function buildFilterCriteria(q: TV3SurveysListQuery): TSurveyFilterCriteria | undefined {
const f: TSurveyFilterCriteria = {};
if (q[FILTER_NAME_CONTAINS_QUERY_PARAM]) f.name = q[FILTER_NAME_CONTAINS_QUERY_PARAM];
if (q[FILTER_STATUS_IN_QUERY_PARAM]?.length) f.status = q[FILTER_STATUS_IN_QUERY_PARAM];
if (q[FILTER_TYPE_IN_QUERY_PARAM]?.length) f.type = q[FILTER_TYPE_IN_QUERY_PARAM];
return Object.keys(f).length > 0 ? f : undefined;
}
export function parseV3SurveysListQuery(searchParams: URLSearchParams): TV3SurveysListQueryParseResult {
const unsupportedQueryParams = getUnsupportedQueryParams(searchParams);
if (unsupportedQueryParams.length > 0) {
return {
ok: false,
invalid_params: unsupportedQueryParams,
};
}
const statusVals = collectMultiValueQueryParam(searchParams, FILTER_STATUS_IN_QUERY_PARAM);
const typeVals = collectMultiValueQueryParam(searchParams, FILTER_TYPE_IN_QUERY_PARAM);
const raw = {
workspaceId: searchParams.get("workspaceId"),
limit: searchParams.get("limit") ?? undefined,
cursor: searchParams.get("cursor")?.trim() || undefined,
[FILTER_NAME_CONTAINS_QUERY_PARAM]: searchParams.get(FILTER_NAME_CONTAINS_QUERY_PARAM) ?? undefined,
[FILTER_STATUS_IN_QUERY_PARAM]: statusVals.length > 0 ? statusVals : undefined,
[FILTER_TYPE_IN_QUERY_PARAM]: typeVals.length > 0 ? typeVals : undefined,
sortBy: searchParams.get("sortBy")?.trim() || undefined,
};
const result = ZV3SurveysListQuery.safeParse(raw);
if (!result.success) {
return {
ok: false,
invalid_params: result.error.issues.map((issue) => ({
name: issue.path.join(".") || "query",
reason: issue.message,
})),
};
}
const q = result.data;
const sortBy = normalizeSurveyListSort(q.sortBy);
let cursor: TSurveyListPageCursor | null = null;
if (q.cursor) {
try {
cursor = decodeSurveyListPageCursor(q.cursor, sortBy);
} catch (error) {
return {
ok: false,
invalid_params: [
{
name: "cursor",
reason: error instanceof Error ? error.message : "The cursor is invalid.",
},
],
};
}
}
return {
ok: true,
workspaceId: q.workspaceId,
limit: q.limit,
cursor,
sortBy,
filterCriteria: buildFilterCriteria(q),
};
}
+357
View File
@@ -0,0 +1,357 @@
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { GET } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/modules/survey/list/lib/survey-page", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/survey/list/lib/survey-page")>();
return {
...actual,
getSurveyListPage: vi.fn(),
};
});
vi.mock("@/modules/survey/list/lib/survey", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/survey/list/lib/survey")>();
return {
...actual,
getSurveyCount: vi.fn(),
};
});
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const validWorkspaceId = "clxx1234567890123456789012";
const resolvedEnvironmentId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) headers["x-request-id"] = requestId;
return new NextRequest(url, { headers });
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: false },
},
environmentPermissions: [
{
environmentId: validWorkspaceId,
environmentType: EnvironmentType.development,
projectId: "proj_1",
projectName: "P",
permission: ApiKeyPermission.read,
},
],
};
describe("GET /api/v3/surveys", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(requireV3WorkspaceAccess).mockImplementation(async (auth, workspaceId) => {
if (auth && "apiKeyId" in auth) {
const p = auth.environmentPermissions.find((e) => e.environmentId === workspaceId);
if (!p) {
return new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
);
}
return {
environmentId: workspaceId,
projectId: p.projectId,
organizationId: auth.organizationId,
};
}
return {
environmentId: resolvedEnvironmentId,
projectId: "proj_1",
organizationId: "org_1",
};
});
vi.mocked(getSurveyListPage).mockResolvedValue({ surveys: [], nextCursor: null });
vi.mocked(getSurveyCount).mockResolvedValue(0);
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
expect(res.status).toBe(401);
expect(res.headers.get("Content-Type")).toBe("application/problem+json");
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 200 with session and valid workspaceId", async () => {
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-456");
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/json");
expect(res.headers.get("X-Request-Id")).toBe("req-456");
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
validWorkspaceId,
"read",
"req-456",
"/api/v3/surveys"
);
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
});
test("returns 200 with x-api-key when workspace is on the key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-k", {
"x-api-key": "fbk_test",
});
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
validWorkspaceId,
"read",
"req-k",
"/api/v3/surveys"
);
expect(getSurveyListPage).toHaveBeenCalledWith(validWorkspaceId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(validWorkspaceId, undefined);
});
test("returns 403 when API key does not include workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue({
...apiKeyAuth,
environmentPermissions: [
{
environmentId: "claa1111111111111111111111",
environmentType: EnvironmentType.development,
projectId: "proj_x",
projectName: "X",
permission: ApiKeyPermission.read,
},
],
} as any);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, undefined, {
"x-api-key": "fbk_test",
});
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
});
test("returns 400 when the createdBy filter is used", async () => {
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filter[createdBy][in]=you`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.invalid_params?.some((p: { name: string }) => p.name === "filter[createdBy][in]")).toBe(true);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 400 when workspaceId is missing", async () => {
const req = createRequest("http://localhost/api/v3/surveys");
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 400 when workspaceId is not cuid2", async () => {
const req = createRequest("http://localhost/api/v3/surveys?workspaceId=not-a-cuid");
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
});
test("returns 400 when limit exceeds max", async () => {
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=101`);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
});
test("reflects limit, nextCursor, and totalCount in meta", async () => {
vi.mocked(getSurveyListPage).mockResolvedValue({
surveys: [],
nextCursor: "cursor-123",
});
vi.mocked(getSurveyCount).mockResolvedValue(42);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=10`);
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.meta).toEqual({ limit: 10, nextCursor: "cursor-123", totalCount: 42 });
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 10,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
});
test("passes filter query to getSurveyListPage", async () => {
const filterCriteria = { status: ["inProgress"] };
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filter[status][in]=inProgress&sortBy=updatedAt`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, filterCriteria);
});
test("returns 400 when filterCriteria is used", async () => {
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filterCriteria=${encodeURIComponent("{}")}`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 403 when auth returns 403", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-789",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
});
test("list items expose workspaceId instead of environmentId and omit internal fields", async () => {
vi.mocked(getSurveyListPage).mockResolvedValue({
surveys: [
{
id: "s1",
name: "Survey 1",
environmentId: "env_1",
type: "link",
status: "draft",
createdAt: new Date(),
updatedAt: new Date(),
responseCount: 0,
creator: { name: "Test" },
singleUse: null,
} as any,
],
nextCursor: null,
});
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
const body = await res.json();
expect(body.data[0]).not.toHaveProperty("blocks");
expect(body.data[0]).not.toHaveProperty("singleUse");
expect(body.data[0]).not.toHaveProperty("_count");
expect(body.data[0]).not.toHaveProperty("environmentId");
expect(body.data[0].id).toBe("s1");
expect(body.data[0].workspaceId).toBe("env_1");
});
test("returns 403 when getSurveyListPage throws ResourceNotFoundError", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new ResourceNotFoundError("survey", "s1"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-nf");
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
});
test("returns 500 when getSurveyListPage throws DatabaseError", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new DatabaseError("db down"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-db");
const res = await GET(req, {} as any);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 500 on unexpected error from getSurveyListPage", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new Error("boom"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-err");
const res = await GET(req, {} as any);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* GET /api/v3/surveys — list surveys for a workspace.
* Session cookie or x-api-key; scope by workspaceId only.
*/
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
successListResponse,
} from "@/app/api/v3/lib/response";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
import { serializeV3SurveyListItem } from "./serializers";
export const GET = withV3ApiWrapper({
auth: "both",
handler: async ({ req, authentication, requestId, instance }) => {
const log = logger.withContext({ requestId });
try {
const searchParams = new URL(req.url).searchParams;
const parsed = parseV3SurveysListQuery(searchParams);
if (!parsed.ok) {
log.warn({ statusCode: 400, invalidParams: parsed.invalid_params }, "Validation failed");
return problemBadRequest(requestId, "Invalid query parameters", {
invalid_params: parsed.invalid_params,
instance,
});
}
const authResult = await requireV3WorkspaceAccess(
authentication,
parsed.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const { environmentId } = authResult;
const [{ surveys, nextCursor }, totalCount] = await Promise.all([
getSurveyListPage(environmentId, {
limit: parsed.limit,
cursor: parsed.cursor,
sortBy: parsed.sortBy,
filterCriteria: parsed.filterCriteria,
}),
getSurveyCount(environmentId, parsed.filterCriteria),
]);
return successListResponse(
surveys.map(serializeV3SurveyListItem),
{
limit: parsed.limit,
nextCursor,
totalCount,
},
{ requestId, cache: "private, no-store" }
);
} catch (err) {
if (err instanceof ResourceNotFoundError) {
log.warn({ statusCode: 403, errorCode: err.name }, "Resource not found");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
if (err instanceof DatabaseError) {
log.error({ error: err, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error: err, statusCode: 500 }, "V3 surveys list unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -0,0 +1,18 @@
import type { TSurvey } from "@/modules/survey/list/types/surveys";
export type TV3SurveyListItem = Omit<TSurvey, "environmentId" | "singleUse"> & {
workspaceId: string;
};
/**
* Keep the v3 API contract isolated from internal persistence naming.
* Internally surveys are still scoped by environmentId; externally v3 exposes workspaceId.
*/
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
const { environmentId, singleUse: _omitSingleUse, ...rest } = survey;
return {
...rest,
workspaceId: environmentId,
};
}
+49 -19
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { getIsActiveCustomerAction } from "./actions";
interface ChatwootWidgetProps {
chatwootBaseUrl: string;
@@ -12,6 +13,18 @@ interface ChatwootWidgetProps {
const CHATWOOT_SCRIPT_ID = "chatwoot-script";
interface ChatwootInstance {
setUser: (
userId: string,
userInfo: {
email?: string | null;
name?: string | null;
}
) => void;
setCustomAttributes: (attributes: Record<string, unknown>) => void;
reset: () => void;
}
export const ChatwootWidget = ({
userEmail,
userName,
@@ -20,15 +33,14 @@ export const ChatwootWidget = ({
chatwootBaseUrl,
}: ChatwootWidgetProps) => {
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 = (
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot;
const $chatwoot = getChatwoot();
if (userId && $chatwoot && !userSetRef.current) {
$chatwoot.setUser(userId, {
email: userEmail,
@@ -36,7 +48,19 @@ export const ChatwootWidget = ({
});
userSetRef.current = true;
}
}, [userId, userEmail, userName]);
}, [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(() => {
if (!chatwootWebsiteToken) return;
@@ -65,23 +89,19 @@ export const ChatwootWidget = ({
const handleChatwootReady = () => setUserInfo();
globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
const handleChatwootOpen = () => setCustomerStatus();
globalThis.addEventListener("chatwoot:open", handleChatwootOpen);
// Check if Chatwoot is already ready
if (
(
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot
) {
if (getChatwoot()) {
setUserInfo();
}
return () => {
globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
globalThis.removeEventListener("chatwoot:open", handleChatwootOpen);
const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot;
const $chatwoot = getChatwoot();
if ($chatwoot) {
$chatwoot.reset();
}
@@ -90,8 +110,18 @@ export const ChatwootWidget = ({
scriptElement?.remove();
userSetRef.current = false;
customerStatusSetRef.current = false;
};
}, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
}, [
chatwootBaseUrl,
chatwootWebsiteToken,
userId,
userEmail,
userName,
setUserInfo,
setCustomerStatus,
getChatwoot,
]);
return null;
};
+18
View File
@@ -0,0 +1,18 @@
"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<TCloudBillingPlan>(["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;
});
});
@@ -421,6 +421,38 @@ describe("withV1ApiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
test("uses unauthenticatedResponse when provided instead of default 401", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { getServerSession } = await import("next-auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Session,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(getServerSession).mockResolvedValue(null);
const custom401 = new Response(JSON.stringify({ title: "Custom", status: 401 }), {
status: 401,
headers: { "Content-Type": "application/problem+json" },
});
const handler = vi.fn();
const req = createMockRequest({ url: "https://api.test/api/v3/surveys" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({
handler,
unauthenticatedResponse: () => custom401,
});
const res = await wrapped(req, undefined);
expect(res).toBe(custom401);
expect(handler).not.toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
});
test("handles rate limiting errors", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
+11 -1
View File
@@ -38,6 +38,11 @@ export interface TWithV1ApiWrapperParams<TResult extends { response: Response },
action?: TAuditAction;
targetType?: TAuditTarget;
customRateLimitConfig?: TRateLimitConfig;
/**
* When the route requires auth but the client is unauthenticated, the wrapper normally returns
* the legacy JSON 401. Use this to return a custom response (e.g. RFC 9457 problem+json for V3).
*/
unauthenticatedResponse?: (req: NextRequest) => Response;
}
enum ApiV1RouteTypeEnum {
@@ -265,7 +270,7 @@ const getRouteType = (
export const withV1ApiWrapper = <TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { handler, action, targetType, customRateLimitConfig } = params;
const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
// === Audit Log Setup ===
const saveAuditLog = action && targetType;
@@ -287,6 +292,11 @@ export const withV1ApiWrapper = <TResult extends { response: Response }, TProps
const authentication = await handleAuthentication(authenticationMethod, req);
if (!authentication && routeType !== ApiV1RouteTypeEnum.Client) {
if (unauthenticatedResponse) {
const res = unauthenticatedResponse(req);
await processResponse(res, req, auditLog);
return res;
}
return responses.notAuthenticatedResponse();
}
@@ -90,6 +90,17 @@ describe("endpoint-validator", () => {
});
describe("isManagementApiRoute", () => {
test("should return Both for v3 surveys routes", () => {
expect(isManagementApiRoute("/api/v3/surveys")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Both,
});
expect(isManagementApiRoute("/api/v3/surveys/clxxxxxxxxxxxxxxxxxxxxxxxx")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Both,
});
});
test("should return correct object for management API routes with API key authentication", () => {
expect(isManagementApiRoute("/api/v1/management/something")).toEqual({
isManagementApi: true,
@@ -22,6 +22,9 @@ export const isClientSideApiRoute = (url: string): { isClientSideApi: boolean; i
export const isManagementApiRoute = (
url: string
): { isManagementApi: boolean; authenticationMethod: AuthenticationMethod } => {
// V3 surveys: session cookie or x-api-key (same pattern as management storage)
if (/^\/api\/v3\/surveys(?:\/|$)/.test(url))
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both };
if (url.includes("/api/v1/management/storage"))
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both };
if (url.includes("/api/v1/webhooks"))
+3 -1
View File
@@ -355,6 +355,7 @@ checksums:
common/select: 5ac04c47a98deb85906bc02e0de91ab0
common/select_all: eedc7cdb02de467c15dc418a066a77f2
common/select_filter: c50082c3981f1161022f9787a19aed71
common/select_language: d75cf5fbce8a4c7a9055e2210af74480
common/select_survey: bac52e59c7847417bef6fe7b7096b475
common/select_teams: ae5d451929846ae6367562bc671a1af9
common/selected: 9f09e059ba20c88ed34e2b4e8e032d56
@@ -808,6 +809,7 @@ checksums:
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39
environments/integrations/webhooks/no_triggers: 6b68cddfc45b3f7e20644a24a1bbea69
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
environments/integrations/webhooks/please_enter_a_url: c24c74d0ce7ed3a6b858aadbc82108fe
environments/integrations/webhooks/response_created: 8c43b1b6d748f6096f6f8d9232a3c469
@@ -1632,13 +1634,13 @@ checksums:
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
environments/surveys/edit/simple: 65575bd903091299bc4a94b7517a6288
environments/surveys/edit/six_points: c6c09b3f07171dc388cb5a610ea79af7
environments/surveys/edit/smiley: e68e3b28fc3c04255e236c6a0feb662b
environments/surveys/edit/spam_protection_note: 94059310d07c30f6704e216297036d05
environments/surveys/edit/spam_protection_threshold_description: ed8b8c9c583077a88bf5dd3ec8b59e60
environments/surveys/edit/spam_protection_threshold_heading: 29f9a8b00c5bcbb43aedc48138a5cf9c
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
environments/surveys/edit/star: 0586c1c76e8a0367c0a7b93adf598cb7
environments/surveys/edit/starts_with: f6673c17475708313c6a0f245b561781
environments/surveys/edit/state: 118de561d4525b14f9bb29ac9e86161d
+13 -1
View File
@@ -1,7 +1,19 @@
import * as Sentry from "@sentry/nextjs";
import { type Instrumentation } from "next";
import { isExpectedError } from "@formbricks/types/errors";
import { IS_PRODUCTION, PROMETHEUS_ENABLED, SENTRY_DSN } from "@/lib/constants";
export const onRequestError = Sentry.captureRequestError;
export const onRequestError: Instrumentation.onRequestError = (...args) => {
const [error] = args;
// Skip expected business-logic errors (AuthorizationError, ResourceNotFoundError, etc.)
// These are handled gracefully in the UI and don't need server-side Sentry reporting
if (error instanceof Error && isExpectedError(error)) {
return;
}
Sentry.captureRequestError(...args);
};
export const register = async () => {
if (process.env.NEXT_RUNTIME === "nodejs") {
+11 -1
View File
@@ -382,6 +382,7 @@
"select": "Auswählen",
"select_all": "Alles auswählen",
"select_filter": "Filter auswählen",
"select_language": "Sprache auswählen",
"select_survey": "Umfrage auswählen",
"select_teams": "Teams auswählen",
"selected": "Ausgewählt",
@@ -852,9 +853,16 @@
"created_by_third_party": "Erstellt von einer dritten Partei",
"discord_webhook_not_supported": "Discord-Webhooks werden derzeit nicht unterstützt.",
"empty_webhook_message": "Deine Webhooks werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
"endpoint_bad_gateway_error": "Ungültiges Gateway (502): Proxy-/Gateway-Fehler, Dienst nicht erreichbar",
"endpoint_gateway_timeout_error": "Gateway-Zeitüberschreitung (504): Gateway-Zeitüberschreitung, Dienst nicht erreichbar",
"endpoint_internal_server_error": "Interner Serverfehler (500): Der Dienst ist auf einen unerwarteten Fehler gestoßen",
"endpoint_method_not_allowed_error": "Methode nicht erlaubt (405): Der Endpoint existiert, akzeptiert aber keine POST-Anfragen",
"endpoint_not_found_error": "Nicht gefunden (404): Der Endpoint existiert nicht",
"endpoint_pinged": "Juhu! Wir können den Webhook anpingen!",
"endpoint_pinged_error": "Kann den Webhook nicht anpingen!",
"endpoint_service_unavailable_error": "Dienst nicht verfügbar (503): Dienst ist vorübergehend nicht verfügbar",
"learn_to_verify": "Erfahren Sie, wie Sie Webhook-Signaturen verifizieren",
"no_triggers": "Keine Trigger",
"please_check_console": "Bitte überprüfe die Konsole für weitere Details",
"please_enter_a_url": "Bitte gib eine URL ein",
"response_created": "Antwort erstellt",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Antwortlimit muss die Anzahl der erhaltenen Antworten ({responseCount}) überschreiten.",
"response_limits_redirections_and_more": "Antwort Limits, Weiterleitungen und mehr.",
"response_options": "Antwortoptionen",
"reverse_order_occasionally": "Reihenfolge gelegentlich umkehren",
"reverse_order_occasionally_except_last": "Reihenfolge gelegentlich umkehren, außer letzter",
"roundness": "Rundheit",
"roundness_description": "Steuert, wie abgerundet die Ecken sind.",
"row_used_in_logic_error": "Diese Zeile wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Umfrage maximal anzeigen von",
"show_survey_to_users": "Umfrage % der Nutzer anzeigen",
"show_to_x_percentage_of_targeted_users": "Zeige {percentage}% der Zielbenutzer",
"shrink_preview": "Vorschau verkleinern",
"simple": "Einfach",
"six_points": "6 Punkte",
"smiley": "Smiley",
"spam_protection_note": "Spamschutz funktioniert nicht für Umfragen, die mit den iOS-, React Native- und Android-SDKs angezeigt werden. Es wird die Umfrage unterbrechen.",
"spam_protection_threshold_description": "Wert zwischen 0 und 1 festlegen, Antworten unter diesem Wert werden abgelehnt.",
"spam_protection_threshold_heading": "Antwortschwelle",
"shrink_preview": "Vorschau verkleinern",
"star": "Stern",
"starts_with": "Fängt an mit",
"state": "Bundesland",
+11 -1
View File
@@ -382,6 +382,7 @@
"select": "Select",
"select_all": "Select all",
"select_filter": "Select filter",
"select_language": "Select Language",
"select_survey": "Select Survey",
"select_teams": "Select teams",
"selected": "Selected",
@@ -852,9 +853,16 @@
"created_by_third_party": "Created by a Third Party",
"discord_webhook_not_supported": "Discord webhooks are currently not supported.",
"empty_webhook_message": "Your webhooks will appear here as soon as you add them. ⏲️",
"endpoint_bad_gateway_error": "Bad Gateway (502): Proxy/gateway error, service not reachable",
"endpoint_gateway_timeout_error": "Gateway Timeout (504): Gateway timeout, service not reachable",
"endpoint_internal_server_error": "Internal Server Error (500): The service encountered an unexpected error",
"endpoint_method_not_allowed_error": "Method Not Allowed (405): The endpoint exists, but doesn't accept POST requests",
"endpoint_not_found_error": "Not Found (404): The endpoint doesn't exist",
"endpoint_pinged": "Yay! We are able to ping the webhook!",
"endpoint_pinged_error": "Unable to ping the webhook!",
"endpoint_service_unavailable_error": "Service Unavailable (503): Service is temporarily down",
"learn_to_verify": "Learn how to verify webhook signatures",
"no_triggers": "No Triggers",
"please_check_console": "Please check the console for more details",
"please_enter_a_url": "Please enter a URL",
"response_created": "Response Created",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Response limit needs to exceed number of received responses ({responseCount}).",
"response_limits_redirections_and_more": "Response limits, redirections and more.",
"response_options": "Response Options",
"reverse_order_occasionally": "Reverse order occasionally",
"reverse_order_occasionally_except_last": "Reverse order occasionally except last",
"roundness": "Roundness",
"roundness_description": "Controls how rounded corners are.",
"row_used_in_logic_error": "This row is used in logic of question {questionIndex}. Please remove it from logic first.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Show survey maximum of",
"show_survey_to_users": "Show survey to % of users",
"show_to_x_percentage_of_targeted_users": "Show to {percentage}% of targeted users",
"shrink_preview": "Shrink Preview",
"simple": "Simple",
"six_points": "6 points",
"smiley": "Smiley",
"spam_protection_note": "Spam protection does not work for surveys displayed with the iOS, React Native, and Android SDKs. It will break the survey.",
"spam_protection_threshold_description": "Set value between 0 and 1, responses below this value will be rejected.",
"spam_protection_threshold_heading": "Response threshold",
"shrink_preview": "Shrink Preview",
"star": "Star",
"starts_with": "Starts with",
"state": "State",
+11 -1
View File
@@ -382,6 +382,7 @@
"select": "Seleccionar",
"select_all": "Seleccionar todo",
"select_filter": "Seleccionar filtro",
"select_language": "Seleccionar idioma",
"select_survey": "Seleccionar encuesta",
"select_teams": "Seleccionar equipos",
"selected": "Seleccionado",
@@ -852,9 +853,16 @@
"created_by_third_party": "Creado por un tercero",
"discord_webhook_not_supported": "Los webhooks de Discord no son compatibles actualmente.",
"empty_webhook_message": "Tus webhooks aparecerán aquí tan pronto como los añadas. ⏲️",
"endpoint_bad_gateway_error": "Puerta de enlace incorrecta (502): Error de proxy o puerta de enlace, servicio no accesible",
"endpoint_gateway_timeout_error": "Tiempo de espera de la puerta de enlace agotado (504): Tiempo de espera de la puerta de enlace agotado, servicio no accesible",
"endpoint_internal_server_error": "Error interno del servidor (500): El servicio encontró un error inesperado",
"endpoint_method_not_allowed_error": "Método no permitido (405): El endpoint existe, pero no acepta solicitudes POST",
"endpoint_not_found_error": "No encontrado (404): El endpoint no existe",
"endpoint_pinged": "¡Genial! ¡Podemos hacer ping al webhook!",
"endpoint_pinged_error": "¡No se puede hacer ping al webhook!",
"endpoint_service_unavailable_error": "Servicio no disponible (503): El servicio está temporalmente caído",
"learn_to_verify": "Aprende a verificar las firmas de webhook",
"no_triggers": "Sin activadores",
"please_check_console": "Por favor, consulta la consola para más detalles",
"please_enter_a_url": "Por favor, introduce una URL",
"response_created": "Respuesta creada",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "El límite de respuestas debe superar el número de respuestas recibidas ({responseCount}).",
"response_limits_redirections_and_more": "Límites de respuestas, redirecciones y más.",
"response_options": "Opciones de respuesta",
"reverse_order_occasionally": "Invertir orden ocasionalmente",
"reverse_order_occasionally_except_last": "Invertir orden ocasionalmente excepto el último",
"roundness": "Redondez",
"roundness_description": "Controla qué tan redondeadas están las esquinas.",
"row_used_in_logic_error": "Esta fila se utiliza en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Mostrar encuesta un máximo de",
"show_survey_to_users": "Mostrar encuesta al % de usuarios",
"show_to_x_percentage_of_targeted_users": "Mostrar al {percentage} % de usuarios objetivo",
"shrink_preview": "Contraer vista previa",
"simple": "Simple",
"six_points": "6 puntos",
"smiley": "Emoticono",
"spam_protection_note": "La protección contra spam no funciona para encuestas mostradas con los SDK de iOS, React Native y Android. Romperá la encuesta.",
"spam_protection_threshold_description": "Establece un valor entre 0 y 1, las respuestas por debajo de este valor serán rechazadas.",
"spam_protection_threshold_heading": "Umbral de respuesta",
"shrink_preview": "Contraer vista previa",
"star": "Estrella",
"starts_with": "Comienza con",
"state": "Estado",
+11 -1
View File
@@ -382,6 +382,7 @@
"select": "Sélectionner",
"select_all": "Sélectionner tout",
"select_filter": "Sélectionner un filtre",
"select_language": "Sélectionner la langue",
"select_survey": "Sélectionner l'enquête",
"select_teams": "Sélectionner les équipes",
"selected": "Sélectionné",
@@ -852,9 +853,16 @@
"created_by_third_party": "Créé par un tiers",
"discord_webhook_not_supported": "Les webhooks Discord ne sont actuellement pas pris en charge.",
"empty_webhook_message": "Vos webhooks apparaîtront ici dès que vous les ajouterez. ⏲️",
"endpoint_bad_gateway_error": "Mauvaise passerelle (502) : Erreur de proxy/passerelle, service inaccessible",
"endpoint_gateway_timeout_error": "Délai d'attente de la passerelle dépassé (504) : Le délai d'attente de la passerelle a expiré, service inaccessible",
"endpoint_internal_server_error": "Erreur interne du serveur (500) : Le service a rencontré une erreur inattendue",
"endpoint_method_not_allowed_error": "Méthode non autorisée (405) : Le point de terminaison existe, mais n'accepte pas les requêtes POST",
"endpoint_not_found_error": "Introuvable (404) : Le point de terminaison n'existe pas",
"endpoint_pinged": "Yay ! Nous pouvons pinger le webhook !",
"endpoint_pinged_error": "Impossible de pinger le webhook !",
"endpoint_service_unavailable_error": "Service indisponible (503) : Le service est temporairement indisponible",
"learn_to_verify": "Découvrez comment vérifier les signatures de webhook",
"no_triggers": "Aucun déclencheur",
"please_check_console": "Veuillez vérifier la console pour plus de détails.",
"please_enter_a_url": "Veuillez entrer une URL.",
"response_created": "Réponse créée",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "La limite de réponses doit dépasser le nombre de réponses reçues ({responseCount}).",
"response_limits_redirections_and_more": "Limites de réponse, redirections et plus.",
"response_options": "Options de réponse",
"reverse_order_occasionally": "Inverser l'ordre occasionnellement",
"reverse_order_occasionally_except_last": "Inverser l'ordre occasionnellement sauf le dernier",
"roundness": "Rondeur",
"roundness_description": "Contrôle l'arrondi des coins.",
"row_used_in_logic_error": "Cette ligne est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Afficher le maximum du sondage de",
"show_survey_to_users": "Afficher l'enquête à % des utilisateurs",
"show_to_x_percentage_of_targeted_users": "Afficher à {percentage}% des utilisateurs ciblés",
"shrink_preview": "Réduire l'aperçu",
"simple": "Simple",
"six_points": "6 points",
"smiley": "Sourire",
"spam_protection_note": "La protection contre le spam ne fonctionne pas pour les enquêtes affichées avec les SDK iOS, React Native et Android. Cela cassera l'enquête.",
"spam_protection_threshold_description": "Définir une valeur entre 0 et 1, les réponses en dessous de cette valeur seront rejetées.",
"spam_protection_threshold_heading": "Seuil de réponse",
"shrink_preview": "Réduire l'aperçu",
"star": "Étoile",
"starts_with": "Commence par",
"state": "État",
+11 -1
View File
@@ -382,6 +382,7 @@
"select": "Kiválasztás",
"select_all": "Összes kiválasztása",
"select_filter": "Szűrő kiválasztása",
"select_language": "Nyelv kiválasztása",
"select_survey": "Kérdőív kiválasztása",
"select_teams": "Csapatok kiválasztása",
"selected": "Kiválasztva",
@@ -852,9 +853,16 @@
"created_by_third_party": "Harmadik fél által létrehozva",
"discord_webhook_not_supported": "A Discord webhorgok jelenleg nem támogatottak.",
"empty_webhook_message": "A webhorgai itt fognak megjelenni, amint hozzáadja azokat. ⏲️",
"endpoint_bad_gateway_error": "Hibás átjáró (502): Proxy-/átjáróhiba, a szolgáltatás nem érhető el",
"endpoint_gateway_timeout_error": "Átjáró időtúllépés (504): Átjáró időtúllépés, a szolgáltatás nem érhető el",
"endpoint_internal_server_error": "Belső szerverhiba (500): A szolgáltatás váratlan hibába ütközött",
"endpoint_method_not_allowed_error": "A metódus nem engedélyezett (405): A végpont létezik, de nem fogad POST kéréseket",
"endpoint_not_found_error": "Nem található (404): A végpont nem létezik",
"endpoint_pinged": "Hurrá! Képesek vagyunk pingelni a webhorgot!",
"endpoint_pinged_error": "Nem lehet pingelni a webhorgot!",
"endpoint_service_unavailable_error": "A szolgáltatás nem érhető el (503): A szolgáltatás átmenetileg nem elérhető",
"learn_to_verify": "Tudja meg, hogy kell ellenőrizni a webhorog aláírásait",
"no_triggers": "Nincsenek Triggerek",
"please_check_console": "További részletekért nézze meg a konzolt",
"please_enter_a_url": "Adjon meg egy URL-t",
"response_created": "Válasz létrehozva",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "A válaszkorlátnak meg kell haladnia a kapott válaszok számát ({responseCount}).",
"response_limits_redirections_and_more": "Válaszkorlátok, átirányítások és egyebek.",
"response_options": "Válasz beállításai",
"reverse_order_occasionally": "Sorrend alkalmi megfordítása",
"reverse_order_occasionally_except_last": "Sorrend alkalmi megfordítása az utolsó kivételével",
"roundness": "Kerekesség",
"roundness_description": "Annak vezérlése, hogy a sarkok mennyire legyenek lekerekítve.",
"row_used_in_logic_error": "Ez a sor használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Kérdőív megjelenítése legfeljebb:",
"show_survey_to_users": "Kérdőív megjelenítése a felhasználók ennyi százalékának",
"show_to_x_percentage_of_targeted_users": "Megjelenítés a célzott felhasználók {percentage}%-ának",
"shrink_preview": "Előnézet összecsukása",
"simple": "Egyszerű",
"six_points": "6 pont",
"smiley": "Hangulatjel",
"spam_protection_note": "A szemét elleni védekezés nem működik az iOS, React Native és Android SDK-kkal megjelenített kérdőíveknél. El fogja rontani a kérdőívet.",
"spam_protection_threshold_description": "Állítsa az értéket 0 és 1 közé, az ezen érték alatt lévő válaszok elutasításra kerülnek.",
"spam_protection_threshold_heading": "Válasz küszöbszintje",
"shrink_preview": "Előnézet összecsukása",
"star": "Csillag",
"starts_with": "Ezzel kezdődik",
"state": "Állapot",
+11 -1
View File
@@ -382,6 +382,7 @@
"select": "選択",
"select_all": "すべて選択",
"select_filter": "フィルターを選択",
"select_language": "言語を選択",
"select_survey": "フォームを選択",
"select_teams": "チームを選択",
"selected": "選択済み",
@@ -852,9 +853,16 @@
"created_by_third_party": "サードパーティによって作成",
"discord_webhook_not_supported": "現在、Discord Webhook はサポートしていません。",
"empty_webhook_message": "Webhook は追加するとここに表示されます。⏲️",
"endpoint_bad_gateway_error": "不正なゲートウェイ (502): プロキシまたはゲートウェイのエラーにより、サービスに到達できません",
"endpoint_gateway_timeout_error": "ゲートウェイタイムアウト (504): ゲートウェイのタイムアウトにより、サービスに到達できません",
"endpoint_internal_server_error": "内部サーバーエラー (500): サービスで予期しないエラーが発生しました",
"endpoint_method_not_allowed_error": "許可されていないメソッド (405): エンドポイントは存在しますが、POST リクエストを受け付けません",
"endpoint_not_found_error": "見つかりません (404): エンドポイントが存在しません",
"endpoint_pinged": "成功!Webhook に ping できました。",
"endpoint_pinged_error": "Webhook への ping に失敗しました。",
"endpoint_service_unavailable_error": "サービス利用不可 (503): サービスは一時的に停止しています",
"learn_to_verify": "Webhook署名の検証方法を学ぶ",
"no_triggers": "トリガーなし",
"please_check_console": "詳細はコンソールを確認してください",
"please_enter_a_url": "URL を入力してください",
"response_created": "回答作成",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "回答数の上限は、受信済みの回答数 ({responseCount}) を超える必要があります。",
"response_limits_redirections_and_more": "回答数の上限、リダイレクトなど。",
"response_options": "回答オプション",
"reverse_order_occasionally": "順序をランダムに逆転",
"reverse_order_occasionally_except_last": "最後以外の順序をランダムに逆転",
"roundness": "丸み",
"roundness_description": "角の丸みを調整します。",
"row_used_in_logic_error": "この行は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "フォームの最大表示回数",
"show_survey_to_users": "ユーザーの {percentage}% にフォームを表示",
"show_to_x_percentage_of_targeted_users": "ターゲットユーザーの {percentage}% に表示",
"shrink_preview": "プレビューを縮小",
"simple": "シンプル",
"six_points": "6点",
"smiley": "スマイリー",
"spam_protection_note": "スパム対策は、iOS、React Native、およびAndroid SDKで表示されるフォームでは機能しません。フォームが壊れます。",
"spam_protection_threshold_description": "値を0から1の間で設定してください。この値より低い回答は拒否されます。",
"spam_protection_threshold_heading": "回答のしきい値",
"shrink_preview": "プレビューを縮小",
"star": "星",
"starts_with": "で始まる",
"state": "都道府県",
+11 -1
View File
@@ -382,6 +382,7 @@
"select": "Selecteer",
"select_all": "Selecteer alles",
"select_filter": "Filter selecteren",
"select_language": "Selecteer taal",
"select_survey": "Selecteer Enquête",
"select_teams": "Selecteer teams",
"selected": "Gekozen",
@@ -852,9 +853,16 @@
"created_by_third_party": "Gemaakt door een derde partij",
"discord_webhook_not_supported": "Discord-webhooks worden momenteel niet ondersteund.",
"empty_webhook_message": "Uw webhooks verschijnen hier zodra u ze toevoegt. ⏲️",
"endpoint_bad_gateway_error": "Ongeldige gateway (502): Proxy-/gatewayfout, service niet bereikbaar",
"endpoint_gateway_timeout_error": "Gateway-time-out (504): Gateway-time-out, service niet bereikbaar",
"endpoint_internal_server_error": "Interne serverfout (500): De service is een onverwachte fout tegengekomen",
"endpoint_method_not_allowed_error": "Methode niet toegestaan (405): Het endpoint bestaat, maar accepteert geen POST-verzoeken",
"endpoint_not_found_error": "Niet gevonden (404): Het endpoint bestaat niet",
"endpoint_pinged": "Jawel! We kunnen de webhook pingen!",
"endpoint_pinged_error": "Kan de webhook niet pingen!",
"endpoint_service_unavailable_error": "Service niet beschikbaar (503): De service is tijdelijk niet beschikbaar",
"learn_to_verify": "Leer hoe je webhook-handtekeningen kunt verifiëren",
"no_triggers": "Geen triggers",
"please_check_console": "Controleer de console voor meer details",
"please_enter_a_url": "Voer een URL in",
"response_created": "Reactie gemaakt",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "De responslimiet moet groter zijn dan het aantal ontvangen reacties ({responseCount}).",
"response_limits_redirections_and_more": "Reactielimieten, omleidingen en meer.",
"response_options": "Reactieopties",
"reverse_order_occasionally": "Volgorde af en toe omkeren",
"reverse_order_occasionally_except_last": "Volgorde af en toe omkeren behalve laatste",
"roundness": "Rondheid",
"roundness_description": "Bepaalt hoe afgerond de hoeken zijn.",
"row_used_in_logic_error": "Deze rij wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Toon onderzoek maximaal",
"show_survey_to_users": "Enquête tonen aan % van de gebruikers",
"show_to_x_percentage_of_targeted_users": "Toon aan {percentage}% van de getargete gebruikers",
"shrink_preview": "Voorbeeld invouwen",
"simple": "Eenvoudig",
"six_points": "6 punten",
"smiley": "Smiley",
"spam_protection_note": "Spambeveiliging werkt niet voor enquêtes die worden weergegeven met de iOS-, React Native- en Android SDK's. Het zal de enquête breken.",
"spam_protection_threshold_description": "Stel een waarde in tussen 0 en 1, reacties onder deze waarde worden afgewezen.",
"spam_protection_threshold_heading": "Reactiedrempel",
"shrink_preview": "Voorbeeld invouwen",
"star": "Ster",
"starts_with": "Begint met",
"state": "Staat",
+11 -1
View File
@@ -382,6 +382,7 @@
"select": "Selecionar",
"select_all": "Selecionar tudo",
"select_filter": "Selecionar filtro",
"select_language": "Selecionar Idioma",
"select_survey": "Selecionar Pesquisa",
"select_teams": "Selecionar times",
"selected": "Selecionado",
@@ -852,9 +853,16 @@
"created_by_third_party": "Criado por um Terceiro",
"discord_webhook_not_supported": "Webhooks do Discord não são suportados no momento.",
"empty_webhook_message": "Seus webhooks vão aparecer aqui assim que você adicioná-los. ⏲️",
"endpoint_bad_gateway_error": "Gateway inválido (502): Erro de proxy/gateway, serviço inacessível",
"endpoint_gateway_timeout_error": "Tempo limite do gateway esgotado (504): Tempo limite do gateway esgotado, serviço inacessível",
"endpoint_internal_server_error": "Erro interno do servidor (500): O serviço encontrou um erro inesperado",
"endpoint_method_not_allowed_error": "Método não permitido (405): O endpoint existe, mas não aceita solicitações POST",
"endpoint_not_found_error": "Não encontrado (404): O endpoint não existe",
"endpoint_pinged": "Uhul! Conseguimos pingar o webhook!",
"endpoint_pinged_error": "Não consegui pingar o webhook!",
"endpoint_service_unavailable_error": "Serviço indisponível (503): O serviço está temporariamente indisponível",
"learn_to_verify": "Aprenda como verificar assinaturas de webhook",
"no_triggers": "Nenhum Gatilho",
"please_check_console": "Por favor, verifica o console para mais detalhes",
"please_enter_a_url": "Por favor, insira uma URL",
"response_created": "Resposta Criada",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).",
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
"response_options": "Opções de Resposta",
"reverse_order_occasionally": "Inverter ordem ocasionalmente",
"reverse_order_occasionally_except_last": "Inverter ordem ocasionalmente exceto o último",
"roundness": "Circularidade",
"roundness_description": "Controla o arredondamento dos cantos.",
"row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Mostrar no máximo",
"show_survey_to_users": "Mostrar pesquisa para % dos usuários",
"show_to_x_percentage_of_targeted_users": "Mostrar para {percentage}% dos usuários segmentados",
"shrink_preview": "Recolher prévia",
"simple": "Simples",
"six_points": "6 pontos",
"smiley": "Sorridente",
"spam_protection_note": "A proteção contra spam não funciona para pesquisas exibidas com os SDKs iOS, React Native e Android. Isso vai quebrar a pesquisa.",
"spam_protection_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo desse valor serão rejeitadas.",
"spam_protection_threshold_heading": "Limite de resposta",
"shrink_preview": "Recolher prévia",
"star": "Estrela",
"starts_with": "Começa com",
"state": "Estado",
+11 -1
View File
@@ -382,6 +382,7 @@
"select": "Selecionar",
"select_all": "Selecionar tudo",
"select_filter": "Selecionar filtro",
"select_language": "Selecionar Idioma",
"select_survey": "Selecionar Inquérito",
"select_teams": "Selecionar equipas",
"selected": "Selecionado",
@@ -852,9 +853,16 @@
"created_by_third_party": "Criado por um Terceiro",
"discord_webhook_not_supported": "Os webhooks do Discord não são atualmente suportados.",
"empty_webhook_message": "Os seus webhooks aparecerão aqui assim que os adicionar. ⏲️",
"endpoint_bad_gateway_error": "Gateway inválido (502): Erro de proxy/gateway, serviço inacessível",
"endpoint_gateway_timeout_error": "Tempo limite do gateway excedido (504): Tempo limite do gateway excedido, serviço inacessível",
"endpoint_internal_server_error": "Erro interno do servidor (500): O serviço encontrou um erro inesperado",
"endpoint_method_not_allowed_error": "Método não permitido (405): O endpoint existe, mas não aceita pedidos POST",
"endpoint_not_found_error": "Não encontrado (404): O endpoint não existe",
"endpoint_pinged": "Yay! Conseguimos aceder ao webhook!",
"endpoint_pinged_error": "Não foi possível aceder ao webhook!",
"endpoint_service_unavailable_error": "Serviço indisponível (503): O serviço está temporariamente indisponível",
"learn_to_verify": "Aprenda a verificar assinaturas de webhook",
"no_triggers": "Sem Acionadores",
"please_check_console": "Por favor, verifique a consola para mais detalhes",
"please_enter_a_url": "Por favor, insira um URL",
"response_created": "Resposta Criada",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).",
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
"response_options": "Opções de Resposta",
"reverse_order_occasionally": "Inverter ordem ocasionalmente",
"reverse_order_occasionally_except_last": "Inverter ordem ocasionalmente exceto o último",
"roundness": "Arredondamento",
"roundness_description": "Controla o arredondamento dos cantos.",
"row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Mostrar inquérito máximo de",
"show_survey_to_users": "Mostrar inquérito a % dos utilizadores",
"show_to_x_percentage_of_targeted_users": "Mostrar a {percentage}% dos utilizadores alvo",
"shrink_preview": "Reduzir pré-visualização",
"simple": "Simples",
"six_points": "6 pontos",
"smiley": "Sorridente",
"spam_protection_note": "A proteção contra spam não funciona para inquéritos exibidos com os SDKs iOS, React Native e Android. Isso irá quebrar o inquérito.",
"spam_protection_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo deste valor serão rejeitadas.",
"spam_protection_threshold_heading": "Limite de resposta",
"shrink_preview": "Reduzir pré-visualização",
"star": "Estrela",
"starts_with": "Começa com",
"state": "Estado",
+11 -1
View File
@@ -382,6 +382,7 @@
"select": "Selectați",
"select_all": "Selectați toate",
"select_filter": "Selectați filtrul",
"select_language": "Selectează limba",
"select_survey": "Selectați chestionar",
"select_teams": "Selectați echipele",
"selected": "Selectat",
@@ -852,9 +853,16 @@
"created_by_third_party": "Creat de o Parte Terță",
"discord_webhook_not_supported": "Webhook-urile Discord nu sunt în prezent suportate.",
"empty_webhook_message": "Webhook-urile tale vor apărea aici de îndată ce le vei adăuga. ⏲️",
"endpoint_bad_gateway_error": "Gateway invalid (502): Eroare de proxy/gateway, serviciul nu este accesibil",
"endpoint_gateway_timeout_error": "Timp de așteptare gateway depășit (504): Timpul de așteptare al gateway-ului a fost depășit, serviciul nu este accesibil",
"endpoint_internal_server_error": "Eroare internă de server (500): Serviciul a întâmpinat o eroare neașteptată",
"endpoint_method_not_allowed_error": "Metodă nepermisă (405): Endpointul există, dar nu acceptă cereri POST",
"endpoint_not_found_error": "Negăsit (404): Endpointul nu există",
"endpoint_pinged": "Grozav! Am reușit să ping-ui webhooks-ul!",
"endpoint_pinged_error": "Nu pot să ping-ui webhooks-ul!",
"endpoint_service_unavailable_error": "Serviciu indisponibil (503): Serviciul este temporar indisponibil",
"learn_to_verify": "Află cum să verifici semnăturile webhook",
"no_triggers": "Fără declanșatori",
"please_check_console": "Vă rugăm să verificați consola pentru mai multe detalii",
"please_enter_a_url": "Vă rugăm să introduceți un URL",
"response_created": "Răspuns creat",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Limita răspunsurilor trebuie să depășească numărul de răspunsuri primite ({responseCount}).",
"response_limits_redirections_and_more": "Limite de răspunsuri, redirecționări și altele.",
"response_options": "Opțiuni răspuns",
"reverse_order_occasionally": "Inversare ordine ocazional",
"reverse_order_occasionally_except_last": "Inversare ordine ocazional cu excepția ultimului",
"roundness": "Rotunjire",
"roundness_description": "Controlează cât de rotunjite sunt colțurile.",
"row_used_in_logic_error": "Această linie este folosită în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Afișează sondajul de maxim",
"show_survey_to_users": "Afișați sondajul la % din utilizatori",
"show_to_x_percentage_of_targeted_users": "Afișați la {percentage}% din utilizatorii vizați",
"shrink_preview": "Restrânge previzualizarea",
"simple": "Simplu",
"six_points": "6 puncte",
"smiley": "Smiley",
"spam_protection_note": "Protecția împotriva spamului nu funcționează pentru sondajele afișate folosind SDK-urile iOS, React Native și Android. Va întrerupe sondajul.",
"spam_protection_threshold_description": "Setați valoarea între 0 și 1, răspunsurile sub această valoare vor fi respinse.",
"spam_protection_threshold_heading": "Pragul răspunsurilor",
"shrink_preview": "Restrânge previzualizarea",
"star": "Stea",
"starts_with": "Începe cu",
"state": "Stare",
+11 -1
View File
@@ -382,6 +382,7 @@
"select": "Выбрать",
"select_all": "Выбрать все",
"select_filter": "Выбрать фильтр",
"select_language": "Выберите язык",
"select_survey": "Выбрать опрос",
"select_teams": "Выбрать команды",
"selected": "Выбрано",
@@ -852,9 +853,16 @@
"created_by_third_party": "Создано сторонней организацией",
"discord_webhook_not_supported": "В настоящее время webhooks Discord не поддерживаются.",
"empty_webhook_message": "Ваши webhooks появятся здесь, как только вы их добавите. ⏲️",
"endpoint_bad_gateway_error": "Ошибка шлюза (502): Ошибка прокси/шлюза, сервис недоступен",
"endpoint_gateway_timeout_error": "Тайм-аут шлюза (504): Тайм-аут шлюза, сервис недоступен",
"endpoint_internal_server_error": "Внутренняя ошибка сервера (500): Сервис столкнулся с непредвиденной ошибкой",
"endpoint_method_not_allowed_error": "Метод не разрешен (405): Конечная точка существует, но не принимает POST-запросы",
"endpoint_not_found_error": "Не найдено (404): Конечная точка не существует",
"endpoint_pinged": "Ура! Нам удалось отправить ping на webhook!",
"endpoint_pinged_error": "Не удалось отправить ping на webhook!",
"endpoint_service_unavailable_error": "Сервис недоступен (503): Сервис временно недоступен",
"learn_to_verify": "Узнайте, как проверить подписи вебхуков",
"no_triggers": "Нет триггеров",
"please_check_console": "Пожалуйста, проверьте консоль для получения подробностей",
"please_enter_a_url": "Пожалуйста, введите URL",
"response_created": "Ответ создан",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Лимит ответов должен превышать количество полученных ответов ({responseCount}).",
"response_limits_redirections_and_more": "Лимиты ответов, перенаправления и другое.",
"response_options": "Параметры ответа",
"reverse_order_occasionally": "Иногда обращать порядок",
"reverse_order_occasionally_except_last": "Иногда обращать порядок кроме последнего",
"roundness": "Скругление",
"roundness_description": "Определяет степень скругления углов.",
"row_used_in_logic_error": "Эта строка используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите её из логики.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Показать опрос максимум",
"show_survey_to_users": "Показать опрос % пользователей",
"show_to_x_percentage_of_targeted_users": "Показать {percentage}% целевых пользователей",
"shrink_preview": "Свернуть предпросмотр",
"simple": "Простой",
"six_points": "6 баллов",
"smiley": "Смайлик",
"spam_protection_note": "Защита от спама не работает для опросов, отображаемых с помощью SDK iOS, React Native и Android. Это приведёт к сбою опроса.",
"spam_protection_threshold_description": "Установите значение от 0 до 1, ответы ниже этого значения будут отклонены.",
"spam_protection_threshold_heading": "Порог ответа",
"shrink_preview": "Свернуть предпросмотр",
"star": "Звезда",
"starts_with": "Начинается с",
"state": "Состояние",
+11 -1
View File
@@ -382,6 +382,7 @@
"select": "Välj",
"select_all": "Välj alla",
"select_filter": "Välj filter",
"select_language": "Välj språk",
"select_survey": "Välj enkät",
"select_teams": "Välj team",
"selected": "Vald",
@@ -852,9 +853,16 @@
"created_by_third_party": "Skapad av tredje part",
"discord_webhook_not_supported": "Discord-webhooks stöds för närvarande inte.",
"empty_webhook_message": "Dina webhooks visas här så snart du lägger till dem. ⏲️",
"endpoint_bad_gateway_error": "Felaktig gateway (502): Proxy-/gatewayfel, tjänsten kan inte nås",
"endpoint_gateway_timeout_error": "Gateway-timeout (504): Gateway-timeout, tjänsten kan inte nås",
"endpoint_internal_server_error": "Internt serverfel (500): Tjänsten stötte på ett oväntat fel",
"endpoint_method_not_allowed_error": "Metoden tillåts inte (405): Endpointen finns, men accepterar inte POST-förfrågningar",
"endpoint_not_found_error": "Hittades inte (404): Endpointen finns inte",
"endpoint_pinged": "Ja! Vi kan nå webhooken!",
"endpoint_pinged_error": "Kunde inte nå webhooken!",
"endpoint_service_unavailable_error": "Tjänsten är inte tillgänglig (503): Tjänsten är tillfälligt nere",
"learn_to_verify": "Lär dig hur du verifierar webhook-signaturer",
"no_triggers": "Inga utlösare",
"please_check_console": "Vänligen kontrollera konsolen för mer information",
"please_enter_a_url": "Vänligen ange en URL",
"response_created": "Svar skapat",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Svarsgränsen måste överstiga antalet mottagna svar ({responseCount}).",
"response_limits_redirections_and_more": "Svarsgränser, omdirigeringar och mer.",
"response_options": "Svarsalternativ",
"reverse_order_occasionally": "Vänd ordning ibland",
"reverse_order_occasionally_except_last": "Vänd ordning ibland utom sista",
"roundness": "Rundhet",
"roundness_description": "Styr hur rundade hörnen är.",
"row_used_in_logic_error": "Denna rad används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Visa enkät maximalt",
"show_survey_to_users": "Visa enkät för % av användare",
"show_to_x_percentage_of_targeted_users": "Visa för {percentage}% av målgruppens användare",
"shrink_preview": "Minimera förhandsgranskning",
"simple": "Enkel",
"six_points": "6 poäng",
"smiley": "Smiley",
"spam_protection_note": "Spamskydd fungerar inte för enkäter som visas med iOS, React Native och Android SDK:er. Det kommer att bryta enkäten.",
"spam_protection_threshold_description": "Ställ in värde mellan 0 och 1, svar under detta värde kommer att avvisas.",
"spam_protection_threshold_heading": "Svarströskel",
"shrink_preview": "Minimera förhandsgranskning",
"star": "Stjärna",
"starts_with": "Börjar med",
"state": "Delstat",
+11 -1
View File
@@ -382,6 +382,7 @@
"select": "选择",
"select_all": "选择 全部",
"select_filter": "选择过滤器",
"select_language": "选择语言",
"select_survey": "选择 调查",
"select_teams": "选择 团队",
"selected": "已选择",
@@ -852,9 +853,16 @@
"created_by_third_party": "由 第三方 创建",
"discord_webhook_not_supported": "Discord webhooks 目前不 支持。",
"empty_webhook_message": "您的 Webhooks 会在您 添加 后 出现在这里。 ⏲️",
"endpoint_bad_gateway_error": "错误网关 (502):代理/网关错误,服务不可达",
"endpoint_gateway_timeout_error": "网关超时 (504):网关超时,服务不可达",
"endpoint_internal_server_error": "内部服务器错误 (500):服务遇到了意外错误",
"endpoint_method_not_allowed_error": "方法不被允许 (405):该端点存在,但不接受 POST 请求",
"endpoint_not_found_error": "未找到 (404):该端点不存在",
"endpoint_pinged": "太好了! 我们能 ping 该 webhook!",
"endpoint_pinged_error": "无法 ping 该 webhook",
"endpoint_service_unavailable_error": "服务不可用 (503):服务暂时不可用",
"learn_to_verify": "了解如何验证 webhook 签名",
"no_triggers": "无触发器",
"please_check_console": "请查看控制台以获取更多详情",
"please_enter_a_url": "请输入一个 URL",
"response_created": "创建 响应",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "限制 响应 需要 超过 收到 的 响应 数量 {responseCount})。",
"response_limits_redirections_and_more": "响应 限制 、 重定向 和 更多 。",
"response_options": "响应 选项",
"reverse_order_occasionally": "偶尔反转顺序",
"reverse_order_occasionally_except_last": "偶尔反转顺序(最后一项除外)",
"roundness": "圆度",
"roundness_description": "控制圆角的弧度。",
"row_used_in_logic_error": "\"这个 行 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "显示 调查 最大 一次",
"show_survey_to_users": "显示 问卷 给 % 的 用户",
"show_to_x_percentage_of_targeted_users": "显示 给 {percentage}% 的 目标 用户",
"shrink_preview": "收起预览",
"simple": "简单",
"six_points": "6 分",
"smiley": "笑脸",
"spam_protection_note": "垃圾 邮件 保护 对于 与 iOS 、 React Native 和 Android SDK 一起 显示 的 调查 无效 。 它 将 破坏 调查。",
"spam_protection_threshold_description": "设置 值 在 0 和 1 之间,响应 低于 此 值 将 被 拒绝。",
"spam_protection_threshold_heading": "响应 阈值",
"shrink_preview": "收起预览",
"star": "星",
"starts_with": "以...开始",
"state": "状态",
+11 -1
View File
@@ -382,6 +382,7 @@
"select": "選擇",
"select_all": "全選",
"select_filter": "選擇篩選器",
"select_language": "選擇語言",
"select_survey": "選擇問卷",
"select_teams": "選擇 團隊",
"selected": "已選取",
@@ -852,9 +853,16 @@
"created_by_third_party": "由第三方建立",
"discord_webhook_not_supported": "目前不支援 Discord webhooks。",
"empty_webhook_message": "您的 Webhook 將在您新增後立即顯示在此處。⏲️",
"endpoint_bad_gateway_error": "錯誤的閘道 (502):代理/閘道錯誤,服務無法連線",
"endpoint_gateway_timeout_error": "閘道逾時 (504):閘道逾時,服務無法連線",
"endpoint_internal_server_error": "內部伺服器錯誤 (500):服務遇到了未預期的錯誤",
"endpoint_method_not_allowed_error": "不允許的方法 (405):該端點存在,但不接受 POST 請求",
"endpoint_not_found_error": "找不到 (404):該端點不存在",
"endpoint_pinged": "耶!我們能夠 ping Webhook",
"endpoint_pinged_error": "無法 ping Webhook",
"endpoint_service_unavailable_error": "服務無法使用 (503):服務暫時無法使用",
"learn_to_verify": "了解如何驗證 webhook 簽章",
"no_triggers": "無觸發條件",
"please_check_console": "請檢查主控台以取得更多詳細資料",
"please_enter_a_url": "請輸入網址",
"response_created": "已建立回應",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "回應限制必須超過收到的回應數 ('{'responseCount'}')。",
"response_limits_redirections_and_more": "回應限制、重新導向等。",
"response_options": "回應選項",
"reverse_order_occasionally": "偶爾反轉順序",
"reverse_order_occasionally_except_last": "偶爾反轉順序(最後一項除外)",
"roundness": "圓角",
"roundness_description": "調整邊角的圓潤程度。",
"row_used_in_logic_error": "此 row 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "最多顯示問卷",
"show_survey_to_users": "將問卷顯示給 % 的使用者",
"show_to_x_percentage_of_targeted_users": "顯示給 '{'percentage'}'% 的目標使用者",
"shrink_preview": "收合預覽",
"simple": "簡單",
"six_points": "6 分",
"smiley": "表情符號",
"spam_protection_note": "垃圾郵件保護不適用於使用 iOS、React Native 和 Android SDK 顯示的問卷。它會破壞問卷。",
"spam_protection_threshold_description": "設置值在 0 和 1 之間,低於此值的回應將被拒絕。",
"spam_protection_threshold_heading": "回應閾值",
"shrink_preview": "收合預覽",
"star": "星形",
"starts_with": "開頭為",
"state": "州/省",
@@ -1,5 +1,6 @@
import { Languages } from "lucide-react";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getEnabledLanguages } from "@/lib/i18n/utils";
@@ -17,7 +18,12 @@ interface LanguageDropdownProps {
locale: TUserLocale;
}
export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdownProps) => {
export const LanguageDropdown = ({
survey,
setLanguage,
locale,
}: LanguageDropdownProps) => {
const { t } = useTranslation();
const enabledLanguages = getEnabledLanguages(survey.languages ?? []);
if (enabledLanguages.length <= 1) {
@@ -27,7 +33,7 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" title="Select Language" aria-label="Select Language">
<Button variant="secondary" title={t("common.select_language")} aria-label={t("common.select_language")}>
<Languages className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
@@ -84,7 +84,7 @@ describe("helpers", () => {
test("should allow request when rate limit check passes", async () => {
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
await expect(applyRateLimit(mockConfig, mockIdentifier)).resolves.toBeUndefined();
await expect(applyRateLimit(mockConfig, mockIdentifier)).resolves.toEqual({ allowed: true });
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier);
});
@@ -127,7 +127,7 @@ describe("helpers", () => {
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
await expect(applyRateLimit(customConfig, "api-key-identifier")).resolves.toBeUndefined();
await expect(applyRateLimit(customConfig, "api-key-identifier")).resolves.toEqual({ allowed: true });
expect(checkRateLimit).toHaveBeenCalledWith(customConfig, "api-key-identifier");
});
@@ -138,7 +138,7 @@ describe("helpers", () => {
const identifiers = ["user-123", "ip-192.168.1.1", "auth-login-hashedip", "api-key-abc123"];
for (const identifier of identifiers) {
await expect(applyRateLimit(mockConfig, identifier)).resolves.toBeUndefined();
await expect(applyRateLimit(mockConfig, identifier)).resolves.toEqual({ allowed: true });
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, identifier);
}
@@ -161,7 +161,7 @@ describe("helpers", () => {
(hashString as any).mockReturnValue("hashed-ip-123");
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
await expect(applyIPRateLimit(mockConfig)).resolves.toBeUndefined();
await expect(applyIPRateLimit(mockConfig)).resolves.toEqual({ allowed: true });
expect(getClientIpFromHeaders).toHaveBeenCalledTimes(1);
expect(hashString).toHaveBeenCalledWith("192.168.1.1");
+13 -5
View File
@@ -3,7 +3,7 @@ import { TooManyRequestsError } from "@formbricks/types/errors";
import { hashString } from "@/lib/hash-string";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { checkRateLimit } from "./rate-limit";
import { type TRateLimitConfig } from "./types/rate-limit";
import { type TRateLimitConfig, type TRateLimitResponse } from "./types/rate-limit";
/**
* Get client identifier for rate limiting with IP hashing
@@ -31,12 +31,20 @@ export const getClientIdentifier = async (): Promise<string> => {
* @param identifier - Unique identifier for rate limiting (IP hash, user ID, API key, etc.)
* @throws {Error} When rate limit is exceeded or rate limiting system fails
*/
export const applyRateLimit = async (config: TRateLimitConfig, identifier: string): Promise<void> => {
export const applyRateLimit = async (
config: TRateLimitConfig,
identifier: string
): Promise<TRateLimitResponse> => {
const result = await checkRateLimit(config, identifier);
if (!result.ok || !result.data.allowed) {
throw new TooManyRequestsError("Maximum number of requests reached. Please try again later.");
throw new TooManyRequestsError(
"Maximum number of requests reached. Please try again later.",
result.ok ? result.data.retryAfter : undefined
);
}
return result.data;
};
/**
@@ -46,7 +54,7 @@ export const applyRateLimit = async (config: TRateLimitConfig, identifier: strin
* @param config - Rate limit configuration to apply
* @throws {Error} When rate limit is exceeded or IP hashing fails
*/
export const applyIPRateLimit = async (config: TRateLimitConfig): Promise<void> => {
export const applyIPRateLimit = async (config: TRateLimitConfig): Promise<TRateLimitResponse> => {
const identifier = await getClientIdentifier();
await applyRateLimit(config, identifier);
return await applyRateLimit(config, identifier);
};
@@ -68,7 +68,7 @@ describe("rateLimitConfigs", () => {
test("should have all API configurations", () => {
const apiConfigs = Object.keys(rateLimitConfigs.api);
expect(apiConfigs).toEqual(["v1", "v2", "client"]);
expect(apiConfigs).toEqual(["v1", "v2", "v3", "client"]);
});
test("should have all action configurations", () => {
@@ -127,7 +127,7 @@ describe("rateLimitConfigs", () => {
mockEval.mockResolvedValue([1, 1]);
const config = rateLimitConfigs.api.v1;
await expect(applyRateLimit(config, "api-key-123")).resolves.toBeUndefined();
await expect(applyRateLimit(config, "api-key-123")).resolves.toEqual({ allowed: true });
});
test("should enforce limits correctly for each config type", async () => {
@@ -136,6 +136,7 @@ describe("rateLimitConfigs", () => {
{ config: rateLimitConfigs.auth.signup, identifier: "user-signup" },
{ config: rateLimitConfigs.api.v1, identifier: "api-v1-key" },
{ config: rateLimitConfigs.api.v2, identifier: "api-v2-key" },
{ config: rateLimitConfigs.api.v3, identifier: "api-v3-key" },
{ config: rateLimitConfigs.api.client, identifier: "client-api-key" },
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
{ config: rateLimitConfigs.storage.upload, identifier: "storage-upload" },
@@ -11,6 +11,7 @@ export const rateLimitConfigs = {
api: {
v1: { interval: 60, allowedPerInterval: 100, namespace: "api:v1" }, // 100 per minute (Management API)
v2: { interval: 60, allowedPerInterval: 100, namespace: "api:v2" }, // 100 per minute
v3: { interval: 60, allowedPerInterval: 100, namespace: "api:v3" }, // 100 per minute
client: { interval: 60, allowedPerInterval: 100, namespace: "api:client" }, // 100 per minute (Client API)
},
@@ -82,6 +82,7 @@ export const checkRateLimit = async (
const response: TRateLimitResponse = {
allowed: isAllowed === 1,
retryAfter: isAllowed === 1 ? undefined : ttlSeconds,
};
// Log rate limit violations for security monitoring
@@ -13,6 +13,7 @@ export type TRateLimitConfig = z.infer<typeof ZRateLimitConfig>;
const ZRateLimitResponse = z.object({
allowed: z.boolean().describe("Whether the request is allowed"),
retryAfter: z.int().positive().optional().describe("Seconds until the current rate-limit window resets"),
});
export type TRateLimitResponse = z.infer<typeof ZRateLimitResponse>;
@@ -6,11 +6,11 @@ const bulkContactEndpoint: ZodOpenApiOperationObject = {
operationId: "uploadBulkContacts",
summary: "Upload Bulk Contacts",
description:
"Uploads contacts in bulk. Each contact in the payload must have an 'email' attribute present in their attributes array. The email attribute is mandatory and must be a valid email format. Without a valid email, the contact will be skipped during processing.",
"Uploads contacts in bulk. This endpoint expects the bulk request shape: `contacts` must be an array, and each contact item must contain an `attributes` array of `{ attributeKey, value }` objects. Unlike `POST /management/contacts`, this endpoint does not accept a top-level `attributes` object. Each contact must include an `email` attribute in its `attributes` array, and that email must be valid.",
requestBody: {
required: true,
description:
"The contacts to upload. Each contact must include an 'email' attribute in their attributes array. The email is used as the unique identifier for the contact.",
"The contacts to upload. Use the full nested bulk body shown in the example or cURL snippet: `{ environmentId, contacts: [{ attributes: [{ attributeKey: { key, name }, value }] }] }`. Each contact must include an `email` attribute inside its `attributes` array.",
content: {
"application/json": {
schema: ZContactBulkUploadRequest,
@@ -6,13 +6,13 @@ export const createContactEndpoint: ZodOpenApiOperationObject = {
operationId: "createContact",
summary: "Create a contact",
description:
"Creates a contact in the database. Each contact must have a valid email address in the attributes. All attribute keys must already exist in the environment. The email is used as the unique identifier along with the environment.",
"Creates a single contact in the database. This endpoint expects a top-level `attributes` object. For bulk uploads, use `PUT /management/contacts/bulk`, which expects `contacts[].attributes[]` instead. Each contact must have a valid email address in the attributes. All attribute keys must already exist in the environment. The email is used as the unique identifier along with the environment.",
tags: ["Management API - Contacts"],
requestBody: {
required: true,
description:
"The contact to create. Must include an email attribute and all attribute keys must already exist in the environment.",
"The single contact to create. Must include a top-level `attributes` object with an email attribute, and all attribute keys must already exist in the environment.",
content: {
"application/json": {
schema: ZContactCreateRequest,
@@ -85,9 +85,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
const errMessage = err instanceof Error ? err.message : "Unknown error occurred";
toast.error(
`${t("environments.integrations.webhooks.endpoint_pinged_error")} \n ${
errMessage.length < 250
? `${t("common.error")}: ${errMessage}`
: t("environments.integrations.webhooks.please_check_console")
errMessage.length < 250 ? errMessage : t("environments.integrations.webhooks.please_check_console")
}`,
{ className: errMessage.length < 250 ? "break-all" : "" }
);
@@ -9,21 +9,33 @@ import { timeSince } from "@/lib/time";
import { Badge } from "@/modules/ui/components/badge";
const renderSelectedSurveysText = (webhook: Webhook, allSurveys: TSurvey[]) => {
let surveyNames: string[];
if (webhook.surveyIds.length === 0) {
const allSurveyNames = allSurveys.map((survey) => survey.name);
return <p className="text-slate-400">{allSurveyNames.join(", ")}</p>;
surveyNames = allSurveys.map((survey) => survey.name);
} else {
const selectedSurveyNames = webhook.surveyIds.map((surveyId) => {
const survey = allSurveys.find((survey) => survey.id === surveyId);
return survey ? survey.name : "";
});
return <p className="text-slate-400">{selectedSurveyNames.join(", ")}</p>;
surveyNames = webhook.surveyIds
.map((surveyId) => {
const survey = allSurveys.find((s) => s.id === surveyId);
return survey ? survey.name : "";
})
.filter(Boolean);
}
if (surveyNames.length === 0) {
return <p className="text-slate-400">-</p>;
}
return (
<p className="truncate text-slate-400" title={surveyNames.join(", ")}>
{surveyNames.join(", ")}
</p>
);
};
const renderSelectedTriggersText = (webhook: Webhook, t: TFunction) => {
if (webhook.triggers.length === 0) {
return <p className="text-slate-400">No Triggers</p>;
return <p className="text-slate-400">{t("environments.integrations.webhooks.no_triggers")}</p>;
} else {
let cleanedTriggers = webhook.triggers.map((trigger) => {
if (trigger === "responseCreated") {
@@ -82,7 +82,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
setHittingEndpoint(false);
const errMessage = err instanceof Error ? err.message : "Unknown error occurred";
toast.error(
`${t("environments.integrations.webhooks.endpoint_pinged_error")} \n ${errMessage.length < 250 ? `${t("common.error")}: ${errMessage}` : t("environments.integrations.webhooks.please_check_console")}`,
`${t("environments.integrations.webhooks.endpoint_pinged_error")} \n ${errMessage.length < 250 ? errMessage : t("environments.integrations.webhooks.please_check_console")}`,
{ className: errMessage.length < 250 ? "break-all" : "" }
);
console.error(t("environments.integrations.webhooks.webhook_test_failed_due_to"), errMessage);
@@ -300,7 +300,9 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
)}
<Button variant="secondary" asChild>
<Link href="https://formbricks.com/docs/api/management/webhooks" target="_blank">
<Link
href="https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks"
target="_blank">
{t("common.read_docs")}
</Link>
</Button>
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { InvalidInputError } from "@formbricks/types/errors";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { getTranslate } from "@/lingodotdev/server";
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
import { testEndpoint } from "./webhook";
vi.mock("@formbricks/database", () => ({
prisma: {
webhook: {
create: vi.fn(),
delete: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/crypto", () => ({
generateStandardWebhookSignature: vi.fn(() => "signed-payload"),
generateWebhookSecret: vi.fn(() => "generated-secret"),
}));
vi.mock("@/lib/utils/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn(async () => undefined),
}));
vi.mock("@/lingodotdev/server", () => ({
getTranslate: vi.fn(async () => (key: string) => key),
}));
vi.mock("@/modules/integrations/webhooks/lib/utils", () => ({
isDiscordWebhook: vi.fn(() => false),
}));
vi.mock("uuid", () => ({
v7: vi.fn(() => "webhook-message-id"),
}));
describe("testEndpoint", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(generateStandardWebhookSignature).mockReturnValue("signed-payload");
vi.mocked(validateWebhookUrl).mockResolvedValue(undefined);
vi.mocked(getTranslate).mockResolvedValue((key: string) => key);
vi.mocked(isDiscordWebhook).mockReturnValue(false);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
test.each([
[500, "environments.integrations.webhooks.endpoint_internal_server_error"],
[404, "environments.integrations.webhooks.endpoint_not_found_error"],
[405, "environments.integrations.webhooks.endpoint_method_not_allowed_error"],
[502, "environments.integrations.webhooks.endpoint_bad_gateway_error"],
[503, "environments.integrations.webhooks.endpoint_service_unavailable_error"],
[504, "environments.integrations.webhooks.endpoint_gateway_timeout_error"],
])("throws a translated InvalidInputError for blocked status %s", async (statusCode, messageKey) => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
status: statusCode,
}))
);
await expect(testEndpoint("https://example.com/webhook", "secret")).rejects.toThrow(
new InvalidInputError(messageKey)
);
expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
expect(generateStandardWebhookSignature).toHaveBeenCalled();
expect(getTranslate).toHaveBeenCalled();
});
test("allows non-blocked non-2xx statuses", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
status: 418,
}))
);
await expect(testEndpoint("https://example.com/webhook")).resolves.toBe(true);
expect(getTranslate).not.toHaveBeenCalled();
});
test("rejects Discord webhooks before sending the request", async () => {
vi.mocked(isDiscordWebhook).mockReturnValue(true);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(testEndpoint("https://discord.com/api/webhooks/123")).rejects.toThrow(
"Discord webhooks are currently not supported."
);
expect(fetchMock).not.toHaveBeenCalled();
});
test("throws a timeout error when the request is aborted", async () => {
vi.useFakeTimers();
vi.stubGlobal(
"fetch",
vi.fn((_url, init) => {
const signal = init?.signal as AbortSignal;
return new Promise((_, reject) => {
signal.addEventListener("abort", () => {
const abortError = new Error("The operation was aborted");
abortError.name = "AbortError";
reject(abortError);
});
});
})
);
const requestPromise = testEndpoint("https://example.com/webhook");
const assertion = expect(requestPromise).rejects.toThrow("Request timed out after 5 seconds");
await vi.advanceTimersByTimeAsync(5000);
await assertion;
});
test("wraps unexpected fetch errors", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => Promise.reject(new Error("socket hang up")))
);
await expect(testEndpoint("https://example.com/webhook")).rejects.toThrow(
"Error while fetching the URL: socket hang up"
);
});
});
@@ -12,9 +12,41 @@ import {
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
import { validateInputs } from "@/lib/utils/validate";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { getTranslate } from "@/lingodotdev/server";
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
import { TWebhookInput } from "../types/webhooks";
const getWebhookTestErrorMessage = async (statusCode: number): Promise<string | null> => {
switch (statusCode) {
case 500: {
const t = await getTranslate();
return t("environments.integrations.webhooks.endpoint_internal_server_error");
}
case 404: {
const t = await getTranslate();
return t("environments.integrations.webhooks.endpoint_not_found_error");
}
case 405: {
const t = await getTranslate();
return t("environments.integrations.webhooks.endpoint_method_not_allowed_error");
}
case 502: {
const t = await getTranslate();
return t("environments.integrations.webhooks.endpoint_bad_gateway_error");
}
case 503: {
const t = await getTranslate();
return t("environments.integrations.webhooks.endpoint_service_unavailable_error");
}
case 504: {
const t = await getTranslate();
return t("environments.integrations.webhooks.endpoint_gateway_timeout_error");
}
default:
return null;
}
};
export const updateWebhook = async (
webhookId: string,
webhookInput: Partial<TWebhookInput>
@@ -132,14 +164,14 @@ export const getWebhooks = async (environmentId: string): Promise<Webhook[]> =>
export const testEndpoint = async (url: string, secret?: string): Promise<boolean> => {
await validateWebhookUrl(url);
if (isDiscordWebhook(url)) {
throw new UnknownError("Discord webhooks are currently not supported.");
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
if (isDiscordWebhook(url)) {
throw new UnknownError("Discord webhooks are currently not supported.");
}
const webhookMessageId = uuidv7();
const webhookTimestamp = Math.floor(Date.now() / 1000);
const body = JSON.stringify({ event: "testEndpoint" });
@@ -165,27 +197,27 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
headers: requestHeaders,
signal: controller.signal,
});
clearTimeout(timeout);
const statusCode = response.status;
const errorMessage = await getWebhookTestErrorMessage(statusCode);
if (statusCode >= 200 && statusCode < 300) {
return true;
} else {
const errorMessage = await response.text().then(
(text) => text.substring(0, 1000) // Limit error message size
);
throw new UnknownError(`Request failed with status code ${statusCode}: ${errorMessage}`);
if (errorMessage) {
throw new InvalidInputError(errorMessage);
}
return true;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new UnknownError("Request timed out after 5 seconds");
}
if (error instanceof UnknownError) {
if (error instanceof InvalidInputError || error instanceof UnknownError) {
throw error;
}
throw new UnknownError(
`Error while fetching the URL: ${error instanceof Error ? error.message : "Unknown error occurred"}`
);
} finally {
clearTimeout(timeout);
}
};
@@ -11,7 +11,7 @@ export const TagsLoading = () => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.workspace_configuration")}>
<ProjectConfigNavigation activeId="tags" />
<ProjectConfigNavigation activeId="tags" loading />
</PageHeader>
<SettingsCard
title={t("environments.workspace.tags.manage_tags")}
+32 -17
View File
@@ -26,22 +26,27 @@ import { getOrganizationBilling, getSurvey } from "@/modules/survey/lib/survey";
import { getProject } from "./lib/project";
/**
* Checks if survey follow-ups are enabled for the given organization.
*
* @param { string } organizationId The ID of the organization to check.
* @returns { Promise<void> } A promise that resolves if the permission is granted.
* @throws { ResourceNotFoundError } If the organization is not found.
* @throws { OperationNotAllowedError } If survey follow-ups are not enabled for the organization.
* Checks if survey follow-ups can be added for the given organization.
* Grandfathers existing follow-ups (allows keeping them even if the org lost access).
* Only throws when new follow-ups are being added.
*/
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
const checkSurveyFollowUpsPermission = async (
organizationId: string,
newFollowUpIds: string[],
oldFollowUpIds: Set<string>
): Promise<void> => {
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new ResourceNotFoundError("Organization", organizationId);
}
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
if (isSurveyFollowUpsEnabled) return;
for (const id of newFollowUpIds) {
if (!oldFollowUpIds.has(id)) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
}
};
@@ -71,14 +76,19 @@ export const updateSurveyDraftAction = authenticatedActionClient.inputSchema(ZSu
await checkSpamProtectionPermission(organizationId);
}
if (survey.followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = survey.id;
const oldObject = await getSurvey(survey.id);
if (survey.followUps.length) {
const oldFollowUpIds = new Set((oldObject?.followUps ?? []).map((f) => f.id));
await checkSurveyFollowUpsPermission(
organizationId,
survey.followUps.map((f) => f.id),
oldFollowUpIds
);
}
await checkExternalUrlsPermission(organizationId, survey, oldObject);
// Use the draft version that skips validation
@@ -116,14 +126,19 @@ export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey)
await checkSpamProtectionPermission(organizationId);
}
if (parsedInput.followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.id;
const oldObject = await getSurvey(parsedInput.id);
if (parsedInput.followUps?.length) {
const oldFollowUpIds = new Set((oldObject?.followUps ?? []).map((f) => f.id));
await checkSurveyFollowUpsPermission(
organizationId,
parsedInput.followUps.map((f) => f.id),
oldFollowUpIds
);
}
// Check external URLs permission (with grandfathering)
await checkExternalUrlsPermission(organizationId, parsedInput, oldObject);
const result = await updateSurvey(parsedInput);
@@ -129,8 +129,10 @@ export const EditWelcomeCard = ({
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
environmentId={environmentId}
onFileUpload={(url: string[] | undefined, _fileType: "image" | "video") => {
if (url?.[0]) {
if (url?.length) {
updateSurvey({ fileUrl: url[0] });
} else {
updateSurvey({ fileUrl: undefined });
}
}}
fileUrl={localSurvey?.welcomeCard?.fileUrl}
@@ -188,6 +188,16 @@ export const MatrixElementForm = ({
label: t("environments.surveys.edit.randomize_all_except_last"),
show: true,
},
reverseOrderOccasionally: {
id: "reverseOrderOccasionally",
label: t("environments.surveys.edit.reverse_order_occasionally"),
show: true,
},
reverseOrderExceptLast: {
id: "reverseOrderExceptLast",
label: t("environments.surveys.edit.reverse_order_occasionally_except_last"),
show: true,
},
};
const [parent] = useAutoAnimate();
@@ -63,7 +63,7 @@ export const MultipleChoiceElementForm = ({
const elementRef = useRef<HTMLInputElement>(null);
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
const surveyLanguages = localSurvey.languages ?? [];
const shuffleOptionsTypes = {
const shuffleOptionsTypes: Record<TShuffleOption, { id: TShuffleOption; label: string; show: boolean }> = {
none: {
id: "none",
label: t("environments.surveys.edit.keep_current_order"),
@@ -79,6 +79,16 @@ export const MultipleChoiceElementForm = ({
label: t("environments.surveys.edit.randomize_all_except_last"),
show: true,
},
reverseOrderOccasionally: {
id: "reverseOrderOccasionally",
label: t("environments.surveys.edit.reverse_order_occasionally"),
show: element.choices.every((c) => c.id !== "other" && c.id !== "none"),
},
reverseOrderExceptLast: {
id: "reverseOrderExceptLast",
label: t("environments.surveys.edit.reverse_order_occasionally_except_last"),
show: true,
},
};
const multipleChoiceOptionDisplayTypeOptions = [
@@ -167,7 +177,10 @@ export const MultipleChoiceElementForm = ({
updateElement(elementIdx, {
choices: newChoices,
...(element.shuffleOption === shuffleOptionsTypes.all.id && {
shuffleOption: shuffleOptionsTypes.exceptLast.id as TShuffleOption,
shuffleOption: shuffleOptionsTypes.exceptLast.id,
}),
...(element.shuffleOption === shuffleOptionsTypes.reverseOrderOccasionally.id && {
shuffleOption: shuffleOptionsTypes.reverseOrderExceptLast.id,
}),
});
};
@@ -193,8 +206,18 @@ export const MultipleChoiceElementForm = ({
setisInvalidValue(null);
}
const hasRemainingSpecialChoices = newChoices.some((c) => c.id === "other" || c.id === "none");
updateElement(elementIdx, {
choices: newChoices,
...(!hasRemainingSpecialChoices &&
element.shuffleOption === "reverseOrderExceptLast" && {
shuffleOption: "reverseOrderOccasionally",
}),
...(!hasRemainingSpecialChoices &&
element.shuffleOption === "exceptLast" && {
shuffleOption: "all",
}),
});
};
@@ -115,6 +115,21 @@ export const RankingElementForm = ({
label: t("environments.surveys.edit.randomize_all"),
show: element.choices.length > 0,
},
exceptLast: {
id: "exceptLast",
label: t("environments.surveys.edit.randomize_all_except_last"),
show: true,
},
reverseOrderOccasionally: {
id: "reverseOrderOccasionally",
label: t("environments.surveys.edit.reverse_order_occasionally"),
show: true,
},
reverseOrderExceptLast: {
id: "reverseOrderExceptLast",
label: t("environments.surveys.edit.reverse_order_occasionally_except_last"),
show: true,
},
};
useEffect(() => {
@@ -0,0 +1,324 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { buildWhereClause } from "@/modules/survey/lib/utils";
import { decodeSurveyListPageCursor, encodeSurveyListPageCursor, getSurveyListPage } from "./survey-page";
vi.mock("@/modules/survey/lib/utils", () => ({
buildWhereClause: vi.fn(() => ({ AND: [] })),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
findMany: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const environmentId = "env_123";
function makeSurveyRow(overrides: Record<string, unknown> = {}) {
return {
id: "survey_1",
name: "Survey 1",
environmentId,
type: "link",
status: "draft",
createdAt: new Date("2025-01-01T00:00:00.000Z"),
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
creator: { name: "Alice" },
singleUse: null,
_count: { responses: 3 },
...overrides,
};
}
describe("survey-page cursor helpers", () => {
test("encodes and decodes an updatedAt cursor", () => {
const encoded = encodeSurveyListPageCursor({
version: 1,
sortBy: "updatedAt",
value: "2025-01-02T00:00:00.000Z",
id: "survey_1",
});
expect(decodeSurveyListPageCursor(encoded, "updatedAt")).toEqual({
version: 1,
sortBy: "updatedAt",
value: "2025-01-02T00:00:00.000Z",
id: "survey_1",
});
});
test("rejects a cursor that does not match the requested sort order", () => {
const encoded = encodeSurveyListPageCursor({
version: 1,
sortBy: "name",
value: "Survey 1",
id: "survey_1",
});
expect(() => decodeSurveyListPageCursor(encoded, "updatedAt")).toThrow(InvalidInputError);
});
});
describe("getSurveyListPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("uses a stable updatedAt order with a next cursor", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue([
makeSurveyRow({ id: "survey_2", updatedAt: new Date("2025-01-03T00:00:00.000Z") }),
makeSurveyRow({ id: "survey_1", updatedAt: new Date("2025-01-02T00:00:00.000Z") }),
] as never);
const page = await getSurveyListPage(environmentId, {
limit: 1,
cursor: null,
sortBy: "updatedAt",
});
expect(buildWhereClause).toHaveBeenCalledWith(undefined);
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId, AND: [] },
select: expect.any(Object),
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: 2,
});
expect(page.surveys).toHaveLength(1);
expect(page.surveys[0].responseCount).toBe(3);
expect(page.surveys[0]).not.toHaveProperty("_count");
expect(page.nextCursor).not.toBeNull();
expect(decodeSurveyListPageCursor(page.nextCursor as string, "updatedAt")).toEqual({
version: 1,
sortBy: "updatedAt",
value: "2025-01-03T00:00:00.000Z",
id: "survey_2",
});
});
test("applies a name cursor for forward pagination", async () => {
const cursor = decodeSurveyListPageCursor(
encodeSurveyListPageCursor({
version: 1,
sortBy: "name",
value: "Bravo",
id: "survey_b",
}),
"name"
);
vi.mocked(prisma.survey.findMany).mockResolvedValue([
makeSurveyRow({ id: "survey_c", name: "Charlie" }),
] as never);
await getSurveyListPage(environmentId, {
limit: 2,
cursor,
sortBy: "name",
});
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: {
environmentId,
AND: [],
OR: [{ name: { gt: "Bravo" } }, { name: "Bravo", id: { gt: "survey_b" } }],
},
select: expect.any(Object),
orderBy: [{ name: "asc" }, { id: "asc" }],
take: 3,
});
});
test("paginates relevance by exhausting in-progress surveys before others", async () => {
vi.mocked(prisma.survey.findMany)
.mockResolvedValueOnce([
makeSurveyRow({
id: "survey_in_progress",
status: "inProgress",
updatedAt: new Date("2025-01-03T00:00:00.000Z"),
}),
] as never)
.mockResolvedValueOnce([
makeSurveyRow({
id: "survey_other_1",
status: "completed",
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
}),
makeSurveyRow({
id: "survey_other_2",
status: "paused",
updatedAt: new Date("2025-01-01T00:00:00.000Z"),
}),
] as never);
const page = await getSurveyListPage(environmentId, {
limit: 2,
cursor: null,
sortBy: "relevance",
});
expect(prisma.survey.findMany).toHaveBeenNthCalledWith(1, {
where: {
environmentId,
AND: [],
status: "inProgress",
},
select: expect.any(Object),
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: 3,
});
expect(prisma.survey.findMany).toHaveBeenNthCalledWith(2, {
where: {
environmentId,
AND: [],
status: { not: "inProgress" },
},
select: expect.any(Object),
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: 2,
});
expect(page.surveys.map((survey) => survey.id)).toEqual(["survey_in_progress", "survey_other_1"]);
expect(decodeSurveyListPageCursor(page.nextCursor as string, "relevance")).toEqual({
version: 1,
sortBy: "relevance",
bucket: "other",
updatedAt: "2025-01-02T00:00:00.000Z",
id: "survey_other_1",
});
});
test("returns an in-progress next cursor when the page fills before switching to other surveys", async () => {
vi.mocked(prisma.survey.findMany)
.mockResolvedValueOnce([
makeSurveyRow({
id: "survey_in_progress",
status: "inProgress",
updatedAt: new Date("2025-01-03T00:00:00.000Z"),
}),
] as never)
.mockResolvedValueOnce([
makeSurveyRow({
id: "survey_other_1",
status: "completed",
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
}),
] as never);
const page = await getSurveyListPage(environmentId, {
limit: 1,
cursor: null,
sortBy: "relevance",
});
expect(page.surveys.map((survey) => survey.id)).toEqual(["survey_in_progress"]);
expect(decodeSurveyListPageCursor(page.nextCursor as string, "relevance")).toEqual({
version: 1,
sortBy: "relevance",
bucket: "inProgress",
updatedAt: "2025-01-03T00:00:00.000Z",
id: "survey_in_progress",
});
});
test("continues relevance pagination from the other bucket cursor", async () => {
const cursor = decodeSurveyListPageCursor(
encodeSurveyListPageCursor({
version: 1,
sortBy: "relevance",
bucket: "other",
updatedAt: "2025-01-02T00:00:00.000Z",
id: "survey_other_1",
}),
"relevance"
);
vi.mocked(prisma.survey.findMany).mockResolvedValue([
makeSurveyRow({
id: "survey_other_2",
status: "completed",
updatedAt: new Date("2025-01-01T00:00:00.000Z"),
}),
] as never);
const page = await getSurveyListPage(environmentId, {
limit: 2,
cursor,
sortBy: "relevance",
});
expect(prisma.survey.findMany).toHaveBeenCalledOnce();
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: {
environmentId,
AND: [],
status: { not: "inProgress" },
OR: [
{ updatedAt: { lt: new Date("2025-01-02T00:00:00.000Z") } },
{
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
id: { lt: "survey_other_1" },
},
],
},
select: expect.any(Object),
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: 3,
});
expect(page.surveys.map((survey) => survey.id)).toEqual(["survey_other_2"]);
expect(page.nextCursor).toBeNull();
});
test("wraps Prisma errors as DatabaseError", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("db failed", {
code: "P2025",
clientVersion: "test",
});
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
await expect(
getSurveyListPage(environmentId, {
limit: 1,
cursor: null,
sortBy: "updatedAt",
})
).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting paginated surveys");
});
test("rethrows InvalidInputError unchanged", async () => {
const invalidInputError = new InvalidInputError("bad cursor");
vi.mocked(prisma.survey.findMany).mockRejectedValue(invalidInputError);
await expect(
getSurveyListPage(environmentId, {
limit: 1,
cursor: null,
sortBy: "updatedAt",
})
).rejects.toThrow(invalidInputError);
});
test("rethrows unexpected errors unchanged", async () => {
const unexpectedError = new Error("boom");
vi.mocked(prisma.survey.findMany).mockRejectedValue(unexpectedError);
await expect(
getSurveyListPage(environmentId, {
limit: 1,
cursor: null,
sortBy: "updatedAt",
})
).rejects.toThrow(unexpectedError);
});
});
@@ -0,0 +1,427 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import type { TSurveyFilterCriteria } from "@formbricks/types/surveys/types";
import { buildWhereClause } from "@/modules/survey/lib/utils";
import type { TSurvey } from "../types/surveys";
import { type TSurveyRow, mapSurveyRowsToSurveys, surveySelect } from "./survey-record";
const SURVEY_LIST_CURSOR_VERSION = 1 as const;
const IN_PROGRESS_BUCKET = "inProgress" as const;
const OTHER_BUCKET = "other" as const;
const ZDateCursor = z.object({
version: z.literal(SURVEY_LIST_CURSOR_VERSION),
sortBy: z.enum(["updatedAt", "createdAt"]),
value: z.iso.datetime(),
id: z.string().min(1),
});
const ZNameCursor = z.object({
version: z.literal(SURVEY_LIST_CURSOR_VERSION),
sortBy: z.literal("name"),
value: z.string(),
id: z.string().min(1),
});
const ZRelevanceCursor = z.object({
version: z.literal(SURVEY_LIST_CURSOR_VERSION),
sortBy: z.literal("relevance"),
bucket: z.enum([IN_PROGRESS_BUCKET, OTHER_BUCKET]),
updatedAt: z.iso.datetime(),
id: z.string().min(1),
});
const ZSurveyListPageCursor = z.union([ZDateCursor, ZNameCursor, ZRelevanceCursor]);
export type TSurveyListSort = NonNullable<TSurveyFilterCriteria["sortBy"]>;
export type TSurveyListPageCursor = z.infer<typeof ZSurveyListPageCursor>;
type TStandardSurveyListSort = Exclude<TSurveyListSort, "relevance">;
type TStandardSurveyListCursor = Extract<TSurveyListPageCursor, { sortBy: TStandardSurveyListSort }>;
type TRelevanceSurveyListCursor = Extract<TSurveyListPageCursor, { sortBy: "relevance" }>;
type TRelevanceBucket = typeof IN_PROGRESS_BUCKET | typeof OTHER_BUCKET;
export type TSurveyListPage = {
surveys: TSurvey[];
nextCursor: string | null;
};
type TGetSurveyListPageOptions = {
limit: number;
cursor: TSurveyListPageCursor | null;
sortBy: TSurveyListSort;
filterCriteria?: TSurveyFilterCriteria;
};
type TCursorDirection = "asc" | "desc";
export function normalizeSurveyListSort(sortBy?: TSurveyFilterCriteria["sortBy"]): TSurveyListSort {
return sortBy ?? "updatedAt";
}
export function encodeSurveyListPageCursor(cursor: TSurveyListPageCursor): string {
return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
}
export function decodeSurveyListPageCursor(
encodedCursor: string,
sortBy: TSurveyListSort
): TSurveyListPageCursor {
try {
const decodedJson = Buffer.from(encodedCursor, "base64url").toString("utf8");
const parsedCursor = ZSurveyListPageCursor.parse(JSON.parse(decodedJson));
if (parsedCursor.sortBy !== sortBy) {
throw new InvalidInputError("The cursor does not match the requested sort order.");
}
return parsedCursor;
} catch (error) {
if (error instanceof InvalidInputError) {
throw error;
}
throw new InvalidInputError("The cursor is invalid.");
}
}
function getSurveyOrderBy(sortBy: TStandardSurveyListSort): Prisma.SurveyOrderByWithRelationInput[] {
switch (sortBy) {
case "name":
return [{ name: "asc" }, { id: "asc" }];
case "createdAt":
return [{ createdAt: "desc" }, { id: "desc" }];
case "updatedAt":
default:
return [{ updatedAt: "desc" }, { id: "desc" }];
}
}
function buildDateCursorWhere(
field: "createdAt" | "updatedAt",
cursorValue: string,
cursorId: string,
direction: TCursorDirection
): Prisma.SurveyWhereInput {
const comparisonOperator = direction === "desc" ? "lt" : "gt";
const cursorDate = new Date(cursorValue);
return {
OR: [
{ [field]: { [comparisonOperator]: cursorDate } },
{
[field]: cursorDate,
id: { [comparisonOperator]: cursorId },
},
],
};
}
function buildNameCursorWhere(cursorValue: string, cursorId: string): Prisma.SurveyWhereInput {
return {
OR: [
{ name: { gt: cursorValue } },
{
name: cursorValue,
id: { gt: cursorId },
},
],
};
}
function buildStandardCursorWhere(
sortBy: TStandardSurveyListSort,
cursor: TStandardSurveyListCursor
): Prisma.SurveyWhereInput {
switch (sortBy) {
case "name":
return buildNameCursorWhere(cursor.value, cursor.id);
case "createdAt":
return buildDateCursorWhere("createdAt", cursor.value, cursor.id, "desc");
case "updatedAt":
default:
return buildDateCursorWhere("updatedAt", cursor.value, cursor.id, "desc");
}
}
function buildBaseWhere(
environmentId: string,
filterCriteria?: TSurveyFilterCriteria,
extraWhere?: Prisma.SurveyWhereInput
): Prisma.SurveyWhereInput {
return {
environmentId,
...buildWhereClause(filterCriteria),
...extraWhere,
};
}
function getStandardNextCursor(survey: TSurveyRow, sortBy: TStandardSurveyListSort): TSurveyListPageCursor {
switch (sortBy) {
case "name":
return {
version: SURVEY_LIST_CURSOR_VERSION,
sortBy,
value: survey.name,
id: survey.id,
};
case "createdAt":
return {
version: SURVEY_LIST_CURSOR_VERSION,
sortBy,
value: survey.createdAt.toISOString(),
id: survey.id,
};
case "updatedAt":
default:
return {
version: SURVEY_LIST_CURSOR_VERSION,
sortBy,
value: survey.updatedAt.toISOString(),
id: survey.id,
};
}
}
function getRelevanceNextCursor(survey: TSurveyRow, bucket: TRelevanceBucket): TSurveyListPageCursor {
return {
version: SURVEY_LIST_CURSOR_VERSION,
sortBy: "relevance",
bucket,
updatedAt: survey.updatedAt.toISOString(),
id: survey.id,
};
}
async function findSurveyRows(
environmentId: string,
limit: number,
sortBy: TStandardSurveyListSort,
filterCriteria?: TSurveyFilterCriteria,
cursor?: TStandardSurveyListCursor | null,
extraWhere?: Prisma.SurveyWhereInput
): Promise<TSurveyRow[]> {
const cursorWhere = cursor ? buildStandardCursorWhere(sortBy, cursor) : undefined;
return prisma.survey.findMany({
where: buildBaseWhere(environmentId, filterCriteria, {
...extraWhere,
...cursorWhere,
}),
select: surveySelect,
orderBy: getSurveyOrderBy(sortBy),
take: limit + 1,
});
}
function getLastSurveyRow(rows: TSurveyRow[]): TSurveyRow | null {
return rows.at(-1) ?? null;
}
function getPageRows<T>(rows: T[], limit: number): { pageRows: T[]; hasMore: boolean } {
const hasMore = rows.length > limit;
return {
pageRows: hasMore ? rows.slice(0, limit) : rows,
hasMore,
};
}
function buildSurveyListPage(rows: TSurveyRow[], cursor: TSurveyListPageCursor | null): TSurveyListPage {
return {
surveys: mapSurveyRowsToSurveys(rows),
nextCursor: cursor ? encodeSurveyListPageCursor(cursor) : null,
};
}
async function getStandardSurveyListPage(
environmentId: string,
options: TGetSurveyListPageOptions & { sortBy: TStandardSurveyListSort }
): Promise<TSurveyListPage> {
const surveyRows = await findSurveyRows(
environmentId,
options.limit,
options.sortBy,
options.filterCriteria,
options.cursor as TStandardSurveyListCursor | null
);
const { pageRows, hasMore } = getPageRows(surveyRows, options.limit);
const lastRow = getLastSurveyRow(pageRows);
return buildSurveyListPage(
pageRows,
hasMore && lastRow ? getStandardNextCursor(lastRow, options.sortBy) : null
);
}
async function findRelevanceRows(
environmentId: string,
limit: number,
filterCriteria: TSurveyFilterCriteria | undefined,
bucket: TRelevanceBucket,
cursor: TRelevanceSurveyListCursor | null
): Promise<TSurveyRow[]> {
const statusWhere: Prisma.SurveyWhereInput =
bucket === IN_PROGRESS_BUCKET ? { status: "inProgress" } : { status: { not: "inProgress" } };
const cursorWhere = cursor
? buildDateCursorWhere("updatedAt", cursor.updatedAt, cursor.id, "desc")
: undefined;
return prisma.survey.findMany({
where: buildBaseWhere(environmentId, filterCriteria, {
...statusWhere,
...cursorWhere,
}),
select: surveySelect,
orderBy: getSurveyOrderBy("updatedAt"),
take: limit + 1,
});
}
async function hasMoreRelevanceRowsInOtherBucket(
environmentId: string,
filterCriteria?: TSurveyFilterCriteria
): Promise<boolean> {
const otherRows = await findRelevanceRows(environmentId, 1, filterCriteria, OTHER_BUCKET, null);
return otherRows.length > 0;
}
function getRelevanceCursor(cursor: TSurveyListPageCursor | null): TRelevanceSurveyListCursor | null {
if (cursor && cursor.sortBy !== "relevance") {
throw new InvalidInputError("The cursor does not match the requested sort order.");
}
return cursor;
}
function getRelevanceBucketCursor(
cursor: TRelevanceSurveyListCursor | null,
bucket: TRelevanceBucket
): TRelevanceSurveyListCursor | null {
return cursor?.bucket === bucket ? cursor : null;
}
function shouldReadInProgressBucket(cursor: TRelevanceSurveyListCursor | null): boolean {
return !cursor || cursor.bucket === IN_PROGRESS_BUCKET;
}
function buildRelevancePage(rows: TSurveyRow[], bucket: TRelevanceBucket | null): TSurveyListPage {
const lastRow = getLastSurveyRow(rows);
return buildSurveyListPage(rows, bucket && lastRow ? getRelevanceNextCursor(lastRow, bucket) : null);
}
async function getInProgressRelevanceStep(
environmentId: string,
limit: number,
filterCriteria: TSurveyFilterCriteria | undefined,
cursor: TRelevanceSurveyListCursor | null
): Promise<{ pageRows: TSurveyRow[]; remaining: number; response: TSurveyListPage | null }> {
const inProgressRows = await findRelevanceRows(
environmentId,
limit,
filterCriteria,
IN_PROGRESS_BUCKET,
getRelevanceBucketCursor(cursor, IN_PROGRESS_BUCKET)
);
const { pageRows, hasMore } = getPageRows(inProgressRows, limit);
return {
pageRows,
remaining: limit - pageRows.length,
response: hasMore ? buildRelevancePage(pageRows, IN_PROGRESS_BUCKET) : null,
};
}
async function buildInProgressOnlyRelevancePage(
environmentId: string,
rows: TSurveyRow[],
filterCriteria: TSurveyFilterCriteria | undefined,
cursor: TRelevanceSurveyListCursor | null
): Promise<TSurveyListPage> {
const hasOtherRows =
rows.length > 0 &&
shouldReadInProgressBucket(cursor) &&
(await hasMoreRelevanceRowsInOtherBucket(environmentId, filterCriteria));
return buildRelevancePage(rows, hasOtherRows ? IN_PROGRESS_BUCKET : null);
}
async function getRelevanceSurveyListPage(
environmentId: string,
options: TGetSurveyListPageOptions & { sortBy: "relevance" }
): Promise<TSurveyListPage> {
const relevanceCursor = getRelevanceCursor(options.cursor);
const pageRows: TSurveyRow[] = [];
let remaining = options.limit;
if (shouldReadInProgressBucket(relevanceCursor)) {
const inProgressStep = await getInProgressRelevanceStep(
environmentId,
remaining,
options.filterCriteria,
relevanceCursor
);
pageRows.push(...inProgressStep.pageRows);
if (inProgressStep.response) {
return inProgressStep.response;
}
remaining = inProgressStep.remaining;
}
if (remaining <= 0) {
return await buildInProgressOnlyRelevancePage(
environmentId,
pageRows,
options.filterCriteria,
relevanceCursor
);
}
const otherRows = await findRelevanceRows(
environmentId,
remaining,
options.filterCriteria,
OTHER_BUCKET,
getRelevanceBucketCursor(relevanceCursor, OTHER_BUCKET)
);
const { pageRows: otherPageRows, hasMore: hasMoreOther } = getPageRows(otherRows, remaining);
pageRows.push(...otherPageRows);
return buildRelevancePage(pageRows, hasMoreOther ? OTHER_BUCKET : null);
}
export async function getSurveyListPage(
environmentId: string,
options: TGetSurveyListPageOptions
): Promise<TSurveyListPage> {
try {
if (options.sortBy === "relevance") {
return await getRelevanceSurveyListPage(environmentId, {
...options,
sortBy: "relevance",
});
}
return await getStandardSurveyListPage(environmentId, {
...options,
sortBy: options.sortBy,
});
} catch (error) {
if (error instanceof InvalidInputError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error getting paginated surveys");
throw new DatabaseError(error.message);
}
throw error;
}
}
@@ -0,0 +1,36 @@
import { Prisma } from "@prisma/client";
import type { TSurvey } from "@/modules/survey/list/types/surveys";
export const surveySelect = {
id: true,
createdAt: true,
updatedAt: true,
name: true,
type: true,
creator: {
select: {
name: true,
},
},
status: true,
singleUse: true,
environmentId: true,
_count: {
select: { responses: true },
},
} satisfies Prisma.SurveySelect;
export type TSurveyRow = Prisma.SurveyGetPayload<{ select: typeof surveySelect }>;
export function mapSurveyRowToSurvey(row: TSurveyRow): TSurvey {
const { _count, ...survey } = row;
return {
...survey,
responseCount: _count.responses,
};
}
export function mapSurveyRowsToSurveys(rows: TSurveyRow[]): TSurvey[] {
return rows.map(mapSurveyRowToSurvey);
}
@@ -22,8 +22,8 @@ import {
getSurveyCount,
getSurveys,
getSurveysSortedByRelevance,
surveySelect,
} from "./survey";
import { surveySelect } from "./survey-record";
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
@@ -197,7 +197,19 @@ describe("getSurvey", () => {
const survey = await getSurvey(surveyId);
expect(survey).toEqual({ ...prismaSurvey, responseCount: 5 });
expect(survey).toEqual({
id: prismaSurvey.id,
createdAt: prismaSurvey.createdAt,
updatedAt: prismaSurvey.updatedAt,
name: prismaSurvey.name,
type: prismaSurvey.type,
creator: prismaSurvey.creator,
status: prismaSurvey.status,
singleUse: prismaSurvey.singleUse,
environmentId: prismaSurvey.environmentId,
responseCount: 5,
});
expect(survey).not.toHaveProperty("_count");
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { id: surveyId },
select: surveySelect,
@@ -234,7 +246,15 @@ describe("getSurveys", () => {
{ ...mockSurveyPrisma, id: "s2", name: "Survey 2", _count: { responses: 2 } },
];
const expectedSurveys: TSurvey[] = mockPrismaSurveys.map((s) => ({
...s,
id: s.id,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
name: s.name,
type: s.type,
creator: s.creator,
status: s.status,
singleUse: s.singleUse,
environmentId: s.environmentId,
responseCount: s._count.responses,
}));
@@ -243,6 +263,7 @@ describe("getSurveys", () => {
const surveys = await getSurveys(environmentId);
expect(surveys).toEqual(expectedSurveys);
expect(surveys[0]).not.toHaveProperty("_count");
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId, ...buildWhereClause() },
select: surveySelect,
@@ -317,8 +338,30 @@ describe("getSurveysSortedByRelevance", () => {
_count: { responses: 5 },
};
const expectedInProgressSurvey: TSurvey = { ...mockInProgressPrisma, responseCount: 3 };
const expectedOtherSurvey: TSurvey = { ...mockOtherPrisma, responseCount: 5 };
const expectedInProgressSurvey: TSurvey = {
id: mockInProgressPrisma.id,
createdAt: mockInProgressPrisma.createdAt,
updatedAt: mockInProgressPrisma.updatedAt,
name: mockInProgressPrisma.name,
type: mockInProgressPrisma.type,
creator: mockInProgressPrisma.creator,
status: mockInProgressPrisma.status,
singleUse: mockInProgressPrisma.singleUse,
environmentId: mockInProgressPrisma.environmentId,
responseCount: 3,
};
const expectedOtherSurvey: TSurvey = {
id: mockOtherPrisma.id,
createdAt: mockOtherPrisma.createdAt,
updatedAt: mockOtherPrisma.updatedAt,
name: mockOtherPrisma.name,
type: mockOtherPrisma.type,
creator: mockOtherPrisma.creator,
status: mockOtherPrisma.status,
singleUse: mockOtherPrisma.singleUse,
environmentId: mockOtherPrisma.environmentId,
responseCount: 5,
};
test("should fetch inProgress surveys first, then others if limit not met", async () => {
vi.mocked(prisma.survey.count).mockResolvedValue(1); // 1 inProgress survey
@@ -329,6 +372,7 @@ describe("getSurveysSortedByRelevance", () => {
const surveys = await getSurveysSortedByRelevance(environmentId, 2, 0);
expect(surveys).toEqual([expectedInProgressSurvey, expectedOtherSurvey]);
expect(surveys[0]).not.toHaveProperty("_count");
expect(prisma.survey.count).toHaveBeenCalledWith({
where: { environmentId, status: "inProgress", ...buildWhereClause() },
});
+25 -57
View File
@@ -17,25 +17,7 @@ import { buildOrderByClause, buildWhereClause } from "@/modules/survey/lib/utils
import { doesEnvironmentExist } from "@/modules/survey/list/lib/environment";
import { getProjectWithLanguagesByEnvironmentId } from "@/modules/survey/list/lib/project";
import { TProjectWithLanguages, TSurvey } from "@/modules/survey/list/types/surveys";
export const surveySelect: Prisma.SurveySelect = {
id: true,
createdAt: true,
updatedAt: true,
name: true,
type: true,
creator: {
select: {
name: true,
},
},
status: true,
singleUse: true,
environmentId: true,
_count: {
select: { responses: true },
},
};
import { mapSurveyRowToSurvey, mapSurveyRowsToSurveys, surveySelect } from "./survey-record";
export const getSurveys = reactCache(
async (
@@ -62,12 +44,7 @@ export const getSurveys = reactCache(
skip: offset,
});
return surveysPrisma.map((survey) => {
return {
...survey,
responseCount: survey._count.responses,
};
});
return mapSurveyRowsToSurveys(surveysPrisma);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error getting surveys");
@@ -112,12 +89,7 @@ export const getSurveysSortedByRelevance = reactCache(
skip: offset,
});
surveys = inProgressSurveys.map((survey) => {
return {
...survey,
responseCount: survey._count.responses,
};
});
surveys = mapSurveyRowsToSurveys(inProgressSurveys);
// Determine if additional surveys are needed
if (offset !== undefined && limit && inProgressSurveys.length < limit) {
@@ -135,15 +107,7 @@ export const getSurveysSortedByRelevance = reactCache(
skip: newOffset,
});
surveys = [
...surveys,
...additionalSurveys.map((survey) => {
return {
...survey,
responseCount: survey._count.responses,
};
}),
];
surveys = [...surveys, ...mapSurveyRowsToSurveys(additionalSurveys)];
}
return surveys;
@@ -178,7 +142,7 @@ export const getSurvey = reactCache(async (surveyId: string): Promise<TSurvey |
return null;
}
return { ...surveyPrisma, responseCount: surveyPrisma?._count.responses };
return mapSurveyRowToSurvey(surveyPrisma);
});
export const deleteSurvey = async (surveyId: string): Promise<boolean> => {
@@ -605,22 +569,26 @@ export const copySurveyToOtherEnvironment = async (
}
};
export const getSurveyCount = reactCache(async (environmentId: string): Promise<number> => {
validateInputs([environmentId, z.cuid2()]);
try {
const surveyCount = await prisma.survey.count({
where: {
environmentId: environmentId,
},
});
/** Count surveys in an environment, optionally with the same filter as getSurveys (so total matches list). */
export const getSurveyCount = reactCache(
async (environmentId: string, filterCriteria?: TSurveyFilterCriteria): Promise<number> => {
validateInputs([environmentId, z.cuid2()]);
try {
const surveyCount = await prisma.survey.count({
where: {
environmentId,
...buildWhereClause(filterCriteria),
},
});
return surveyCount;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error getting survey count");
throw new DatabaseError(error.message);
return surveyCount;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error getting survey count");
throw new DatabaseError(error.message);
}
throw error;
}
throw error;
}
});
);
@@ -163,6 +163,9 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
}
} else {
setIsMultiLanguageActivated(true);
if (!open) {
setOpen(true);
}
}
};
@@ -25,6 +25,8 @@ interface ShuffleOptionsTypes {
none?: ShuffleOptionType;
all?: ShuffleOptionType;
exceptLast?: ShuffleOptionType;
reverseOrderOccasionally?: ShuffleOptionType;
reverseOrderExceptLast?: ShuffleOptionType;
}
interface ShuffleOptionSelectProps {
-1
View File
@@ -269,7 +269,6 @@ test.describe("Multi Language Survey Create", async () => {
await page.getByText("Start from scratch").click();
await page.getByRole("button", { name: "Create survey", exact: true }).click();
await page.locator("#multi-lang-toggle").click();
await page.getByText("Multiple languages").click();
await page.getByRole("combobox").click();
await page.getByLabel("English (en)").click();
await page.getByRole("button", { name: "Confirm" }).click();
+20 -12
View File
@@ -1386,17 +1386,22 @@ paths:
put:
operationId: uploadBulkContacts
summary: Upload Bulk Contacts
description: Uploads contacts in bulk. Each contact in the payload must have an
'email' attribute present in their attributes array. The email attribute
is mandatory and must be a valid email format. Without a valid email,
the contact will be skipped during processing.
description: >-
Uploads contacts in bulk. This endpoint expects the bulk request
shape: `contacts` must be an array, and each contact item must contain
an `attributes` array of `{ attributeKey, value }` objects. Unlike `POST
/management/contacts`, this endpoint does not accept a top-level `attributes`
object. Each contact must include an `email` attribute in its `attributes`
array, and that email must be valid.
tags:
- Management API - Contacts
requestBody:
required: true
description: The contacts to upload. Each contact must include an 'email'
attribute in their attributes array. The email is used as the unique
identifier for the contact.
description: >-
The contacts to upload. Use the full nested bulk body shown
in the example or cURL snippet: `{ environmentId, contacts: [{ attributes:
[{ attributeKey: { key, name }, value }] }] }`. Each contact must include
an `email` attribute inside its `attributes` array.
content:
application/json:
schema:
@@ -1520,16 +1525,19 @@ paths:
post:
operationId: createContact
summary: Create a contact
description: Creates a contact in the database. Each contact must have a valid
email address in the attributes. All attribute keys must already exist
in the environment. The email is used as the unique identifier along
description: Creates a single contact in the database. This endpoint expects
a top-level `attributes` object. For bulk uploads, use `PUT /management/contacts/bulk`,
which expects `contacts[].attributes[]` instead. Each contact must have
a valid email address in the attributes. All attribute keys must already
exist in the environment. The email is used as the unique identifier along
with the environment.
tags:
- Management API - Contacts
requestBody:
required: true
description: The contact to create. Must include an email attribute and all
attribute keys must already exist in the environment.
description: The single contact to create. Must include a top-level `attributes`
object with an email attribute, and all attribute keys must already exist
in the environment.
content:
application/json:
schema:
+231
View File
@@ -0,0 +1,231 @@
# V3 API — GET Surveys (hand-maintained; not generated by generate-api-specs).
# Implementation: apps/web/app/api/v3/surveys/route.ts
# See apps/web/app/api/v3/README.md and docs/Survey-Server-Actions.md (Part III) for full context.
openapi: 3.1.0
info:
title: Formbricks API v3
description: |
**GET /api/v3/surveys** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace environment).
**Spec location:** `docs/api-v3-reference/openapi.yml` (alongside v2 at `docs/api-v2-reference/openapi.yml`).
**workspaceId (today)**
Query param `workspaceId` is the **environment id** (survey container in the DB). The API uses the name *workspace* because the product is moving toward **Workspace** as the default container; until that exists, resolution is implemented in `workspace-context.ts` (single place to change when Environment is deprecated).
**Auth**
Authenticate with either a session cookie or **`x-api-key`**. In dual-auth mode, V3 checks the API key first when the header is present, otherwise it uses the session path. Unauthenticated callers get **401** before query validation.
**Pagination**
Cursor-based pagination with **limit** + opaque **cursor** token. Responses return `meta.nextCursor`; pass that value back as `cursor` to fetch the next page. Responses also include `meta.totalCount`, the total number of surveys matching the current filters across all pages. There is no `offset` in this contract.
**Filtering**
Filters use explicit operator-style query parameters under the **`filter[...]` family**. This endpoint supports `filter[name][contains]`, `filter[status][in]`, and `filter[type][in]`. Multi-value filters use repeated keys or comma-separated values (e.g. `filter[status][in]=draft&filter[status][in]=inProgress` or `filter[status][in]=draft,inProgress`). Sorting remains a flat `sortBy` query parameter.
**Security**
Missing/forbidden workspace returns **403** with a generic message (not **404**) so resource existence is not leaked. List responses use `private, no-store`.
**OpenAPI**
This YAML is **not** produced by `pnpm generate-api-specs` (that script only builds v2 → `docs/api-v2-reference/openapi.yml`). Update this file when the route contract changes.
**Next steps (out of scope for this spec)**
Additional v3 survey endpoints (single survey, CRUD), frontend cutover from `getSurveysAction`, optional ETag/304, field selection — see Survey-Server-Actions.md Part III.
version: 0.1.0
x-implementation-notes:
route: apps/web/app/api/v3/surveys/route.ts
query-parser: apps/web/app/api/v3/surveys/parse-v3-surveys-list-query.ts
auth: apps/web/app/api/v3/lib/auth.ts
workspace-resolution: apps/web/app/api/v3/lib/workspace-context.ts
openapi-generated: false
pagination-model: cursor
cursor-pagination: supported
paths:
/api/v3/surveys:
get:
operationId: getSurveysV3
summary: List surveys
description: Returns surveys for the workspace. Session cookie or x-api-key.
tags:
- V3 Surveys
parameters:
- in: query
name: workspaceId
required: true
schema:
type: string
format: cuid2
description: |
Workspace identifier. **Today:** pass the **environment id** (the environment that contains the surveys). When Workspace replaces Environment in the data model, clients may pass workspace ids instead; resolution is centralized in workspace-context.
- in: query
name: limit
schema:
type: integer
minimum: 1
maximum: 100
default: 20
description: Page size (max 100)
- in: query
name: cursor
schema:
type: string
description: |
Opaque cursor returned as `meta.nextCursor` from the previous page. Omit on the first request.
- in: query
name: filter[name][contains]
schema:
type: string
maxLength: 512
description: Case-insensitive substring match on survey name (same as in-app list filters).
- in: query
name: filter[status][in]
schema:
type: array
items:
type: string
enum: [draft, inProgress, paused, completed]
style: form
explode: true
description: |
Survey status filter. Repeat the parameter (`filter[status][in]=draft&filter[status][in]=inProgress`) or use comma-separated values (`filter[status][in]=draft,inProgress`). Invalid values → **400**.
- in: query
name: filter[type][in]
schema:
type: array
items:
type: string
enum: [link, app]
style: form
explode: true
description: Survey type filter (`link` / `app`). Same repeat-or-comma rules as `filter[status][in]`.
- in: query
name: sortBy
schema:
type: string
enum: [createdAt, updatedAt, name, relevance]
description: Sort order. Defaults to `updatedAt`. The `cursor` token is bound to the selected sort order.
responses:
"200":
description: Surveys retrieved successfully
headers:
X-Request-Id:
schema: { type: string }
description: Request correlation ID
Cache-Control:
schema: { type: string }
example: "private, no-store"
content:
application/json:
schema:
type: object
required: [data, meta]
properties:
data:
type: array
items:
$ref: "#/components/schemas/SurveyListItem"
meta:
type: object
required: [limit, nextCursor, totalCount]
properties:
limit: { type: integer }
nextCursor:
type: string
nullable: true
description: Opaque cursor for the next page. `null` when there are no more results.
totalCount:
type: integer
minimum: 0
description: Total number of surveys matching the current filters across all pages.
"400":
description: Bad Request
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"401":
description: Not authenticated (no valid session or API key)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"403":
description: Forbidden — no access, or workspace/environment does not exist (404 not used; avoids existence leak)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"429":
description: Rate limit exceeded
headers:
Retry-After:
schema: { type: integer }
description: Seconds until the current rate-limit window resets
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"500":
description: Internal Server Error
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
security:
- sessionAuth: []
- apiKeyAuth: []
components:
securitySchemes:
sessionAuth:
type: apiKey
in: cookie
name: next-auth.session-token
description: |
NextAuth session JWT cookie. **Development:** often `next-auth.session-token`.
**Production (HTTPS):** often `__Secure-next-auth.session-token`. Send the cookie your browser receives after sign-in.
apiKeyAuth:
type: apiKey
in: header
name: x-api-key
description: |
Management API key; must include **workspaceId** as an allowed environment with read, write, or manage permission.
schemas:
SurveyListItem:
type: object
description: |
Shape from `getSurveys` (`surveySelect` + `responseCount`). Serialized dates are ISO 8601 strings.
Legacy DB rows may include survey **type** values `website` or `web` (see Prisma); filter **type** only accepts `link` | `app`.
properties:
id: { type: string }
name: { type: string }
environmentId: { type: string }
type: { type: string, enum: [link, app, website, web] }
status:
type: string
enum: [draft, inProgress, paused, completed]
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
responseCount: { type: integer }
creator: { type: object, nullable: true, properties: { name: { type: string } } }
Problem:
type: object
description: RFC 9457 Problem Details for HTTP APIs (`application/problem+json`). Responses typically include a machine-readable `code` field alongside `title`, `status`, `detail`, and `requestId`.
required: [title, status, detail, requestId]
properties:
type: { type: string, format: uri }
title: { type: string }
status: { type: integer }
detail: { type: string }
instance: { type: string }
code:
type: string
enum: [bad_request, not_authenticated, forbidden, internal_server_error, too_many_requests]
requestId: { type: string }
details: { type: object }
invalid_params:
type: array
items:
type: object
properties:
name: { type: string }
reason: { type: string }
@@ -1,7 +0,0 @@
---
title: "Multi-language Surveys"
description: "Survey respondents in multiple-languages."
icon: "language"
---
If you'd like to survey users in multiple languages while keeping all results in the same survey, you can make use of [Multi-language Surveys](/xm-and-surveys/surveys/general-features/multi-language-surveys#multi-language-surveys)
+1 -1
View File
@@ -77,6 +77,7 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
| Pin-protected surveys | ✅ | ✅ |
| Webhooks | ✅ | ✅ |
| Email follow-ups | ✅ | ✅ |
| Multi-language surveys | ✅ | ✅ |
| Multi-language UI | ✅ | ✅ |
| All integrations (Slack, Zapier, Notion, etc.) | ✅ | ✅ |
| Domain Split Configuration | ✅ | ✅ |
@@ -85,7 +86,6 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
| Whitelabel email follow-ups | ❌ | ✅ |
| Teams & access roles | ❌ | ✅ |
| Contact management & segments | ❌ | ✅ |
| Multi-language surveys | ❌ | ✅ |
| Quota Management | ❌ | ✅ |
| Audit Logs | ❌ | ✅ |
| OIDC SSO (AzureAD, Google, OpenID) | ❌ | ✅ |
@@ -30,13 +30,14 @@ You can create webhooks either through the **Formbricks App UI** or programmatic
![Step two](https://res.cloudinary.com/dwdb9tvii/image/upload/v1738094259/j4a92z2q43twgamogpny.webp)
- Add your webhook listener endpoint & test it to make sure it can receive the test endpoint otherwise you will not be able to save it.
- Add your webhook listener endpoint and test it to make sure the endpoint is reachable and accepts `POST`
requests.
![Step three](https://res.cloudinary.com/dwdb9tvii/image/upload/v1738094617/image_kubsnz.jpg)
- Now add the triggers you want to listen to and the surveys!
- Thats it! Your webhooks will not start receiving data as soon as it arrives!
- Thats it! Your webhooks will now start receiving data as soon as it arrives!
![Step five](https://res.cloudinary.com/dwdb9tvii/image/upload/v1738094816/image_xvrel1.jpg)
@@ -44,6 +45,31 @@ You can create webhooks either through the **Formbricks App UI** or programmatic
Use our documented methods on the **Creation**, **List**, and **Deletion** endpoints of the Webhook API mentioned in our [API v2 playground](https://formbricks.com/docs/api-v2-reference/management-api-%3E-webhooks/get-webhooks).
## Testing Webhooks Locally
If you want to test a webhook consumer running on your machine before deploying it, you can expose your local
endpoint with [ngrok](https://ngrok.com/docs/universal-gateway/http).
<Steps>
<Step title="Start your local webhook listener">
Run your local endpoint on a port like `3000`.
</Step>
<Step title="Expose it with ngrok">
Create a public HTTPS URL for your local service, for example with `ngrok http http://localhost:3000`.
</Step>
<Step title="Use the public URL in Formbricks">
Paste the ngrok URL into your webhook endpoint, click **Test Endpoint**, and then save the webhook once the
endpoint is reachable.
</Step>
</Steps>
<Note>
To avoid sending unwanted test responses to production workflows, copy the survey to your [Test
Environment](/xm-and-surveys/core-features/test-environment) and use that survey copy in your development
workflow while validating the webhook setup.
</Note>
If you encounter any issues or need help setting up webhooks, feel free to reach out to us on [GitHub Discussions](https://github.com/formbricks/formbricks/discussions). 😃
---
@@ -220,49 +246,47 @@ We provide the following webhook payloads, `responseCreated`, `responseUpdated`,
Example of Response Created webhook payload:
```json
[
{
{
"data": {
"contact": null,
"contactAttributes": null,
"createdAt": "2025-07-24T07:47:29.507Z",
"data": {
"contact": null,
"contactAttributes": null,
"createdAt": "2025-07-24T07:47:29.507Z",
"data": {
"q1": "clicked"
},
"displayId": "displayId",
"endingId": null,
"finished": false,
"id": "responseId",
"language": "en",
"meta": {
"country": "DE",
"url": "https://app.formbricks.com/s/surveyId",
"userAgent": {
"browser": "Chrome",
"device": "desktop",
"os": "macOS"
}
},
"singleUseId": null,
"survey": {
"createdAt": "2025-07-20T10:30:00.000Z",
"status": "inProgress",
"title": "Customer Satisfaction Survey",
"type": "link",
"updatedAt": "2025-07-24T07:45:00.000Z"
},
"surveyId": "surveyId",
"tags": [],
"ttc": {
"q1": 2154.700000047684
},
"updatedAt": "2025-07-24T07:47:29.507Z",
"variables": {}
"welcome_card_cta": "clicked"
},
"event": "responseCreated",
"webhookId": "webhookId"
}
]
"displayId": "displayId",
"endingId": null,
"finished": false,
"id": "responseId",
"language": "en",
"meta": {
"country": "DE",
"url": "https://app.formbricks.com/s/surveyId",
"userAgent": {
"browser": "Chrome",
"device": "desktop",
"os": "macOS"
}
},
"singleUseId": null,
"survey": {
"createdAt": "2025-07-20T10:30:00.000Z",
"status": "inProgress",
"title": "Customer Satisfaction Survey",
"type": "link",
"updatedAt": "2025-07-24T07:45:00.000Z"
},
"surveyId": "surveyId",
"tags": [],
"ttc": {
"welcome_card_cta": 2154.700000047684
},
"updatedAt": "2025-07-24T07:47:29.507Z",
"variables": {}
},
"event": "responseCreated",
"webhookId": "webhookId"
}
```
### Response Updated
@@ -270,51 +294,49 @@ Example of Response Created webhook payload:
Example of Response Updated webhook payload:
```json
[
{
{
"data": {
"contact": null,
"contactAttributes": null,
"createdAt": "2025-07-24T07:47:29.507Z",
"data": {
"contact": null,
"contactAttributes": null,
"createdAt": "2025-07-24T07:47:29.507Z",
"data": {
"q1": "clicked",
"q2": "Just browsing"
},
"displayId": "displayId",
"endingId": null,
"finished": false,
"id": "responseId",
"language": "en",
"meta": {
"country": "DE",
"url": "https://app.formbricks.com/s/surveyId",
"userAgent": {
"browser": "Chrome",
"device": "desktop",
"os": "macOS"
}
},
"singleUseId": null,
"survey": {
"createdAt": "2025-07-20T10:30:00.000Z",
"status": "inProgress",
"title": "Customer Satisfaction Survey",
"type": "link",
"updatedAt": "2025-07-24T07:45:00.000Z"
},
"surveyId": "surveyId",
"tags": [],
"ttc": {
"q1": 2154.700000047684,
"q2": 3855.799999952316
},
"updatedAt": "2025-07-24T07:47:33.696Z",
"variables": {}
"visit_reason": "Just browsing",
"welcome_card_cta": "clicked"
},
"event": "responseUpdated",
"webhookId": "webhookId"
}
]
"displayId": "displayId",
"endingId": null,
"finished": false,
"id": "responseId",
"language": "en",
"meta": {
"country": "DE",
"url": "https://app.formbricks.com/s/surveyId",
"userAgent": {
"browser": "Chrome",
"device": "desktop",
"os": "macOS"
}
},
"singleUseId": null,
"survey": {
"createdAt": "2025-07-20T10:30:00.000Z",
"status": "inProgress",
"title": "Customer Satisfaction Survey",
"type": "link",
"updatedAt": "2025-07-24T07:45:00.000Z"
},
"surveyId": "surveyId",
"tags": [],
"ttc": {
"visit_reason": 3855.799999952316,
"welcome_card_cta": 2154.700000047684
},
"updatedAt": "2025-07-24T07:47:33.696Z",
"variables": {}
},
"event": "responseUpdated",
"webhookId": "webhookId"
}
```
### Response Finished
@@ -322,50 +344,48 @@ Example of Response Updated webhook payload:
Example of Response Finished webhook payload:
```json
[
{
{
"data": {
"contact": null,
"contactAttributes": null,
"createdAt": "2025-07-24T07:47:29.507Z",
"data": {
"contact": null,
"contactAttributes": null,
"createdAt": "2025-07-24T07:47:29.507Z",
"data": {
"q1": "clicked",
"q2": "accepted"
},
"displayId": "displayId",
"endingId": "endingId",
"finished": true,
"id": "responseId",
"language": "en",
"meta": {
"country": "DE",
"url": "https://app.formbricks.com/s/surveyId",
"userAgent": {
"browser": "Chrome",
"device": "desktop",
"os": "macOS"
}
},
"singleUseId": null,
"survey": {
"createdAt": "2025-07-20T10:30:00.000Z",
"status": "inProgress",
"title": "Customer Satisfaction Survey",
"type": "link",
"updatedAt": "2025-07-24T07:45:00.000Z"
},
"surveyId": "surveyId",
"tags": [],
"ttc": {
"_total": 4947.899999035763,
"q1": 2154.700000047684,
"q2": 2793.199999988079
},
"updatedAt": "2025-07-24T07:47:56.116Z",
"variables": {}
"newsletter_consent": "accepted",
"welcome_card_cta": "clicked"
},
"event": "responseFinished",
"webhookId": "webhookId"
}
]
"displayId": "displayId",
"endingId": "endingId",
"finished": true,
"id": "responseId",
"language": "en",
"meta": {
"country": "DE",
"url": "https://app.formbricks.com/s/surveyId",
"userAgent": {
"browser": "Chrome",
"device": "desktop",
"os": "macOS"
}
},
"singleUseId": null,
"survey": {
"createdAt": "2025-07-20T10:30:00.000Z",
"status": "inProgress",
"title": "Customer Satisfaction Survey",
"type": "link",
"updatedAt": "2025-07-24T07:45:00.000Z"
},
"surveyId": "surveyId",
"tags": [],
"ttc": {
"_total": 4947.899999035763,
"newsletter_consent": 2793.199999988079,
"welcome_card_cta": 2154.700000047684
},
"updatedAt": "2025-07-24T07:47:56.116Z",
"variables": {}
},
"event": "responseFinished",
"webhookId": "webhookId"
}
```
@@ -4,10 +4,6 @@ description: "Create surveys that support multiple languages using translations.
icon: "language"
---
<Note>
Multi-language surveys are part of the Formbricks [Enterprise Edition](/self-hosting/advanced/license)
</Note>
How to deliver a specific language depends on the survey type (app or link survey):
- App & Website survey: Set a `language` attribute for the user. [Read this guide for App Surveys](#app-surveys-configuration)
@@ -73,12 +73,6 @@ Use the `setLanguage` function to set the user's preferred language for surveys.
formbricks.setLanguage("de"); // ISO identifier or Alias set when creating language
```
<Note>
If a user has a language assigned, a survey has multi-language activated and it is missing a translation in
the language of the user, the survey will not be displayed. Learn more about [Multi-language Surveys](/docs/xm-and-surveys/surveys/general-features/multi-language-surveys).
</Note>
### Logging Out Users
When a user logs out of your webpage, also log them out of Formbricks to prevent activity from being linked to the wrong user. Use the logout function:
+10 -3
View File
@@ -82,15 +82,22 @@
},
"pnpm": {
"overrides": {
"axios": ">=1.12.2",
"@hono/node-server": "1.19.10",
"axios": "1.13.5",
"flatted": "3.4.2",
"hono": "4.12.4",
"@microsoft/api-extractor>minimatch": "10.2.4",
"node-forge": ">=1.3.2",
"rollup": "4.59.0",
"socket.io-parser": "4.2.6",
"tar": ">=7.5.11",
"typeorm": ">=0.3.26",
"fast-xml-parser": "5.4.2",
"undici": "7.24.0",
"fast-xml-parser": "5.5.7",
"diff": ">=8.0.3"
},
"comments": {
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | tar (CVE-2026-23745/23950/24842/26960) - awaiting @boxyhq/saml-jackson/sqlite3 dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | fast-xml-parser (CVE-2026-25896/26278) - awaiting exact upstream pin updates | diff (Dependabot #269) - awaiting upstream patch range adoption"
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: @hono/node-server/hono (Dependabot #313/#316) - awaiting Prisma update | axios (CVE-2025-58754, CVE-2026-25639) - awaiting @boxyhq/saml-jackson update | flatted (Dependabot #324/#338) - awaiting eslint/flat-cache update | minimatch (Dependabot #288/#294/#297) - awaiting react-email/glob update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | rollup (Dependabot #291) - awaiting Vite patch adoption | socket.io-parser (Dependabot #334) - awaiting react-email/socket.io update | tar (CVE-2026-23745/23950/24842/26960) - awaiting @boxyhq/saml-jackson/sqlite3 dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | undici (Dependabot #319/#322/#323) - awaiting jsdom/vitest/isomorphic-dompurify updates | fast-xml-parser (CVE-2026-25896/26278/33036/33349) - awaiting exact upstream pin updates | diff (Dependabot #269) - awaiting upstream patch range adoption"
},
"patchedDependencies": {
"next-auth@4.24.13": "patches/next-auth@4.24.13.patch"
+3 -3
View File
@@ -13,14 +13,14 @@
"clean": "rimraf .turbo node_modules dist"
},
"dependencies": {
"@react-email/components": "1.0.9",
"react-email": "5.2.9"
"@react-email/components": "1.0.10",
"react-email": "5.2.10"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@formbricks/types": "workspace:*",
"@react-email/preview-server": "5.2.9",
"@react-email/preview-server": "5.2.10",
"autoprefixer": "10.4.27",
"clsx": "2.1.1",
"postcss": "8.5.8",
+4 -1
View File
@@ -6,7 +6,7 @@ import { Logger } from "@/lib/common/logger";
import { getIsSetup, setIsSetup } from "@/lib/common/status";
import { filterSurveys, getIsDebug, isNowExpired, wrapThrows } from "@/lib/common/utils";
import { fetchEnvironmentState } from "@/lib/environment/state";
import { closeSurvey } from "@/lib/survey/widget";
import { closeSurvey, preloadSurveysScript } from "@/lib/survey/widget";
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
import { sendUpdatesToBackend } from "@/lib/user/update";
import {
@@ -316,6 +316,9 @@ export const setup = async (
addEventListeners();
addCleanupEventListeners();
// Preload surveys script so it's ready when a survey triggers
preloadSurveysScript(configInput.appUrl);
setIsSetup(true);
logger.debug("Set up complete");
@@ -70,6 +70,12 @@ vi.mock("@/lib/survey/no-code-action", () => ({
checkPageUrl: vi.fn(),
}));
// 9) Mock survey widget
vi.mock("@/lib/survey/widget", () => ({
closeSurvey: vi.fn(),
preloadSurveysScript: vi.fn(),
}));
describe("setup.ts", () => {
let getInstanceConfigMock: MockInstance<() => Config>;
let getInstanceLoggerMock: MockInstance<() => Logger>;
@@ -67,6 +67,8 @@ describe("widget-file", () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = "";
// @ts-expect-error -- cleaning up mock
delete window.formbricksSurveys;
getInstanceConfigMock = vi.spyOn(Config, "getInstance");
getInstanceLoggerMock = vi.spyOn(Logger, "getInstance").mockReturnValue(mockLogger as unknown as Logger);
@@ -464,6 +466,214 @@ describe("widget-file", () => {
expect(window.formbricksSurveys.renderSurvey).not.toHaveBeenCalled();
});
describe("loadFormbricksSurveysExternally and waitForSurveysGlobal", () => {
const scriptLoadMockConfig = {
get: vi.fn().mockReturnValue({
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
userId: "user_abc",
contactId: "contact_abc",
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
}),
update: vi.fn(),
};
// Helper to get the script element passed to document.head.appendChild
const getAppendedScript = (): Record<string, unknown> => {
// eslint-disable-next-line @typescript-eslint/unbound-method -- accessing mock for test assertions
const appendChildMock = vi.mocked(document.head.appendChild);
for (const call of appendChildMock.mock.calls) {
const el = call[0] as unknown as Record<string, unknown>;
if (typeof el.src === "string" && el.src.includes("surveys.umd.cjs")) {
return el;
}
}
throw new Error("No script element for surveys.umd.cjs was appended to document.head");
};
beforeEach(() => {
// Reset mock return values that may have been overridden by previous tests
mockUpdateQueue.hasPendingWork.mockReturnValue(false);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
});
// Test onerror first so surveysLoadPromise is reset to null for subsequent tests
test("rejects when script fails to load (onerror) and allows retry", async () => {
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
widget.setIsSurveyRunning(false);
// eslint-disable-next-line @typescript-eslint/no-empty-function -- suppress console.error in test
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const renderPromise = widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
const scriptEl = getAppendedScript();
expect(scriptEl.src).toBe("https://fake.app/js/surveys.umd.cjs");
expect(scriptEl.async).toBe(true);
// Simulate network error
(scriptEl.onerror as (error: unknown) => void)("Network error");
// renderWidget catches the error internally — it resolves, not rejects
await renderPromise;
expect(consoleSpy).toHaveBeenCalledWith("Failed to load Formbricks Surveys library:", "Network error");
consoleSpy.mockRestore();
});
test("rejects when script loads but surveys global never becomes available (timeout)", async () => {
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
widget.setIsSurveyRunning(false);
// eslint-disable-next-line @typescript-eslint/no-empty-function -- suppress console.error in test
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.useFakeTimers();
const renderPromise = widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
const scriptEl = getAppendedScript();
// Script loaded but window.formbricksSurveys is never set
(scriptEl.onload as () => void)();
// Advance past the 10s timeout (polls every 200ms)
await vi.advanceTimersByTimeAsync(10001);
await renderPromise;
expect(consoleSpy).toHaveBeenCalledWith(
"Failed to load Formbricks Surveys library:",
expect.any(Error)
);
vi.useRealTimers();
consoleSpy.mockRestore();
});
test("resolves after polling when surveys global becomes available and applies stored nonce", async () => {
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
widget.setIsSurveyRunning(false);
// Set nonce before surveys load to test nonce application
window.__formbricksNonce = "test-nonce-123";
vi.useFakeTimers();
const renderPromise = widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
const scriptEl = getAppendedScript();
// Simulate script loaded
(scriptEl.onload as () => void)();
// Set the global after script "loads" — simulates browser finishing execution
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
// Advance one polling interval for waitForSurveysGlobal to find it
await vi.advanceTimersByTimeAsync(200);
await renderPromise;
// Run remaining timers for survey.delay setTimeout
vi.runAllTimers();
expect(window.formbricksSurveys.setNonce).toHaveBeenCalledWith("test-nonce-123");
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
appUrl: "https://fake.app",
environmentId: "env_123",
contactId: "contact_abc",
})
);
vi.useRealTimers();
delete window.__formbricksNonce;
});
test("deduplicates concurrent calls (returns cached promise)", async () => {
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
widget.setIsSurveyRunning(false);
// After the previous successful test, surveysLoadPromise holds a resolved promise.
// Calling renderWidget again (without formbricksSurveys on window, but with cached promise)
// should reuse the cached promise rather than creating a new script element.
// @ts-expect-error -- cleaning up mock to force dedup path
delete window.formbricksSurveys;
const appendChildSpy = vi.spyOn(document.head, "appendChild");
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
vi.advanceTimersByTime(0);
// No new script element should have been appended (dedup via early return or cached promise)
const scriptAppendCalls = appendChildSpy.mock.calls.filter((call: unknown[]) => {
const el = call[0] as Record<string, unknown> | undefined;
return typeof el?.src === "string" && el.src.includes("surveys.umd.cjs");
});
expect(scriptAppendCalls.length).toBe(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
vi.useRealTimers();
});
});
test("preloadSurveysScript adds a preload link and deduplicates subsequent calls", () => {
const createElementSpy = vi.spyOn(document, "createElement");
const appendChildSpy = vi.spyOn(document.head, "appendChild");
widget.preloadSurveysScript("https://fake.app");
expect(createElementSpy).toHaveBeenCalledWith("link");
expect(appendChildSpy).toHaveBeenCalledTimes(1);
const linkEl = createElementSpy.mock.results[0].value as Record<string, string>;
expect(linkEl.rel).toBe("preload");
expect(linkEl.as).toBe("script");
expect(linkEl.href).toBe("https://fake.app/js/surveys.umd.cjs");
// Second call should be a no-op (deduplication)
widget.preloadSurveysScript("https://fake.app");
expect(appendChildSpy).toHaveBeenCalledTimes(1);
});
test("renderWidget proceeds when identification fails but survey has no segment filters", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);
+84 -19
View File
@@ -106,7 +106,15 @@ export const renderWidget = async (
const overlay = projectOverwrites.overlay ?? project.overlay;
const placement = projectOverwrites.placement ?? project.placement;
const isBrandingEnabled = project.inAppSurveyBranding;
const formbricksSurveys = await loadFormbricksSurveysExternally();
let formbricksSurveys: TFormbricksSurveys;
try {
formbricksSurveys = await loadFormbricksSurveysExternally();
} catch (error) {
logger.error(`Failed to load surveys library: ${String(error)}`);
setIsSurveyRunning(false);
return;
}
const recaptchaSiteKey = config.get().environment.data.recaptchaSiteKey;
const isSpamProtectionEnabled = Boolean(recaptchaSiteKey && survey.recaptcha?.enabled);
@@ -219,30 +227,87 @@ export const removeWidgetContainer = (): void => {
document.getElementById(CONTAINER_ID)?.remove();
};
const loadFormbricksSurveysExternally = (): Promise<typeof globalThis.window.formbricksSurveys> => {
const config = Config.getInstance();
const SURVEYS_LOAD_TIMEOUT_MS = 10000;
const SURVEYS_POLL_INTERVAL_MS = 200;
type TFormbricksSurveys = typeof globalThis.window.formbricksSurveys;
let surveysLoadPromise: Promise<TFormbricksSurveys> | null = null;
const waitForSurveysGlobal = (): Promise<TFormbricksSurveys> => {
return new Promise((resolve, reject) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- We need to check if the formbricksSurveys object exists
if (globalThis.window.formbricksSurveys) {
resolve(globalThis.window.formbricksSurveys);
} else {
const script = document.createElement("script");
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
script.async = true;
script.onload = () => {
// Apply stored nonce if it was set before surveys package loaded
const startTime = Date.now();
const check = (): void => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
if (globalThis.window.formbricksSurveys) {
const storedNonce = globalThis.window.__formbricksNonce;
if (storedNonce) {
globalThis.window.formbricksSurveys.setNonce(storedNonce);
}
resolve(globalThis.window.formbricksSurveys);
};
script.onerror = (error) => {
console.error("Failed to load Formbricks Surveys library:", error);
reject(new Error(`Failed to load Formbricks Surveys library: ${error as string}`));
};
document.head.appendChild(script);
}
return;
}
if (Date.now() - startTime >= SURVEYS_LOAD_TIMEOUT_MS) {
reject(new Error("Formbricks Surveys library did not become available within timeout"));
return;
}
setTimeout(check, SURVEYS_POLL_INTERVAL_MS);
};
check();
});
};
const loadFormbricksSurveysExternally = (): Promise<TFormbricksSurveys> => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
if (globalThis.window.formbricksSurveys) {
return Promise.resolve(globalThis.window.formbricksSurveys);
}
if (surveysLoadPromise) {
return surveysLoadPromise;
}
surveysLoadPromise = new Promise<TFormbricksSurveys>((resolve, reject: (error: unknown) => void) => {
const config = Config.getInstance();
const script = document.createElement("script");
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
script.async = true;
script.onload = () => {
waitForSurveysGlobal()
.then(resolve)
.catch((error: unknown) => {
surveysLoadPromise = null;
console.error("Failed to load Formbricks Surveys library:", error);
reject(new Error(`Failed to load Formbricks Surveys library`));
});
};
script.onerror = (error) => {
surveysLoadPromise = null;
console.error("Failed to load Formbricks Surveys library:", error);
reject(new Error(`Failed to load Formbricks Surveys library`));
};
document.head.appendChild(script);
});
return surveysLoadPromise;
};
let isPreloaded = false;
export const preloadSurveysScript = (appUrl: string): void => {
// Don't preload if already loaded or already preloading
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
if (globalThis.window.formbricksSurveys) return;
if (isPreloaded) return;
isPreloaded = true;
const link = document.createElement("link");
link.rel = "preload";
link.as = "script";
link.href = `${appUrl}/js/surveys.umd.cjs`;
document.head.appendChild(link);
};
@@ -93,8 +93,9 @@ function CTA({
type="button"
onClick={handleButtonClick}
disabled={disabled}
className="flex items-center gap-2"
variant={buttonVariant}>
className="text-button font-button-weight flex items-center gap-2"
variant={buttonVariant}
size={"custom"}>
{buttonLabel}
<SquareArrowOutUpRightIcon className="size-4" />
</Button>
@@ -8,6 +8,11 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/general/dropdown-menu";
import {
DropdownSearchInput,
SEARCH_THRESHOLD,
useDropdownSearch,
} from "@/components/general/dropdown-search";
import { ElementError } from "@/components/general/element-error";
import { ElementHeader } from "@/components/general/element-header";
import { Input } from "@/components/general/input";
@@ -73,6 +78,10 @@ interface MultiSelectProps {
imageUrl?: string;
/** Video URL to display above the headline */
videoUrl?: string;
/** Placeholder text for the search input in dropdown mode */
searchPlaceholder?: string;
/** Message shown when search yields no results */
searchNoResultsText?: string;
}
// Shared className for option labels
@@ -109,6 +118,8 @@ interface DropdownVariantProps {
dir: TextDirection;
otherInputRef: React.RefObject<HTMLInputElement | null>;
required: boolean;
searchPlaceholder: string;
searchNoResultsText: string;
}
function DropdownVariant({
@@ -131,6 +142,8 @@ function DropdownVariant({
dir,
otherInputRef,
required,
searchPlaceholder,
searchNoResultsText,
}: Readonly<DropdownVariantProps>): React.JSX.Element {
const handleOptionToggle = (optionId: string) => {
if (selectedValues.includes(optionId)) {
@@ -140,10 +153,33 @@ function DropdownVariant({
}
};
// Search + side-locking
const allDropdownOptionCount = options.length + (hasOtherOption ? 1 : 0);
const showSearch = allDropdownOptionCount > SEARCH_THRESHOLD;
const {
searchQuery,
setSearchQuery,
searchInputRef,
lockedSide,
contentRef,
noneOption,
noneMatchesSearch,
filteredRegularOptions,
otherMatchesSearch,
hasNoResults,
handleDropdownOpen,
handleDropdownClose,
} = useDropdownSearch({ options, hasOtherOption, otherOptionLabel, isSearchEnabled: showSearch });
return (
<div>
<ElementError errorMessage={errorMessage} dir={dir} />
<DropdownMenu>
<DropdownMenu
onOpenChange={(open) => {
if (open) handleDropdownOpen();
else handleDropdownClose();
}}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
@@ -156,53 +192,22 @@ function DropdownVariant({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="bg-option-bg max-h-[300px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto"
ref={contentRef}
side={lockedSide}
avoidCollisions={lockedSide === undefined}
className="bg-option-bg border-input-border w-(--radix-dropdown-menu-trigger-width) overflow-hidden"
align="start">
{options
.filter((option) => option.id !== "none")
.map((option) => {
const isChecked = selectedValues.includes(option.id);
const optionId = `${inputId}-${option.id}`;
return (
<DropdownMenuCheckboxItem
key={option.id}
id={optionId}
dir={dir}
checked={isChecked}
onCheckedChange={() => {
handleOptionToggle(option.id);
}}
onSelect={(e) => {
e.preventDefault();
}}
disabled={disabled}>
<span className="font-input font-input-weight text-input-text">{option.label}</span>
</DropdownMenuCheckboxItem>
);
})}
{hasOtherOption && otherOptionId ? (
<DropdownMenuCheckboxItem
id={`${inputId}-${otherOptionId}`}
{showSearch ? (
<DropdownSearchInput
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchInputRef={searchInputRef}
placeholder={searchPlaceholder}
dir={dir}
checked={isOtherSelected}
onCheckedChange={() => {
if (isOtherSelected) {
handleOptionRemove(otherOptionId);
} else {
handleOptionAdd(otherOptionId);
}
}}
onSelect={(e) => {
e.preventDefault();
}}
disabled={disabled}>
<span className="font-input font-input-weight text-input-text">{otherOptionLabel}</span>
</DropdownMenuCheckboxItem>
/>
) : null}
{options
.filter((option) => option.id === "none")
.map((option) => {
<div className="max-h-[260px] overflow-y-auto">
{filteredRegularOptions.map((option) => {
const isChecked = selectedValues.includes(option.id);
const optionId = `${inputId}-${option.id}`;
@@ -223,6 +228,47 @@ function DropdownVariant({
</DropdownMenuCheckboxItem>
);
})}
{otherMatchesSearch && otherOptionId ? (
<DropdownMenuCheckboxItem
id={`${inputId}-${otherOptionId}`}
dir={dir}
checked={isOtherSelected}
onCheckedChange={() => {
if (isOtherSelected) {
handleOptionRemove(otherOptionId);
} else {
handleOptionAdd(otherOptionId);
}
}}
onSelect={(e) => {
e.preventDefault();
}}
disabled={disabled}>
<span className="font-input font-input-weight text-input-text">{otherOptionLabel}</span>
</DropdownMenuCheckboxItem>
) : null}
{noneOption && noneMatchesSearch ? (
<DropdownMenuCheckboxItem
key={noneOption.id}
id={`${inputId}-${noneOption.id}`}
dir={dir}
checked={selectedValues.includes(noneOption.id)}
onCheckedChange={() => {
handleOptionToggle(noneOption.id);
}}
onSelect={(e) => {
e.preventDefault();
}}
disabled={disabled}>
<span className="font-input font-input-weight text-input-text">{noneOption.label}</span>
</DropdownMenuCheckboxItem>
) : null}
{hasNoResults ? (
<div className="text-input-placeholder px-2 py-4 text-center text-sm">
{searchNoResultsText}
</div>
) : null}
</div>
</DropdownMenuContent>
</DropdownMenu>
{isOtherSelected ? (
@@ -423,6 +469,8 @@ function MultiSelect({
exclusiveOptionIds = [],
imageUrl,
videoUrl,
searchPlaceholder = "Search...",
searchNoResultsText = "No results found",
}: Readonly<MultiSelectProps>): React.JSX.Element {
// Ensure value is always an array
const selectedValues = Array.isArray(value) ? value : [];
@@ -514,6 +562,8 @@ function MultiSelect({
dir={dir}
otherInputRef={otherInputRef}
required={required}
searchPlaceholder={searchPlaceholder}
searchNoResultsText={searchNoResultsText}
/>
) : (
<ListVariant
@@ -8,6 +8,11 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/general/dropdown-menu";
import {
DropdownSearchInput,
SEARCH_THRESHOLD,
useDropdownSearch,
} from "@/components/general/dropdown-search";
import { ElementError } from "@/components/general/element-error";
import { ElementHeader } from "@/components/general/element-header";
import { Input } from "@/components/general/input";
@@ -45,7 +50,7 @@ interface SingleSelectProps {
requiredLabel?: string;
/** Error message to display below the options */
errorMessage?: string;
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-right), or 'auto' (auto-detect from content) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the options are disabled */
disabled?: boolean;
@@ -67,6 +72,10 @@ interface SingleSelectProps {
imageUrl?: string;
/** Video URL to display above the headline */
videoUrl?: string;
/** Placeholder text for the search input in dropdown mode */
searchPlaceholder?: string;
/** Message shown when search yields no results */
searchNoResultsText?: string;
}
function SingleSelect({
@@ -91,6 +100,8 @@ function SingleSelect({
onOtherValueChange,
imageUrl,
videoUrl,
searchPlaceholder = "Search...",
searchNoResultsText = "No results found",
}: Readonly<SingleSelectProps>): React.JSX.Element {
// Ensure value is always a string or undefined
const selectedValue = value ?? undefined;
@@ -98,6 +109,25 @@ function SingleSelect({
const isOtherSelected = hasOtherOption && selectedValue === otherOptionId;
const otherInputRef = React.useRef<HTMLInputElement>(null);
// Search + side-locking for the dropdown variant
const allDropdownOptionCount = options.length + (hasOtherOption ? 1 : 0);
const showSearch = variant === "dropdown" && allDropdownOptionCount > SEARCH_THRESHOLD;
const {
searchQuery,
setSearchQuery,
searchInputRef,
lockedSide,
contentRef,
noneOption,
noneMatchesSearch,
filteredRegularOptions,
otherMatchesSearch,
hasNoResults,
handleDropdownOpen,
handleDropdownClose,
} = useDropdownSearch({ options, hasOtherOption, otherOptionLabel, isSearchEnabled: showSearch });
React.useEffect(() => {
if (!isOtherSelected || disabled) return;
@@ -155,7 +185,11 @@ function SingleSelect({
{variant === "dropdown" ? (
<>
<ElementError errorMessage={errorMessage} dir={dir} />
<DropdownMenu>
<DropdownMenu
onOpenChange={(open) => {
if (open) handleDropdownOpen();
else handleDropdownClose();
}}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
@@ -168,12 +202,23 @@ function SingleSelect({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="bg-option-bg max-h-[300px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto"
ref={contentRef}
side={lockedSide}
avoidCollisions={lockedSide === undefined}
className="bg-option-bg border-input-border w-(--radix-dropdown-menu-trigger-width) overflow-hidden"
align="start">
<DropdownMenuRadioGroup value={selectedValue} onValueChange={onChange}>
{options
.filter((option) => option.id !== "none")
.map((option) => {
{showSearch ? (
<DropdownSearchInput
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchInputRef={searchInputRef}
placeholder={searchPlaceholder}
dir={dir}
/>
) : null}
<div className="max-h-[260px] overflow-y-auto">
<DropdownMenuRadioGroup value={selectedValue} onValueChange={onChange}>
{filteredRegularOptions.map((option) => {
const optionId = `${inputId}-${option.id}`;
return (
@@ -187,34 +232,36 @@ function SingleSelect({
</DropdownMenuRadioItem>
);
})}
{hasOtherOption && otherOptionId ? (
<DropdownMenuRadioItem
value={otherOptionId}
id={`${inputId}-${otherOptionId}`}
dir={dir}
disabled={disabled}>
<span className="font-input font-input-weight text-input-text">
{otherValue || otherOptionLabel}
</span>
</DropdownMenuRadioItem>
) : null}
{options
.filter((option) => option.id === "none")
.map((option) => {
const optionId = `${inputId}-${option.id}`;
return (
<DropdownMenuRadioItem
key={option.id}
value={option.id}
id={optionId}
dir={dir}
disabled={disabled}>
<span className="font-input font-input-weight text-input-text">{option.label}</span>
</DropdownMenuRadioItem>
);
})}
</DropdownMenuRadioGroup>
{otherMatchesSearch && otherOptionId ? (
<DropdownMenuRadioItem
value={otherOptionId}
id={`${inputId}-${otherOptionId}`}
dir={dir}
disabled={disabled}>
<span className="font-input font-input-weight text-input-text">
{otherValue || otherOptionLabel}
</span>
</DropdownMenuRadioItem>
) : null}
{noneOption && noneMatchesSearch ? (
<DropdownMenuRadioItem
key={noneOption.id}
value={noneOption.id}
id={`${inputId}-${noneOption.id}`}
dir={dir}
disabled={disabled}>
<span className="font-input font-input-weight text-input-text">
{noneOption.label}
</span>
</DropdownMenuRadioItem>
) : null}
{hasNoResults ? (
<div className="text-input-placeholder px-2 py-4 text-center text-sm">
{searchNoResultsText}
</div>
) : null}
</DropdownMenuRadioGroup>
</div>
</DropdownMenuContent>
</DropdownMenu>
{isOtherSelected ? (
@@ -23,6 +23,7 @@ const buttonVariants = cva(
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
custom: "",
},
},
defaultVariants: {
@@ -0,0 +1,143 @@
import { Search } from "lucide-react";
import * as React from "react";
/** Number of options above which the search input is shown inside the dropdown */
export const SEARCH_THRESHOLD = 3;
interface UseDropdownSearchOptions<T extends { id: string; label: string }> {
options: T[];
hasOtherOption: boolean;
otherOptionLabel: string;
isSearchEnabled: boolean;
}
/**
* Shared hook that encapsulates search filtering, "none"-option separation,
* and side-locking logic used by both single-select and multi-select dropdowns.
*/
export function useDropdownSearch<T extends { id: string; label: string }>({
options,
hasOtherOption,
otherOptionLabel,
isSearchEnabled,
}: UseDropdownSearchOptions<T>) {
const [searchQuery, setSearchQuery] = React.useState("");
const searchInputRef = React.useRef<HTMLInputElement>(null);
// Lock the dropdown side (top/bottom) so it doesn't jump when search filters shrink the content
const [lockedSide, setLockedSide] = React.useState<"top" | "bottom" | undefined>(undefined);
const contentRef = React.useRef<HTMLDivElement>(null);
// Separate "none" option from regular options so it renders at the bottom of the list
const noneOption = React.useMemo(() => options.find((opt) => opt.id === "none"), [options]);
const regularOptions = React.useMemo(() => options.filter((opt) => opt.id !== "none"), [options]);
// Filtered regular options based on the search query
const filteredRegularOptions = React.useMemo(() => {
if (!isSearchEnabled || !searchQuery) return regularOptions;
const lowerQuery = searchQuery.toLowerCase();
return regularOptions.filter((opt) => opt.label.toLowerCase().includes(lowerQuery));
}, [isSearchEnabled, searchQuery, regularOptions]);
// Whether the "other" option matches the search
const otherMatchesSearch = React.useMemo(() => {
if (!hasOtherOption) return false;
if (!isSearchEnabled || !searchQuery) return true;
return otherOptionLabel.toLowerCase().includes(searchQuery.toLowerCase());
}, [isSearchEnabled, searchQuery, hasOtherOption, otherOptionLabel]);
// Whether the "none" option matches the search
const noneMatchesSearch = React.useMemo(() => {
if (!noneOption) return false;
if (!isSearchEnabled || !searchQuery) return true;
return noneOption.label.toLowerCase().includes(searchQuery.toLowerCase());
}, [isSearchEnabled, searchQuery, noneOption]);
const hasNoResults =
isSearchEnabled && filteredRegularOptions.length === 0 && !otherMatchesSearch && !noneMatchesSearch;
const focusSearchAndLockSide = (): void => {
searchInputRef.current?.focus();
const side = contentRef.current?.dataset.side;
if (side === "top" || side === "bottom") setLockedSide(side);
};
const handleDropdownOpen = (): void => {
if (isSearchEnabled) {
// Double-defer to win against Radix focus management
globalThis.setTimeout(() => {
globalThis.requestAnimationFrame(focusSearchAndLockSide);
}, 0);
}
};
const handleDropdownClose = (): void => {
setSearchQuery("");
setLockedSide(undefined);
};
return {
searchQuery,
setSearchQuery,
searchInputRef,
lockedSide,
contentRef,
noneOption,
noneMatchesSearch,
filteredRegularOptions,
otherMatchesSearch,
hasNoResults,
handleDropdownOpen,
handleDropdownClose,
};
}
interface DropdownSearchInputProps {
searchQuery: string;
setSearchQuery: (query: string) => void;
searchInputRef: React.RefObject<HTMLInputElement | null>;
placeholder: string;
dir?: string;
}
/**
* Search input rendered at the top of a searchable dropdown.
*/
export function DropdownSearchInput({
searchQuery,
setSearchQuery,
searchInputRef,
placeholder,
dir,
}: Readonly<DropdownSearchInputProps>): React.JSX.Element {
return (
<div className="border-option-border border-b pb-0.5" role="search">
<div className="relative flex items-center">
<Search className="text-input-text pointer-events-none absolute left-1.5 h-4 w-4 shrink-0" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
}}
placeholder={placeholder}
dir={dir}
onKeyDown={(e) => {
if (e.key === "Escape") {
if (searchQuery) {
e.stopPropagation();
setSearchQuery("");
}
} else if (e.key !== "ArrowDown" && e.key !== "ArrowUp") {
e.stopPropagation();
}
}}
className="bg-input-bg text-input-text placeholder:text-input-placeholder font-input font-input-weight h-9 w-full rounded-sm pr-3 pl-8 text-sm outline-none"
aria-label={placeholder}
autoComplete="off"
/>
</div>
</div>
);
}
+7 -4
View File
@@ -4,11 +4,14 @@ import { extendTailwindMerge } from "tailwind-merge";
const twMerge = extendTailwindMerge({
extend: {
// Custom tokens from `packages/survey-ui/tailwind.config.ts`
fontSize: ["input", "option", "button"],
textColor: ["input-text", "input-placeholder", "option-label", "button-text"],
classGroups: {
// Custom tokens from `packages/survey-ui/tailwind.config.ts`
"font-size": ["text-input", "text-option", "text-button"],
"font-weight": ["font-input-weight", "font-option-weight", "font-button-weight"],
"text-color": ["text-input-text", "text-input-placeholder", "text-option-label", "text-button-text"],
},
},
} as Parameters<typeof extendTailwindMerge>[0]);
});
/**
* Utility function to merge Tailwind CSS classes
+1 -1
View File
@@ -64,7 +64,7 @@ packages/surveys/
```bash
# packages/surveys/.env
LINGODOTDEV_API_KEY=<YOUR_API_KEY>
LINGO_API_KEY=<YOUR_API_KEY>
```
4. **Generate Translations**
+2
View File
@@ -9,6 +9,7 @@
"finish": "إنهاء",
"language_switch": "تبديل اللغة",
"next": "التالي",
"no_results_found": "لم يتم العثور على نتائج",
"open_in_new_tab": "فتح في علامة تبويب جديدة",
"people_responded": "{count, plural, one {شخص واحد استجاب} two {شخصان استجابا} few {{count} أشخاص استجابوا} many {{count} شخصًا استجابوا} other {{count} شخص استجابوا}}",
"please_retry_now_or_try_again_later": "يرجى إعادة المحاولة الآن أو المحاولة مرة أخرى لاحقًا.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "لن يرى المستجيبون هذه البطاقة",
"retry": "إعادة المحاولة",
"retrying": "إعادة المحاولة...",
"search": "بحث...",
"select_option": "اختر خيارًا",
"select_options": "اختر الخيارات",
"sending_responses": "جارٍ إرسال الردود...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Afslut",
"language_switch": "Sprogskift",
"next": "Næste",
"no_results_found": "Ingen resultater fundet",
"open_in_new_tab": "Åbn i ny fane",
"people_responded": "{count, plural, one {1 person har svaret} other {{count} personer har svaret}}",
"please_retry_now_or_try_again_later": "Prøv igen nu eller prøv senere.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "Respondenter vil ikke se dette kort",
"retry": "Prøv igen",
"retrying": "Prøver igen…",
"search": "Søg...",
"select_option": "Vælg en mulighed",
"select_options": "Vælg muligheder",
"sending_responses": "Sender svar…",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Fertig",
"language_switch": "Sprachwechsel",
"next": "Weiter",
"no_results_found": "Keine Ergebnisse gefunden",
"open_in_new_tab": "In neuem Tab öffnen",
"people_responded": "{count, plural, one {1 Person hat geantwortet} other {{count} Personen haben geantwortet}}",
"please_retry_now_or_try_again_later": "Bitte versuchen Sie es jetzt erneut oder später noch einmal.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "Befragte werden diese Karte nicht sehen",
"retry": "Wiederholen",
"retrying": "Erneuter Versuch...",
"search": "Suchen...",
"select_option": "Wähle eine Option",
"select_options": "Wähle Optionen",
"sending_responses": "Antworten werden gesendet...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Finish",
"language_switch": "Language switch",
"next": "Next",
"no_results_found": "No results found",
"open_in_new_tab": "Open in new tab",
"people_responded": "{count, plural, one {1 person responded} other {{count} people responded}}",
"please_retry_now_or_try_again_later": "Please retry now or try again later.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "Respondents will not see this card",
"retry": "Retry",
"retrying": "Retrying…",
"search": "Search...",
"select_option": "Select an option",
"select_options": "Select options",
"sending_responses": "Sending responses…",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Finalizar",
"language_switch": "Cambio de idioma",
"next": "Siguiente",
"no_results_found": "No se encontraron resultados",
"open_in_new_tab": "Abrir en nueva pestaña",
"people_responded": "{count, plural, one {1 persona respondió} other {{count} personas respondieron}}",
"please_retry_now_or_try_again_later": "Por favor, inténtalo ahora o prueba más tarde.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "Los encuestados no verán esta tarjeta",
"retry": "Reintentar",
"retrying": "Reintentando...",
"search": "Buscar...",
"select_option": "Selecciona una opción",
"select_options": "Selecciona opciones",
"sending_responses": "Enviando respuestas...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Terminer",
"language_switch": "Changement de langue",
"next": "Suivant",
"no_results_found": "Aucun résultat trouvé",
"open_in_new_tab": "Ouvrir dans un nouvel onglet",
"people_responded": "{count, plural, one {1 personne a répondu} other {{count} personnes ont répondu}}",
"please_retry_now_or_try_again_later": "Veuillez réessayer maintenant ou réessayer plus tard.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "Les répondants ne verront pas cette carte",
"retry": "Réessayer",
"retrying": "Nouvelle tentative...",
"search": "Rechercher...",
"select_option": "Sélectionner une option",
"select_options": "Sélectionner des options",
"sending_responses": "Envoi des réponses...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "समाप्त करें",
"language_switch": "भाषा बदलें",
"next": "अगला",
"no_results_found": "कोई परिणाम नहीं मिला",
"open_in_new_tab": "नए टैब में खोलें",
"people_responded": "{count, plural, one {1 व्यक्ति ने जवाब दिया} other {{count} लोगों ने जवाब दिया}}",
"please_retry_now_or_try_again_later": "कृपया अभी पुनः प्रयास करें या बाद में फिर से प्रयास करें।",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "उत्तरदाता इस कार्ड को नहीं देखेंगे",
"retry": "पुनः प्रयास करें",
"retrying": "पुनः प्रयास कर रहे हैं...",
"search": "खोजें...",
"select_option": "एक विकल्प चुनें",
"select_options": "विकल्प चुनें",
"sending_responses": "प्रतिक्रियाएँ भेज रहे हैं...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Befejezés",
"language_switch": "Nyelvválasztó",
"next": "Következő",
"no_results_found": "Nincs találat",
"open_in_new_tab": "Megnyitás új lapon",
"people_responded": "{count, plural, one {1 személy válaszolt} other {{count} személy válaszolt}}",
"please_retry_now_or_try_again_later": "Próbálkozzon újra most, vagy próbálja meg később újra.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "A válaszadók nem fogják látni ezt a kártyát",
"retry": "Újrapróbálkozás",
"retrying": "Újrapróbálkozás…",
"search": "Keresés...",
"select_option": "Lehetőség kiválasztása",
"select_options": "Lehetőségek kiválasztása",
"sending_responses": "Válaszok küldése…",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Fine",
"language_switch": "Cambio lingua",
"next": "Avanti",
"no_results_found": "Nessun risultato trovato",
"open_in_new_tab": "Apri in una nuova scheda",
"people_responded": "{count, plural, one {1 persona ha risposto} other {{count} persone hanno risposto}}",
"please_retry_now_or_try_again_later": "Riprova ora o più tardi.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "I rispondenti non vedranno questa scheda",
"retry": "Riprova",
"retrying": "Riprovando...",
"search": "Cerca...",
"select_option": "Seleziona un'opzione",
"select_options": "Seleziona opzioni",
"sending_responses": "Invio risposte in corso...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "完了",
"language_switch": "言語切替",
"next": "次へ",
"no_results_found": "結果が見つかりません",
"open_in_new_tab": "新しいタブで開く",
"people_responded": "{count, plural, other {{count}人が回答しました}}",
"please_retry_now_or_try_again_later": "今すぐ再試行するか、後でもう一度お試しください。",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "回答者はこのカードを見ることができません",
"retry": "再試行",
"retrying": "再試行中...",
"search": "検索...",
"select_option": "オプションを選択",
"select_options": "オプションを選択",
"sending_responses": "回答を送信中...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Voltooien",
"language_switch": "Taalschakelaar",
"next": "Volgende",
"no_results_found": "Geen resultaten gevonden",
"open_in_new_tab": "Openen in nieuw tabblad",
"people_responded": "{count, plural, one {1 persoon heeft gereageerd} other {{count} mensen hebben gereageerd}}",
"please_retry_now_or_try_again_later": "Probeer het nu opnieuw of probeer het later opnieuw.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "Respondenten zien deze kaart niet",
"retry": "Opnieuw proberen",
"retrying": "Opnieuw proberen...",
"search": "Zoeken...",
"select_option": "Selecteer een optie",
"select_options": "Selecteer opties",
"sending_responses": "Reacties verzenden...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Finalizar",
"language_switch": "Alternar idioma",
"next": "Próximo",
"no_results_found": "Nenhum resultado encontrado",
"open_in_new_tab": "Abrir em nova aba",
"people_responded": "{count, plural, one {1 pessoa respondeu} other {{count} pessoas responderam}}",
"please_retry_now_or_try_again_later": "Por favor, tente novamente agora ou mais tarde.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "Os respondentes não verão este cartão",
"retry": "Tentar novamente",
"retrying": "Tentando novamente...",
"search": "Pesquisar...",
"select_option": "Selecione uma opção",
"select_options": "Selecione opções",
"sending_responses": "Enviando respostas...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Finalizează",
"language_switch": "Schimbare limbă",
"next": "Următorul",
"no_results_found": "Nu s-au găsit rezultate",
"open_in_new_tab": "Deschide într-o filă nouă",
"people_responded": "{count, plural, one {1 persoană a răspuns} other {{count} persoane au răspuns}}",
"please_retry_now_or_try_again_later": "Te rugăm să încerci din nou acum sau mai târziu.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "Respondenții nu vor vedea acest card",
"retry": "Reîncearcă",
"retrying": "Se reîncearcă...",
"search": "Căutare...",
"select_option": "Selectează o opțiune",
"select_options": "Selectează opțiuni",
"sending_responses": "Trimiterea răspunsurilor...",

Some files were not shown because too many files have changed in this diff Show More