Compare commits

...

6 Commits

Author SHA1 Message Date
Tiago Farto 892c4e3b5f feat: extending v3 api 2026-04-16 14:56:56 +00:00
Dhruwang Jariwala e6f347aa07 fix: remove dark: variant classes from survey-ui to prevent host page style leakage (#7747) 2026-04-16 05:50:46 +00:00
Dhruwang Jariwala 367bc23dd4 fix: prevent offline replay from dropping survey blocks after completion (#7743) 2026-04-15 19:59:15 +00:00
XHamzaX a1a11b2bb8 fix: prevent OIDC button text overlap with 'last used' indicator (#7731)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-15 09:42:20 +00:00
Marius 0653c6a59f fix: strip @layer properties block to prevent host page CSS pollution (#7685)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 06:58:35 +00:00
Anshuman Pandey b6d793e109 fix: fixes unique constraint error with singleUseId and surveyId (#7737) 2026-04-15 06:50:20 +00:00
35 changed files with 2861 additions and 222 deletions
@@ -2,7 +2,7 @@ import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -175,10 +175,34 @@ describe("createResponse V2", () => {
).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
test("should throw UniqueConstraintError on P2002 with singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["surveyId", "singleUseId"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(UniqueConstraintError);
});
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["displayId"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(DatabaseError);
});
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
clientVersion: "test",
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
@@ -2,7 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -129,6 +129,13 @@ export const createResponse = async (
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
const target = (error.meta?.target as string[]) ?? [];
if (target?.includes("singleUseId")) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
}
throw new DatabaseError(error.message);
}
@@ -1,6 +1,6 @@
import { UAParser } from "ua-parser-js";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { InvalidInputError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
@@ -177,6 +177,10 @@ const createResponseForRequest = async ({
return responses.badRequestResponse(error.message, undefined, true);
}
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message, undefined, true);
}
const response = getUnexpectedPublicErrorResponse();
reportApiError({
request,
@@ -25,6 +25,14 @@ vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
@@ -321,4 +329,67 @@ describe("withV3ApiWrapper", () => {
expect(body.code).toBe("internal_server_error");
expect(body.requestId).toBe("req-boom");
});
test("reports handled non-ok responses and queues audit logs when configured", async () => {
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
const { reportApiError } = await import("@/app/lib/api/api-error-reporter");
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
const wrapped = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
handler: async ({ auditLog }) => {
if (auditLog) {
auditLog.organizationId = "org_1";
auditLog.targetId = "survey_1";
}
return new Response(null, { status: 204 });
},
});
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys/survey_1"), {} as never);
expect(response.status).toBe(204);
expect(vi.mocked(reportApiError)).not.toHaveBeenCalled();
expect(vi.mocked(queueAuditEvent)).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "survey_1",
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
})
);
});
test("reports handler error responses through reportApiError", async () => {
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
const { reportApiError } = await import("@/app/lib/api/api-error-reporter");
const wrapped = withV3ApiWrapper({
auth: "both",
handler: async () => new Response(JSON.stringify({ error: true }), { status: 403 }),
});
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
expect(response.status).toBe(403);
expect(vi.mocked(reportApiError)).toHaveBeenCalledWith(
expect.objectContaining({
status: 403,
apiVersion: "v3",
})
);
});
});
+117 -11
View File
@@ -4,10 +4,14 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging";
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 { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import type { TAuditAction, TAuditTarget } from "@/modules/ee/audit-logs/types/audit-log";
import {
type InvalidParam,
problemBadRequest,
@@ -15,7 +19,7 @@ import {
problemTooManyRequests,
problemUnauthorized,
} from "./response";
import type { TV3Authentication } from "./types";
import type { TV3AuditLog, TV3Authentication } from "./types";
type TV3Schema = z.ZodTypeAny;
type MaybePromise<T> = T | Promise<T>;
@@ -41,6 +45,7 @@ export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unkn
parsedInput: TParsedInput;
requestId: string;
instance: string;
auditLog?: TV3AuditLog;
};
export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = unknown> = {
@@ -48,6 +53,8 @@ export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = u
schemas?: S;
rateLimit?: boolean;
customRateLimitConfig?: TRateLimitConfig;
action?: TAuditAction;
targetType?: TAuditTarget;
handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>;
};
@@ -64,10 +71,22 @@ function getUnauthenticatedDetail(authMode: TV3AuthMode): string {
}
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,
}));
return error.issues.flatMap((issue) => {
if (issue.code === "unrecognized_keys" && issue.keys.length > 0) {
const prefix = issue.path.length > 0 ? `${issue.path.join(".")}.` : "";
return issue.keys.map((key) => ({
name: `${prefix}${key}`,
reason: "Unsupported field",
}));
}
return [
{
name: issue.path.length > 0 ? issue.path.join(".") : fallbackName,
reason: issue.message,
},
];
});
}
function searchParamsToObject(searchParams: URLSearchParams): Record<string, string | string[]> {
@@ -239,6 +258,56 @@ function ensureRequestIdHeader(response: Response, requestId: string): Response
});
}
function enrichV3AuditLog(authentication: TV3Authentication, auditLog?: TV3AuditLog): void {
if (!authentication || !auditLog) {
return;
}
if ("user" in authentication && authentication.user?.id) {
auditLog.userId = authentication.user.id;
auditLog.userType = "user";
return;
}
if ("apiKeyId" in authentication) {
auditLog.userId = authentication.apiKeyId;
auditLog.userType = "api";
auditLog.organizationId = authentication.organizationId;
}
}
async function processV3Response(params: {
response: Response;
request: NextRequest;
requestId: string;
auditLog?: TV3AuditLog;
error?: unknown;
}): Promise<Response> {
const responseWithRequestId = ensureRequestIdHeader(params.response, params.requestId);
if (params.auditLog) {
params.auditLog.status = responseWithRequestId.ok ? "success" : "failure";
if (!responseWithRequestId.ok) {
params.auditLog.eventId = params.requestId;
}
}
if (!responseWithRequestId.ok) {
reportApiError({
request: params.request,
status: responseWithRequestId.status,
error: params.error,
apiVersion: "v3",
});
}
if (params.auditLog) {
await queueAuditEvent(params.auditLog);
}
return responseWithRequestId;
}
async function authenticateV3RequestOrRespond(
req: NextRequest,
authMode: TV3AuthMode,
@@ -296,11 +365,20 @@ async function applyV3RateLimitOrRespond(params: {
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;
const {
auth = "both",
schemas,
rateLimit = true,
customRateLimitConfig,
action,
targetType,
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 auditLog = action && targetType ? buildAuditLogBaseObject(action, targetType, req.url) : undefined;
const log = logger.withContext({
requestId,
method: req.method,
@@ -311,13 +389,24 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
const authResult = await authenticateV3RequestOrRespond(req, auth, requestId, instance);
if (authResult.response) {
log.warn({ statusCode: authResult.response.status }, "V3 API authentication failed");
return authResult.response;
return await processV3Response({
response: authResult.response,
request: req,
requestId,
auditLog,
});
}
enrichV3AuditLog(authResult.authentication, auditLog);
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;
return await processV3Response({
response: parsedInputResult.response,
request: req,
requestId,
auditLog,
});
}
const rateLimitResponse = await applyV3RateLimitOrRespond({
@@ -328,7 +417,12 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
log,
});
if (rateLimitResponse) {
return rateLimitResponse;
return await processV3Response({
response: rateLimitResponse,
request: req,
requestId,
auditLog,
});
}
const response = await handler({
@@ -338,12 +432,24 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
parsedInput: parsedInputResult.parsedInput,
requestId,
instance,
auditLog,
});
return ensureRequestIdHeader(response, requestId);
return await processV3Response({
response,
request: req,
requestId,
auditLog,
});
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 API unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
return await processV3Response({
response: problemInternalError(requestId, "An unexpected error occurred.", instance),
request: req,
requestId,
auditLog,
error,
});
}
};
};
+90 -1
View File
@@ -4,7 +4,11 @@ import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/err
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";
import { requireSessionWorkspaceAccess, requireV3SurveyAccess, requireV3WorkspaceAccess } from "./auth";
const { mockGetSurvey } = vi.hoisted(() => ({
mockGetSurvey: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
@@ -27,6 +31,10 @@ vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
vi.mock("@/modules/survey/lib/survey", () => ({
getSurvey: mockGetSurvey,
}));
const requestId = "req-123";
describe("requireSessionWorkspaceAccess", () => {
@@ -272,3 +280,84 @@ describe("requireV3WorkspaceAccess", () => {
expect((r as Response).status).toBe(401);
});
});
describe("requireV3SurveyAccess", () => {
beforeEach(() => {
vi.mocked(getEnvironment).mockResolvedValue({
id: "env_survey",
projectId: "proj_survey",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValue("org_survey");
});
test("returns 404 when the survey does not exist", async () => {
mockGetSurvey.mockResolvedValueOnce(null);
const result = await requireV3SurveyAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"survey_missing",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(404);
expect(getEnvironment).not.toHaveBeenCalled();
});
test("returns survey context when the survey exists and the caller has access", async () => {
const survey = {
id: "survey_1",
environmentId: "env_survey",
};
mockGetSurvey.mockResolvedValueOnce(survey as any);
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const result = await requireV3SurveyAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"survey_1",
"readWrite",
requestId
);
expect(result).toEqual({
environmentId: "env_survey",
projectId: "proj_survey",
organizationId: "org_survey",
survey,
});
});
test("returns 403 when the survey exists but the caller lacks access", async () => {
mockGetSurvey.mockResolvedValueOnce({
id: "survey_forbidden",
environmentId: "env_survey",
} as any);
vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Forbidden"));
const result = await requireV3SurveyAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"survey_forbidden",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(403);
});
test("returns 404 when loading the survey throws ResourceNotFoundError", async () => {
mockGetSurvey.mockRejectedValueOnce(new ResourceNotFoundError("Survey", "survey_err"));
const result = await requireV3SurveyAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"survey_err",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(404);
});
});
+152 -54
View File
@@ -5,9 +5,11 @@ 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 type { TSurvey } from "@formbricks/types/surveys/types";
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 { getSurvey } from "@/modules/survey/lib/survey";
import { problemForbidden, problemNotFound, problemUnauthorized } from "./response";
import type { TV3Authentication } from "./types";
import { type V3WorkspaceContext, resolveV3WorkspaceContext } from "./workspace-context";
@@ -27,6 +29,97 @@ function apiKeyPermissionAllows(permission: ApiKeyPermission, minPermission: TTe
return grantedRank >= requiredRank;
}
export type V3SurveyContext = V3WorkspaceContext & {
survey: TSurvey;
};
async function authorizeSessionWorkspaceContext(
authentication: TV3Authentication,
context: V3WorkspaceContext,
minPermission: TTeamPermission,
requestId: string,
instance?: string
): Promise<Response | V3WorkspaceContext> {
if (!("user" in authentication) || !authentication.user?.id) {
return problemUnauthorized(requestId, "Session required", instance);
}
const log = logger.withContext({ requestId, workspaceId: context.environmentId });
try {
await checkAuthorizationUpdated({
userId: authentication.user.id,
organizationId: context.organizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: context.projectId, minPermission },
],
});
return context;
} catch (err) {
if (err instanceof AuthorizationError) {
log.warn({ statusCode: 403, errorCode: err.name }, "Forbidden");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
throw err;
}
}
function authorizeApiKeyWorkspaceContext(
authentication: TAuthenticationApiKey,
context: V3WorkspaceContext,
minPermission: TTeamPermission,
requestId: string,
instance?: string
): Response | V3WorkspaceContext {
const log = logger.withContext({
requestId,
workspaceId: context.environmentId,
apiKeyId: authentication.apiKeyId,
});
const permission = authentication.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;
}
async function authorizeV3WorkspaceContext(
authentication: TV3Authentication,
context: V3WorkspaceContext,
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 await authorizeSessionWorkspaceContext(
authentication,
context,
minPermission,
requestId,
instance
);
}
if ("apiKeyId" in authentication && Array.isArray(authentication.environmentPermissions)) {
return authorizeApiKeyWorkspaceContext(authentication, context, minPermission, requestId, instance);
}
return problemUnauthorized(requestId, "Not authenticated", instance);
}
/**
* 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
@@ -40,7 +133,6 @@ export async function requireSessionWorkspaceAccess(
requestId: string,
instance?: string
): Promise<Response | V3WorkspaceContext> {
// --- Session checks ---
if (!authentication) {
return problemUnauthorized(requestId, "Not authenticated", instance);
}
@@ -48,28 +140,19 @@ export async function requireSessionWorkspaceAccess(
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;
return await authorizeSessionWorkspaceContext(
authentication,
context,
minPermission,
requestId,
instance
);
} 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);
const log = logger.withContext({ requestId, workspaceId });
if (err instanceof ResourceNotFoundError) {
log.warn({ statusCode: 403, errorCode: err.name }, "Workspace not found");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
throw err;
@@ -84,39 +167,54 @@ export async function requireV3WorkspaceAccess(
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;
try {
const context = await resolveV3WorkspaceContext(workspaceId);
return await authorizeV3WorkspaceContext(authentication, context, minPermission, requestId, instance);
} catch (error) {
const log = logger.withContext({ requestId, workspaceId });
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);
}
}
return problemUnauthorized(requestId, "Not authenticated", instance);
throw error;
}
}
export async function requireV3SurveyAccess(
authentication: TV3Authentication,
surveyId: string,
minPermission: TTeamPermission,
requestId: string,
instance?: string
): Promise<Response | V3SurveyContext> {
try {
const survey = await getSurvey(surveyId);
if (!survey) {
return problemNotFound(requestId, "Survey", surveyId, instance);
}
const workspaceAccess = await requireV3WorkspaceAccess(
authentication,
survey.environmentId,
minPermission,
requestId,
instance
);
if (workspaceAccess instanceof Response) {
return workspaceAccess;
}
return {
...workspaceAccess,
survey,
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return problemNotFound(requestId, "Survey", surveyId, instance);
}
throw error;
}
}
+34
View File
@@ -1,5 +1,7 @@
import { describe, expect, test } from "vitest";
import {
createdResponse,
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
@@ -7,6 +9,7 @@ import {
problemTooManyRequests,
problemUnauthorized,
successListResponse,
successResponse,
} from "./response";
describe("v3 problem responses", () => {
@@ -70,6 +73,37 @@ describe("v3 problem responses", () => {
});
});
describe("item success responses", () => {
test("successResponse sets request id and omits meta by default", async () => {
const res = successResponse({ id: "survey_1" }, { requestId: "req-success" });
expect(res.status).toBe(200);
expect(res.headers.get("X-Request-Id")).toBe("req-success");
expect(await res.json()).toEqual({
data: { id: "survey_1" },
});
});
test("createdResponse sets location header", async () => {
const res = createdResponse(
{ id: "survey_1" },
{ requestId: "req-created", location: "/api/v3/surveys/survey_1" }
);
expect(res.status).toBe(201);
expect(res.headers.get("Location")).toBe("/api/v3/surveys/survey_1");
expect(res.headers.get("X-Request-Id")).toBe("req-created");
expect(await res.json()).toEqual({
data: { id: "survey_1" },
});
});
test("noContentResponse keeps request id and no-store cache", () => {
const res = noContentResponse({ requestId: "req-no-content" });
expect(res.status).toBe(204);
expect(res.headers.get("X-Request-Id")).toBe("req-no-content");
expect(res.headers.get("Cache-Control")).toContain("no-store");
});
});
describe("successListResponse", () => {
test("sets X-Request-Id and default cache", async () => {
const res = successListResponse(
+77 -8
View File
@@ -59,6 +59,46 @@ function problemResponse(
return Response.json(body, { status, headers });
}
function buildSuccessHeaders(options?: {
requestId?: string;
cache?: string;
headers?: Record<string, string>;
}): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
...options?.headers,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return headers;
}
function successJsonResponse<T, TMeta extends Record<string, unknown> | undefined>(
status: number,
data: T,
options?: {
requestId?: string;
cache?: string;
meta?: TMeta;
headers?: Record<string, string>;
}
): Response {
return Response.json(
{
data,
...(options?.meta ? { meta: options.meta } : {}),
},
{
status,
headers: buildSuccessHeaders(options),
}
);
}
export function problemBadRequest(
requestId: string,
detail: string,
@@ -133,17 +173,46 @@ export function problemTooManyRequests(requestId: string, detail: string, retryA
});
}
export function successResponse<T, TMeta extends Record<string, unknown> | undefined = undefined>(
data: T,
options?: { requestId?: string; cache?: string; meta?: TMeta }
): Response {
return successJsonResponse(200, data, options);
}
export function createdResponse<T, TMeta extends Record<string, unknown> | undefined = undefined>(
data: T,
options?: {
requestId?: string;
cache?: string;
meta?: TMeta;
location?: string;
}
): Response {
return successJsonResponse(201, data, {
...options,
headers: options?.location ? { Location: options.location } : undefined,
});
}
export function noContentResponse(options?: { requestId?: string; cache?: string }): Response {
return new Response(null, {
status: 204,
headers: {
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
...(options?.requestId ? { "X-Request-Id": options.requestId } : {}),
},
});
}
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 });
return successJsonResponse(200, data, {
requestId: options?.requestId,
cache: options?.cache,
meta,
});
}
+2
View File
@@ -1,4 +1,6 @@
import type { Session } from "next-auth";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import type { TApiAuditLog } from "@/app/lib/api/with-api-logging";
export type TV3Authentication = TAuthenticationApiKey | Session | null;
export type TV3AuditLog = TApiAuditLog;
@@ -0,0 +1,288 @@
import { createId } from "@paralleldrive/cuid2";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { requireV3SurveyAccess } from "@/app/api/v3/lib/auth";
import { updateSurvey } from "@/lib/survey/service";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { checkExternalUrlsPermission } from "@/modules/survey/editor/lib/check-external-urls-permission";
import { deleteSurvey } from "@/modules/survey/list/lib/survey";
import { buildV3SurveyCreateInput, buildV3SurveyPreview } from "../adapters";
import { ZV3SurveyCreateBody } from "../schemas";
import { DELETE, GET, PATCH } 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", () => ({
requireV3SurveyAccess: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
updateSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/list/lib/survey", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/survey/list/lib/survey")>();
return {
...actual,
deleteSurvey: vi.fn(),
};
});
vi.mock("@/modules/survey/editor/lib/check-external-urls-permission", () => ({
checkExternalUrlsPermission: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/ee/audit-logs/lib/handler")>();
return {
...actual,
queueAuditEvent: 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 workspaceId = createId();
const surveyId = createId();
const requestId = "req-item";
const baseCreateBody = {
workspaceId,
name: "Item API Survey",
blocks: [
{
id: createId(),
name: "Intro",
elements: [
{
id: "question_1",
type: "openText",
headline: { default: "What should we improve?" },
required: true,
},
],
},
],
};
const parsedCreateBody = ZV3SurveyCreateBody.parse(baseCreateBody);
function buildSurveyFixture(name = "Item API Survey") {
return buildV3SurveyPreview(
workspaceId,
buildV3SurveyCreateInput(
{
...parsedCreateBody,
name,
},
"user_1"
),
surveyId
);
}
function createRequest(method: string, url: string, body?: unknown): NextRequest {
return new NextRequest(url, {
method,
headers: {
"Content-Type": "application/json",
"x-request-id": requestId,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
}
describe("/api/v3/surveys/[surveyId]", () => {
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(requireV3SurveyAccess).mockResolvedValue({
environmentId: workspaceId,
projectId: "proj_1",
organizationId: "org_1",
survey: buildSurveyFixture(),
} as any);
vi.mocked(updateSurvey).mockResolvedValue(buildSurveyFixture("Updated survey"));
vi.mocked(deleteSurvey).mockResolvedValue(true);
vi.mocked(checkExternalUrlsPermission).mockResolvedValue(undefined);
vi.mocked(queueAuditEvent).mockResolvedValue(undefined);
});
afterEach(() => {
vi.clearAllMocks();
});
test("GET returns the survey resource", async () => {
const res = await GET(createRequest("GET", `http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as any);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.workspaceId).toBe(workspaceId);
expect(body.data).not.toHaveProperty("environmentId");
expect(requireV3SurveyAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
surveyId,
"read",
requestId,
`/api/v3/surveys/${surveyId}`
);
});
test("GET returns route parameter validation errors", async () => {
const res = await GET(createRequest("GET", "http://localhost/api/v3/surveys/not-valid"), {
params: Promise.resolve({ surveyId: "not-valid" }),
} as any);
expect(res.status).toBe(400);
expect(requireV3SurveyAccess).not.toHaveBeenCalled();
});
test("PATCH updates the survey and returns 200", async () => {
const res = await PATCH(
createRequest("PATCH", `http://localhost/api/v3/surveys/${surveyId}`, {
name: "Updated survey",
}),
{ params: Promise.resolve({ surveyId }) } as any
);
expect(res.status).toBe(200);
expect(requireV3SurveyAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
surveyId,
"readWrite",
requestId,
`/api/v3/surveys/${surveyId}`
);
expect(checkExternalUrlsPermission).toHaveBeenCalled();
expect(updateSurvey).toHaveBeenCalledWith(
expect.objectContaining({ id: surveyId, name: "Updated survey" })
);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "updated",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
status: "success",
})
);
});
test("PATCH rejects immutable fields", async () => {
const res = await PATCH(
createRequest("PATCH", `http://localhost/api/v3/surveys/${surveyId}`, {
workspaceId: createId(),
}),
{ params: Promise.resolve({ surveyId }) } as any
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.invalid_params).toContainEqual({
name: "workspaceId",
reason: "Unsupported field",
});
expect(updateSurvey).not.toHaveBeenCalled();
});
test("PATCH returns 400 for an empty body", async () => {
const res = await PATCH(createRequest("PATCH", `http://localhost/api/v3/surveys/${surveyId}`, {}), {
params: Promise.resolve({ surveyId }),
} as any);
expect(res.status).toBe(400);
});
test("PATCH returns 403 when external url permission blocks the change", async () => {
vi.mocked(checkExternalUrlsPermission).mockRejectedValueOnce(
new OperationNotAllowedError("External URLs are not enabled")
);
const res = await PATCH(
createRequest("PATCH", `http://localhost/api/v3/surveys/${surveyId}`, {
name: "Blocked update",
}),
{ params: Promise.resolve({ surveyId }) } as any
);
expect(res.status).toBe(403);
});
test("PATCH propagates a not found response from survey auth", async () => {
vi.mocked(requireV3SurveyAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Not Found",
status: 404,
detail: "Survey not found",
requestId,
}),
{ status: 404, headers: { "Content-Type": "application/problem+json" } }
)
);
const res = await PATCH(
createRequest("PATCH", `http://localhost/api/v3/surveys/${surveyId}`, {
name: "Missing survey",
}),
{ params: Promise.resolve({ surveyId }) } as any
);
expect(res.status).toBe(404);
expect(updateSurvey).not.toHaveBeenCalled();
});
test("DELETE removes the survey and returns 204", async () => {
const res = await DELETE(createRequest("DELETE", `http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as any);
expect(res.status).toBe(204);
expect(deleteSurvey).toHaveBeenCalledWith(surveyId);
expect(res.headers.get("X-Request-Id")).toBe(requestId);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
status: "success",
})
);
});
});
@@ -0,0 +1,169 @@
import { logger } from "@formbricks/logger";
import {
DatabaseError,
InvalidInputError,
OperationNotAllowedError,
ValidationError,
} from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3SurveyAccess } from "@/app/api/v3/lib/auth";
import {
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
successResponse,
} from "@/app/api/v3/lib/response";
import { updateSurvey } from "@/lib/survey/service";
import { checkExternalUrlsPermission } from "@/modules/survey/editor/lib/check-external-urls-permission";
import { deleteSurvey } from "@/modules/survey/list/lib/survey";
import { applyV3SurveyPatch } from "../adapters";
import { ZV3SurveyPatchBody, ZV3SurveyRouteParams } from "../schemas";
import { serializeV3SurveyResource } from "../serializers";
function handleSurveyMutationError(
error: unknown,
requestId: string,
instance: string,
action: string
): Response {
const log = logger.withContext({ requestId });
if (error instanceof OperationNotAllowedError) {
log.warn({ statusCode: 403, errorCode: error.name }, `Survey ${action} forbidden`);
return problemForbidden(requestId, error.message, instance);
}
if (error instanceof InvalidInputError || error instanceof ValidationError) {
log.warn({ statusCode: 400, errorCode: error.name }, `Survey ${action} validation failed`);
return problemBadRequest(requestId, error.message, {
instance,
});
}
if (error instanceof DatabaseError) {
log.error({ error, statusCode: 500 }, `Database error during survey ${action}`);
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error, statusCode: 500 }, `V3 survey ${action} unexpected error`);
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZV3SurveyRouteParams,
},
handler: async ({ authentication, parsedInput, requestId, instance }) => {
const log = logger.withContext({ requestId, surveyId: parsedInput.params.surveyId });
try {
const authResult = await requireV3SurveyAccess(
authentication,
parsedInput.params.surveyId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
return successResponse(serializeV3SurveyResource(authResult.survey), {
requestId,
cache: "private, no-store",
});
} catch (error) {
if (error instanceof DatabaseError) {
log.error({ error, statusCode: 500 }, "Database error during survey fetch");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error, statusCode: 500 }, "V3 survey fetch unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
export const PATCH = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZV3SurveyRouteParams,
body: ZV3SurveyPatchBody,
},
action: "updated",
targetType: "survey",
handler: async ({ authentication, parsedInput, requestId, instance, auditLog }) => {
try {
const authResult = await requireV3SurveyAccess(
authentication,
parsedInput.params.surveyId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const updatedSurveyInput = applyV3SurveyPatch(authResult.survey, parsedInput.body);
await checkExternalUrlsPermission(authResult.organizationId, updatedSurveyInput, authResult.survey);
const survey = await updateSurvey(updatedSurveyInput);
const serializedSurvey = serializeV3SurveyResource(survey);
if (auditLog) {
auditLog.organizationId = authResult.organizationId;
auditLog.targetId = survey.id;
auditLog.oldObject = serializeV3SurveyResource(authResult.survey);
auditLog.newObject = serializedSurvey;
}
return successResponse(serializedSurvey, {
requestId,
cache: "private, no-store",
});
} catch (error) {
return handleSurveyMutationError(error, requestId, instance, "update");
}
},
});
export const DELETE = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZV3SurveyRouteParams,
},
action: "deleted",
targetType: "survey",
handler: async ({ authentication, parsedInput, requestId, instance, auditLog }) => {
try {
const authResult = await requireV3SurveyAccess(
authentication,
parsedInput.params.surveyId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
await deleteSurvey(parsedInput.params.surveyId);
if (auditLog) {
auditLog.organizationId = authResult.organizationId;
auditLog.targetId = parsedInput.params.surveyId;
auditLog.oldObject = serializeV3SurveyResource(authResult.survey);
}
return noContentResponse({
requestId,
cache: "private, no-store",
});
} catch (error) {
return handleSurveyMutationError(error, requestId, instance, "delete");
}
},
});
@@ -0,0 +1,120 @@
import { createId } from "@paralleldrive/cuid2";
import { describe, expect, test } from "vitest";
import { ValidationError } from "@formbricks/types/errors";
import { applyV3SurveyPatch, buildV3SurveyCreateInput, buildV3SurveyPreview } from "./adapters";
const workspaceId = createId();
const surveyId = createId();
function buildCreateBody() {
return {
workspaceId,
name: "Adapter Survey",
blocks: [
{
id: createId(),
name: "Intro",
elements: [
{
id: "question_1",
type: "openText",
headline: { default: "What should we improve?" },
required: true,
},
],
},
],
};
}
describe("v3 survey adapters", () => {
test("buildV3SurveyCreateInput injects defaults and creator identity", () => {
const result = buildV3SurveyCreateInput(buildCreateBody(), "user_1");
expect(result.createdBy).toBe("user_1");
expect(result.type).toBe("link");
expect(result.status).toBe("draft");
expect(result.questions).toEqual([]);
expect(result.followUps).toEqual([]);
expect(result.hiddenFields).toEqual({ enabled: false });
});
test("buildV3SurveyPreview creates a full survey resource candidate", () => {
const createInput = buildV3SurveyCreateInput(buildCreateBody(), null);
const survey = buildV3SurveyPreview(workspaceId, createInput, surveyId);
expect(survey.id).toBe(surveyId);
expect(survey.environmentId).toBe(workspaceId);
expect(survey.createdBy).toBeNull();
expect(survey.name).toBe("Adapter Survey");
expect(survey.questions).toEqual([]);
});
test("buildV3SurveyCreateInput throws for invalid create payloads", () => {
expect(() =>
buildV3SurveyCreateInput(
{
...buildCreateBody(),
blocks: [],
} as any,
"user_1"
)
).toThrow(ValidationError);
});
test("buildV3SurveyPreview throws when the generated survey candidate is invalid", () => {
expect(() =>
buildV3SurveyPreview(
workspaceId,
{
...buildV3SurveyCreateInput(buildCreateBody(), "user_1"),
blocks: [],
} as any,
surveyId
)
).toThrow(ValidationError);
});
test("applyV3SurveyPatch replaces nested subtrees and preserves omitted top-level fields", () => {
const currentSurvey = buildV3SurveyPreview(
workspaceId,
buildV3SurveyCreateInput(
{
...buildCreateBody(),
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
},
},
"user_1"
),
surveyId
);
const updatedSurvey = applyV3SurveyPatch(currentSurvey, {
name: "Patched Survey",
welcomeCard: {
enabled: false,
},
});
expect(updatedSurvey.name).toBe("Patched Survey");
expect(updatedSurvey.status).toBe(currentSurvey.status);
expect(updatedSurvey.blocks).toEqual(currentSurvey.blocks);
expect(updatedSurvey.welcomeCard).toEqual({
enabled: false,
timeToFinish: true,
showResponseCount: false,
});
});
test("applyV3SurveyPatch throws when a patch would make the survey invalid", () => {
const currentSurvey = buildV3SurveyPreview(
workspaceId,
buildV3SurveyCreateInput(buildCreateBody(), "user_1"),
surveyId
);
expect(() => applyV3SurveyPatch(currentSurvey, { blocks: [] })).toThrow(ValidationError);
});
});
+146
View File
@@ -0,0 +1,146 @@
import { createId } from "@paralleldrive/cuid2";
import { z } from "zod";
import { ValidationError } from "@formbricks/types/errors";
import { TSurvey, TSurveyCreateInput, ZSurvey, ZSurveyCreateInput } from "@formbricks/types/surveys/types";
import type { TV3SurveyCreateBody, TV3SurveyPatchBody } from "./schemas";
const V3_SURVEY_SYSTEM_DEFAULTS = {
displayOption: "displayOnce",
autoClose: null,
triggers: [],
recontactDays: null,
displayLimit: null,
questions: [],
followUps: [],
delay: 0,
autoComplete: null,
projectOverwrites: null,
styling: null,
showLanguageSwitch: null,
surveyClosedMessage: null,
segment: null,
singleUse: null,
isVerifyEmailEnabled: false,
recaptcha: null,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
isAutoProgressingEnabled: false,
isCaptureIpEnabled: false,
pin: null,
displayPercentage: null,
languages: [],
metadata: {},
slug: null,
customHeadScripts: null,
customHeadScriptsMode: null,
} satisfies Omit<
TSurvey,
| "id"
| "createdAt"
| "updatedAt"
| "environmentId"
| "createdBy"
| "name"
| "type"
| "status"
| "welcomeCard"
| "blocks"
| "endings"
| "hiddenFields"
| "variables"
>;
function formatValidationError(error: z.ZodError): string {
return error.issues
.map((issue) => {
const path = issue.path.length > 0 ? `${issue.path.join(".")}: ` : "";
return `${path}${issue.message}`;
})
.join("; ");
}
function toValidationError(error: z.ZodError): ValidationError {
return new ValidationError(formatValidationError(error));
}
export function normalizeV3SurveyCreateInput(
body: TV3SurveyCreateBody,
createdBy: string | null
): TSurveyCreateInput {
return {
...V3_SURVEY_SYSTEM_DEFAULTS,
name: body.name,
type: body.type ?? "link",
status: body.status ?? "draft",
welcomeCard: body.welcomeCard ?? {
enabled: false,
},
blocks: body.blocks,
endings: body.endings ?? [],
hiddenFields: body.hiddenFields ?? { enabled: false },
variables: body.variables ?? [],
createdBy,
};
}
export function buildV3SurveyCreateInput(
body: TV3SurveyCreateBody,
createdBy: string | null
): TSurveyCreateInput {
const input = normalizeV3SurveyCreateInput(body, createdBy);
const result = ZSurveyCreateInput.safeParse(input);
if (!result.success) {
throw toValidationError(result.error);
}
return result.data;
}
export function buildV3SurveyPreview(
environmentId: string,
createInput: TSurveyCreateInput,
surveyId = createId()
): TSurvey {
const now = new Date();
const surveyCandidate: TSurvey = {
...V3_SURVEY_SYSTEM_DEFAULTS,
id: surveyId,
createdAt: now,
updatedAt: now,
environmentId,
createdBy: createInput.createdBy ?? null,
name: createInput.name,
type: createInput.type ?? "link",
status: createInput.status ?? "draft",
welcomeCard: createInput.welcomeCard ?? {
enabled: false,
},
blocks: createInput.blocks ?? [],
endings: createInput.endings ?? [],
hiddenFields: createInput.hiddenFields ?? { enabled: false },
variables: createInput.variables ?? [],
};
const result = ZSurvey.safeParse(surveyCandidate);
if (!result.success) {
throw toValidationError(result.error);
}
return result.data;
}
export function applyV3SurveyPatch(currentSurvey: TSurvey, patch: TV3SurveyPatchBody): TSurvey {
const mergedSurvey: TSurvey = {
...currentSurvey,
...patch,
updatedAt: new Date(),
};
const result = ZSurvey.safeParse(mergedSurvey);
if (!result.success) {
throw toValidationError(result.error);
}
return result.data;
}
+160 -2
View File
@@ -1,11 +1,17 @@
import { createId } from "@paralleldrive/cuid2";
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 { DatabaseError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { createSurvey } from "@/lib/survey/service";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { checkExternalUrlsPermission } from "@/modules/survey/editor/lib/check-external-urls-permission";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { GET } from "./route";
import { buildV3SurveyCreateInput, buildV3SurveyPreview } from "./adapters";
import { GET, POST } from "./route";
import { ZV3SurveyCreateBody } from "./schemas";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
@@ -34,6 +40,22 @@ vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
createSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/editor/lib/check-external-urls-permission", () => ({
checkExternalUrlsPermission: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/ee/audit-logs/lib/handler")>();
return {
...actual,
queueAuditEvent: 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 {
@@ -63,6 +85,27 @@ const getServerSession = vi.mocked((await import("next-auth")).getServerSession)
const validWorkspaceId = "clxx1234567890123456789012";
const resolvedEnvironmentId = "clzz9876543210987654321098";
const surveyId = "clsv1234567890123456789012";
const createBody = {
workspaceId: validWorkspaceId,
name: "API Survey",
blocks: [
{
id: createId(),
name: "Intro",
elements: [
{
id: "question_1",
type: "openText",
headline: { default: "How can we help?" },
required: true,
},
],
},
],
};
const parsedCreateBody = ZV3SurveyCreateBody.parse(createBody);
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
@@ -124,6 +167,15 @@ describe("GET /api/v3/surveys", () => {
});
vi.mocked(getSurveyListPage).mockResolvedValue({ surveys: [], nextCursor: null });
vi.mocked(getSurveyCount).mockResolvedValue(0);
vi.mocked(checkExternalUrlsPermission).mockResolvedValue(undefined);
vi.mocked(queueAuditEvent).mockResolvedValue(undefined);
vi.mocked(createSurvey).mockResolvedValue(
buildV3SurveyPreview(
resolvedEnvironmentId,
buildV3SurveyCreateInput(parsedCreateBody, "user_1"),
surveyId
)
);
});
afterEach(() => {
@@ -355,3 +407,109 @@ describe("GET /api/v3/surveys", () => {
expect(body.code).toBe("internal_server_error");
});
});
describe("POST /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).mockResolvedValue({
environmentId: resolvedEnvironmentId,
projectId: "proj_1",
organizationId: "org_1",
} as any);
vi.mocked(checkExternalUrlsPermission).mockResolvedValue(undefined);
vi.mocked(queueAuditEvent).mockResolvedValue(undefined);
vi.mocked(createSurvey).mockResolvedValue(
buildV3SurveyPreview(
resolvedEnvironmentId,
buildV3SurveyCreateInput(parsedCreateBody, "user_1"),
surveyId
)
);
});
afterEach(() => {
vi.clearAllMocks();
});
test("creates a survey and returns 201 with location header", async () => {
const requestId = "req-create";
const req = new NextRequest("http://localhost/api/v3/surveys", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-request-id": requestId,
},
body: JSON.stringify(createBody),
});
const res = await POST(req, {} as any);
expect(res.status).toBe(201);
expect(res.headers.get("Location")).toBe(`/api/v3/surveys/${surveyId}`);
expect(res.headers.get("X-Request-Id")).toBe(requestId);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
validWorkspaceId,
"readWrite",
requestId,
"/api/v3/surveys"
);
expect(checkExternalUrlsPermission).toHaveBeenCalled();
expect(createSurvey).toHaveBeenCalledWith(
resolvedEnvironmentId,
buildV3SurveyCreateInput(parsedCreateBody, "user_1")
);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "created",
targetType: "survey",
organizationId: "org_1",
targetId: surveyId,
status: "success",
})
);
});
test("returns 400 when unsupported top-level fields are provided", async () => {
const req = new NextRequest("http://localhost/api/v3/surveys", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...createBody,
questions: [],
}),
});
const res = await POST(req, {} as any);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.invalid_params).toContainEqual({
name: "questions",
reason: "Unsupported field",
});
expect(createSurvey).not.toHaveBeenCalled();
});
test("returns 403 when external url permission blocks creation", async () => {
vi.mocked(checkExternalUrlsPermission).mockRejectedValueOnce(
new OperationNotAllowedError("External URLs are not enabled")
);
const req = new NextRequest("http://localhost/api/v3/surveys", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(createBody),
});
const res = await POST(req, {} as any);
expect(res.status).toBe(403);
});
});
+86 -4
View File
@@ -1,21 +1,31 @@
/**
* GET /api/v3/surveys — list surveys for a workspace.
* Session cookie or x-api-key; scope by workspaceId only.
* /api/v3/surveys — list and create surveys for a workspace.
*/
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
OperationNotAllowedError,
ResourceNotFoundError,
ValidationError,
} from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
createdResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
successListResponse,
} from "@/app/api/v3/lib/response";
import { createSurvey } from "@/lib/survey/service";
import { checkExternalUrlsPermission } from "@/modules/survey/editor/lib/check-external-urls-permission";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { buildV3SurveyCreateInput, buildV3SurveyPreview } from "./adapters";
import { parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
import { serializeV3SurveyListItem } from "./serializers";
import { ZV3SurveyCreateBody } from "./schemas";
import { serializeV3SurveyListItem, serializeV3SurveyResource } from "./serializers";
export const GET = withV3ApiWrapper({
auth: "both",
@@ -79,3 +89,75 @@ export const GET = withV3ApiWrapper({
}
},
});
export const POST = withV3ApiWrapper({
auth: "both",
schemas: {
body: ZV3SurveyCreateBody,
},
action: "created",
targetType: "survey",
handler: async ({ authentication, parsedInput, requestId, instance, auditLog }) => {
const log = logger.withContext({ requestId });
try {
const authResult = await requireV3WorkspaceAccess(
authentication,
parsedInput.body.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const createdBy =
authentication && "user" in authentication && authentication.user?.id ? authentication.user.id : null;
const createInput = buildV3SurveyCreateInput(parsedInput.body, createdBy);
const surveyPreview = buildV3SurveyPreview(authResult.environmentId, createInput);
await checkExternalUrlsPermission(authResult.organizationId, surveyPreview, null);
const survey = await createSurvey(authResult.environmentId, createInput);
const serializedSurvey = serializeV3SurveyResource(survey);
if (auditLog) {
auditLog.organizationId = authResult.organizationId;
auditLog.targetId = survey.id;
auditLog.newObject = serializedSurvey;
}
return createdResponse(serializedSurvey, {
requestId,
cache: "private, no-store",
location: `/api/v3/surveys/${survey.id}`,
});
} catch (error) {
if (error instanceof OperationNotAllowedError) {
log.warn({ statusCode: 403, errorCode: error.name }, "Survey creation forbidden");
return problemForbidden(requestId, error.message, instance);
}
if (error instanceof InvalidInputError || error instanceof ValidationError) {
log.warn({ statusCode: 400, errorCode: error.name }, "Survey creation validation failed");
return problemBadRequest(requestId, error.message, {
instance,
});
}
if (error instanceof DatabaseError) {
log.error({ error, statusCode: 500 }, "Database error during survey creation");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
if (error instanceof ResourceNotFoundError) {
log.error({ error, statusCode: 500 }, "Missing resource during survey creation");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error, statusCode: 500 }, "V3 survey create unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
+163
View File
@@ -0,0 +1,163 @@
import { createId } from "@paralleldrive/cuid2";
import { describe, expect, test } from "vitest";
import { ZV3SurveyCreateBody, ZV3SurveyPatchBody } from "./schemas";
const workspaceId = createId();
function buildCreateBody() {
return {
workspaceId,
name: "Schema Survey",
blocks: [
{
id: createId(),
name: "Intro",
elements: [
{
id: "question_1",
type: "openText",
headline: { default: "How did it go?" },
required: true,
},
],
},
],
};
}
describe("v3 survey schemas", () => {
test("applies public defaults for create requests", () => {
const result = ZV3SurveyCreateBody.parse(buildCreateBody());
expect(result.type).toBe("link");
expect(result.status).toBe("draft");
expect(result.welcomeCard).toEqual({
enabled: false,
timeToFinish: true,
showResponseCount: false,
});
expect(result.endings).toEqual([]);
expect(result.hiddenFields).toEqual({ enabled: false });
expect(result.variables).toEqual([]);
});
test("rejects unsupported create fields", () => {
const result = ZV3SurveyCreateBody.safeParse({
...buildCreateBody(),
questions: [],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.code === "unrecognized_keys")).toBe(true);
}
});
test("rejects invalid nested block logic on create", () => {
const missingTarget = createId();
const result = ZV3SurveyCreateBody.safeParse({
...buildCreateBody(),
blocks: [
{
id: createId(),
name: "Logic block",
elements: [
{
id: "question_1",
type: "openText",
headline: { default: "How did it go?" },
required: true,
},
],
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
type: "element",
value: "question_1",
},
operator: "isSubmitted",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToBlock",
target: missingTarget,
},
],
},
],
},
],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.join(".").includes("blocks"))).toBe(true);
}
});
test("rejects invalid hidden field identifiers", () => {
const result = ZV3SurveyCreateBody.safeParse({
...buildCreateBody(),
hiddenFields: {
enabled: true,
fieldIds: ["userId", "bad field"],
},
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.join(".").includes("hiddenFields"))).toBe(true);
}
});
test("rejects invalid variable names", () => {
const result = ZV3SurveyCreateBody.safeParse({
...buildCreateBody(),
variables: [
{
id: createId(),
name: "Bad-Variable",
type: "text",
},
],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.join(".").includes("variables"))).toBe(true);
}
});
test("accepts strict top-level partial patch requests", () => {
const result = ZV3SurveyPatchBody.parse({
name: "Updated name",
status: "inProgress",
});
expect(result).toEqual({
name: "Updated name",
status: "inProgress",
});
});
test("rejects immutable patch fields", () => {
const result = ZV3SurveyPatchBody.safeParse({
id: createId(),
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.code === "unrecognized_keys")).toBe(true);
}
});
});
+157
View File
@@ -0,0 +1,157 @@
import { z } from "zod";
import { ZEndingCardUrl, ZId, ZStorageUrl } from "@formbricks/types/common";
import { ZI18nString } from "@formbricks/types/i18n";
import { ZSurveyBlocks } from "@formbricks/types/surveys/blocks";
import { ZSurveyCreateInput, ZSurveyStatus, ZSurveyType } from "@formbricks/types/surveys/types";
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
import { normalizeV3SurveyCreateInput } from "./adapters";
const ZV3SurveyEndScreen = z.strictObject({
id: z.cuid2(),
type: z.literal("endScreen"),
headline: ZI18nString.optional(),
subheader: ZI18nString.optional(),
buttonLabel: ZI18nString.optional(),
buttonLink: ZEndingCardUrl.optional(),
imageUrl: ZStorageUrl.optional(),
videoUrl: ZStorageUrl.optional(),
});
const ZV3SurveyRedirectEnding = z.strictObject({
id: z.cuid2(),
type: z.literal("redirectToUrl"),
url: ZEndingCardUrl.optional(),
label: z.string().optional(),
});
const ZV3SurveyEndings = z.array(z.union([ZV3SurveyEndScreen, ZV3SurveyRedirectEnding]));
const ZV3SurveyWelcomeCard = z
.strictObject({
enabled: z.boolean(),
headline: ZI18nString.optional(),
subheader: ZI18nString.optional(),
fileUrl: ZStorageUrl.optional(),
buttonLabel: ZI18nString.optional(),
timeToFinish: z.boolean().prefault(true),
showResponseCount: z.boolean().prefault(false),
videoUrl: ZStorageUrl.optional(),
})
.refine((value) => !(value.enabled && !value.headline), {
error: "Welcome card must have a headline",
});
const ZV3HiddenFieldId = z.string().superRefine((field, ctx) => {
if (FORBIDDEN_IDS.includes(field)) {
ctx.addIssue({
code: "custom",
message: "Hidden field id is not allowed",
});
}
if (field.includes(" ")) {
ctx.addIssue({
code: "custom",
message: "Hidden field id not allowed, avoid using spaces.",
});
}
if (!/^[a-zA-Z0-9_-]+$/.test(field)) {
ctx.addIssue({
code: "custom",
message: "Hidden field id not allowed, use only alphanumeric characters, hyphens, or underscores.",
});
}
});
const ZV3SurveyHiddenFields = z.strictObject({
enabled: z.boolean(),
fieldIds: z.array(ZV3HiddenFieldId).optional(),
});
const ZV3SurveyVariable = z
.discriminatedUnion("type", [
z.strictObject({
id: z.cuid2(),
name: z.string(),
type: z.literal("number"),
value: z.number().prefault(0),
}),
z.strictObject({
id: z.cuid2(),
name: z.string(),
type: z.literal("text"),
value: z.string().prefault(""),
}),
])
.superRefine((value, ctx) => {
if (!/^[a-z0-9_]+$/.test(value.name)) {
ctx.addIssue({
code: "custom",
message: "Variable name can only contain lowercase letters, numbers, and underscores",
path: ["name"],
});
}
});
const ZV3SurveyVariables = z.array(ZV3SurveyVariable);
function addCreateInputIssues(body: TV3SurveyCreateBody, ctx: z.RefinementCtx): void {
const result = ZSurveyCreateInput.safeParse(normalizeV3SurveyCreateInput(body, null));
if (result.success) {
return;
}
for (const issue of result.error.issues) {
ctx.addIssue(issue as any);
}
}
export const ZV3SurveyCreateBody = z
.strictObject({
workspaceId: ZId,
name: z.string().trim().min(1),
type: ZSurveyType.default("link"),
status: ZSurveyStatus.default("draft"),
welcomeCard: ZV3SurveyWelcomeCard.prefault({
enabled: false,
}),
blocks: ZSurveyBlocks.min(1, {
error: "Survey must have at least one block",
}),
endings: ZV3SurveyEndings.default([]),
hiddenFields: ZV3SurveyHiddenFields.prefault({
enabled: false,
}),
variables: ZV3SurveyVariables.default([]),
})
.superRefine(addCreateInputIssues);
export const ZV3SurveyPatchBody = z
.strictObject({
name: z.string().trim().min(1).optional(),
type: ZSurveyType.optional(),
status: ZSurveyStatus.optional(),
welcomeCard: ZV3SurveyWelcomeCard.optional(),
blocks: ZSurveyBlocks.optional(),
endings: ZV3SurveyEndings.optional(),
hiddenFields: ZV3SurveyHiddenFields.optional(),
variables: ZV3SurveyVariables.optional(),
})
.superRefine((body, ctx) => {
if (Object.keys(body).length === 0) {
ctx.addIssue({
code: "custom",
message: "Request body must include at least one updatable field",
path: [],
});
}
});
export const ZV3SurveyRouteParams = z.strictObject({
surveyId: ZId,
});
export type TV3SurveyCreateBody = z.infer<typeof ZV3SurveyCreateBody>;
export type TV3SurveyPatchBody = z.infer<typeof ZV3SurveyPatchBody>;
export type TV3SurveyRouteParams = z.infer<typeof ZV3SurveyRouteParams>;
@@ -1,9 +1,25 @@
import type { TSurvey as TFullSurvey } from "@formbricks/types/surveys/types";
import type { TSurvey } from "@/modules/survey/list/types/surveys";
export type TV3SurveyListItem = Omit<TSurvey, "environmentId" | "singleUse"> & {
workspaceId: string;
};
export type TV3SurveyResource = {
id: TFullSurvey["id"];
workspaceId: string;
createdAt: TFullSurvey["createdAt"];
updatedAt: TFullSurvey["updatedAt"];
name: TFullSurvey["name"];
type: TFullSurvey["type"];
status: TFullSurvey["status"];
welcomeCard: TFullSurvey["welcomeCard"];
blocks: TFullSurvey["blocks"];
endings: TFullSurvey["endings"];
hiddenFields: TFullSurvey["hiddenFields"];
variables: TFullSurvey["variables"];
};
/**
* Keep the v3 API contract isolated from internal persistence naming.
* Internally surveys are still scoped by environmentId; externally v3 exposes workspaceId.
@@ -16,3 +32,20 @@ export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
workspaceId: environmentId,
};
}
export function serializeV3SurveyResource(survey: TFullSurvey): TV3SurveyResource {
return {
id: survey.id,
workspaceId: survey.environmentId,
createdAt: survey.createdAt,
updatedAt: survey.updatedAt,
name: survey.name,
type: survey.type,
status: survey.status,
welcomeCard: survey.welcomeCard,
blocks: survey.blocks,
endings: survey.endings,
hiddenFields: survey.hiddenFields,
variables: survey.variables,
};
}
+50
View File
@@ -339,6 +339,56 @@ describe("API Response Utilities", () => {
});
});
describe("conflictResponse", () => {
test("should return a conflict response", () => {
const message = "Resource already exists";
const details = { field: "singleUseId" };
const response = responses.conflictResponse(message, details);
expect(response.status).toBe(409);
return response.json().then((body) => {
expect(body).toEqual({
code: "conflict",
message,
details,
});
});
});
test("should handle undefined details", () => {
const message = "Resource already exists";
const response = responses.conflictResponse(message);
expect(response.status).toBe(409);
return response.json().then((body) => {
expect(body).toEqual({
code: "conflict",
message,
details: {},
});
});
});
test("should include CORS headers when cors is true", () => {
const message = "Resource already exists";
const response = responses.conflictResponse(message, undefined, true);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE, OPTIONS");
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, Authorization");
});
test("should use custom cache control header when provided", () => {
const message = "Resource already exists";
const customCache = "no-cache";
const response = responses.conflictResponse(message, undefined, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("tooManyRequestsResponse", () => {
test("should return a too many requests response", () => {
const message = "Rate limit exceeded";
+27 -1
View File
@@ -16,7 +16,8 @@ interface ApiErrorResponse {
| "method_not_allowed"
| "not_authenticated"
| "forbidden"
| "too_many_requests";
| "too_many_requests"
| "conflict";
message: string;
details: {
[key: string]: string | string[] | number | number[] | boolean | boolean[];
@@ -236,6 +237,30 @@ const internalServerErrorResponse = (
);
};
const conflictResponse = (
message: string,
details?: { [key: string]: string },
cors: boolean = false,
cache: string = "private, no-store"
) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
code: "conflict",
message,
details: details || {},
} as ApiErrorResponse,
{
status: 409,
headers,
}
);
};
const tooManyRequestsResponse = (
message: string,
cors: boolean = false,
@@ -270,4 +295,5 @@ export const responses = {
successResponse,
tooManyRequestsResponse,
forbiddenResponse,
conflictResponse,
};
@@ -98,14 +98,11 @@ describe("Users Lib", () => {
test("returns conflict error if user with email already exists", async () => {
(prisma.user.create as any).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError(
"Unique constraint failed on the fields: (`email`)",
{
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "1.0.0",
meta: { target: ["email"] },
}
)
new Prisma.PrismaClientKnownRequestError("Unique constraint failed on the fields: (`email`)", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "1.0.0",
meta: { target: ["email"] },
})
);
const result = await createUser(
{ name: "Duplicate", email: "test@example.com", role: "member" },
@@ -46,9 +46,9 @@ export const OpenIdButton = ({
type="button"
onClick={handleLogin}
variant="secondary"
className="relative w-full justify-center">
{text ? text : t("auth.continue_with_openid")}
{lastUsed && <span className="absolute right-3 text-xs opacity-50">{t("auth.last_used")}</span>}
className="w-full items-center justify-center gap-2 px-2">
<span className="truncate">{text || t("auth.continue_with_openid")}</span>
{lastUsed && <span className="shrink-0 text-xs opacity-50">{t("auth.last_used")}</span>}
</Button>
);
};
@@ -0,0 +1,24 @@
`#engineering-chat`
Ive opened the Scope 1 v3 survey management implementation and RFC artifacts for review:
- OpenAPI: `docs/api-v3-reference/openapi.yml`
- RFC: `docs/api-v3-reference/v3-survey-management-scope-1-rfc.md`
Main decisions:
- single-object survey create/update payloads instead of fragmented endpoints
- strict scope limited to survey structure (`name`, `type`, `status`, `welcomeCard`, `blocks`, `endings`, `hiddenFields`, `variables`)
- strict rejection of unsupported top-level fields
- PATCH uses top-level partial updates with full subtree replacement for provided objects/arrays
- DELETE returns `204 No Content`
Implementation follows the existing v3 conventions:
- session or `x-api-key`
- RFC 9457 problem responses
- `X-Request-Id`
- `private, no-store`
- shared wrapper/auth/error-reporting behavior
Focused tests for the new v3 surface are in place and the targeted coverage for the new `app/api/v3/lib` and `app/api/v3/surveys` surface is above 85%.
+647 -99
View File
@@ -1,52 +1,65 @@
# 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.
# V3 API — Survey Management
# Implementation:
# - apps/web/app/api/v3/surveys/route.ts
# - apps/web/app/api/v3/surveys/[surveyId]/route.ts
# - apps/web/app/api/v3/surveys/schemas.ts
# - apps/web/app/api/v3/surveys/adapters.ts
openapi: 3.1.0
info:
title: Formbricks API v3
version: 0.2.0
description: |
**GET /api/v3/surveys** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace environment).
Survey management endpoints for Formbricks' v3 API.
**Spec location:** `docs/api-v3-reference/openapi.yml` (alongside v2 at `docs/api-v2-reference/openapi.yml`).
Key properties of this contract:
- Authenticate with either a session cookie or `x-api-key`.
- Use `workspaceId` externally; today this resolves to the underlying environment id.
- Create and update surveys with a single strict survey object.
- Reject unsupported top-level fields instead of silently ignoring them.
- Return RFC 9457 problem responses for errors.
**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).
Scope 1 is intentionally limited to survey structure:
- `name`
- `type`
- `status`
- `welcomeCard`
- `blocks`
- `endings`
- `hiddenFields`
- `variables`
**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
Out of scope and therefore rejected on create/update:
- `questions`
- distribution, targeting, and styling settings
- follow-ups
- recaptcha / spam protection
- single-use or email-verification settings
- languages, metadata, slug, and custom scripts
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
route-collection: apps/web/app/api/v3/surveys/route.ts
route-item: apps/web/app/api/v3/surveys/[surveyId]/route.ts
auth: apps/web/app/api/v3/lib/auth.ts
wrapper: apps/web/app/api/v3/lib/api-wrapper.ts
workspace-resolution: apps/web/app/api/v3/lib/workspace-context.ts
schema-source: apps/web/app/api/v3/surveys/schemas.ts
adapter-source: apps/web/app/api/v3/surveys/adapters.ts
openapi-generated: false
pagination-model: cursor
cursor-pagination: supported
servers:
- url: https://app.formbricks.com
tags:
- name: V3 Surveys
paths:
/api/v3/surveys:
get:
operationId: getSurveysV3
operationId: listSurveysV3
summary: List surveys
description: Returns surveys for the workspace. Session cookie or x-api-key.
tags:
- V3 Surveys
tags: [V3 Surveys]
description: |
Returns surveys for the given workspace.
Auth happens before query parsing. Unauthenticated callers receive `401`.
Missing or unauthorized workspaces receive `403` to avoid leaking resource existence.
parameters:
- in: query
name: workspaceId
@@ -55,7 +68,7 @@ paths:
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.
Workspace identifier. Today this maps to the environment id used by the current data model.
- in: query
name: limit
schema:
@@ -63,19 +76,16 @@ paths:
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.
description: Opaque cursor returned as `meta.nextCursor`.
- 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:
@@ -85,8 +95,6 @@ paths:
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:
@@ -96,23 +104,20 @@ paths:
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"
example: private, no-store
content:
application/json:
schema:
@@ -127,49 +132,221 @@ paths:
type: object
required: [limit, nextCursor, totalCount]
properties:
limit: { type: integer }
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"
$ref: "#/components/responses/Problem400"
"401":
description: Not authenticated (no valid session or API key)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
$ref: "#/components/responses/Problem401"
"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"
$ref: "#/components/responses/Problem403"
"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"
$ref: "#/components/responses/Problem429"
"500":
description: Internal Server Error
$ref: "#/components/responses/Problem500"
security:
- sessionAuth: []
- apiKeyAuth: []
post:
operationId: createSurveyV3
summary: Create a survey
tags: [V3 Surveys]
description: |
Creates a survey from a single strict survey document.
Defaults applied by the API:
- `type = "link"`
- `status = "draft"`
- `welcomeCard = { enabled: false }`
- `endings = []`
- `hiddenFields = { enabled: false }`
- `variables = []`
`createdBy` is injected from the session user when available and remains `null` for API-key callers.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SurveyCreateRequest"
examples:
minimal:
value:
workspaceId: clxx1234567890123456789012
name: Product Feedback Survey
blocks:
- id: clbk1234567890123456789012
name: Intro
elements:
- id: satisfaction
type: openText
headline:
default: What should we improve?
required: true
responses:
"201":
description: Survey created successfully
headers:
X-Request-Id:
schema: { type: string }
Cache-Control:
schema: { type: string }
example: private, no-store
Location:
schema: { type: string }
example: /api/v3/surveys/clsv1234567890123456789012
content:
application/problem+json:
application/json:
schema:
$ref: "#/components/schemas/Problem"
type: object
required: [data]
properties:
data:
$ref: "#/components/schemas/SurveyResource"
"400":
$ref: "#/components/responses/Problem400"
"401":
$ref: "#/components/responses/Problem401"
"403":
$ref: "#/components/responses/Problem403"
"429":
$ref: "#/components/responses/Problem429"
"500":
$ref: "#/components/responses/Problem500"
security:
- sessionAuth: []
- apiKeyAuth: []
/api/v3/surveys/{surveyId}:
get:
operationId: getSurveyV3
summary: Retrieve a survey
tags: [V3 Surveys]
parameters:
- $ref: "#/components/parameters/SurveyId"
responses:
"200":
description: Survey retrieved successfully
headers:
X-Request-Id:
schema: { type: string }
Cache-Control:
schema: { type: string }
example: private, no-store
content:
application/json:
schema:
type: object
required: [data]
properties:
data:
$ref: "#/components/schemas/SurveyResource"
"400":
$ref: "#/components/responses/Problem400"
"401":
$ref: "#/components/responses/Problem401"
"403":
$ref: "#/components/responses/Problem403"
"404":
$ref: "#/components/responses/Problem404"
"429":
$ref: "#/components/responses/Problem429"
"500":
$ref: "#/components/responses/Problem500"
security:
- sessionAuth: []
- apiKeyAuth: []
patch:
operationId: updateSurveyV3
summary: Update a survey
tags: [V3 Surveys]
description: |
Applies a strict top-level partial update.
Patch semantics:
- omitted top-level fields are preserved
- provided objects and arrays replace the entire subtree
- immutable and out-of-scope top-level fields are rejected
parameters:
- $ref: "#/components/parameters/SurveyId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SurveyPatchRequest"
examples:
rename:
value:
name: Updated Survey Name
replaceWelcomeCard:
value:
welcomeCard:
enabled: false
responses:
"200":
description: Survey updated successfully
headers:
X-Request-Id:
schema: { type: string }
Cache-Control:
schema: { type: string }
example: private, no-store
content:
application/json:
schema:
type: object
required: [data]
properties:
data:
$ref: "#/components/schemas/SurveyResource"
"400":
$ref: "#/components/responses/Problem400"
"401":
$ref: "#/components/responses/Problem401"
"403":
$ref: "#/components/responses/Problem403"
"404":
$ref: "#/components/responses/Problem404"
"429":
$ref: "#/components/responses/Problem429"
"500":
$ref: "#/components/responses/Problem500"
security:
- sessionAuth: []
- apiKeyAuth: []
delete:
operationId: deleteSurveyV3
summary: Delete a survey
tags: [V3 Surveys]
parameters:
- $ref: "#/components/parameters/SurveyId"
responses:
"204":
description: Survey deleted successfully
headers:
X-Request-Id:
schema: { type: string }
Cache-Control:
schema: { type: string }
example: private, no-store
"400":
$ref: "#/components/responses/Problem400"
"401":
$ref: "#/components/responses/Problem401"
"403":
$ref: "#/components/responses/Problem403"
"404":
$ref: "#/components/responses/Problem404"
"429":
$ref: "#/components/responses/Problem429"
"500":
$ref: "#/components/responses/Problem500"
security:
- sessionAuth: []
- apiKeyAuth: []
@@ -181,51 +358,422 @@ components:
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.
NextAuth session cookie. In production this may be `__Secure-next-auth.session-token`.
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.
Management API key with workspace-scoped environment permissions.
parameters:
SurveyId:
in: path
name: surveyId
required: true
schema:
type: string
format: cuid2
responses:
Problem400:
description: Bad Request
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
Problem401:
description: Not authenticated
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
Problem403:
description: Forbidden
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
Problem404:
description: Not Found
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
Problem429:
description: Rate limit exceeded
headers:
Retry-After:
schema: { type: integer }
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
Problem500:
description: Internal Server Error
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
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`.
additionalProperties: false
required:
- id
- workspaceId
- name
- type
- status
- createdAt
- updatedAt
- responseCount
- creator
properties:
id: { type: string }
name: { type: string }
environmentId: { type: string }
type: { type: string, enum: [link, app, website, web] }
id:
type: string
workspaceId:
type: string
name:
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 } } }
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
responseCount:
type: integer
creator:
type: object
nullable: true
additionalProperties: false
properties:
name:
type: string
SurveyCreateRequest:
type: object
additionalProperties: false
required: [workspaceId, name, blocks]
properties:
workspaceId:
type: string
format: cuid2
name:
type: string
minLength: 1
type:
type: string
enum: [link, app]
default: link
status:
type: string
enum: [draft, inProgress, paused, completed]
default: draft
welcomeCard:
$ref: "#/components/schemas/SurveyWelcomeCard"
blocks:
type: array
minItems: 1
items:
$ref: "#/components/schemas/SurveyBlock"
endings:
type: array
items:
$ref: "#/components/schemas/SurveyEnding"
hiddenFields:
$ref: "#/components/schemas/SurveyHiddenFields"
variables:
type: array
items:
$ref: "#/components/schemas/SurveyVariable"
SurveyPatchRequest:
type: object
additionalProperties: false
minProperties: 1
properties:
name:
type: string
minLength: 1
type:
type: string
enum: [link, app]
status:
type: string
enum: [draft, inProgress, paused, completed]
welcomeCard:
$ref: "#/components/schemas/SurveyWelcomeCard"
blocks:
type: array
items:
$ref: "#/components/schemas/SurveyBlock"
endings:
type: array
items:
$ref: "#/components/schemas/SurveyEnding"
hiddenFields:
$ref: "#/components/schemas/SurveyHiddenFields"
variables:
type: array
items:
$ref: "#/components/schemas/SurveyVariable"
SurveyResource:
type: object
additionalProperties: false
required:
- id
- workspaceId
- createdAt
- updatedAt
- name
- type
- status
- welcomeCard
- blocks
- endings
- hiddenFields
- variables
properties:
id:
type: string
workspaceId:
type: string
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
name:
type: string
type:
type: string
enum: [link, app]
status:
type: string
enum: [draft, inProgress, paused, completed]
welcomeCard:
$ref: "#/components/schemas/SurveyWelcomeCard"
blocks:
type: array
items:
$ref: "#/components/schemas/SurveyBlock"
endings:
type: array
items:
$ref: "#/components/schemas/SurveyEnding"
hiddenFields:
$ref: "#/components/schemas/SurveyHiddenFields"
variables:
type: array
items:
$ref: "#/components/schemas/SurveyVariable"
SurveyWelcomeCard:
type: object
additionalProperties: false
required: [enabled]
properties:
enabled:
type: boolean
headline:
$ref: "#/components/schemas/I18nString"
subheader:
$ref: "#/components/schemas/I18nString"
fileUrl:
type: string
buttonLabel:
$ref: "#/components/schemas/I18nString"
timeToFinish:
type: boolean
default: true
showResponseCount:
type: boolean
default: false
videoUrl:
type: string
SurveyBlock:
type: object
additionalProperties: false
required: [id, name, elements]
properties:
id:
type: string
format: cuid2
name:
type: string
minLength: 1
elements:
type: array
minItems: 1
items:
$ref: "#/components/schemas/SurveyElement"
logic:
type: array
items:
type: object
additionalProperties: true
description: Block logic objects validated by the shared survey logic schema.
logicFallback:
type: string
format: cuid2
buttonLabel:
$ref: "#/components/schemas/I18nString"
backButtonLabel:
$ref: "#/components/schemas/I18nString"
SurveyElement:
type: object
additionalProperties: true
required: [id, type, headline, required]
description: |
Shared survey element shape. Common keys are documented here; additional
element-type-specific keys are validated by the shared Formbricks survey schema.
properties:
id:
type: string
type:
type: string
headline:
$ref: "#/components/schemas/I18nString"
subheader:
$ref: "#/components/schemas/I18nString"
required:
type: boolean
SurveyEnding:
oneOf:
- $ref: "#/components/schemas/SurveyEndScreenEnding"
- $ref: "#/components/schemas/SurveyRedirectEnding"
SurveyEndScreenEnding:
type: object
additionalProperties: false
required: [id, type]
properties:
id:
type: string
format: cuid2
type:
type: string
enum: [endScreen]
headline:
$ref: "#/components/schemas/I18nString"
subheader:
$ref: "#/components/schemas/I18nString"
buttonLabel:
$ref: "#/components/schemas/I18nString"
buttonLink:
type: string
imageUrl:
type: string
videoUrl:
type: string
SurveyRedirectEnding:
type: object
additionalProperties: false
required: [id, type]
properties:
id:
type: string
format: cuid2
type:
type: string
enum: [redirectToUrl]
url:
type: string
label:
type: string
SurveyHiddenFields:
type: object
additionalProperties: false
required: [enabled]
properties:
enabled:
type: boolean
fieldIds:
type: array
items:
type: string
SurveyVariable:
oneOf:
- $ref: "#/components/schemas/NumberSurveyVariable"
- $ref: "#/components/schemas/TextSurveyVariable"
NumberSurveyVariable:
type: object
additionalProperties: false
required: [id, name, type]
properties:
id:
type: string
format: cuid2
name:
type: string
type:
type: string
enum: [number]
value:
type: number
default: 0
TextSurveyVariable:
type: object
additionalProperties: false
required: [id, name, type]
properties:
id:
type: string
format: cuid2
name:
type: string
type:
type: string
enum: [text]
value:
type: string
default: ""
I18nString:
type: object
additionalProperties:
type: string
description: Language-keyed string object with a required `default` entry in practice.
properties:
default:
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 }
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 }
enum:
- bad_request
- not_authenticated
- forbidden
- not_found
- internal_server_error
- too_many_requests
requestId:
type: string
details:
type: object
invalid_params:
type: array
items:
type: object
additionalProperties: false
properties:
name: { type: string }
reason: { type: string }
name:
type: string
reason:
type: string
@@ -4,17 +4,16 @@ import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-button text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-button text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20",
outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
custom: "button-custom",
},
@@ -225,7 +225,7 @@ function CalendarDayButton({
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-brand data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground hover:text-primary-foreground data-[selected-single=true]:hover:bg-brand data-[selected-single=true]:hover:text-primary-foreground data-[range-start=true]:hover:bg-primary data-[range-start=true]:hover:text-primary-foreground data-[range-end=true]:hover:bg-primary data-[range-end=true]:hover:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] hover:bg-[color-mix(in_srgb,var(--fb-survey-brand-color)_70%,transparent)] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
"data-[selected-single=true]:bg-brand data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground hover:text-primary-foreground data-[selected-single=true]:hover:bg-brand data-[selected-single=true]:hover:text-primary-foreground data-[range-start=true]:hover:bg-primary data-[range-start=true]:hover:text-primary-foreground data-[range-end=true]:hover:bg-primary data-[range-end=true]:hover:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] hover:bg-[color-mix(in_srgb,var(--fb-survey-brand-color)_70%,transparent)] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
@@ -11,7 +11,7 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"border-input-border dark:bg-input/30 data-[state=checked]:bg-brand data-[state=checked]:text-brand-foreground dark:data-[state=checked]:bg-brand data-[state=checked]:border-brand focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive text-input-text peer size-4 shrink-0 rounded-[4px] border bg-white shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"border-input-border data-[state=checked]:bg-brand data-[state=checked]:text-brand-foreground data-[state=checked]:border-brand focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive text-input-text peer size-4 shrink-0 rounded-[4px] border bg-white shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}>
@@ -58,7 +58,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@@ -41,7 +41,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(function Input(
// Focus ring
"focus-visible:border-ring focus-visible:ring-ring focus-visible:ring-[3px]",
// Error state ring
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
// Disabled state
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className
@@ -31,7 +31,7 @@ function RadioGroupItem({
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input-border text-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border bg-white shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"border-input-border text-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive aspect-square size-4 shrink-0 rounded-full border bg-white shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}>
@@ -13,7 +13,7 @@ function Textarea({ className, dir = "auto", ...props }: TextareaProps): React.J
style={{ fontSize: "var(--fb-input-font-size)" }}
dir={dir}
className={cn(
"w-input bg-input-bg border-input-border rounded-input font-input font-input-weight px-input-x py-input-y shadow-input placeholder:text-input-placeholder placeholder:opacity-input-placeholder focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 text-input text-input-text flex field-sizing-content min-h-16 border transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"w-input bg-input-bg border-input-border rounded-input font-input font-input-weight px-input-x py-input-y shadow-input placeholder:text-input-placeholder placeholder:opacity-input-placeholder focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive text-input text-input-text flex field-sizing-content min-h-16 border transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
+35 -4
View File
@@ -1,5 +1,6 @@
// basic regex -- [whitespace](number)(rem)[whitespace or ;]
const REM_REGEX = /\b(\d+(\.\d+)?)(rem)\b/gi;
// Matches a CSS numeric value followed by "rem" — e.g. "1rem", "1.5rem", "16rem".
// Single character-class + single quantifier: no nested quantifiers, no backtracking risk.
const REM_REGEX = /([\d.]+)(rem)/gi; // NOSONAR -- single character-class quantifier on trusted CSS input; no backtracking risk
const PROCESSED = Symbol("processed");
const remtoEm = (opts = {}) => {
@@ -26,6 +27,36 @@ const remtoEm = (opts = {}) => {
};
};
module.exports = {
plugins: [require("@tailwindcss/postcss"), require("autoprefixer"), remtoEm()],
// Strips the `@layer properties { ... }` block that Tailwind v4 emits as a
// browser-compatibility fallback for `@property` declarations.
//
// Problem: CSS `@layer` at-rules are globally scoped by spec — they cannot be
// confined by a surrounding selector. Even though all other Formbricks survey
// styles are correctly scoped to `#fbjs`, the `@layer properties` block
// contains a bare `*, :before, :after, ::backdrop` selector that resets all
// `--tw-*` CSS custom properties on every element of the host page. This
// breaks shadows, rings, transforms, and other Tailwind utilities on any site
// that uses Tailwind v4 alongside the Formbricks SDK.
//
// The `@property` declarations already present in the same stylesheet cover
// the same browser-compatibility need for all supporting browsers, so removing
// `@layer properties` does not affect survey rendering.
//
// See: https://github.com/formbricks/js/issues/46
const stripLayerProperties = () => {
return {
postcssPlugin: "postcss-strip-layer-properties",
AtRule: {
layer: (atRule) => {
if (atRule.params === "properties") {
atRule.remove();
}
},
},
};
};
stripLayerProperties.postcss = true;
module.exports = {
plugins: [require("@tailwindcss/postcss"), require("autoprefixer"), remtoEm(), stripLayerProperties()],
};
+62 -4
View File
@@ -32,16 +32,32 @@ export const delay = (ms: number): Promise<void> => {
});
};
// Module-level locks keyed by surveyId.
// Survive ResponseQueue instance recreation (e.g. React useMemo recomputation)
// so that only one sync/send runs at a time per survey, even across instances.
const syncingBySurvey = new Map<string, boolean>();
const requestInProgressBySurvey = new Map<string, boolean>();
/** @internal Exposed for tests only. */
export const _syncLocks = {
clear: () => {
syncingBySurvey.clear();
requestInProgressBySurvey.clear();
},
set: (surveyId: string, value: boolean) => syncingBySurvey.set(surveyId, value),
get: (surveyId: string) => syncingBySurvey.get(surveyId) ?? false,
setRequestInProgress: (surveyId: string, value: boolean) => requestInProgressBySurvey.set(surveyId, value),
getRequestInProgress: (surveyId: string) => requestInProgressBySurvey.get(surveyId) ?? false,
};
export class ResponseQueue {
readonly queue: TResponseUpdate[] = [];
readonly config: QueueConfig;
private surveyState: SurveyState;
private isRequestInProgress = false;
readonly api: ApiClient;
private responseRecaptchaToken?: string;
// Maps in-memory queue index → IndexedDB id for cleanup after successful send
private readonly pendingDbIds: Map<TResponseUpdate, number> = new Map();
private isSyncing = false;
constructor(config: QueueConfig, surveyState: SurveyState) {
this.config = config;
@@ -52,6 +68,26 @@ export class ResponseQueue {
});
}
private get isSyncing(): boolean {
return this.config.surveyId ? (syncingBySurvey.get(this.config.surveyId) ?? false) : false;
}
private set isSyncing(value: boolean) {
if (this.config.surveyId) {
syncingBySurvey.set(this.config.surveyId, value);
}
}
private get isRequestInProgress(): boolean {
return this.config.surveyId ? (requestInProgressBySurvey.get(this.config.surveyId) ?? false) : false;
}
private set isRequestInProgress(value: boolean) {
if (this.config.surveyId) {
requestInProgressBySurvey.set(this.config.surveyId, value);
}
}
setResponseRecaptchaToken(token?: string) {
this.responseRecaptchaToken = token;
}
@@ -111,9 +147,26 @@ export class ResponseQueue {
return { success: false };
}
this.isRequestInProgress = true;
const responseUpdate = this.queue[0];
// When offline support is active and there are multiple pending entries in
// IndexedDB, defer to syncPersistedResponses which drains them in order.
// This prevents processQueue and syncPersistedResponses from racing to
// create the same response concurrently (duplicate POSTs).
if (this.config.persistOffline && this.config.surveyId) {
const pendingCount = await countPendingResponses(this.config.surveyId);
// Re-check after await — another processQueue/sync may have started during the yield
if (this.isSyncing || this.isRequestInProgress || this.queue.length === 0) {
return { success: false };
}
if (pendingCount > 1) {
void this.syncPersistedResponses();
return { success: false };
}
}
const responseUpdate = this.queue[0];
this.isRequestInProgress = true;
const result = await this.sendResponseWithRetry(responseUpdate);
if (result.success) {
@@ -169,6 +222,11 @@ export class ResponseQueue {
// Concurrency guard: prevent duplicate syncs from online/offline flicker
if (this.isSyncing) return { success: false, syncedCount: 0 };
// If processQueue already has a request in flight, don't start syncing —
// let it finish first to avoid both paths creating the same response.
if (this.isRequestInProgress) return { success: false, syncedCount: 0 };
this.isSyncing = true;
try {
@@ -3,7 +3,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vit
import { err, ok } from "@formbricks/types/error-handlers";
import { TResponseUpdate } from "@formbricks/types/responses";
import { TResponseErrorCodesEnum } from "@/types/response-error-codes";
import { ResponseQueue, delay } from "./response-queue";
import { ResponseQueue, _syncLocks, delay } from "./response-queue";
import { SurveyState } from "./survey-state";
// Suppress noisy console output from retry logic during tests
@@ -86,6 +86,7 @@ describe("ResponseQueue", () => {
queue = new ResponseQueue(config, surveyState);
apiMock = queue.api;
vi.clearAllMocks();
_syncLocks.clear();
});
test("constructor initializes properties", () => {
@@ -309,12 +310,75 @@ describe("ResponseQueue", () => {
});
test("processQueue returns false when isSyncing is true", async () => {
queue.queue.push(responseUpdate);
queue["isSyncing"] = true;
const result = await queue.processQueue();
const offlineQueue = new ResponseQueue(
getConfig({ persistOffline: true, surveyId: "s1" }),
getSurveyState()
);
offlineQueue.queue.push(responseUpdate);
_syncLocks.set("s1", true);
const result = await offlineQueue.processQueue();
expect(result.success).toBe(false);
});
test("processQueue defers to sync when multiple IDB entries exist", async () => {
const { countPendingResponses } = await import("./offline-storage");
vi.mocked(countPendingResponses).mockResolvedValue(3);
const offlineQueue = new ResponseQueue(
getConfig({ persistOffline: true, surveyId: "s1" }),
getSurveyState()
);
offlineQueue.queue.push({ data: { q1: "answer" }, finished: false });
const syncSpy = vi.spyOn(offlineQueue, "syncPersistedResponses").mockResolvedValue({
success: true,
syncedCount: 3,
});
const result = await offlineQueue.processQueue();
expect(result.success).toBe(false);
expect(syncSpy).toHaveBeenCalled();
expect(_syncLocks.getRequestInProgress("s1")).toBe(false);
});
test("processQueue bails out if syncPersistedResponses starts during countPendingResponses await", async () => {
const { countPendingResponses } = await import("./offline-storage");
// Simulate syncPersistedResponses starting during the async gap
vi.mocked(countPendingResponses).mockImplementation(async () => {
// While countPendingResponses is resolving, isSyncing becomes true
_syncLocks.set("s1", true);
return 1;
});
const offlineQueue = new ResponseQueue(
getConfig({ persistOffline: true, surveyId: "s1" }),
getSurveyState()
);
offlineQueue.queue.push({ data: { q1: "answer" }, finished: false });
const sendSpy = vi.spyOn(offlineQueue as any, "sendResponseWithRetry");
const result = await offlineQueue.processQueue();
expect(result.success).toBe(false);
expect(sendSpy).not.toHaveBeenCalled();
});
test("processQueue sends directly when it is the only IDB entry", async () => {
const { countPendingResponses } = await import("./offline-storage");
vi.mocked(countPendingResponses).mockResolvedValue(1);
const offlineQueue = new ResponseQueue(
getConfig({ persistOffline: true, surveyId: "s1" }),
getSurveyState()
);
offlineQueue.queue.push({ data: { q1: "answer" }, finished: false });
vi.spyOn(offlineQueue as any, "sendResponseWithRetry").mockResolvedValue({ success: true });
const result = await offlineQueue.processQueue();
expect(result.success).toBe(true);
});
test("loadPersistedQueue returns 0 when persistOffline is disabled", async () => {
const count = await queue.loadPersistedQueue();
expect(count).toBe(0);
@@ -347,11 +411,33 @@ describe("ResponseQueue", () => {
getConfig({ persistOffline: true, surveyId: "s1" }),
getSurveyState()
);
offlineQueue["isSyncing"] = true;
_syncLocks.set("s1", true);
const result = await offlineQueue.syncPersistedResponses();
expect(result).toEqual({ success: false, syncedCount: 0 });
});
test("syncPersistedResponses returns early when a processQueue request is in flight", async () => {
_syncLocks.setRequestInProgress("s1", true);
const offlineQueue = new ResponseQueue(
getConfig({ persistOffline: true, surveyId: "s1" }),
getSurveyState()
);
const result = await offlineQueue.syncPersistedResponses();
expect(result).toEqual({ success: false, syncedCount: 0 });
});
test("syncPersistedResponses on a new instance sees isRequestInProgress from an old instance", async () => {
// Simulate instance A having a request in flight (module-level lock)
_syncLocks.setRequestInProgress("s1", true);
// Instance B is newly created (e.g. React useMemo recomputation)
const instanceB = new ResponseQueue(
getConfig({ persistOffline: true, surveyId: "s1" }),
getSurveyState()
);
const result = await instanceB.syncPersistedResponses();
expect(result).toEqual({ success: false, syncedCount: 0 });
});
test("syncPersistedResponses sends entries and clears queue on success", async () => {
const { getPendingResponses, removePendingResponse } = await import("./offline-storage");
vi.mocked(getPendingResponses).mockResolvedValue([
@@ -382,7 +468,7 @@ describe("ResponseQueue", () => {
expect(result).toEqual({ success: true, syncedCount: 1 });
expect(removePendingResponse).toHaveBeenCalledWith(10);
expect(offlineQueue.queue.length).toBe(0);
expect(offlineQueue["isSyncing"]).toBe(false);
expect(_syncLocks.get("s1")).toBe(false);
});
test("syncPersistedResponses stops on server error", async () => {
@@ -415,7 +501,7 @@ describe("ResponseQueue", () => {
const result = await offlineQueue.syncPersistedResponses();
expect(result).toEqual({ success: false, syncedCount: 0 });
expect(offlineQueue["isSyncing"]).toBe(false);
expect(_syncLocks.get("s1")).toBe(false);
});
test("syncPersistedResponses retries 404 as createResponse by resetting responseId", async () => {