Compare commits

..

12 Commits

Author SHA1 Message Date
Tiago 5ec8218666 fix: (backport) password hash visibility improvement (#7814) (#7833) 2026-04-24 14:33:26 +00:00
Tiago Farto e1a44817f2 fix: password hash visibility improvement
(cherry picked from commit 73ad130ece)
2026-04-24 13:10:40 +00:00
Dhruwang Jariwala 7f5b2bf69d fix: prevent split offline responses on restore (backport #7767) (#7777) 2026-04-20 12:00:34 +05:30
Dhruwang 60e7c7e8ee fix(surveys): prevent split offline responses on restore (backport #7767)
Backport of #7767 to release/4.9. Anchors displayId and responseId back
into saved survey progress as soon as they are created, recovers a
missing responseId from displayId on restore, and falls back to a
bootstrap create path that uses the full accumulated response state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 11:43:46 +05:30
Dhruwang Jariwala 7988d7775c fix: [backport] remove dark: variant classes from survey-ui to prevent host page style leakage (#7748) 2026-04-16 11:20:33 +05:30
Dhruwang Jariwala b7ede6c578 fix: prevent offline replay from dropping survey blocks after completion (#7744) 2026-04-15 22:00:29 +02:00
Bhagya Amarasinghe 8204a5c652 fix: restore legacy SSO auto-linking hotfix (#7728) 2026-04-13 20:42:33 +05:30
Anshuman Pandey e823e10f9a fix: backports missing posthog events fix (#7723) 2026-04-13 17:36:39 +05:30
Dhruwang Jariwala f5c3212b2c revert: enhance welcome card to support video uploads (backport #7712) (#7720)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 14:59:20 +05:30
Dhruwang Jariwala 2d66fc6987 fix: prevent TTC overcount for multi-question blocks (backport #7713) (#7719) 2026-04-13 14:40:35 +05:30
Dhruwang Jariwala 652970003d fix: validate "Other" option text on required questions and remove duplicate response entry (backport #7716) (#7717) 2026-04-13 12:27:08 +04:00
Dhruwang Jariwala a8b5e286b6 fix: only show beforeunload warning when offline support is active (backport #7715) (#7718) 2026-04-13 12:26:30 +04:00
101 changed files with 891 additions and 3948 deletions
@@ -1,22 +0,0 @@
"use client";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
const Loading = () => {
return (
<PageContentWrapper>
<PageHeader pageTitle="" />
<div className="flex h-9 animate-pulse gap-2">
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
</div>
<SkeletonLoader type="summary" />
</PageContentWrapper>
);
};
export default Loading;
@@ -1,23 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
const Loading = () => {
const { t } = useTranslation();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.responses")} />
<div className="flex h-9 animate-pulse gap-1.5">
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
</div>
<SkeletonLoader type="responseTable" />
</PageContentWrapper>
);
};
export default Loading;
@@ -191,61 +191,6 @@ describe("getSurveySummaryMeta", () => {
expect(meta.dropOffPercentage).toBe(0);
expect(meta.ttcAverage).toBe(0);
});
test("uses block-level TTC to avoid multiplying by number of elements", () => {
const surveyWithOneBlockThreeElements: TSurvey = {
...mockBaseSurvey,
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
{
id: "q2",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q2" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
{
id: "q3",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q3" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
] as TSurveyElement[],
},
],
questions: [],
};
const responses = [
{
id: "r1",
data: { q1: "a", q2: "b", q3: "c" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 5000, q2: 5000, q3: 4800, _total: 14800 },
finished: true,
},
] as any;
const meta = getSurveySummaryMeta(surveyWithOneBlockThreeElements, responses, 1, mockQuotas);
expect(meta.ttcAverage).toBe(5000);
});
});
describe("getSurveySummaryDropOff", () => {
@@ -1094,9 +1094,7 @@ export const getResponsesForSummary = reactCache(
const transformedResponses: TSurveySummaryResponse[] = await Promise.all(
responses.map((responsePrisma) => {
return {
id: responsePrisma.id,
data: (responsePrisma.data ?? {}) as TResponseData,
updatedAt: responsePrisma.updatedAt,
...responsePrisma,
contact: responsePrisma.contact
? {
id: responsePrisma.contact.id as string,
@@ -1105,10 +1103,6 @@ export const getResponsesForSummary = reactCache(
)?.value as string,
}
: null,
contactAttributes: (responsePrisma.contactAttributes ?? {}) as TResponseContactAttributes,
language: responsePrisma.language,
ttc: (responsePrisma.ttc ?? {}) as TResponseTtc,
finished: responsePrisma.finished,
};
})
);
@@ -1,81 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { captureSurveyResponsePostHogEvent } from "./posthog";
vi.mock("@/lib/posthog", () => ({
capturePostHogEvent: vi.fn(),
}));
describe("captureSurveyResponsePostHogEvent", () => {
afterEach(() => {
vi.clearAllMocks();
});
const makeParams = (responseCount: number) => ({
organizationId: "org-1",
surveyId: "survey-1",
surveyType: "link",
environmentId: "env-1",
responseCount,
});
test("fires on 1st response with milestone 'first'", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
captureSurveyResponsePostHogEvent(makeParams(1));
expect(capturePostHogEvent).toHaveBeenCalledWith("org-1", "survey_response_received", {
survey_id: "survey-1",
survey_type: "link",
organization_id: "org-1",
environment_id: "env-1",
response_count: 1,
is_first_response: true,
milestone: "first",
});
});
test("fires on every 100th response", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [100, 200, 300, 500, 1000, 5000]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
expect(capturePostHogEvent).toHaveBeenCalledTimes(6);
});
test("does NOT fire for 2nd through 99th responses", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [2, 5, 10, 50, 99]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
expect(capturePostHogEvent).not.toHaveBeenCalled();
});
test("does NOT fire for non-100th counts above 100", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [101, 150, 250, 499, 501]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
expect(capturePostHogEvent).not.toHaveBeenCalled();
});
test("sets milestone to count string for non-first milestones", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
captureSurveyResponsePostHogEvent(makeParams(200));
expect(capturePostHogEvent).toHaveBeenCalledWith(
"org-1",
"survey_response_received",
expect.objectContaining({
is_first_response: false,
milestone: "200",
})
);
});
});
@@ -0,0 +1,44 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const getResponseIdByDisplayId = async (
environmentId: string,
displayId: string
): Promise<{ responseId: string | null }> => {
validateInputs([environmentId, ZId], [displayId, ZId]);
try {
const display = await prisma.display.findFirst({
where: {
id: displayId,
survey: {
environmentId,
},
},
select: {
response: {
select: {
id: true,
},
},
},
});
if (!display) {
throw new ResourceNotFoundError("Display", displayId);
}
return {
responseId: display.response?.id ?? null,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -0,0 +1,40 @@
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getResponseIdByDisplayId } from "./lib/response";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: THandlerParams<{ params: Promise<{ environmentId: string; displayId: string }> }>) => {
const params = await props.params;
try {
const response = await getResponseIdByDisplayId(params.environmentId, params.displayId);
return {
response: responses.successResponse(response, true),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Display", params.displayId, true),
};
}
logger.error(
{ error, url: req.url, environmentId: params.environmentId, displayId: params.displayId },
"Error in GET /api/v1/client/[environmentId]/displays/[displayId]/response"
);
return {
response: responses.internalServerErrorResponse("Something went wrong. Please try again."),
};
}
},
});
@@ -70,7 +70,6 @@ const mockEnvironmentData = {
displayOption: "displayOnce",
hiddenFields: { enabled: false },
isBackButtonHidden: false,
isAutoProgressingEnabled: true,
triggers: [],
displayPercentage: null,
delay: 0,
@@ -123,13 +122,6 @@ describe("getEnvironmentStateData", () => {
surveys: expect.any(Object),
}),
});
const prismaCall = vi.mocked(prisma.environment.findUnique).mock.calls[0][0];
expect(prismaCall.select.surveys.select).toEqual(
expect.objectContaining({
isAutoProgressingEnabled: true,
})
);
});
test("should throw ResourceNotFoundError when environment is not found", async () => {
@@ -121,7 +121,6 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
displayOption: true,
hiddenFields: true,
isBackButtonHidden: true,
isAutoProgressingEnabled: true,
triggers: {
select: {
actionClass: {
@@ -0,0 +1,178 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { publicUserSelect } from "@/lib/user/public-user";
import { GET } from "./route";
const mocks = vi.hoisted(() => ({
headers: vi.fn(),
getSessionUser: vi.fn(),
parseApiKeyV2: vi.fn(),
hashSha256: vi.fn(),
verifySecret: vi.fn(),
applyRateLimit: vi.fn(),
notAuthenticatedResponse: vi.fn(
() => new Response(JSON.stringify({ message: "Not authenticated" }), { status: 401 })
),
tooManyRequestsResponse: vi.fn(
(message: string) => new Response(JSON.stringify({ message }), { status: 429 })
),
badRequestResponse: vi.fn((message: string) => new Response(JSON.stringify({ message }), { status: 400 })),
}));
vi.mock("next/headers", () => ({
headers: mocks.headers,
}));
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
apiKey: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/app/api/v1/management/me/lib/utils", () => ({
getSessionUser: mocks.getSessionUser,
}));
vi.mock("@/app/lib/api/response", () => ({
responses: {
notAuthenticatedResponse: mocks.notAuthenticatedResponse,
tooManyRequestsResponse: mocks.tooManyRequestsResponse,
badRequestResponse: mocks.badRequestResponse,
},
}));
vi.mock("@/lib/crypto", () => ({
hashSha256: mocks.hashSha256,
parseApiKeyV2: mocks.parseApiKeyV2,
verifySecret: mocks.verifySecret,
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: mocks.applyRateLimit,
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
v1: { windowMs: 60_000, max: 1000 },
},
},
}));
const getMockHeaders = (apiKey: string | null) => ({
get: (headerName: string) => (headerName === "x-api-key" ? apiKey : null),
});
describe("v1 management me route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.headers.mockResolvedValue(getMockHeaders(null));
mocks.getSessionUser.mockResolvedValue(undefined);
mocks.parseApiKeyV2.mockReturnValue(null);
mocks.hashSha256.mockReturnValue("hashed-api-key");
mocks.verifySecret.mockResolvedValue(false);
mocks.applyRateLimit.mockResolvedValue(undefined);
});
test("returns a sanitized authenticated user for session-based requests", async () => {
const publicUser = {
id: "user_123",
name: "Test User",
email: "test@example.com",
emailVerified: new Date("2025-04-17T20:11:54.947Z"),
createdAt: new Date("2025-04-17T20:09:14.021Z"),
updatedAt: new Date("2026-04-22T22:12:39.104Z"),
twoFactorEnabled: false,
identityProvider: "email" as const,
notificationSettings: {
alert: {},
unsubscribedOrganizationIds: [],
},
locale: "en-US" as const,
lastLoginAt: new Date("2026-04-22T22:12:39.104Z"),
isActive: true,
};
mocks.getSessionUser.mockResolvedValue({ id: publicUser.id });
vi.mocked(prisma.user.findUnique).mockResolvedValue(publicUser as never);
const response = await GET();
const responseBody = await response.json();
expect(response.status).toBe(200);
expect(responseBody).toStrictEqual(JSON.parse(JSON.stringify(publicUser)));
expect(responseBody).not.toHaveProperty("password");
expect(responseBody).not.toHaveProperty("twoFactorSecret");
expect(responseBody).not.toHaveProperty("backupCodes");
expect(responseBody).not.toHaveProperty("identityProviderAccountId");
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: publicUser.id },
select: publicUserSelect,
});
expect(mocks.applyRateLimit).toHaveBeenCalledWith(expect.any(Object), publicUser.id);
});
test("returns the existing unauthenticated response when no session is present", async () => {
const response = await GET();
const responseBody = await response.json();
expect(response.status).toBe(401);
expect(responseBody).toEqual({ message: "Not authenticated" });
expect(mocks.notAuthenticatedResponse).toHaveBeenCalled();
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
test("preserves the API key response path", async () => {
const apiKeyData = {
id: "api_key_123",
organizationId: "org_123",
hashedKey: "stored-hash",
lastUsedAt: new Date(),
apiKeyEnvironments: [
{
permission: "manage",
environment: {
id: "env_123",
type: "development",
createdAt: new Date("2025-01-01T00:00:00.000Z"),
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
projectId: "project_123",
appSetupCompleted: true,
project: {
id: "project_123",
name: "My Project",
},
},
},
],
};
mocks.headers.mockResolvedValue(getMockHeaders("api-key"));
vi.mocked(prisma.apiKey.findFirst).mockResolvedValue(apiKeyData as never);
const response = await GET();
const responseBody = await response.json();
expect(response.status).toBe(200);
expect(responseBody).toStrictEqual({
id: "env_123",
type: "development",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
appSetupCompleted: true,
project: {
id: "project_123",
name: "My Project",
},
});
expect(mocks.getSessionUser).not.toHaveBeenCalled();
expect(mocks.applyRateLimit).toHaveBeenCalledWith(expect.any(Object), apiKeyData.id);
});
});
@@ -4,6 +4,7 @@ import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
import { responses } from "@/app/lib/api/response";
import { CONTROL_HASH } from "@/lib/constants";
import { hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
import { publicUserSelect } from "@/lib/user/public-user";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
@@ -176,6 +177,7 @@ const handleSessionAuthentication = async () => {
const user = await prisma.user.findUnique({
where: { id: sessionUser.id },
select: publicUserSelect,
});
return Response.json(user);
@@ -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, UniqueConstraintError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -175,33 +175,9 @@ describe("createResponse V2", () => {
).rejects.toThrow(ResourceNotFoundError);
});
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 () => {
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
code: "P2002",
clientVersion: "test",
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
@@ -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, UniqueConstraintError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -129,13 +129,6 @@ 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, UniqueConstraintError } from "@formbricks/types/errors";
import { InvalidInputError } 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,10 +177,6 @@ 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,14 +25,6 @@ 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(() => ({
@@ -329,67 +321,4 @@ 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",
})
);
});
});
+11 -117
View File
@@ -4,14 +4,10 @@ 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,
@@ -19,7 +15,7 @@ import {
problemTooManyRequests,
problemUnauthorized,
} from "./response";
import type { TV3AuditLog, TV3Authentication } from "./types";
import type { TV3Authentication } from "./types";
type TV3Schema = z.ZodTypeAny;
type MaybePromise<T> = T | Promise<T>;
@@ -45,7 +41,6 @@ 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> = {
@@ -53,8 +48,6 @@ 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>;
};
@@ -71,22 +64,10 @@ function getUnauthenticatedDetail(authMode: TV3AuthMode): string {
}
function formatZodIssues(error: z.ZodError, fallbackName: "body" | "query" | "params"): InvalidParam[] {
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,
},
];
});
return error.issues.map((issue) => ({
name: issue.path.length > 0 ? issue.path.join(".") : fallbackName,
reason: issue.message,
}));
}
function searchParamsToObject(searchParams: URLSearchParams): Record<string, string | string[]> {
@@ -258,56 +239,6 @@ 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,
@@ -365,20 +296,11 @@ 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,
action,
targetType,
handler,
} = params;
const { auth = "both", schemas, rateLimit = true, customRateLimitConfig, handler } = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
const instance = req.nextUrl.pathname;
const auditLog = action && targetType ? buildAuditLogBaseObject(action, targetType, req.url) : undefined;
const log = logger.withContext({
requestId,
method: req.method,
@@ -389,24 +311,13 @@ 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 await processV3Response({
response: authResult.response,
request: req,
requestId,
auditLog,
});
return authResult.response;
}
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 await processV3Response({
response: parsedInputResult.response,
request: req,
requestId,
auditLog,
});
return parsedInputResult.response;
}
const rateLimitResponse = await applyV3RateLimitOrRespond({
@@ -417,12 +328,7 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
log,
});
if (rateLimitResponse) {
return await processV3Response({
response: rateLimitResponse,
request: req,
requestId,
auditLog,
});
return rateLimitResponse;
}
const response = await handler({
@@ -432,24 +338,12 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
parsedInput: parsedInputResult.parsedInput,
requestId,
instance,
auditLog,
});
return await processV3Response({
response,
request: req,
requestId,
auditLog,
});
return ensureRequestIdHeader(response, requestId);
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 API unexpected error");
return await processV3Response({
response: problemInternalError(requestId, "An unexpected error occurred.", instance),
request: req,
requestId,
auditLog,
error,
});
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
};
};
+1 -90
View File
@@ -4,11 +4,7 @@ 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, requireV3SurveyAccess, requireV3WorkspaceAccess } from "./auth";
const { mockGetSurvey } = vi.hoisted(() => ({
mockGetSurvey: vi.fn(),
}));
import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth";
vi.mock("@formbricks/logger", () => ({
logger: {
@@ -31,10 +27,6 @@ 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", () => {
@@ -280,84 +272,3 @@ 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);
});
});
+53 -151
View File
@@ -5,11 +5,9 @@ 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 { getSurvey } from "@/modules/survey/lib/survey";
import { problemForbidden, problemNotFound, problemUnauthorized } from "./response";
import { problemForbidden, problemUnauthorized } from "./response";
import type { TV3Authentication } from "./types";
import { type V3WorkspaceContext, resolveV3WorkspaceContext } from "./workspace-context";
@@ -29,97 +27,6 @@ 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
@@ -133,6 +40,7 @@ export async function requireSessionWorkspaceAccess(
requestId: string,
instance?: string
): Promise<Response | V3WorkspaceContext> {
// --- Session checks ---
if (!authentication) {
return problemUnauthorized(requestId, "Not authenticated", instance);
}
@@ -140,19 +48,28 @@ 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);
return await authorizeSessionWorkspaceContext(
authentication,
context,
minPermission,
requestId,
instance
);
// Org + project-team access; we use internal IDs from context.
await checkAuthorizationUpdated({
userId,
organizationId: context.organizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: context.projectId, minPermission },
],
});
return context;
} catch (err) {
const log = logger.withContext({ requestId, workspaceId });
if (err instanceof ResourceNotFoundError) {
log.warn({ statusCode: 403, errorCode: err.name }, "Workspace not found");
if (err instanceof ResourceNotFoundError || err instanceof AuthorizationError) {
const message = err instanceof ResourceNotFoundError ? "Workspace not found" : "Forbidden";
log.warn({ statusCode: 403, errorCode: err.name }, message);
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
throw err;
@@ -167,54 +84,39 @@ export async function requireV3WorkspaceAccess(
requestId: string,
instance?: string
): Promise<Response | V3WorkspaceContext> {
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);
}
throw error;
if (!authentication) {
return problemUnauthorized(requestId, "Not authenticated", instance);
}
}
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;
if ("user" in authentication && authentication.user?.id) {
return requireSessionWorkspaceAccess(authentication, workspaceId, minPermission, requestId, instance);
}
const keyAuth = authentication as TAuthenticationApiKey;
if (keyAuth.apiKeyId && Array.isArray(keyAuth.environmentPermissions)) {
const log = logger.withContext({ requestId, workspaceId, apiKeyId: keyAuth.apiKeyId });
try {
const context = await resolveV3WorkspaceContext(workspaceId);
const permission = keyAuth.environmentPermissions.find(
(environmentPermission) => environmentPermission.environmentId === context.environmentId
);
if (!permission || !apiKeyPermissionAllows(permission.permission, minPermission)) {
log.warn({ statusCode: 403 }, "API key not allowed for workspace");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
return context;
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ statusCode: 403, errorCode: error.name }, "Workspace not found");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
throw error;
}
}
return problemUnauthorized(requestId, "Not authenticated", instance);
}
-34
View File
@@ -1,7 +1,5 @@
import { describe, expect, test } from "vitest";
import {
createdResponse,
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
@@ -9,7 +7,6 @@ import {
problemTooManyRequests,
problemUnauthorized,
successListResponse,
successResponse,
} from "./response";
describe("v3 problem responses", () => {
@@ -73,37 +70,6 @@ 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(
+8 -77
View File
@@ -59,46 +59,6 @@ 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,
@@ -173,46 +133,17 @@ 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 {
return successJsonResponse(200, data, {
requestId: options?.requestId,
cache: options?.cache,
meta,
});
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 });
}
-2
View File
@@ -1,6 +1,4 @@
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;
@@ -1,288 +0,0 @@
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",
})
);
});
});
@@ -1,169 +0,0 @@
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");
}
},
});
@@ -1,120 +0,0 @@
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
@@ -1,146 +0,0 @@
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;
}
+2 -160
View File
@@ -1,17 +1,11 @@
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, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, 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 { buildV3SurveyCreateInput, buildV3SurveyPreview } from "./adapters";
import { GET, POST } from "./route";
import { ZV3SurveyCreateBody } from "./schemas";
import { GET } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
@@ -40,22 +34,6 @@ 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 {
@@ -85,27 +63,6 @@ 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 };
@@ -167,15 +124,6 @@ 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(() => {
@@ -407,109 +355,3 @@ 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);
});
});
+4 -86
View File
@@ -1,31 +1,21 @@
/**
* /api/v3/surveys list and create surveys for a workspace.
* GET /api/v3/surveys list surveys for a workspace.
* Session cookie or x-api-key; scope by workspaceId only.
*/
import { logger } from "@formbricks/logger";
import {
DatabaseError,
InvalidInputError,
OperationNotAllowedError,
ResourceNotFoundError,
ValidationError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
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 { ZV3SurveyCreateBody } from "./schemas";
import { serializeV3SurveyListItem, serializeV3SurveyResource } from "./serializers";
import { serializeV3SurveyListItem } from "./serializers";
export const GET = withV3ApiWrapper({
auth: "both",
@@ -89,75 +79,3 @@ 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
@@ -1,163 +0,0 @@
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
@@ -1,157 +0,0 @@
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,25 +1,9 @@
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.
@@ -32,20 +16,3 @@ 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,56 +339,6 @@ 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";
+1 -27
View File
@@ -16,8 +16,7 @@ interface ApiErrorResponse {
| "method_not_allowed"
| "not_authenticated"
| "forbidden"
| "too_many_requests"
| "conflict";
| "too_many_requests";
message: string;
details: {
[key: string]: string | string[] | number | number[] | boolean | boolean[];
@@ -237,30 +236,6 @@ 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,
@@ -295,5 +270,4 @@ export const responses = {
successResponse,
tooManyRequestsResponse,
forbiddenResponse,
conflictResponse,
};
-1
View File
@@ -4926,7 +4926,6 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
showLanguageSwitch: false,
followUps: [],
isBackButtonHidden: false,
isAutoProgressingEnabled: true,
isCaptureIpEnabled: false,
metadata: {},
questions: [], // Required for build-time type checking (Zod defaults to [] at runtime)
-2
View File
@@ -1288,8 +1288,6 @@ checksums:
environments/surveys/edit/assign: e80715ab64bf7cf463abb3a9fd1ad516
environments/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0
environments/surveys/edit/auto_close_on_inactivity: 093db516799315ccd4242a3675693012
environments/surveys/edit/auto_progress_rating_and_nps: 76b98e95a5b850850baa0ccc3c7fbf7c
environments/surveys/edit/auto_progress_rating_and_nps_description: cbf676789b9f3f47e36bdf35fa58282b
environments/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
environments/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
environments/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
@@ -209,7 +209,6 @@ const baseSurveyProperties = {
},
],
isBackButtonHidden: false,
isAutoProgressingEnabled: false,
isCaptureIpEnabled: false,
endings: [
{
-1
View File
@@ -48,7 +48,6 @@ export const selectSurvey = {
isVerifyEmailEnabled: true,
isSingleResponsePerEmailEnabled: true,
isBackButtonHidden: true,
isAutoProgressingEnabled: true,
isCaptureIpEnabled: true,
redirectUrl: true,
projectOverwrites: true,
+20
View File
@@ -0,0 +1,20 @@
import { Prisma } from "@prisma/client";
export const publicUserSelect = {
id: true,
name: true,
email: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
twoFactorEnabled: true,
identityProvider: true,
notificationSettings: true,
locale: true,
lastLoginAt: true,
isActive: true,
} as const satisfies Prisma.UserSelect;
export type TPublicUser = Prisma.UserGetPayload<{
select: typeof publicUserSelect;
}>;
+10 -10
View File
@@ -6,6 +6,7 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TUserLocale, TUserUpdateInput } from "@formbricks/types/user";
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { publicUserSelect } from "./public-user";
import { deleteUser, getUser, getUserByEmail, getUsersWithOrganization, updateUser } from "./service";
vi.mock("@formbricks/database", () => ({
@@ -47,11 +48,6 @@ describe("User Service", () => {
locale: "en-US" as TUserLocale,
lastLoginAt: new Date(),
isActive: true,
twoFactorSecret: null,
backupCodes: null,
password: null,
identityProviderAccountId: null,
groupId: null,
};
const mockOrganizations: TOrganization[] = [
@@ -102,8 +98,12 @@ describe("User Service", () => {
expect(result).toEqual(mockPrismaUser);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: "user1" },
select: expect.any(Object),
select: publicUserSelect,
});
expect(result).not.toHaveProperty("password");
expect(result).not.toHaveProperty("twoFactorSecret");
expect(result).not.toHaveProperty("backupCodes");
expect(result).not.toHaveProperty("identityProviderAccountId");
});
test("should return null when user not found", async () => {
@@ -134,7 +134,7 @@ describe("User Service", () => {
expect(result).toEqual(mockPrismaUser);
expect(prisma.user.findFirst).toHaveBeenCalledWith({
where: { email: "test@example.com" },
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -176,7 +176,7 @@ describe("User Service", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user1" },
data: updateData,
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -204,7 +204,7 @@ describe("User Service", () => {
expect(deleteOrganization).toHaveBeenCalledWith("org1");
expect(prisma.user.delete).toHaveBeenCalledWith({
where: { id: "user1" },
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -236,7 +236,7 @@ describe("User Service", () => {
},
},
},
select: expect.any(Object),
select: publicUserSelect,
});
});
+7 -21
View File
@@ -10,21 +10,7 @@ import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbri
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo";
import { validateInputs } from "../utils/validate";
const responseSelection = {
id: true,
name: true,
email: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
twoFactorEnabled: true,
identityProvider: true,
notificationSettings: true,
locale: true,
lastLoginAt: true,
isActive: true,
};
import { publicUserSelect } from "./public-user";
// function to retrive basic information about a user's user
export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
@@ -35,7 +21,7 @@ export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
if (!user) {
@@ -59,7 +45,7 @@ export const getUserByEmail = reactCache(async (email: string): Promise<TUser |
where: {
email,
},
select: responseSelection,
select: publicUserSelect,
});
return user;
@@ -82,7 +68,7 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
id: personId,
},
data: data,
select: responseSelection,
select: publicUserSelect,
});
return updatedUser;
@@ -105,7 +91,7 @@ const deleteUserById = async (id: string): Promise<TUser> => {
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
return user;
} catch (error) {
@@ -153,7 +139,7 @@ export const getUsersWithOrganization = async (organizationId: string): Promise<
},
},
},
select: responseSelection,
select: publicUserSelect,
});
return users;
@@ -174,7 +160,7 @@ export const getUserLocale = reactCache(async (id: string): Promise<TUserLocale
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
if (!user) {
-2
View File
@@ -1359,8 +1359,6 @@
"assign": "Zuweisen =",
"audience": "Publikum",
"auto_close_on_inactivity": "Automatisches Schließen bei Inaktivität",
"auto_progress_rating_and_nps": "Bewertungs- und NPS-Fragen automatisch fortsetzen",
"auto_progress_rating_and_nps_description": "Fahre automatisch fort, sobald Befragte eine Antwort bei Bewertungs- oder NPS-Fragen auswählen. Dies gilt nur für Blöcke mit einer einzelnen Frage. Bei Pflichtfragen wird die Weiter-Schaltfläche ausgeblendet; bei optionalen Fragen bleibt sie zum Überspringen sichtbar.",
"auto_save_disabled": "Automatisches Speichern deaktiviert",
"auto_save_disabled_tooltip": "Ihre Umfrage wird nur im Entwurfsmodus automatisch gespeichert. So wird sichergestellt, dass öffentliche Umfragen nicht unbeabsichtigt aktualisiert werden.",
"auto_save_on": "Automatisches Speichern an",
-2
View File
@@ -1359,8 +1359,6 @@
"assign": "Assign =",
"audience": "Audience",
"auto_close_on_inactivity": "Auto close on inactivity",
"auto_progress_rating_and_nps": "Auto-progress rating and NPS questions",
"auto_progress_rating_and_nps_description": "Automatically advance when respondents select an answer on rating or NPS questions. This only applies to single-question blocks. Required questions hide the Next button; optional questions still show it for skipping.",
"auto_save_disabled": "Auto-save disabled",
"auto_save_disabled_tooltip": "Your survey is only auto-saved when in draft. This assures public surveys are not unintentionally updated.",
"auto_save_on": "Auto-save on",
-2
View File
@@ -1359,8 +1359,6 @@
"assign": "Asignar =",
"audience": "Audiencia",
"auto_close_on_inactivity": "Cierre automático por inactividad",
"auto_progress_rating_and_nps": "Avanzar automáticamente en preguntas de valoración y NPS",
"auto_progress_rating_and_nps_description": "Avanza automáticamente cuando los encuestados seleccionen una respuesta en preguntas de valoración o NPS. Esto solo se aplica a bloques de una sola pregunta. Las preguntas obligatorias ocultan el botón Siguiente; las preguntas opcionales aún lo muestran para omitirlas.",
"auto_save_disabled": "Guardado automático desactivado",
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
"auto_save_on": "Guardado automático activado",
-2
View File
@@ -1359,8 +1359,6 @@
"assign": "Attribuer =",
"audience": "Public",
"auto_close_on_inactivity": "Fermeture automatique en cas d'inactivité",
"auto_progress_rating_and_nps": "Progression automatique pour les questions d'évaluation et NPS",
"auto_progress_rating_and_nps_description": "Passe automatiquement à la question suivante lorsque les répondants sélectionnent une réponse aux questions d'évaluation ou NPS. Cela s'applique uniquement aux blocs à question unique. Les questions obligatoires masquent le bouton Suivant ; les questions facultatives l'affichent toujours pour permettre de passer la question.",
"auto_save_disabled": "Sauvegarde automatique désactivée",
"auto_save_disabled_tooltip": "Votre sondage n'est sauvegardé automatiquement que lorsqu'il est en brouillon. Cela garantit que les sondages publics ne sont pas mis à jour involontairement.",
"auto_save_on": "Sauvegarde automatique activée",
-2
View File
@@ -1359,8 +1359,6 @@
"assign": "= hozzárendelése",
"audience": "Közönség",
"auto_close_on_inactivity": "Automatikus lezárás tétlenségnél",
"auto_progress_rating_and_nps": "Automatikus továbblépés értékelési és NPS kérdéseknél",
"auto_progress_rating_and_nps_description": "Automatikus továbblépés, amikor a válaszadók kiválasztanak egy választ az értékelési vagy NPS kérdéseknél. Ez csak az egykérdéses blokkokra vonatkozik. A kötelező kérdések elrejtik a Tovább gombot; az opcionális kérdések továbbra is megjelenítik azt a kihagyás lehetősége érdekében.",
"auto_save_disabled": "Az automatikus mentés letiltva",
"auto_save_disabled_tooltip": "A kérdőív csak akkor kerül automatikusan mentésre, ha piszkozatban van. Ez biztosítja, hogy a nyilvános kérdőívek ne legyenek véletlenül frissítve.",
"auto_save_on": "Automatikus mentés bekapcsolva",
-2
View File
@@ -1359,8 +1359,6 @@
"assign": "割り当て =",
"audience": "オーディエンス",
"auto_close_on_inactivity": "非アクティブ時に自動閉鎖",
"auto_progress_rating_and_nps": "評価とNPSの質問を自動進行",
"auto_progress_rating_and_nps_description": "評価またはNPSの質問で回答者が選択肢を選んだ際に自動的に次へ進みます。これは単一質問ブロックにのみ適用されます。必須の質問では「次へ」ボタンが非表示になり、任意の質問ではスキップ用に引き続き表示されます。",
"auto_save_disabled": "自動保存が無効",
"auto_save_disabled_tooltip": "アンケートは下書き状態の時のみ自動保存されます。これにより、公開中のアンケートが意図せず更新されることを防ぎます。",
"auto_save_on": "自動保存オン",
-2
View File
@@ -1359,8 +1359,6 @@
"assign": "Toewijzen =",
"audience": "Publiek",
"auto_close_on_inactivity": "Automatisch sluiten bij inactiviteit",
"auto_progress_rating_and_nps": "Automatisch doorgaan bij beoordelings- en NPS-vragen",
"auto_progress_rating_and_nps_description": "Ga automatisch verder wanneer respondenten een antwoord selecteren bij beoordelings- of NPS-vragen. Dit geldt alleen voor blokken met één vraag. Bij verplichte vragen wordt de Volgende-knop verborgen; bij optionele vragen blijft deze zichtbaar om de vraag over te slaan.",
"auto_save_disabled": "Automatisch opslaan uitgeschakeld",
"auto_save_disabled_tooltip": "Uw enquête wordt alleen automatisch opgeslagen wanneer deze een concept is. Dit zorgt ervoor dat openbare enquêtes niet onbedoeld worden bijgewerkt.",
"auto_save_on": "Automatisch opslaan aan",
-2
View File
@@ -1359,8 +1359,6 @@
"assign": "atribuir =",
"audience": "Público",
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
"auto_progress_rating_and_nps": "Avançar automaticamente em perguntas de avaliação e NPS",
"auto_progress_rating_and_nps_description": "Avança automaticamente quando os respondentes selecionam uma resposta em perguntas de avaliação ou NPS. Isso se aplica apenas a blocos com uma única pergunta. Perguntas obrigatórias ocultam o botão Próximo; perguntas opcionais ainda o exibem para permitir pular.",
"auto_save_disabled": "Salvamento automático desativado",
"auto_save_disabled_tooltip": "Sua pesquisa só é salva automaticamente quando está em rascunho. Isso garante que pesquisas públicas não sejam atualizadas involuntariamente.",
"auto_save_on": "Salvamento automático ativado",
-2
View File
@@ -1359,8 +1359,6 @@
"assign": "Atribuir =",
"audience": "Público",
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
"auto_progress_rating_and_nps": "Avançar automaticamente em perguntas de classificação e NPS",
"auto_progress_rating_and_nps_description": "Avança automaticamente quando os inquiridos selecionam uma resposta em perguntas de classificação ou NPS. Isto aplica-se apenas a blocos com uma única pergunta. Perguntas obrigatórias ocultam o botão Seguinte; perguntas opcionais continuam a mostrá-lo para permitir saltar.",
"auto_save_disabled": "Guardar automático desativado",
"auto_save_disabled_tooltip": "O seu inquérito só é guardado automaticamente quando está em rascunho. Isto garante que os inquéritos públicos não sejam atualizados involuntariamente.",
"auto_save_on": "Guardar automático ativado",
-2
View File
@@ -1359,8 +1359,6 @@
"assign": "Atribuire =",
"audience": "Public",
"auto_close_on_inactivity": "Închidere automată la inactivitate",
"auto_progress_rating_and_nps": "Avansare automată pentru întrebări de rating și NPS",
"auto_progress_rating_and_nps_description": "Avansează automat când respondenții selectează un răspuns la întrebările de rating sau NPS. Aceasta se aplică doar blocurilor cu o singură întrebare. Întrebările obligatorii ascund butonul Următorul; întrebările opționale îl afișează în continuare pentru a permite omiterea.",
"auto_save_disabled": "Salvare automată dezactivată",
"auto_save_disabled_tooltip": "Chestionarul dvs. este salvat automat doar când este în ciornă. Acest lucru asigură că sondajele publice nu sunt actualizate neintenționat.",
"auto_save_on": "Salvare automată activată",
-2
View File
@@ -1359,8 +1359,6 @@
"assign": "Назначить =",
"audience": "Аудитория",
"auto_close_on_inactivity": "Автоматически закрывать при бездействии",
"auto_progress_rating_and_nps": "Автоматический переход для вопросов с оценкой и NPS",
"auto_progress_rating_and_nps_description": "Автоматически переходить к следующему шагу, когда респонденты выбирают ответ в вопросах с оценкой или NPS. Это применяется только к блокам с одним вопросом. В обязательных вопросах кнопка «Далее» скрыта; в необязательных вопросах она остается видимой для пропуска.",
"auto_save_disabled": "Автосохранение отключено",
"auto_save_disabled_tooltip": "Ваш опрос автоматически сохраняется только в режиме черновика. Это гарантирует, что публичные опросы не будут случайно обновлены.",
"auto_save_on": "Автосохранение включено",
-2
View File
@@ -1359,8 +1359,6 @@
"assign": "Tilldela =",
"audience": "Målgrupp",
"auto_close_on_inactivity": "Stäng automatiskt vid inaktivitet",
"auto_progress_rating_and_nps": "Gå vidare automatiskt vid betygs- och NPS-frågor",
"auto_progress_rating_and_nps_description": "Gå automatiskt vidare när respondenter väljer ett svar på betygs- eller NPS-frågor. Detta gäller endast block med en enda fråga. Obligatoriska frågor döljer Nästa-knappen; valfria frågor visar den fortfarande för att kunna hoppas över.",
"auto_save_disabled": "Automatisk sparning inaktiverad",
"auto_save_disabled_tooltip": "Din enkät sparas endast automatiskt när den är ett utkast. Detta säkerställer att publika enkäter inte uppdateras oavsiktligt.",
"auto_save_on": "Automatisk sparning på",
-2
View File
@@ -1359,8 +1359,6 @@
"assign": "指派 =",
"audience": "受众",
"auto_close_on_inactivity": "自动关闭 在 无活动时",
"auto_progress_rating_and_nps": "自动推进评分和 NPS 问题",
"auto_progress_rating_and_nps_description": "当受访者在评分或 NPS 问题上选择答案时自动前进。这仅适用于单问题区块。必填问题会隐藏\"下一步\"按钮;可选问题仍会显示该按钮以便跳过。",
"auto_save_disabled": "自动保存已禁用",
"auto_save_disabled_tooltip": "您的调查仅在草稿状态时自动保存。这确保公开的调查不会被意外更新。",
"auto_save_on": "自动保存已启用",
-2
View File
@@ -1359,8 +1359,6 @@
"assign": "等於 =",
"audience": "受眾",
"auto_close_on_inactivity": "非活動時自動關閉",
"auto_progress_rating_and_nps": "自動前進評分與 NPS 問題",
"auto_progress_rating_and_nps_description": "當受訪者在評分或 NPS 問題中選擇答案時自動前進。此設定僅適用於單一問題區塊。必填問題會隱藏「下一步」按鈕;選填問題仍會顯示該按鈕以便跳過。",
"auto_save_disabled": "自動儲存已停用",
"auto_save_disabled_tooltip": "您的問卷僅在草稿狀態時自動儲存。這確保公開的問卷不會被意外更新。",
"auto_save_on": "自動儲存已啟用",
@@ -13,6 +13,10 @@ const mockUser = {
createdAt: new Date(),
updatedAt: new Date(),
isActive: true,
password: "$2b$12$hashedPassword",
twoFactorSecret: "encrypted-2fa-secret",
backupCodes: "encrypted-backup-codes",
identityProviderAccountId: "provider-account-id",
role: "admin",
memberships: [{ organizationId: "org456", role: "admin" }],
teamUsers: [{ team: { name: "Test Team", id: "team123", projectTeams: [{ projectId: "proj789" }] } }],
@@ -60,6 +64,10 @@ describe("Users Lib", () => {
updatedAt: expect.any(Date),
},
]);
expect(result.data.data[0]).not.toHaveProperty("password");
expect(result.data.data[0]).not.toHaveProperty("twoFactorSecret");
expect(result.data.data[0]).not.toHaveProperty("backupCodes");
expect(result.data.data[0]).not.toHaveProperty("identityProviderAccountId");
}
});
@@ -84,6 +92,10 @@ describe("Users Lib", () => {
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.id).toBe(mockUser.id);
expect(result.data).not.toHaveProperty("password");
expect(result.data).not.toHaveProperty("twoFactorSecret");
expect(result.data).not.toHaveProperty("backupCodes");
expect(result.data).not.toHaveProperty("identityProviderAccountId");
}
});
@@ -98,11 +110,14 @@ 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" },
@@ -148,6 +163,10 @@ describe("Users Lib", () => {
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.name).toBe("Updated User");
expect(result.data).not.toHaveProperty("password");
expect(result.data).not.toHaveProperty("twoFactorSecret");
expect(result.data).not.toHaveProperty("backupCodes");
expect(result.data).not.toHaveProperty("identityProviderAccountId");
}
});
@@ -46,9 +46,9 @@ export const OpenIdButton = ({
type="button"
onClick={handleLogin}
variant="secondary"
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>}
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>}
</Button>
);
};
@@ -51,7 +51,7 @@ describe("SSO Providers", () => {
expect((samlProvider as any).authorization?.url).toBe("https://test-app.com/api/auth/saml/authorize");
expect(samlProvider.token).toBe("https://test-app.com/api/auth/saml/token");
expect(samlProvider.userinfo).toBe("https://test-app.com/api/auth/saml/userinfo");
expect(googleProvider.allowDangerousEmailAccountLinking).toBeUndefined();
expect(samlProvider.allowDangerousEmailAccountLinking).toBeUndefined();
expect((googleProvider as any).options?.allowDangerousEmailAccountLinking).toBe(true);
expect(samlProvider.allowDangerousEmailAccountLinking).toBe(true);
});
});
+2
View File
@@ -26,6 +26,7 @@ export const getSSOProviders = () => [
GoogleProvider({
clientId: GOOGLE_CLIENT_ID || "",
clientSecret: GOOGLE_CLIENT_SECRET || "",
allowDangerousEmailAccountLinking: true,
}),
AzureAD({
clientId: AZUREAD_CLIENT_ID || "",
@@ -80,6 +81,7 @@ export const getSSOProviders = () => [
clientId: "dummy",
clientSecret: "dummy",
},
allowDangerousEmailAccountLinking: true,
},
];
+4 -5
View File
@@ -34,8 +34,6 @@ const LINKED_SSO_LOOKUP_SELECT = {
identityProviderAccountId: true,
} as const;
const OAUTH_ACCOUNT_NOT_LINKED_ERROR = "OAuthAccountNotLinked";
const syncSsoAccount = async (userId: string, account: Account, tx?: Prisma.TransactionClient) => {
await upsertAccount(
{
@@ -219,7 +217,7 @@ export const handleSsoCallback = async ({
}
// There is no existing linked account for this identity provider / account id
// check if a user account with this email already exists and fail closed if so
// check if a user account with this email already exists and auto-link it
contextLogger.debug({ lookupType: "email" }, "No linked SSO account found, checking for user by email");
const existingUserWithEmail = await getUserByEmail(user.email);
@@ -230,9 +228,10 @@ export const handleSsoCallback = async ({
existingUserId: existingUserWithEmail.id,
existingIdentityProvider: existingUserWithEmail.identityProvider,
},
"SSO callback blocked: existing user found by email without linked provider account"
"SSO callback successful: existing user found by email"
);
throw new Error(OAUTH_ACCOUNT_NOT_LINKED_ERROR);
await syncSsoAccount(existingUserWithEmail.id, account);
return true;
}
contextLogger.debug(
@@ -338,7 +338,7 @@ describe("handleSsoCallback", () => {
);
});
test("should reject verified email users whose SSO provider is not already linked", async () => {
test("should auto-link verified email users whose SSO provider is not already linked", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue({
id: "existing-user-id",
@@ -349,22 +349,26 @@ describe("handleSsoCallback", () => {
isActive: true,
});
await expect(
handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
})
).rejects.toThrow("OAuthAccountNotLinked");
expect(upsertAccount).not.toHaveBeenCalled();
const result = await handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
});
expect(result).toBe(true);
expect(upsertAccount).toHaveBeenCalledWith(
expect.objectContaining({
userId: "existing-user-id",
provider: mockAccount.provider,
providerAccountId: mockAccount.providerAccountId,
}),
undefined
);
expect(updateUser).not.toHaveBeenCalled();
expect(createUser).not.toHaveBeenCalled();
expect(createMembership).not.toHaveBeenCalled();
expect(createBrevoCustomer).not.toHaveBeenCalled();
expect(capturePostHogEvent).not.toHaveBeenCalled();
});
test("should reject unverified email users whose SSO provider is not already linked", async () => {
test("should auto-link unverified email users whose SSO provider is not already linked", async () => {
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue({
@@ -376,22 +380,26 @@ describe("handleSsoCallback", () => {
isActive: true,
});
await expect(
handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
})
).rejects.toThrow("OAuthAccountNotLinked");
expect(upsertAccount).not.toHaveBeenCalled();
const result = await handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
});
expect(result).toBe(true);
expect(upsertAccount).toHaveBeenCalledWith(
expect.objectContaining({
userId: "existing-user-id",
provider: mockAccount.provider,
providerAccountId: mockAccount.providerAccountId,
}),
undefined
);
expect(updateUser).not.toHaveBeenCalled();
expect(createUser).not.toHaveBeenCalled();
expect(createMembership).not.toHaveBeenCalled();
expect(createBrevoCustomer).not.toHaveBeenCalled();
expect(capturePostHogEvent).not.toHaveBeenCalled();
});
test("should reject existing users from a different SSO provider when no link exists", async () => {
test("should auto-link existing users from a different SSO provider when no link exists", async () => {
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue({
@@ -403,14 +411,53 @@ describe("handleSsoCallback", () => {
isActive: true,
});
await expect(
handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
})
).rejects.toThrow("OAuthAccountNotLinked");
expect(upsertAccount).not.toHaveBeenCalled();
const result = await handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
});
expect(result).toBe(true);
expect(upsertAccount).toHaveBeenCalledWith(
expect.objectContaining({
userId: "existing-user-id",
provider: mockAccount.provider,
providerAccountId: mockAccount.providerAccountId,
}),
undefined
);
expect(updateUser).not.toHaveBeenCalled();
expect(createUser).not.toHaveBeenCalled();
});
test("should auto-link same-email users even when the stored legacy provider account id is stale", async () => {
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue({
id: "existing-user-id",
email: mockUser.email,
emailVerified: new Date(),
identityProvider: "google",
identityProviderAccountId: "old-provider-id",
locale: mockUser.locale,
isActive: true,
} as any);
const result = await handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
});
expect(result).toBe(true);
expect(upsertAccount).toHaveBeenCalledWith(
expect.objectContaining({
userId: "existing-user-id",
provider: mockAccount.provider,
providerAccountId: mockAccount.providerAccountId,
}),
undefined
);
expect(updateUser).not.toHaveBeenCalled();
expect(createUser).not.toHaveBeenCalled();
});
@@ -118,10 +118,6 @@ export const ResponseOptionsCard = ({
setLocalSurvey({ ...localSurvey, isBackButtonHidden: !localSurvey.isBackButtonHidden });
};
const handleAutoProgressToggle = () => {
setLocalSurvey({ ...localSurvey, isAutoProgressingEnabled: !localSurvey.isAutoProgressingEnabled });
};
const handleCaptureIpToggle = () => {
setCaptureIpToggle(!captureIpToggle);
setLocalSurvey({ ...localSurvey, isCaptureIpEnabled: !localSurvey.isCaptureIpEnabled });
@@ -388,13 +384,6 @@ export const ResponseOptionsCard = ({
</AdvancedOptionToggle>
</>
)}
<AdvancedOptionToggle
htmlId="autoProgressRatingNps"
isChecked={Boolean(localSurvey.isAutoProgressingEnabled)}
onToggle={handleAutoProgressToggle}
title={t("environments.surveys.edit.auto_progress_rating_and_nps")}
description={t("environments.surveys.edit.auto_progress_rating_and_nps_description")}
/>
<AdvancedOptionToggle
htmlId="hideBackButton"
isChecked={localSurvey.isBackButtonHidden}
@@ -211,15 +211,11 @@ export const StylingView = ({
render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl>
<Switch
id="overwrite-theme-styling"
checked={!!field.value}
onCheckedChange={handleOverwriteToggle}
/>
<Switch checked={!!field.value} onCheckedChange={handleOverwriteToggle} />
</FormControl>
<div>
<FormLabel htmlFor="overwrite-theme-styling" className="text-base font-semibold text-slate-900">
<FormLabel className="text-base font-semibold text-slate-900">
{t("environments.surveys.edit.add_custom_styles")}
</FormLabel>
<FormDescription className="text-sm text-slate-800">
-1
View File
@@ -41,7 +41,6 @@ export const selectSurvey = {
showLanguageSwitch: true,
recaptcha: true,
isBackButtonHidden: true,
isAutoProgressingEnabled: true,
metadata: true,
slug: true,
customHeadScripts: true,
@@ -79,7 +79,6 @@ describe("data", () => {
redirectUrl: null,
pin: null,
isBackButtonHidden: false,
isAutoProgressingEnabled: true,
singleUse: null,
projectOverwrites: null,
styling: null,
@@ -117,11 +116,6 @@ describe("data", () => {
type: true,
}),
});
expect(vi.mocked(prisma.survey.findUnique).mock.calls[0][0].select).toEqual(
expect.objectContaining({
isAutoProgressingEnabled: true,
})
);
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockSurveyData);
});
@@ -202,7 +196,6 @@ describe("data", () => {
redirectUrl: null,
pin: null,
isBackButtonHidden: false,
isAutoProgressingEnabled: true,
singleUse: null,
projectOverwrites: null,
surveyClosedMessage: null,
-1
View File
@@ -47,7 +47,6 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
redirectUrl: true,
pin: true,
isBackButtonHidden: true,
isAutoProgressingEnabled: true,
isCaptureIpEnabled: true,
// Single use configuration
@@ -41,7 +41,6 @@ export const getMinimalSurvey = (t: TFunction): TSurvey => ({
variables: [],
followUps: [],
isBackButtonHidden: false,
isAutoProgressingEnabled: true,
metadata: {},
slug: null,
isCaptureIpEnabled: false,
@@ -1,7 +1,7 @@
import { Skeleton } from "@/modules/ui/components/skeleton";
type SkeletonLoaderProps = {
type: "response" | "responseTable" | "summary";
type: "response" | "summary";
};
export const SkeletonLoader = ({ type }: SkeletonLoaderProps) => {
@@ -25,43 +25,6 @@ export const SkeletonLoader = ({ type }: SkeletonLoaderProps) => {
);
}
if (type === "responseTable") {
const renderTableCells = () => (
<>
<Skeleton className="h-4 w-4 rounded-xl bg-slate-400" />
<Skeleton className="h-4 w-24 rounded-xl bg-slate-200" />
<Skeleton className="h-4 w-32 rounded-xl bg-slate-200" />
<Skeleton className="h-4 w-40 rounded-xl bg-slate-200" />
<Skeleton className="h-4 w-40 rounded-xl bg-slate-200" />
<Skeleton className="h-4 w-32 rounded-xl bg-slate-200" />
</>
);
return (
<div className="animate-pulse space-y-4" data-testid="skeleton-loader-response-table">
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-48 rounded-md bg-slate-300" />
<div className="flex gap-2">
<Skeleton className="h-8 w-8 rounded-md bg-slate-300" />
<Skeleton className="h-8 w-8 rounded-md bg-slate-300" />
</div>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200">
<div className="flex h-12 items-center gap-4 border-b border-slate-200 bg-slate-100 px-4">
{renderTableCells()}
</div>
{Array.from({ length: 10 }, (_, i) => (
<div
key={i}
className="flex h-12 items-center gap-4 border-b border-slate-100 px-4 last:border-b-0">
{renderTableCells()}
</div>
))}
</div>
</div>
);
}
if (type === "response") {
return (
<div className="group space-y-4 rounded-lg bg-white p-6" data-testid="skeleton-loader-response">
@@ -0,0 +1,38 @@
import { expect } from "@playwright/test";
import { logger } from "@formbricks/logger";
import { test } from "../../lib/fixtures";
test.describe("API Tests for Management Me", () => {
test("Authenticated v1 me endpoint never exposes secret auth fields", async ({ page, users }) => {
const name = `Security Me User ${Date.now()}`;
const email = `security-me-${Date.now()}@example.com`;
try {
const user = await users.create({ name, email });
await user.login();
const response = await page.context().request.get("/api/v1/management/me");
expect(response.ok()).toBe(true);
const responseBody = await response.json();
expect(responseBody).toMatchObject({
id: expect.any(String),
name,
email,
twoFactorEnabled: expect.any(Boolean),
identityProvider: expect.any(String),
notificationSettings: expect.any(Object),
locale: expect.any(String),
isActive: expect.any(Boolean),
});
expect(responseBody).not.toHaveProperty("password");
expect(responseBody).not.toHaveProperty("twoFactorSecret");
expect(responseBody).not.toHaveProperty("backupCodes");
expect(responseBody).not.toHaveProperty("identityProviderAccountId");
} catch (error) {
logger.error(error, "Error during management me API security test");
throw error;
}
});
});
-4
View File
@@ -5631,9 +5631,6 @@ components:
isBackButtonHidden:
type: boolean
description: Whether the back button is hidden
isAutoProgressingEnabled:
type: boolean
description: Whether auto-progress is enabled for eligible question types
recaptcha:
type:
- object
@@ -5709,7 +5706,6 @@ components:
- isSingleResponsePerEmailEnabled
- inlineTriggers
- isBackButtonHidden
- isAutoProgressingEnabled
- recaptcha
- metadata
- displayPercentage
@@ -1,24 +0,0 @@
`#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%.
+95 -643
View File
@@ -1,65 +1,52 @@
# 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
# V3 API — GET Surveys (hand-maintained; not generated by generate-api-specs).
# Implementation: apps/web/app/api/v3/surveys/route.ts
# See apps/web/app/api/v3/README.md and docs/Survey-Server-Actions.md (Part III) for full context.
openapi: 3.1.0
info:
title: Formbricks API v3
version: 0.2.0
description: |
Survey management endpoints for Formbricks' v3 API.
**GET /api/v3/surveys** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace environment).
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.
**Spec location:** `docs/api-v3-reference/openapi.yml` (alongside v2 at `docs/api-v2-reference/openapi.yml`).
Scope 1 is intentionally limited to survey structure:
- `name`
- `type`
- `status`
- `welcomeCard`
- `blocks`
- `endings`
- `hiddenFields`
- `variables`
**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).
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
**Auth**
Authenticate with either a session cookie or **`x-api-key`**. In dual-auth mode, V3 checks the API key first when the header is present, otherwise it uses the session path. Unauthenticated callers get **401** before query validation.
**Pagination**
Cursor-based pagination with **limit** + opaque **cursor** token. Responses return `meta.nextCursor`; pass that value back as `cursor` to fetch the next page. Responses also include `meta.totalCount`, the total number of surveys matching the current filters across all pages. There is no `offset` in this contract.
**Filtering**
Filters use explicit operator-style query parameters under the **`filter[...]` family**. This endpoint supports `filter[name][contains]`, `filter[status][in]`, and `filter[type][in]`. Multi-value filters use repeated keys or comma-separated values (e.g. `filter[status][in]=draft&filter[status][in]=inProgress` or `filter[status][in]=draft,inProgress`). Sorting remains a flat `sortBy` query parameter.
**Security**
Missing/forbidden workspace returns **403** with a generic message (not **404**) so resource existence is not leaked. List responses use `private, no-store`.
**OpenAPI**
This YAML is **not** produced by `pnpm generate-api-specs` (that script only builds v2 → `docs/api-v2-reference/openapi.yml`). Update this file when the route contract changes.
**Next steps (out of scope for this spec)**
Additional v3 survey endpoints (single survey, CRUD), frontend cutover from `getSurveysAction`, optional ETag/304, field selection — see Survey-Server-Actions.md Part III.
version: 0.1.0
x-implementation-notes:
route-collection: apps/web/app/api/v3/surveys/route.ts
route-item: apps/web/app/api/v3/surveys/[surveyId]/route.ts
route: apps/web/app/api/v3/surveys/route.ts
query-parser: apps/web/app/api/v3/surveys/parse-v3-surveys-list-query.ts
auth: apps/web/app/api/v3/lib/auth.ts
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
servers:
- url: https://app.formbricks.com
tags:
- name: V3 Surveys
pagination-model: cursor
cursor-pagination: supported
paths:
/api/v3/surveys:
get:
operationId: listSurveysV3
operationId: getSurveysV3
summary: List 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.
description: Returns surveys for the workspace. Session cookie or x-api-key.
tags:
- V3 Surveys
parameters:
- in: query
name: workspaceId
@@ -68,7 +55,7 @@ paths:
type: string
format: cuid2
description: |
Workspace identifier. Today this maps to the environment id used by the current data model.
Workspace identifier. **Today:** pass the **environment id** (the environment that contains the surveys). When Workspace replaces Environment in the data model, clients may pass workspace ids instead; resolution is centralized in workspace-context.
- in: query
name: limit
schema:
@@ -76,16 +63,19 @@ 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`.
description: |
Opaque cursor returned as `meta.nextCursor` from the previous page. Omit on the first request.
- in: query
name: filter[name][contains]
schema:
type: string
maxLength: 512
description: Case-insensitive substring match on survey name (same as in-app list filters).
- in: query
name: filter[status][in]
schema:
@@ -95,6 +85,8 @@ 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:
@@ -104,20 +96,23 @@ 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:
@@ -132,221 +127,49 @@ 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":
$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: []
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
description: Bad Request
content:
application/json:
application/problem+json:
schema:
type: object
required: [data]
properties:
data:
$ref: "#/components/schemas/SurveyResource"
"400":
$ref: "#/components/responses/Problem400"
$ref: "#/components/schemas/Problem"
"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
description: Not authenticated (no valid session or API key)
content:
application/json:
application/problem+json:
schema:
type: object
required: [data]
properties:
data:
$ref: "#/components/schemas/SurveyResource"
"400":
$ref: "#/components/responses/Problem400"
"401":
$ref: "#/components/responses/Problem401"
$ref: "#/components/schemas/Problem"
"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
description: Forbidden — no access, or workspace/environment does not exist (404 not used; avoids existence leak)
content:
application/json:
application/problem+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"
$ref: "#/components/schemas/Problem"
"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
description: Rate limit exceeded
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"
Retry-After:
schema: { type: integer }
description: Seconds until the current rate-limit window resets
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"500":
$ref: "#/components/responses/Problem500"
description: Internal Server Error
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
security:
- sessionAuth: []
- apiKeyAuth: []
@@ -358,422 +181,51 @@ components:
in: cookie
name: next-auth.session-token
description: |
NextAuth session cookie. In production this may be `__Secure-next-auth.session-token`.
NextAuth session JWT cookie. **Development:** often `next-auth.session-token`.
**Production (HTTPS):** often `__Secure-next-auth.session-token`. Send the cookie your browser receives after sign-in.
apiKeyAuth:
type: apiKey
in: header
name: x-api-key
description: |
Management API key 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"
Management API key; must include **workspaceId** as an allowed environment with read, write, or manage permission.
schemas:
SurveyListItem:
type: object
additionalProperties: false
required:
- id
- workspaceId
- name
- type
- status
- createdAt
- updatedAt
- responseCount
- creator
properties:
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
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.
Shape from `getSurveys` (`surveySelect` + `responseCount`). Serialized dates are ISO 8601 strings.
Legacy DB rows may include survey **type** values `website` or `web` (see Prisma); filter **type** only accepts `link` | `app`.
properties:
id:
type: string
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:
id: { type: string }
name: { type: string }
environmentId: { type: string }
type: { type: string, enum: [link, app, website, web] }
status:
type: string
enum: [draft, inProgress, paused, completed]
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
responseCount: { type: integer }
creator: { type: object, nullable: true, properties: { name: { type: string } } }
Problem:
type: object
description: RFC 9457 Problem Details for HTTP APIs (`application/problem+json`). Responses typically include a machine-readable `code` field alongside `title`, `status`, `detail`, and `requestId`.
required: [title, status, detail, requestId]
properties:
type:
type: string
format: uri
title:
type: string
status:
type: integer
detail:
type: string
instance:
type: string
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
- not_found
- internal_server_error
- too_many_requests
requestId:
type: string
details:
type: object
enum: [bad_request, not_authenticated, forbidden, internal_server_error, too_many_requests]
requestId: { type: string }
details: { type: object }
invalid_params:
type: array
items:
type: object
additionalProperties: false
properties:
name:
type: string
reason:
type: string
name: { type: string }
reason: { type: string }
@@ -1,401 +0,0 @@
---
title: "Background Job Processing"
description: "How BullMQ works in Formbricks today, including the migrated response pipeline workload."
icon: "code"
---
This page documents the current BullMQ-based background job system in Formbricks and the first real workload that now runs on it: the response pipeline.
## Current State
Formbricks now uses BullMQ as an in-process background job system inside the Next.js web application.
The current implementation includes:
- a shared `@formbricks/jobs` package that owns queue creation, schemas, scheduling, and worker runtime concerns
- a Next.js startup hook that starts one BullMQ worker runtime per Node.js process without blocking app boot
- app-level enqueue helpers for request handlers
- an app-owned BullMQ response pipeline processor that replaces the legacy internal HTTP pipeline route
The first migrated workload is:
- `response-pipeline.process`
This means response-related side effects no longer depend on an internal `fetch()` back into the same app process.
## Why This Exists
The original response pipeline lived behind an internal Next.js route:
```text
apps/web/app/api/(internal)/pipeline
```
That model had a few problems:
- it was tightly coupled to the request lifecycle
- it relied on an internal HTTP hop instead of a typed background-job boundary
- it was harder to observe, retry, and scale safely
BullMQ addresses that by moving post-response work behind a queue while keeping the first version operationally simple for self-hosted users.
## High-Level Architecture
```mermaid
graph TD
A["API route or server code"] --> B["enqueueResponsePipelineEvents()"]
B --> C["getResponseSnapshotForPipeline()"]
B --> D["BackgroundJobProducer.enqueueResponsePipeline()"]
D --> E["BullMQ queue: background-jobs"]
F["instrumentation.ts"] --> G["registerJobsWorker()"]
G --> H["startJobsRuntime()"]
H --> I["BullMQ workers"]
I --> J["response-pipeline.process override"]
J --> K["processResponsePipelineJob()"]
E --> I
E --> L["Redis / Valkey"]
I --> L
```
## Responsibilities By Layer
### App Layer
- `apps/web/app/lib/pipelines.ts`
Owns enqueueing for response pipeline events. It gates queueing, hydrates the response snapshot once, logs failures, and never throws back into request handlers.
- `apps/web/modules/response-pipeline/lib/process-response-pipeline-job.ts`
Owns app-specific execution of response-pipeline jobs.
- `apps/web/modules/response-pipeline/lib/handle-integrations.ts`
Owns Slack, Notion, Airtable, and Google Sheets integration fan-out for the pipeline.
- `apps/web/modules/response-pipeline/lib/telemetry.ts`
Owns telemetry dispatch logic used by the response-created path.
- `apps/web/instrumentation-jobs.ts`
Registers the app-owned response-pipeline handler override with the shared BullMQ runtime and schedules retry after transient startup failures.
- `apps/web/lib/jobs/config.ts`
Turns environment configuration into queueing and worker-bootstrap decisions. Queue producers depend on `REDIS_URL`; worker startup additionally depends on `BULLMQ_WORKER_ENABLED`.
### Shared Jobs Layer
- `packages/jobs/src/types.ts`
Defines typed payload schemas such as `TResponsePipelineJobData`.
- `packages/jobs/src/definitions.ts`
Defines stable job names and payload validation.
- `packages/jobs/src/queue.ts`
Owns producer-side enqueueing and scheduling.
- `packages/jobs/src/runtime.ts`
Starts workers, connects Redis, and handles graceful shutdown.
- `packages/jobs/src/processors/registry.ts`
Validates payloads and dispatches named jobs, applying app-provided handler overrides when registered.
## Response Pipeline Flow
The response pipeline now runs fully in the background worker.
### Enqueueing
When a response is created or updated, the request path calls:
```ts
enqueueResponsePipelineEvents({
environmentId,
surveyId,
responseId,
events,
});
```
That helper:
1. deduplicates requested events
2. checks whether BullMQ queueing is enabled
3. uses the just-written response snapshot when the caller already has it
4. otherwise loads the latest response snapshot once via `getResponseSnapshotForPipeline(responseId)` using an uncached read
5. enqueues one BullMQ job per event with the shared snapshot payload
6. waits for the enqueue attempt to complete, then logs enqueue failures without failing the original request
### Execution
At worker startup, `apps/web/instrumentation-jobs.ts` registers an app-owned override for:
- `response-pipeline.process`
That override delegates to `processResponsePipelineJob(...)`, which performs:
- webhook delivery for all pipeline events
- integrations for `responseFinished`
- response-finished notification emails
- follow-up delivery
- survey auto-complete updates and audit logging
- response-created billing metering
- response-created telemetry dispatch
Current retry semantics are intentionally asymmetric:
- webhook delivery failures fail early BullMQ attempts so retries can happen at the job level
- if webhook delivery is still failing on the final BullMQ attempt, the worker logs that retries are exhausted and continues with the remaining event-specific side effects
- integration, email, telemetry, metering, follow-up, and survey auto-complete failures are logged inside the processor and do not fail the whole job
## Acceptance Criteria Review
### Pipeline Execution
Satisfied.
- New response create/update flows enqueue BullMQ jobs instead of calling an internal HTTP route.
- The job payload contains `environmentId`, `surveyId`, `event`, and an authoritative response snapshot.
- The response pipeline executes inside the BullMQ worker runtime.
### Feature Parity
Mostly satisfied for the legacy response pipeline behavior that existed in the old route.
The migrated BullMQ processor preserves:
- webhook delivery
- integrations
- response-finished emails
- follow-up execution
- survey auto-complete and audit logging
- response-created billing metering
- response-created telemetry
One important behavior change still exists today:
- webhook delivery failures delay the remaining side effects until the final BullMQ attempt
That is closer to the legacy route, because the pipeline eventually continues even if webhook delivery never succeeds. It is still not exact feature parity, though, because the legacy route continued immediately while the BullMQ worker waits until retries are exhausted before it degrades webhook failure into a logged condition.
### Architecture
Satisfied.
- Enqueueing lives in the app layer through `apps/web/app/lib/pipelines.ts`.
- Execution lives in the worker path under `apps/web/modules/response-pipeline/lib`.
- `@formbricks/jobs` stays responsible for queue/runtime concerns and typed job contracts.
### Cleanup
Satisfied.
The legacy internal route has been removed:
```text
apps/web/app/api/(internal)/pipeline/route.ts
```
The runtime path no longer depends on the old internal-route folder structure, and the remaining pipeline-only test mock under that deleted folder has been removed as part of the migration cleanup.
### Reliability
Satisfied at the current ticket scope.
BullMQ jobs use shared default retry behavior:
- `attempts: 3`
- exponential backoff starting at `1000ms`
Failures are logged with structured metadata such as:
- `jobId`
- `attempt`
- `jobName`
- `queueName`
- `environmentId`
- `surveyId`
- `responseId`
Request handlers remain non-blocking:
- if Redis is unavailable
- if queueing is disabled
- if snapshot hydration fails
- if enqueueing fails
the request still completes, and the failure is logged.
Worker startup is also non-blocking:
- Next.js boot does not await BullMQ readiness
- startup failures are logged
- the web app schedules a retry instead of requiring an immediate process restart
### Worker Integration
Satisfied.
The response pipeline is processed by the same BullMQ worker runtime started from Next.js instrumentation. No standalone worker service was introduced as part of this migration.
### Developer Experience
Satisfied.
The public app-level API for request handlers is intentionally small:
- `enqueueResponsePipelineEvents(...)`
This keeps queue names, Redis concerns, and BullMQ details out of response routes.
## Comparison With The Legacy Route
### Previous Implementation
The legacy internal route accepted a full response payload directly and then executed the entire pipeline synchronously inside the route handler.
Key characteristics of that model:
- request handlers performed an internal authenticated `fetch()` back into the same app
- the route received the response payload directly instead of hydrating it from a queue-side snapshot
- webhook failures were logged and did not block the rest of the pipeline
- response-finished integrations, emails, follow-ups, and survey auto-complete ran in the same route execution
- response-created metering was fire-and-forget while telemetry was awaited
### Current BullMQ Implementation
The current branch enqueues a typed snapshot-based BullMQ job and executes the pipeline inside the in-process worker registered from Next.js instrumentation.
Key characteristics of the current model:
- request handlers enqueue directly through `enqueueResponsePipelineEvents(...)`
- handlers now pass the just-written `TResponse` snapshot when they already have it
- callers that do not already have a response snapshot use an uncached pipeline-specific lookup
- worker startup is non-blocking and retries after transient failures
- webhook failures fail early attempts so BullMQ can retry them
- on the final attempt, webhook failures are logged and the remaining side effects continue
- response-created metering is awaited before the BullMQ job completes
### Net Result
Compared to the legacy route, the current branch is:
- architecturally stronger
- safer to scale and operate
- easier to observe through structured job logging
- closer to legacy feature parity than the earlier BullMQ iterations on this branch
The main remaining semantic difference is timing:
- the legacy route continued past webhook failures immediately
- the BullMQ worker now continues only after webhook retries are exhausted
That is an intentional trade-off in the current branch, not an accident.
## Current Queue Model
The queue remains intentionally small:
- queue name: `background-jobs`
- prefix: `formbricks:jobs`
- job names:
- `system.test-log`
- `response-pipeline.process`
The response pipeline is the first production workload on this queue.
## Local Development
Local development works end to end as long as Redis is available and the worker is enabled.
Required inputs:
- `REDIS_URL`
- optionally `BULLMQ_WORKER_ENABLED`
- optionally `BULLMQ_WORKER_COUNT`
- optionally `BULLMQ_WORKER_CONCURRENCY`
Behavior:
- if `REDIS_URL` is missing, queueing is skipped
- if `BULLMQ_WORKER_ENABLED=0`, the worker is not started, but request-side enqueueing can still stay enabled in deployments that point at a separate BullMQ worker
- outside tests, the worker is enabled by default
This makes it possible to develop request flows without hard-failing when Redis is absent, while still supporting full local end-to-end verification when Redis is running.
## Operational Notes
### Logging
The current implementation logs:
- worker startup failures
- Redis connection failures
- enqueue failures
- job failures
- webhook delivery failures
- integration failures
- email delivery failures
- follow-up failures
- survey auto-complete update failures
- metering failures
- telemetry failures
### Shutdown
The worker runtime registers `SIGTERM` and `SIGINT` handlers, closes workers and queue handles, and then closes Redis connections. This keeps shutdown behavior predictable inside the web process.
## Current Limitations
The migration satisfies the ticket, but a few larger architectural limits remain by design.
### Dual-Write Boundary
Response writes happen in Postgres and background jobs are enqueued in Redis. Those are separate systems, so this remains a dual-write boundary.
This means Formbricks currently has:
- non-blocking enqueue semantics
- at-least-once background execution
- no transactional guarantee that the product write and Redis enqueue succeed together
That trade-off was accepted for this BullMQ phase.
### In-Process Workers
Workers run inside the Next.js app process.
That keeps self-hosting simple, but it also means:
- job capacity still shares resources with the web process
- heavy background work is still Node.js-local
- scaling job throughput also scales the app runtime
### Webhook-Gated Retries
Webhook delivery still happens before the rest of the `responseFinished` side effects.
That gives Formbricks job-level retries for webhook delivery, but it also means:
- `responseFinished` side effects do not run on the early retry attempts
- the remaining side effects only continue after webhook retries are exhausted
- this is closer to legacy behavior than failing forever, but it is still not immediate parity
This is the current behavior of the branch and should be evaluated explicitly if we want stricter feature parity with the legacy route.
### Logs-First Observability
The current system has strong structured logging, but it does not yet provide:
- queue dashboards
- retry tooling
- latency metrics
- product-native workflow inspection
Those are future improvements, not blockers for the current migration.
## Recommended Next Steps
Now that the response pipeline is on BullMQ, the most useful next steps are:
1. migrate additional low-risk async workloads behind the same producer/runtime boundary
2. add queue metrics and worker health visibility beyond logs
3. define explicit idempotency rules for side-effect-heavy jobs
4. decide which future workloads should remain Node-local and which should eventually move to a different runtime
## Practical Conclusion
Formbricks now has:
- a production-capable BullMQ foundation
- a real migrated workload
- a clean separation between request-time enqueueing and background execution
The response pipeline migration should be considered complete for the current ticket scope.
@@ -1,2 +0,0 @@
ALTER TABLE "Survey"
ADD COLUMN "isAutoProgressingEnabled" BOOLEAN NOT NULL DEFAULT false;
-1
View File
@@ -395,7 +395,6 @@ model Survey {
isVerifyEmailEnabled Boolean @default(false)
isSingleResponsePerEmailEnabled Boolean @default(false)
isBackButtonHidden Boolean @default(false)
isAutoProgressingEnabled Boolean @default(false)
isCaptureIpEnabled Boolean @default(false)
pin String?
displayPercentage Decimal?
-1
View File
@@ -138,7 +138,6 @@ const ZSurveyBase = z.object({
isSingleResponsePerEmailEnabled: z.boolean().describe("Whether single response per email is enabled"),
inlineTriggers: z.array(z.any()).nullable().describe("Inline triggers configuration"),
isBackButtonHidden: z.boolean().describe("Whether the back button is hidden"),
isAutoProgressingEnabled: z.boolean().describe("Whether auto-progress is enabled for eligible questions"),
recaptcha: ZSurveyRecaptcha.describe("Google reCAPTCHA configuration"),
metadata: ZSurveyMetadata.describe("Custom link metadata for social sharing"),
displayPercentage: z.number().nullable().describe("The display percentage of the survey"),
@@ -79,5 +79,4 @@ export const mockSurvey: TEnvironmentStateSurvey = {
brandColor: { light: "#2B6CB0" },
},
isBackButtonHidden: false,
isAutoProgressingEnabled: false,
};
-1
View File
@@ -20,7 +20,6 @@ export type TEnvironmentStateSurvey = Pick<
| "delay"
| "projectOverwrites"
| "isBackButtonHidden"
| "isAutoProgressingEnabled"
| "recaptcha"
> & {
languages: (SurveyLanguage & { language: Language })[];
-9
View File
@@ -1,15 +1,6 @@
import { S3Client, type S3ClientConfig } from "@aws-sdk/client-s3";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
// Mock the AWS SDK S3Client
vi.mock("@aws-sdk/client-s3", () => ({
S3Client: vi.fn(function MockS3Client(
-9
View File
@@ -11,15 +11,6 @@ import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
type Paginator<T> = AsyncGenerator<T, undefined, unknown>;
// Mock AWS SDK modules
+1 -5
View File
@@ -1,10 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import { cn } from "./utils";
vi.mock("isomorphic-dompurify", () => ({
sanitize: vi.fn((value: string) => value),
}));
describe("cn", () => {
test("merges class names correctly", () => {
expect(cn("foo", "bar")).toBe("foo bar");
-1
View File
@@ -64,7 +64,6 @@
"autoprefixer": "10.4.27",
"concurrently": "9.2.1",
"fake-indexeddb": "6.2.5",
"happy-dom": "20.8.9",
"postcss": "8.5.8",
"rollup-plugin-visualizer": "7.0.1",
"tailwindcss": "4.2.1",
+3 -34
View File
@@ -1,6 +1,5 @@
// 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
// basic regex -- [whitespace](number)(rem)[whitespace or ;]
const REM_REGEX = /\b(\d+(\.\d+)?)(rem)\b/gi;
const PROCESSED = Symbol("processed");
const remtoEm = (opts = {}) => {
@@ -27,36 +26,6 @@ const remtoEm = (opts = {}) => {
};
};
// 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()],
plugins: [require("@tailwindcss/postcss"), require("autoprefixer"), remtoEm()],
};
@@ -11,11 +11,6 @@ import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { ElementConditional } from "@/components/general/element-conditional";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import {
getAutoProgressElement,
shouldHideSubmitButtonForAutoProgress,
shouldTriggerAutoProgress,
} from "@/lib/auto-progress";
import { getLocalizedValue } from "@/lib/i18n";
import { cn } from "@/lib/utils";
import { getFirstErrorMessage, validateBlockResponses } from "@/lib/validation/evaluator";
@@ -37,7 +32,6 @@ interface BlockConditionalProps {
surveyId: string;
autoFocusEnabled: boolean;
isBackButtonHidden: boolean;
isAutoProgressingEnabled: boolean;
onOpenExternalURL?: (url: string) => void | Promise<void>;
dir?: "ltr" | "rtl" | "auto";
fullSizeCards: boolean;
@@ -61,7 +55,6 @@ export function BlockConditional({
onFileUpload,
autoFocusEnabled,
isBackButtonHidden,
isAutoProgressingEnabled,
onOpenExternalURL,
dir,
fullSizeCards,
@@ -78,12 +71,6 @@ export function BlockConditional({
// Ref to collect TTC values synchronously (state updates are async)
const ttcCollectorRef = useRef<TResponseTtc>({});
const autoProgressingInFlightRef = useRef(false);
const autoProgressElement = getAutoProgressElement(block.elements, isAutoProgressingEnabled);
const shouldHideSubmitButton = shouldHideSubmitButtonForAutoProgress(
block.elements,
isAutoProgressingEnabled
);
// Handle change for an individual element
const handleElementChange = (elementId: string, responseData: TResponseData) => {
@@ -99,37 +86,8 @@ export function BlockConditional({
return updated;
});
}
const mergedValue = { ...value, ...responseData };
const blockResponses = block.elements.reduce<TResponseData>((acc, element) => {
const elementValue = mergedValue[element.id];
if (elementValue !== undefined) {
acc[element.id] = elementValue;
}
return acc;
}, {});
// Merge with existing block data to preserve other element values
onChange(mergedValue);
if (
shouldTriggerAutoProgress({
changedElementId: elementId,
mergedValue,
autoProgressElement,
isAlreadyInFlight: autoProgressingInFlightRef.current,
})
) {
autoProgressingInFlightRef.current = true;
// Defer submission so element-level change handlers can finalize TTC updates first.
setTimeout(() => {
try {
const blockTtc = collectTtcValues();
onSubmit(blockResponses, blockTtc);
} finally {
autoProgressingInFlightRef.current = false;
}
}, 0);
}
onChange({ ...value, ...responseData });
};
// Handler to collect TTC values synchronously (called from element form submissions)
@@ -260,7 +218,9 @@ export function BlockConditional({
for (const element of block.elements) {
const form = elementFormRefs.current.get(element.id);
if (form && !validateElementForm(element, form)) {
firstInvalidForm ??= form;
if (!firstInvalidForm) {
firstInvalidForm = form;
}
}
}
@@ -390,19 +350,14 @@ export function BlockConditional({
fullSizeCards ? "bg-survey-bg sticky bottom-0" : ""
)}>
<div>
{shouldHideSubmitButton ? (
// Keep layout symmetry for Back button positioning (LTR/RTL).
<div aria-hidden="true" className="mb-1 h-(--fb-button-height)" />
) : (
<SubmitButton
buttonLabel={
block.buttonLabel ? getLocalizedValue(block.buttonLabel, languageCode) : undefined
}
isLastQuestion={isLastBlock}
onClick={handleBlockSubmit}
tabIndex={0}
/>
)}
<SubmitButton
buttonLabel={
block.buttonLabel ? getLocalizedValue(block.buttonLabel, languageCode) : undefined
}
isLastQuestion={isLastBlock}
onClick={handleBlockSubmit}
tabIndex={0}
/>
</div>
{!isFirstBlock && !isBackButtonHidden && (
<BackButton
@@ -29,6 +29,7 @@ import {
type SerializedSurveyState,
clearSurveyProgress,
getSurveyProgress,
patchSurveyProgressSnapshot,
saveSurveyProgress,
} from "@/lib/offline-storage";
import { parseRecallInformation } from "@/lib/recall";
@@ -38,13 +39,28 @@ import { useOnlineStatus } from "@/lib/use-online-status";
import { cn, findBlockByElementId, getDefaultLanguageCode, getElementsFromSurveyBlocks } from "@/lib/utils";
import { TResponseErrorCodesEnum } from "@/types/response-error-codes";
const restoreSurveyStateFromSnapshot = (surveyState: SurveyState, snapshot: SerializedSurveyState): void => {
const restoreSurveyStateFromSnapshot = (
surveyState: SurveyState,
snapshot: SerializedSurveyState,
progress: {
responseData: TResponseData;
ttc: TResponseTtc;
currentVariables: TResponseVariables;
}
): void => {
if (snapshot.responseId) surveyState.updateResponseId(snapshot.responseId);
if (snapshot.displayId) surveyState.updateDisplayId(snapshot.displayId);
if (snapshot.userId) surveyState.updateUserId(snapshot.userId);
if (snapshot.contactId) surveyState.updateContactId(snapshot.contactId);
if (snapshot.singleUseId) surveyState.singleUseId = snapshot.singleUseId;
surveyState.responseAcc = snapshot.responseAcc;
surveyState.disableBootstrapResponseCreate();
surveyState.responseAcc = {
...snapshot.responseAcc,
data: progress.responseData,
ttc: progress.ttc,
variables: progress.currentVariables,
displayId: snapshot.displayId ?? snapshot.responseAcc.displayId,
};
};
interface VariableStackEntry {
@@ -127,6 +143,14 @@ export function Survey({
const offlinePersistEnabled =
offlineSupport && isLinkSurvey && !isPreviewMode && !!appUrl && !!environmentId;
const persistSurveyStateSnapshot = useCallback(
async (snapshotPatch: Partial<SerializedSurveyState>) => {
if (!offlinePersistEnabled) return;
await patchSurveyProgressSnapshot(survey.id, snapshotPatch);
},
[offlinePersistEnabled, survey.id]
);
const responseQueue = useMemo(() => {
if (appUrl && environmentId && surveyState) {
return new ResponseQueue(
@@ -160,6 +184,9 @@ export function Survey({
setBlockId(quotaInfo.endingCardId);
}
},
onResponseCreated: (responseId) => {
void persistSurveyStateSnapshot({ responseId });
},
},
surveyState
);
@@ -173,6 +200,7 @@ export function Survey({
getSetIsResponseSendingFinished,
surveyState,
offlinePersistEnabled,
persistSurveyStateSnapshot,
survey.id,
]);
@@ -319,6 +347,7 @@ export function Survey({
surveyState.updateDisplayId(display.data.id);
responseQueue.updateSurveyState(surveyState);
await persistSurveyStateSnapshot({ displayId: display.data.id });
if (onDisplayCreated) {
onDisplayCreated();
@@ -337,6 +366,7 @@ export function Survey({
onDisplayCreated,
isPreviewMode,
onDisplay,
persistSurveyStateSnapshot,
]);
// Create display on mount. When offline persistence is enabled, wait for progress
@@ -458,7 +488,36 @@ export function Survey({
// Restore survey state from snapshot
if (surveyState && progress.surveyStateSnapshot) {
restoreSurveyStateFromSnapshot(surveyState, progress.surveyStateSnapshot);
restoreSurveyStateFromSnapshot(surveyState, progress.surveyStateSnapshot, progress);
if (pendingCount === 0 && !progress.surveyStateSnapshot.responseId) {
if (progress.surveyStateSnapshot.displayId && apiClient) {
const responseLookup = await apiClient.getResponseIdByDisplayId(
progress.surveyStateSnapshot.displayId
);
if (responseLookup.ok && responseLookup.data.responseId) {
surveyState.updateResponseId(responseLookup.data.responseId);
await persistSurveyStateSnapshot({ responseId: responseLookup.data.responseId });
} else if (responseLookup.ok) {
surveyState.enableBootstrapResponseCreate();
} else if (responseLookup.error.status === 404) {
surveyState.updateDisplayId(null);
surveyState.enableBootstrapResponseCreate();
await persistSurveyStateSnapshot({ displayId: null });
} else {
console.error("Formbricks: Failed to recover responseId from displayId", {
displayId: progress.surveyStateSnapshot.displayId,
error: responseLookup.error,
});
surveyState.enableBootstrapResponseCreate();
}
} else {
surveyState.enableBootstrapResponseCreate();
}
}
responseQueue?.updateSurveyState(surveyState);
}
} else {
// Block no longer exists (survey structure changed) — discard UI progress
@@ -466,7 +525,8 @@ export function Survey({
await clearSurveyProgress(survey.id);
if (surveyState && progress.surveyStateSnapshot) {
restoreSurveyStateFromSnapshot(surveyState, progress.surveyStateSnapshot);
restoreSurveyStateFromSnapshot(surveyState, progress.surveyStateSnapshot, progress);
responseQueue?.updateSurveyState(surveyState);
}
}
@@ -1080,7 +1140,6 @@ export function Survey({
languageCode={selectedLanguage}
autoFocusEnabled={autoFocusEnabled}
isBackButtonHidden={localSurvey.isBackButtonHidden}
isAutoProgressingEnabled={localSurvey.isAutoProgressingEnabled}
onOpenExternalURL={onOpenExternalURL}
dir={dir}
fullSizeCards={fullSizeCards}
+10
View File
@@ -46,6 +46,16 @@ export class ApiClient {
);
}
async getResponseIdByDisplayId(
displayId: string
): Promise<Result<{ responseId: string | null }, ApiErrorResponse>> {
return makeRequest(
this.appUrl,
`/api/v1/client/${this.environmentId}/displays/${displayId}/response`,
"GET"
);
}
async createResponse(
responseInput: Omit<TResponseInput, "environmentId"> & {
contactId: string | null;
@@ -1,77 +0,0 @@
import { describe, expect, test } from "vitest";
import { type TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
getAutoProgressElement,
shouldHideSubmitButtonForAutoProgress,
shouldTriggerAutoProgress,
} from "./auto-progress";
const createElement = (id: string, type: TSurveyElementTypeEnum, required: boolean): TSurveyElement =>
({
id,
type,
required,
}) as unknown as TSurveyElement;
describe("auto-progress helpers", () => {
test("returns auto-progress element for single rating/nps blocks only", () => {
const ratingElement = createElement("rating_1", TSurveyElementTypeEnum.Rating, true);
const npsElement = createElement("nps_1", TSurveyElementTypeEnum.NPS, false);
const openTextElement = createElement("text_1", TSurveyElementTypeEnum.OpenText, false);
expect(getAutoProgressElement([ratingElement], true)).toEqual(ratingElement);
expect(getAutoProgressElement([npsElement], true)).toEqual(npsElement);
expect(getAutoProgressElement([openTextElement], true)).toBeNull();
expect(getAutoProgressElement([ratingElement], false)).toBeNull();
expect(getAutoProgressElement([ratingElement, npsElement], true)).toBeNull();
});
test("hides submit button only for required auto-progress elements", () => {
const requiredRating = createElement("rating_required", TSurveyElementTypeEnum.Rating, true);
const optionalRating = createElement("rating_optional", TSurveyElementTypeEnum.Rating, false);
expect(shouldHideSubmitButtonForAutoProgress([requiredRating], true)).toBe(true);
expect(shouldHideSubmitButtonForAutoProgress([optionalRating], true)).toBe(false);
expect(shouldHideSubmitButtonForAutoProgress([requiredRating], false)).toBe(false);
});
test("triggers auto-progress only when an eligible response was changed", () => {
const autoProgressElement = createElement("rating_1", TSurveyElementTypeEnum.Rating, true);
expect(
shouldTriggerAutoProgress({
changedElementId: "rating_1",
mergedValue: { rating_1: 5 },
autoProgressElement,
isAlreadyInFlight: false,
})
).toBe(true);
expect(
shouldTriggerAutoProgress({
changedElementId: "other",
mergedValue: { rating_1: 5 },
autoProgressElement,
isAlreadyInFlight: false,
})
).toBe(false);
expect(
shouldTriggerAutoProgress({
changedElementId: "rating_1",
mergedValue: {},
autoProgressElement,
isAlreadyInFlight: false,
})
).toBe(false);
expect(
shouldTriggerAutoProgress({
changedElementId: "rating_1",
mergedValue: { rating_1: 5 },
autoProgressElement,
isAlreadyInFlight: true,
})
).toBe(false);
});
});
-43
View File
@@ -1,43 +0,0 @@
import { type TResponseData } from "@formbricks/types/responses";
import { type TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
const isAutoProgressElementType = (type: TSurveyElementTypeEnum): boolean =>
type === TSurveyElementTypeEnum.Rating || type === TSurveyElementTypeEnum.NPS;
export const getAutoProgressElement = (
elements: TSurveyElement[],
isAutoProgressingEnabled: boolean
): TSurveyElement | null => {
if (!isAutoProgressingEnabled || elements.length !== 1) {
return null;
}
const [element] = elements;
return isAutoProgressElementType(element.type) ? element : null;
};
export const shouldHideSubmitButtonForAutoProgress = (
elements: TSurveyElement[],
isAutoProgressingEnabled: boolean
): boolean => {
const autoProgressElement = getAutoProgressElement(elements, isAutoProgressingEnabled);
return Boolean(autoProgressElement?.required);
};
export const shouldTriggerAutoProgress = ({
changedElementId,
mergedValue,
autoProgressElement,
isAlreadyInFlight,
}: {
changedElementId: string;
mergedValue: TResponseData;
autoProgressElement: TSurveyElement | null;
isAlreadyInFlight: boolean;
}): boolean => {
if (!autoProgressElement || isAlreadyInFlight || changedElementId !== autoProgressElement.id) {
return false;
}
return mergedValue[autoProgressElement.id] !== undefined;
};
+1 -14
View File
@@ -1,4 +1,4 @@
// @vitest-environment happy-dom
// @vitest-environment jsdom
import { describe, expect, test } from "vitest";
import { isValidHTML, stripInlineStyles } from "./html-utils";
@@ -42,19 +42,6 @@ describe("html-utils", () => {
test("should handle empty string", () => {
expect(stripInlineStyles("")).toBe("");
});
test("should remove script tags and dangerous event handler attributes", () => {
const input =
'<script>alert("x")</script><img src="x" onerror="alert(1)" /><a href="https://example.com" target="_blank" onclick="alert(1)" style="color:red">Go</a>';
const sanitized = stripInlineStyles(input);
expect(sanitized).not.toContain("<script");
expect(sanitized).not.toContain("</script>");
expect(sanitized).not.toContain("onerror=");
expect(sanitized).not.toContain("onclick=");
expect(sanitized).not.toContain("style=");
expect(sanitized).toContain('target="_blank"');
});
});
describe("isValidHTML", () => {
-1
View File
@@ -136,7 +136,6 @@ describe("Survey Logic", () => {
displayPercentage: 0,
recaptcha: { enabled: false, threshold: 0.5 },
isBackButtonHidden: false,
isAutoProgressingEnabled: false,
segment: null,
welcomeCard: {
enabled: true,
@@ -241,6 +241,44 @@ export const getSurveyProgress = async (surveyId: string): Promise<SurveyProgres
}
};
export const patchSurveyProgressSnapshot = async (
surveyId: string,
snapshotPatch: Partial<SerializedSurveyState>
): Promise<void> => {
try {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_SURVEY_PROGRESS, "readwrite");
const store = tx.objectStore(STORE_SURVEY_PROGRESS);
const getRequest = store.get(surveyId);
getRequest.onsuccess = () => {
const existing = getRequest.result as SurveyProgressEntry | undefined;
if (!existing) {
resolve();
return;
}
const putRequest = store.put({
...existing,
surveyStateSnapshot: {
...existing.surveyStateSnapshot,
...snapshotPatch,
},
updatedAt: Date.now(),
});
putRequest.onsuccess = () => resolve();
putRequest.onerror = () => reject(putRequest.error ?? new Error("IndexedDB request failed"));
};
getRequest.onerror = () => reject(getRequest.error ?? new Error("IndexedDB request failed"));
});
} catch (e) {
console.warn("Formbricks: Failed to patch survey progress snapshot in IndexedDB", e);
}
};
export const clearSurveyProgress = async (surveyId: string): Promise<void> => {
try {
const db = await openDb();
+37 -2
View File
@@ -20,6 +20,7 @@ interface QueueConfig {
retryAttempts: number;
persistOffline?: boolean;
surveyId?: string;
onResponseCreated?: (responseId: string) => void;
onResponseSendingFailed?: (responseUpdate: TResponseUpdate, errorCode?: TResponseErrorCodesEnum) => void;
onResponseSendingFinished?: () => void;
onQuotaFull?: (quotaInfo: TQuotaFullResponse) => void;
@@ -167,6 +168,7 @@ export class ResponseQueue {
const responseUpdate = this.queue[0];
this.isRequestInProgress = true;
const result = await this.sendResponseWithRetry(responseUpdate);
if (result.success) {
@@ -358,6 +360,37 @@ export class ResponseQueue {
return error.details?.code === RECAPTCHA_VERIFICATION_ERROR_CODE;
}
private getCreatePayload(
responseUpdate: TResponseUpdate
): Omit<
Parameters<ApiClient["createResponse"]>[0],
"contactId" | "userId" | "singleUseId" | "surveyId" | "displayId" | "recaptchaToken"
> {
if (!this.surveyState.shouldCreateResponseFromState) {
return {
finished: responseUpdate.finished,
data: { ...responseUpdate.data, ...responseUpdate.hiddenFields },
ttc: responseUpdate.ttc,
variables: responseUpdate.variables,
language: responseUpdate.language,
meta: responseUpdate.meta,
endingId: responseUpdate.endingId,
};
}
const accumulatedResponse = this.surveyState.responseAcc;
return {
finished: accumulatedResponse.finished,
data: { ...accumulatedResponse.data, ...responseUpdate.hiddenFields },
ttc: accumulatedResponse.ttc,
variables: accumulatedResponse.variables,
language: accumulatedResponse.language ?? responseUpdate.language,
meta: accumulatedResponse.meta ?? responseUpdate.meta,
endingId: accumulatedResponse.endingId ?? responseUpdate.endingId,
};
}
private handleSuccessfulResponse(responseUpdate: TResponseUpdate, quotaFullResponse?: TQuotaFullResponse) {
if (responseUpdate.finished) {
this.config.onResponseSendingFinished?.();
@@ -398,13 +431,13 @@ export class ResponseQueue {
return err(response.error);
}
} else {
const createPayload = this.getCreatePayload(responseUpdate);
response = await this.api.createResponse({
...responseUpdate,
...createPayload,
surveyId: this.surveyState.surveyId,
contactId: this.surveyState.contactId || null,
userId: this.surveyState.userId || null,
singleUseId: this.surveyState.singleUseId || null,
data: { ...responseUpdate.data, ...responseUpdate.hiddenFields },
displayId: this.surveyState.displayId,
recaptchaToken: this.responseRecaptchaToken ?? undefined,
});
@@ -414,6 +447,8 @@ export class ResponseQueue {
}
this.surveyState.updateResponseId(response.data.id);
this.surveyState.disableBootstrapResponseCreate();
this.config.onResponseCreated?.(response.data.id);
if (this.config.setSurveyState) {
this.config.setSurveyState(this.surveyState);
}
+34 -104
View File
@@ -1,4 +1,4 @@
// @vitest-environment happy-dom
// @vitest-environment jsdom
import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { err, ok } from "@formbricks/types/error-handlers";
import { TResponseUpdate } from "@formbricks/types/responses";
@@ -38,11 +38,14 @@ const getSurveyState: () => SurveyState = () => ({
contactId: "contact1",
surveyId: "survey1",
singleUseId: "single1",
shouldCreateResponseFromState: false,
responseAcc: { finished: false, data: {}, ttc: {}, variables: {} },
updateResponseId: vi.fn(),
updateDisplayId: vi.fn(),
updateUserId: vi.fn(),
updateContactId: vi.fn(),
enableBootstrapResponseCreate: vi.fn(),
disableBootstrapResponseCreate: vi.fn(),
accumulateResponse: vi.fn(),
isResponseFinished: vi.fn(),
clear: vi.fn(),
@@ -111,26 +114,30 @@ describe("ResponseQueue", () => {
});
test("processQueue does nothing if request in progress or queue empty", async () => {
queue["isRequestInProgress"] = true;
await queue.processQueue();
queue["isRequestInProgress"] = false;
queue.queue.length = 0;
await queue.processQueue();
const reqQueue = new ResponseQueue(getConfig({ surveyId: "s1" }), getSurveyState());
_syncLocks.setRequestInProgress("s1", true);
await reqQueue.processQueue();
_syncLocks.setRequestInProgress("s1", false);
reqQueue.queue.length = 0;
await reqQueue.processQueue();
expect(true).toBe(true); // just to ensure no errors
});
test("processQueue sends response and removes from queue on success", async () => {
queue.queue.push(responseUpdate);
vi.spyOn(queue, "sendResponse").mockResolvedValue(ok(true));
await queue.processQueue();
expect(queue.queue.length).toBe(0);
expect(queue["isRequestInProgress"]).toBe(false);
const reqQueue = new ResponseQueue(getConfig({ surveyId: "s1" }), getSurveyState());
reqQueue.queue.push(responseUpdate);
vi.spyOn(reqQueue, "sendResponse").mockResolvedValue(ok(true));
await reqQueue.processQueue();
expect(reqQueue.queue.length).toBe(0);
expect(_syncLocks.getRequestInProgress("s1")).toBe(false);
});
test("processQueue retries and calls onResponseSendingFailed on recaptcha error", async () => {
queue.queue.push(responseUpdate);
const recaptchaConfig = getConfig({ surveyId: "s1" });
const recaptchaQueue = new ResponseQueue(recaptchaConfig, getSurveyState());
recaptchaQueue.queue.push(responseUpdate);
vi.spyOn(queue, "sendResponse").mockResolvedValue(
vi.spyOn(recaptchaQueue, "sendResponse").mockResolvedValue(
err({
code: "internal_server_error",
message: "An error occurred while sending the response.",
@@ -140,29 +147,31 @@ describe("ResponseQueue", () => {
},
})
);
await queue.processQueue();
expect(config.onResponseSendingFailed).toHaveBeenCalledWith(
await recaptchaQueue.processQueue();
expect(recaptchaConfig.onResponseSendingFailed).toHaveBeenCalledWith(
responseUpdate,
TResponseErrorCodesEnum.RecaptchaError
);
expect(queue["isRequestInProgress"]).toBe(false);
expect(_syncLocks.getRequestInProgress("s1")).toBe(false);
});
test("processQueue retries and calls onResponseSendingFailed after max attempts", async () => {
queue.queue.push(responseUpdate);
vi.spyOn(queue, "sendResponse").mockResolvedValue(
const reqConfig = getConfig({ surveyId: "s1" });
const reqQueue = new ResponseQueue(reqConfig, getSurveyState());
reqQueue.queue.push(responseUpdate);
vi.spyOn(reqQueue, "sendResponse").mockResolvedValue(
err({
code: "internal_server_error",
message: "An error occurred while sending the response.",
status: 500,
})
);
await queue.processQueue();
expect(config.onResponseSendingFailed).toHaveBeenCalledWith(
await reqQueue.processQueue();
expect(reqConfig.onResponseSendingFailed).toHaveBeenCalledWith(
responseUpdate,
TResponseErrorCodesEnum.ResponseSendingError
);
expect(queue["isRequestInProgress"]).toBe(false);
expect(_syncLocks.getRequestInProgress("s1")).toBe(false);
});
test("processQueue calls onResponseSendingFinished if finished", async () => {
@@ -185,6 +194,7 @@ describe("ResponseQueue", () => {
const result = await queue.sendResponse(responseUpdate);
expect(apiMock.createResponse).toHaveBeenCalled();
expect(surveyState.updateResponseId).toHaveBeenCalledWith("newid");
expect(surveyState.disableBootstrapResponseCreate).toHaveBeenCalled();
expect(config.setSurveyState).toHaveBeenCalledWith(surveyState);
expect(result.ok).toBe(true);
});
@@ -219,8 +229,9 @@ describe("ResponseQueue", () => {
});
test("processQueueAsync returns success false if request in progress", async () => {
queue["isRequestInProgress"] = true;
const result = await queue.processQueue();
const reqQueue = new ResponseQueue(getConfig({ surveyId: "s1" }), getSurveyState());
_syncLocks.setRequestInProgress("s1", true);
const result = await reqQueue.processQueue();
expect(result.success).toBe(false);
});
@@ -320,65 +331,6 @@ describe("ResponseQueue", () => {
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);
@@ -416,28 +368,6 @@ describe("ResponseQueue", () => {
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([
+1 -1
View File
@@ -1,4 +1,4 @@
// @vitest-environment happy-dom
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { type TProjectStyling } from "@formbricks/types/project";
import { type TSurveyStyling } from "@formbricks/types/surveys/types";
@@ -14,6 +14,7 @@ describe("SurveyState", () => {
expect(surveyState.surveyId).toBe(initialSurveyId);
expect(surveyState.responseId).toBeNull();
expect(surveyState.displayId).toBeNull();
expect(surveyState.shouldCreateResponseFromState).toBe(false);
expect(surveyState.userId).toBeNull();
expect(surveyState.contactId).toBeNull();
expect(surveyState.singleUseId).toBeNull();
@@ -137,7 +138,7 @@ describe("SurveyState", () => {
expect(surveyState.responseAcc.finished).toBe(true);
expect(surveyState.responseAcc.data).toEqual({ q1: "newAns1", q2: "ans2" });
expect(surveyState.responseAcc.ttc).toEqual({ q2: 200 }); // ttc is overwritten
expect(surveyState.responseAcc.ttc).toEqual({ q1: 100, q2: 200 });
expect(surveyState.responseAcc.variables).toEqual({ varB: "valB" }); // variables are overwritten
expect(surveyState.responseAcc.displayId).toBe("display123");
});
@@ -158,9 +159,11 @@ describe("SurveyState", () => {
describe("clear", () => {
test("should reset responseId and responseAcc", () => {
surveyState.responseId = "someId";
surveyState.enableBootstrapResponseCreate();
surveyState.responseAcc = { finished: true, data: { q: "a" }, ttc: { q: 1 }, variables: { v: "1" } };
surveyState.clear();
expect(surveyState.responseId).toBeNull();
expect(surveyState.shouldCreateResponseFromState).toBe(false);
expect(surveyState.responseAcc).toEqual({ finished: false, data: {}, ttc: {}, variables: {} });
});
});
+18 -4
View File
@@ -6,6 +6,7 @@ export class SurveyState {
userId: string | null = null;
contactId: string | null = null;
surveyId: string;
shouldCreateResponseFromState = false;
responseAcc: TResponseUpdate = { finished: false, data: {}, ttc: {}, variables: {} };
singleUseId: string | null;
@@ -59,7 +60,7 @@ export class SurveyState {
* Update the display ID after a successful display creation
* @param id - The display ID
*/
updateDisplayId(id: string) {
updateDisplayId(id: string | null) {
this.displayId = id;
}
@@ -79,6 +80,14 @@ export class SurveyState {
this.contactId = id;
}
enableBootstrapResponseCreate() {
this.shouldCreateResponseFromState = true;
}
disableBootstrapResponseCreate() {
this.shouldCreateResponseFromState = false;
}
/**
* Accumulate the responses
* @param responseUpdate - The new response data to add
@@ -86,10 +95,14 @@ export class SurveyState {
accumulateResponse(responseUpdate: TResponseUpdate) {
this.responseAcc = {
finished: responseUpdate.finished,
ttc: responseUpdate.ttc,
ttc: { ...this.responseAcc.ttc, ...responseUpdate.ttc },
data: { ...this.responseAcc.data, ...responseUpdate.data },
variables: responseUpdate.variables,
displayId: responseUpdate.displayId,
variables: responseUpdate.variables ?? this.responseAcc.variables,
displayId: responseUpdate.displayId ?? this.responseAcc.displayId,
language: responseUpdate.language ?? this.responseAcc.language,
meta: responseUpdate.meta ?? this.responseAcc.meta,
hiddenFields: responseUpdate.hiddenFields ?? this.responseAcc.hiddenFields,
endingId: responseUpdate.endingId,
};
}
@@ -105,6 +118,7 @@ export class SurveyState {
*/
clear() {
this.responseId = null;
this.shouldCreateResponseFromState = false;
this.responseAcc = { finished: false, data: {}, ttc: {}, variables: {} };
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
// @vitest-environment happy-dom
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/preact";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { TResponseTtc } from "@formbricks/types/responses";
@@ -1,4 +1,4 @@
// @vitest-environment happy-dom
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/preact";
import { describe, expect, test } from "vitest";
import { useOnlineStatus } from "./use-online-status";
-43
View File
@@ -4,7 +4,6 @@ import type { TJsEnvironmentStateSurvey } from "../../../types/js";
import { type TAllowedFileExtension, mimeTypes } from "../../../types/storage";
import type { TSurveyLanguage } from "../../../types/surveys/types";
import {
cn,
findBlockByElementId,
getDefaultLanguageCode,
getElementsFromSurveyBlocks,
@@ -511,45 +510,3 @@ describe("isRTLLanguage", () => {
expect(isRTLLanguage(survey, "default")).toBe(true);
});
});
describe("cn", () => {
test("joins multiple classes", () => {
expect(cn("foo", "bar")).toBe("foo bar");
});
test("filters out undefined values", () => {
expect(cn("foo", undefined, "bar")).toBe("foo bar");
});
test("filters out empty strings", () => {
expect(cn("foo", "", "bar")).toBe("foo bar");
});
test("merges conflicting tailwind classes (last wins)", () => {
expect(cn("mb-6", "mb-8")).toBe("mb-8");
});
test("merges conflicting min-h classes", () => {
expect(cn("min-h-40", "min-h-0")).toBe("min-h-0");
});
test("merges conflicting padding classes", () => {
expect(cn("p-4", "p-2")).toBe("p-2");
});
test("keeps non-conflicting classes", () => {
expect(cn("mb-6 block rounded-md", "w-1/4")).toBe("mb-6 block rounded-md w-1/4");
});
test("handles single class", () => {
expect(cn("foo")).toBe("foo");
});
test("handles no arguments", () => {
expect(cn()).toBe("");
});
test("handles all undefined", () => {
expect(cn(undefined, undefined)).toBe("");
});
});
@@ -88,46 +88,6 @@ describe("validateElementResponse", () => {
expect(result.valid).toBe(true);
});
test("should return error when required multi-select has other selected but no text", () => {
const element = {
id: "mc1",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Pick" },
required: true,
choices: [{ id: "opt1", label: { default: "Option 1" } }],
} as unknown as TSurveyElement;
const result = validateElementResponse(element, ["opt1", ""], "en");
expect(result.valid).toBe(false);
expect(result.errors[0].ruleId).toBe("required");
});
test("should return valid when required multi-select has other with text (legacy sentinel)", () => {
const element = {
id: "mc1",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Pick" },
required: true,
choices: [{ id: "opt1", label: { default: "Option 1" } }],
} as unknown as TSurveyElement;
const result = validateElementResponse(element, ["opt1", "", "custom"], "en");
expect(result.valid).toBe(true);
});
test("should return valid when required multi-select has other text without sentinel", () => {
const element = {
id: "mc1",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Pick" },
required: true,
choices: [{ id: "opt1", label: { default: "Option 1" } }],
} as unknown as TSurveyElement;
const result = validateElementResponse(element, ["opt1", "custom"], "en");
expect(result.valid).toBe(true);
});
test("should handle required ranking element - at least one ranked", () => {
const element: TSurveyElement = {
id: "rank1",
-1
View File
@@ -29,7 +29,6 @@ export const ZJsEnvironmentStateSurvey = ZSurveyBase.pick({
delay: true,
projectOverwrites: true,
isBackButtonHidden: true,
isAutoProgressingEnabled: true,
recaptcha: true,
}).superRefine((survey, ctx) => {
surveyRefinement(survey as z.infer<typeof ZSurveyBase>, ctx);
-3
View File
@@ -914,7 +914,6 @@ export const ZSurveyBase = z.object({
recaptcha: ZSurveyRecaptcha.nullable(),
isSingleResponsePerEmailEnabled: z.boolean(),
isBackButtonHidden: z.boolean(),
isAutoProgressingEnabled: z.boolean().optional().prefault(false),
isCaptureIpEnabled: z.boolean(),
pin: z
.string()
@@ -3799,7 +3798,6 @@ export const ZSurveyCreateInput = makeSchemaOptional(ZSurveyBase)
endings: ZSurveyEndings.prefault([]),
type: ZSurveyType.prefault("link"),
followUps: z.array(ZSurveyFollowUp.omit({ createdAt: true, updatedAt: true })).prefault([]),
isAutoProgressingEnabled: z.boolean().prefault(false),
})
.superRefine((survey, ctx) => {
surveyRefinement(survey as z.infer<typeof ZSurveyBase>, ctx);
@@ -3848,7 +3846,6 @@ export const ZSurveyCreateInputWithEnvironmentId = makeSchemaOptional(ZSurveyBas
endings: ZSurveyEndings.prefault([]),
type: ZSurveyType.prefault("link"),
followUps: z.array(ZSurveyFollowUp.omit({ createdAt: true, updatedAt: true })).prefault([]),
isAutoProgressingEnabled: z.boolean().prefault(false),
})
.superRefine((survey, ctx) => {
surveyRefinement(survey as z.infer<typeof ZSurveyBase>, ctx);

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