mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-20 19:48:52 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| afa4cee908 |
@@ -135,7 +135,7 @@ describe("withV3ApiWrapper", () => {
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
environmentPermissions: [],
|
||||
workspacePermissions: [],
|
||||
});
|
||||
|
||||
const wrapped = withV3ApiWrapper({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
|
||||
import { getWorkspace } from "@/lib/workspace/service";
|
||||
import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
@@ -19,8 +19,8 @@ vi.mock("@/lib/utils/helper", () => ({
|
||||
getOrganizationIdFromWorkspaceId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/resolve-client-id", () => ({
|
||||
findWorkspaceByIdOrLegacyEnvId: vi.fn(),
|
||||
vi.mock("@/lib/workspace/service", () => ({
|
||||
getWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
||||
@@ -39,7 +39,7 @@ describe("requireSessionWorkspaceAccess", () => {
|
||||
expect(body.requestId).toBe(requestId);
|
||||
expect(body.status).toBe(401);
|
||||
expect(body.code).toBe("not_authenticated");
|
||||
expect(findWorkspaceByIdOrLegacyEnvId).not.toHaveBeenCalled();
|
||||
expect(getWorkspace).not.toHaveBeenCalled();
|
||||
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -55,11 +55,11 @@ describe("requireSessionWorkspaceAccess", () => {
|
||||
const body = await (result as Response).json();
|
||||
expect(body.requestId).toBe(requestId);
|
||||
expect(body.code).toBe("not_authenticated");
|
||||
expect(findWorkspaceByIdOrLegacyEnvId).not.toHaveBeenCalled();
|
||||
expect(getWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 403 when workspace is not found (avoid leaking existence)", async () => {
|
||||
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
|
||||
vi.mocked(getWorkspace).mockResolvedValueOnce(null);
|
||||
const result = await requireSessionWorkspaceAccess(
|
||||
{ user: { id: "user_1" }, expires: "" } as any,
|
||||
"ws_nonexistent",
|
||||
@@ -72,12 +72,12 @@ describe("requireSessionWorkspaceAccess", () => {
|
||||
const body = await (result as Response).json();
|
||||
expect(body.requestId).toBe(requestId);
|
||||
expect(body.code).toBe("forbidden");
|
||||
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_nonexistent");
|
||||
expect(getWorkspace).toHaveBeenCalledWith("ws_nonexistent");
|
||||
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 403 when user has no access to workspace", async () => {
|
||||
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_abc" });
|
||||
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_abc" } as any);
|
||||
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_1");
|
||||
vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Not authorized"));
|
||||
const result = await requireSessionWorkspaceAccess(
|
||||
@@ -102,7 +102,7 @@ describe("requireSessionWorkspaceAccess", () => {
|
||||
});
|
||||
|
||||
test("returns workspace context when session is valid and user has access", async () => {
|
||||
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_abc" });
|
||||
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_abc" } as any);
|
||||
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_1");
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
|
||||
const result = await requireSessionWorkspaceAccess(
|
||||
@@ -144,7 +144,7 @@ function wsPerm(workspaceId: string, permission: ApiKeyPermission = ApiKeyPermis
|
||||
|
||||
describe("requireV3WorkspaceAccess", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValue({ id: "proj_k" });
|
||||
vi.mocked(getWorkspace).mockResolvedValue({ id: "proj_k" } as any);
|
||||
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_k");
|
||||
});
|
||||
|
||||
@@ -154,7 +154,7 @@ describe("requireV3WorkspaceAccess", () => {
|
||||
});
|
||||
|
||||
test("delegates to session flow when user is present", async () => {
|
||||
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_s" });
|
||||
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_s" } as any);
|
||||
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_s");
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
|
||||
const r = await requireV3WorkspaceAccess(
|
||||
@@ -179,7 +179,7 @@ describe("requireV3WorkspaceAccess", () => {
|
||||
workspaceId: "proj_k",
|
||||
organizationId: "org_k",
|
||||
});
|
||||
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("proj_k");
|
||||
expect(getWorkspace).toHaveBeenCalledWith("proj_k");
|
||||
});
|
||||
|
||||
test("returns context for API key with write on workspace", async () => {
|
||||
@@ -239,7 +239,7 @@ describe("requireV3WorkspaceAccess", () => {
|
||||
});
|
||||
|
||||
test("returns 403 when the workspace cannot be resolved for an API key", async () => {
|
||||
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
|
||||
vi.mocked(getWorkspace).mockResolvedValueOnce(null);
|
||||
const auth = {
|
||||
...keyBase,
|
||||
workspacePermissions: [wsPerm("proj_k", ApiKeyPermission.manage)],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
createdResponse,
|
||||
noContentResponse,
|
||||
problemBadRequest,
|
||||
problemForbidden,
|
||||
@@ -120,6 +121,27 @@ describe("successResponse", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createdResponse", () => {
|
||||
test("returns 201 with Location, request id, and data envelope", async () => {
|
||||
const res = createdResponse(
|
||||
{ id: "survey_1" },
|
||||
{
|
||||
location: "/api/v3/surveys/survey_1",
|
||||
requestId: "req-created",
|
||||
}
|
||||
);
|
||||
|
||||
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(res.headers.get("Content-Type")).toBe("application/json");
|
||||
expect(res.headers.get("Cache-Control")).toContain("no-store");
|
||||
expect(await res.json()).toEqual({
|
||||
data: { id: "survey_1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("noContentResponse", () => {
|
||||
test("returns 204 without a body", async () => {
|
||||
const res = noContentResponse({ requestId: "req-empty" });
|
||||
|
||||
@@ -172,6 +172,31 @@ export function successResponse<T>(
|
||||
);
|
||||
}
|
||||
|
||||
export function createdResponse<T>(
|
||||
data: T,
|
||||
options: { location: string; requestId?: string; cache?: string }
|
||||
): Response {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": options.cache ?? CACHE_NO_STORE,
|
||||
Location: options.location,
|
||||
};
|
||||
|
||||
if (options.requestId) {
|
||||
headers["X-Request-Id"] = options.requestId;
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
data,
|
||||
},
|
||||
{
|
||||
status: 201,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function noContentResponse(options?: { requestId?: string; cache?: string }): Response {
|
||||
const headers: Record<string, string> = {
|
||||
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
|
||||
|
||||
@@ -1,45 +1,34 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
|
||||
import { getWorkspace } from "@/lib/workspace/service";
|
||||
import { resolveV3WorkspaceContext } from "./workspace-context";
|
||||
|
||||
vi.mock("@/lib/workspace/service", () => ({
|
||||
getWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getOrganizationIdFromWorkspaceId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/resolve-client-id", () => ({
|
||||
findWorkspaceByIdOrLegacyEnvId: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("resolveV3WorkspaceContext", () => {
|
||||
test("returns workspaceId and organizationId when workspace exists", async () => {
|
||||
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "ws_abc" });
|
||||
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "ws_abc" });
|
||||
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_123");
|
||||
const result = await resolveV3WorkspaceContext("ws_abc");
|
||||
expect(result).toEqual({
|
||||
workspaceId: "ws_abc",
|
||||
organizationId: "org_123",
|
||||
});
|
||||
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_abc");
|
||||
expect(getWorkspace).toHaveBeenCalledWith("ws_abc");
|
||||
expect(getOrganizationIdFromWorkspaceId).toHaveBeenCalledWith("ws_abc");
|
||||
});
|
||||
|
||||
test("resolves legacy environmentId to canonical workspaceId", async () => {
|
||||
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "ws_canonical" });
|
||||
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_456");
|
||||
const result = await resolveV3WorkspaceContext("env_legacy");
|
||||
expect(result).toEqual({
|
||||
workspaceId: "ws_canonical",
|
||||
organizationId: "org_456",
|
||||
});
|
||||
expect(getOrganizationIdFromWorkspaceId).toHaveBeenCalledWith("ws_canonical");
|
||||
});
|
||||
|
||||
test("throws when workspace does not exist", async () => {
|
||||
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
|
||||
vi.mocked(getWorkspace).mockResolvedValueOnce(null);
|
||||
await expect(resolveV3WorkspaceContext("ws_nonexistent")).rejects.toThrow(ResourceNotFoundError);
|
||||
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_nonexistent");
|
||||
expect(getWorkspace).toHaveBeenCalledWith("ws_nonexistent");
|
||||
expect(getOrganizationIdFromWorkspaceId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
|
||||
import { getWorkspace } from "@/lib/workspace/service";
|
||||
|
||||
/**
|
||||
* Internal IDs derived from a V3 workspace identifier.
|
||||
@@ -19,21 +19,20 @@ export type V3WorkspaceContext = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a V3 API workspaceId (or legacy environmentId) to internal workspaceId and organizationId.
|
||||
* Resolves a V3 API workspaceId to internal workspaceId and organizationId.
|
||||
*
|
||||
* @throws ResourceNotFoundError if the workspace does not exist.
|
||||
*/
|
||||
export async function resolveV3WorkspaceContext(workspaceId: string): Promise<V3WorkspaceContext> {
|
||||
const workspace = await findWorkspaceByIdOrLegacyEnvId(workspaceId);
|
||||
const workspace = await getWorkspace(workspaceId);
|
||||
if (!workspace) {
|
||||
throw new ResourceNotFoundError("workspace", workspaceId);
|
||||
}
|
||||
|
||||
const canonicalId = workspace.id;
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(canonicalId);
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(workspace.id);
|
||||
|
||||
return {
|
||||
workspaceId: canonicalId,
|
||||
workspaceId: workspace.id,
|
||||
organizationId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
|
||||
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
|
||||
import {
|
||||
noContentResponse,
|
||||
problemBadRequest,
|
||||
@@ -15,8 +14,8 @@ import {
|
||||
V3SurveyUnsupportedShapeError,
|
||||
serializeV3SurveyResource,
|
||||
} from "@/app/api/v3/surveys/serializers";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { deleteSurvey } from "@/modules/survey/lib/surveys";
|
||||
import { getAuthorizedV3Survey } from "../authorization";
|
||||
import { parseV3SurveyLanguageQuery } from "../language";
|
||||
|
||||
const surveyParamsSchema = z.object({
|
||||
@@ -44,39 +43,6 @@ const surveyQuerySchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
async function getAuthorizedSurvey(params: {
|
||||
surveyId: string;
|
||||
authentication: Parameters<typeof requireV3WorkspaceAccess>[0];
|
||||
access: "read" | "readWrite";
|
||||
requestId: string;
|
||||
instance: string;
|
||||
}) {
|
||||
const { surveyId, authentication, access, requestId, instance } = params;
|
||||
const survey = await getSurvey(surveyId);
|
||||
|
||||
if (!survey) {
|
||||
return {
|
||||
survey: null,
|
||||
authResult: null,
|
||||
response: problemForbidden(requestId, "You are not authorized to access this resource", instance),
|
||||
};
|
||||
}
|
||||
|
||||
const authResult = await requireV3WorkspaceAccess(
|
||||
authentication,
|
||||
survey.workspaceId,
|
||||
access,
|
||||
requestId,
|
||||
instance
|
||||
);
|
||||
|
||||
if (authResult instanceof Response) {
|
||||
return { survey: null, authResult: null, response: authResult };
|
||||
}
|
||||
|
||||
return { survey, authResult, response: null };
|
||||
}
|
||||
|
||||
export const GET = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
schemas: {
|
||||
@@ -88,7 +54,7 @@ export const GET = withV3ApiWrapper({
|
||||
const log = logger.withContext({ requestId, surveyId });
|
||||
|
||||
try {
|
||||
const { survey, response } = await getAuthorizedSurvey({
|
||||
const { survey, response } = await getAuthorizedV3Survey({
|
||||
surveyId,
|
||||
authentication,
|
||||
access: "read",
|
||||
@@ -159,7 +125,7 @@ export const DELETE = withV3ApiWrapper({
|
||||
const log = logger.withContext({ requestId, surveyId });
|
||||
|
||||
try {
|
||||
const { survey, authResult, response } = await getAuthorizedSurvey({
|
||||
const { survey, authResult, response } = await getAuthorizedV3Survey({
|
||||
surveyId,
|
||||
authentication,
|
||||
access: "readWrite",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getAuthorizedV3Survey } from "./authorization";
|
||||
|
||||
vi.mock("@/app/api/v3/lib/auth", () => ({
|
||||
requireV3WorkspaceAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
getSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
const survey = {
|
||||
id: "clsv1234567890123456789012",
|
||||
workspaceId: "clxx1234567890123456789012",
|
||||
};
|
||||
const surveyRecord = survey as unknown as NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
|
||||
|
||||
describe("getAuthorizedV3Survey", () => {
|
||||
test("returns a generic forbidden response when the survey does not exist", async () => {
|
||||
vi.mocked(getSurvey).mockResolvedValue(null);
|
||||
|
||||
const result = await getAuthorizedV3Survey({
|
||||
surveyId: survey.id,
|
||||
authentication: null,
|
||||
access: "read",
|
||||
requestId: "req_1",
|
||||
instance: "/api/v3/surveys/clsv1234567890123456789012",
|
||||
});
|
||||
|
||||
expect(result.response?.status).toBe(403);
|
||||
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns the authorization response when workspace access is denied", async () => {
|
||||
const forbiddenResponse = new Response(null, { status: 403 });
|
||||
vi.mocked(getSurvey).mockResolvedValue(surveyRecord);
|
||||
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(forbiddenResponse);
|
||||
|
||||
const result = await getAuthorizedV3Survey({
|
||||
surveyId: survey.id,
|
||||
authentication: null,
|
||||
access: "readWrite",
|
||||
requestId: "req_2",
|
||||
instance: "/api/v3/surveys/clsv1234567890123456789012",
|
||||
});
|
||||
|
||||
expect(result.response).toBe(forbiddenResponse);
|
||||
});
|
||||
|
||||
test("returns the survey and authorization context when access is allowed", async () => {
|
||||
const authResult = { workspaceId: survey.workspaceId, organizationId: "org_1" };
|
||||
vi.mocked(getSurvey).mockResolvedValue(surveyRecord);
|
||||
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(authResult);
|
||||
|
||||
const result = await getAuthorizedV3Survey({
|
||||
surveyId: survey.id,
|
||||
authentication: null,
|
||||
access: "read",
|
||||
requestId: "req_3",
|
||||
instance: "/api/v3/surveys/clsv1234567890123456789012",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
survey,
|
||||
authResult,
|
||||
response: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
|
||||
import { problemForbidden } from "@/app/api/v3/lib/response";
|
||||
import type { TV3Authentication } from "@/app/api/v3/lib/types";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
|
||||
export async function getAuthorizedV3Survey(params: {
|
||||
surveyId: string;
|
||||
authentication: TV3Authentication;
|
||||
access: "read" | "readWrite";
|
||||
requestId: string;
|
||||
instance: string;
|
||||
}) {
|
||||
const { surveyId, authentication, access, requestId, instance } = params;
|
||||
const survey = await getSurvey(surveyId);
|
||||
|
||||
if (!survey) {
|
||||
return {
|
||||
survey: null,
|
||||
authResult: null,
|
||||
response: problemForbidden(requestId, "You are not authorized to access this resource", instance),
|
||||
};
|
||||
}
|
||||
|
||||
const authResult = await requireV3WorkspaceAccess(
|
||||
authentication,
|
||||
survey.workspaceId,
|
||||
access,
|
||||
requestId,
|
||||
instance
|
||||
);
|
||||
|
||||
if (authResult instanceof Response) {
|
||||
return { survey: null, authResult: null, response: authResult };
|
||||
}
|
||||
|
||||
return { survey, authResult, response: null };
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getOrganizationByWorkspaceId } from "@/lib/organization/service";
|
||||
import { createSurvey } from "@/lib/survey/service";
|
||||
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
|
||||
import { V3SurveyCreatePermissionError, createV3Survey } from "./create";
|
||||
import { ZV3CreateSurveyBody } from "./schemas";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
language: {
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
createSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganizationByWorkspaceId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/permission", () => ({
|
||||
getExternalUrlsPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
withContext: vi.fn(() => ({
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
const workspaceId = "clxx1234567890123456789012";
|
||||
|
||||
const rawCreateBody = {
|
||||
workspaceId,
|
||||
name: "Product Feedback",
|
||||
defaultLanguage: "en-US",
|
||||
metadata: {
|
||||
cx_operation: "enterprise_onboarding",
|
||||
title: { "en-US": "Product Feedback", "de-DE": "Produktfeedback" },
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
id: "clbk1234567890123456789012",
|
||||
name: "Main Block",
|
||||
elements: [
|
||||
{
|
||||
id: "satisfaction",
|
||||
type: "openText",
|
||||
headline: {
|
||||
"en-US": "What should we improve?",
|
||||
"de-DE": "Was sollen wir verbessern?",
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createBody = ZV3CreateSurveyBody.parse(rawCreateBody);
|
||||
|
||||
const createdSurvey = {
|
||||
id: "clsv1234567890123456789012",
|
||||
workspaceId,
|
||||
createdAt: new Date("2026-04-21T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-21T10:00:00.000Z"),
|
||||
name: "Product Feedback",
|
||||
type: "link",
|
||||
status: "draft",
|
||||
metadata: {},
|
||||
languages: [],
|
||||
questions: [],
|
||||
welcomeCard: { enabled: false },
|
||||
blocks: createBody.blocks,
|
||||
endings: [],
|
||||
hiddenFields: { enabled: false },
|
||||
variables: [],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
type TLanguageUpsertArgs = Parameters<typeof prisma.language.upsert>[0];
|
||||
type TLanguageUpsertReturn = ReturnType<typeof prisma.language.upsert>;
|
||||
|
||||
describe("createV3Survey", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.language.upsert).mockImplementation(
|
||||
(args: TLanguageUpsertArgs): TLanguageUpsertReturn => {
|
||||
const workspaceIdCode = args.where.workspaceId_code;
|
||||
if (!workspaceIdCode) {
|
||||
throw new Error("Expected workspaceId_code upsert selector");
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
id: `cllang${workspaceIdCode.code.toLowerCase().replaceAll("-", "")}`,
|
||||
code: workspaceIdCode.code,
|
||||
alias: null,
|
||||
workspaceId: workspaceIdCode.workspaceId,
|
||||
createdAt: new Date("2026-04-21T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-21T10:00:00.000Z"),
|
||||
}) as TLanguageUpsertReturn;
|
||||
}
|
||||
);
|
||||
vi.mocked(createSurvey).mockResolvedValue(createdSurvey);
|
||||
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValue({
|
||||
id: "org_1",
|
||||
name: "Organization",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: {
|
||||
limits: { monthly: { responses: 1000 }, workspaces: 1 },
|
||||
stripeCustomerId: null,
|
||||
usageCycleAnchor: null,
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: undefined,
|
||||
});
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("maps the public v3 body to the internal create payload", async () => {
|
||||
await createV3Survey(
|
||||
createBody,
|
||||
{
|
||||
user: { id: "user_1", email: "user@example.com", name: "User" },
|
||||
expires: "2026-05-01",
|
||||
},
|
||||
"req_1"
|
||||
);
|
||||
|
||||
expect(prisma.language.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { workspaceId_code: { workspaceId, code: "en-US" } },
|
||||
create: { workspaceId, code: "en-US", alias: null },
|
||||
})
|
||||
);
|
||||
expect(prisma.language.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { workspaceId_code: { workspaceId, code: "de-DE" } },
|
||||
create: { workspaceId, code: "de-DE", alias: null },
|
||||
})
|
||||
);
|
||||
expect(createSurvey).toHaveBeenCalledWith(
|
||||
workspaceId,
|
||||
expect.objectContaining({
|
||||
name: "Product Feedback",
|
||||
type: "link",
|
||||
status: "draft",
|
||||
createdBy: "user_1",
|
||||
questions: [],
|
||||
metadata: expect.objectContaining({
|
||||
cx_operation: "enterprise_onboarding",
|
||||
title: { default: "Product Feedback", "de-DE": "Produktfeedback" },
|
||||
}),
|
||||
blocks: [
|
||||
expect.objectContaining({
|
||||
elements: [
|
||||
expect.objectContaining({
|
||||
headline: {
|
||||
default: "What should we improve?",
|
||||
"de-DE": "Was sollen wir verbessern?",
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
languages: [
|
||||
expect.objectContaining({ default: true, enabled: true }),
|
||||
expect.objectContaining({ default: false, enabled: true }),
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(getOrganizationByWorkspaceId).not.toHaveBeenCalled();
|
||||
expect(getExternalUrlsPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("keeps createdBy null for API key calls and honors explicit disabled languages", async () => {
|
||||
const body = ZV3CreateSurveyBody.parse({
|
||||
...rawCreateBody,
|
||||
languages: [{ code: "fr-FR", enabled: false }],
|
||||
});
|
||||
|
||||
await createV3Survey(
|
||||
body,
|
||||
{
|
||||
type: "apiKey",
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
},
|
||||
"req_2"
|
||||
);
|
||||
|
||||
expect(createSurvey).toHaveBeenCalledWith(
|
||||
workspaceId,
|
||||
expect.objectContaining({
|
||||
createdBy: null,
|
||||
languages: expect.arrayContaining([
|
||||
expect.objectContaining({ language: expect.objectContaining({ code: "fr-FR" }), enabled: false }),
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects external CTA buttons when the organization does not have external URL permission", async () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
const body = ZV3CreateSurveyBody.parse({
|
||||
...rawCreateBody,
|
||||
blocks: [
|
||||
{
|
||||
...rawCreateBody.blocks[0],
|
||||
elements: [
|
||||
{
|
||||
id: "external_cta",
|
||||
type: "cta",
|
||||
headline: { "en-US": "Continue" },
|
||||
required: false,
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://example.com",
|
||||
ctaButtonLabel: { "en-US": "Open" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(createV3Survey(body, null, "req_3")).rejects.toThrow(V3SurveyCreatePermissionError);
|
||||
expect(createSurvey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import "server-only";
|
||||
import type { TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||
import type { TV3Authentication } from "@/app/api/v3/lib/types";
|
||||
import { getOrganizationByWorkspaceId } from "@/lib/organization/service";
|
||||
import { createSurvey } from "@/lib/survey/service";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
|
||||
import { type TV3SurveyLanguageRequest, ensureV3WorkspaceLanguages } from "./languages";
|
||||
import { prepareV3SurveyCreate } from "./prepare";
|
||||
import { V3SurveyReferenceValidationError } from "./reference-validation";
|
||||
import type { TV3CreateSurveyBody } from "./schemas";
|
||||
|
||||
export class V3SurveyCreatePermissionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "V3SurveyCreatePermissionError";
|
||||
}
|
||||
}
|
||||
|
||||
function getCreatedBy(authentication: TV3Authentication): string | null {
|
||||
if (authentication && "user" in authentication && authentication.user?.id) {
|
||||
return authentication.user.id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasExternalUrlReferences(input: TV3CreateSurveyBody): boolean {
|
||||
const hasExternalEndingLink = input.endings.some(
|
||||
(ending) => ending.type === "endScreen" && Boolean(ending.buttonLink)
|
||||
);
|
||||
const hasExternalCtaButton = getElementsFromBlocks(input.blocks).some(
|
||||
(element) => element.type === "cta" && element.buttonExternal
|
||||
);
|
||||
|
||||
return hasExternalEndingLink || hasExternalCtaButton;
|
||||
}
|
||||
|
||||
async function assertV3SurveyCreatePermissions(
|
||||
input: TV3CreateSurveyBody,
|
||||
organizationId?: string
|
||||
): Promise<void> {
|
||||
if (!hasExternalUrlReferences(input)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedOrganizationId =
|
||||
organizationId ?? (await getOrganizationByWorkspaceId(input.workspaceId))?.id ?? null;
|
||||
if (!resolvedOrganizationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExternalUrlsAllowed = await getExternalUrlsPermission(resolvedOrganizationId);
|
||||
if (!isExternalUrlsAllowed) {
|
||||
throw new V3SurveyCreatePermissionError(
|
||||
"External URLs are not enabled for this organization. Upgrade to use external survey links."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeV3SurveyCreate(params: {
|
||||
input: TV3CreateSurveyBody;
|
||||
authentication: TV3Authentication;
|
||||
languageRequests: TV3SurveyLanguageRequest[];
|
||||
requestId?: string;
|
||||
}) {
|
||||
const { input, authentication, languageRequests, requestId } = params;
|
||||
const languages = await ensureV3WorkspaceLanguages(input.workspaceId, languageRequests, requestId);
|
||||
const surveyCreateInput: TSurveyCreateInput = {
|
||||
name: input.name,
|
||||
type: "link",
|
||||
status: input.status,
|
||||
metadata: input.metadata,
|
||||
welcomeCard: input.welcomeCard,
|
||||
blocks: input.blocks,
|
||||
endings: input.endings,
|
||||
hiddenFields: input.hiddenFields,
|
||||
variables: input.variables,
|
||||
languages,
|
||||
questions: [],
|
||||
createdBy: getCreatedBy(authentication),
|
||||
};
|
||||
|
||||
return await createSurvey(input.workspaceId, surveyCreateInput);
|
||||
}
|
||||
|
||||
export async function createV3Survey(
|
||||
input: TV3CreateSurveyBody,
|
||||
authentication: TV3Authentication,
|
||||
requestId?: string,
|
||||
organizationId?: string
|
||||
) {
|
||||
const preparation = prepareV3SurveyCreate(input);
|
||||
if (!preparation.ok) {
|
||||
throw new V3SurveyReferenceValidationError(preparation.validation.invalidParams);
|
||||
}
|
||||
|
||||
await assertV3SurveyCreatePermissions(input, organizationId);
|
||||
|
||||
return await executeV3SurveyCreate({
|
||||
input: preparation.document,
|
||||
authentication,
|
||||
languageRequests: preparation.languageRequests,
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
type TV3SurveyLanguageInput = {
|
||||
code: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type TV3SurveyLanguage = {
|
||||
code: string;
|
||||
default: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type TV3SurveyLanguageQueryInput = string | string[];
|
||||
|
||||
type TResolveV3SurveyLanguageCodeResult =
|
||||
@@ -110,3 +118,32 @@ export function resolveV3SurveyLanguageCode(
|
||||
message: `Language '${normalizedRequestedLanguage}' is not configured for this survey`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getV3SurveyLanguages(
|
||||
survey: Pick<TInternalSurvey, "languages">,
|
||||
fallbackLanguage: string
|
||||
): TV3SurveyLanguage[] {
|
||||
const languages = (survey.languages ?? []).map((surveyLanguage) => ({
|
||||
code: normalizeV3SurveyLanguageTag(surveyLanguage.language.code) ?? surveyLanguage.language.code,
|
||||
default: surveyLanguage.default,
|
||||
enabled: surveyLanguage.enabled,
|
||||
}));
|
||||
|
||||
if (languages.length === 0) {
|
||||
return [{ code: fallbackLanguage, default: true, enabled: true }];
|
||||
}
|
||||
|
||||
return languages;
|
||||
}
|
||||
|
||||
export function getV3SurveyDefaultLanguage(
|
||||
survey: Pick<TInternalSurvey, "languages">,
|
||||
fallbackLanguage: string
|
||||
): string {
|
||||
const defaultLanguageCode = survey.languages?.find((surveyLanguage) => surveyLanguage.default)?.language
|
||||
.code;
|
||||
|
||||
return defaultLanguageCode
|
||||
? (normalizeV3SurveyLanguageTag(defaultLanguageCode) ?? defaultLanguageCode)
|
||||
: fallbackLanguage;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import type { TI18nString } from "@formbricks/types/i18n";
|
||||
import type { TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
import type { TLanguage } from "@formbricks/types/workspace";
|
||||
import { normalizeV3SurveyLanguageTag } from "./language";
|
||||
import type { TV3SurveyDocument } from "./schemas";
|
||||
|
||||
export type TV3SurveyLanguageRequest = {
|
||||
code: string;
|
||||
default: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
const languageSelect = {
|
||||
id: true,
|
||||
code: true,
|
||||
alias: true,
|
||||
workspaceId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} satisfies Prisma.LanguageSelect;
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isInternalI18nString(value: unknown): value is TI18nString {
|
||||
return (
|
||||
isPlainObject(value) &&
|
||||
typeof value.default === "string" &&
|
||||
Object.values(value).every((entry) => typeof entry === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function collectI18nLanguageCodes(value: unknown, languageCodes: Set<string>): void {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => collectI18nLanguageCodes(entry, languageCodes));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInternalI18nString(value)) {
|
||||
Object.keys(value).forEach((languageCode) => {
|
||||
if (languageCode !== "default") {
|
||||
const normalizedLanguageCode = normalizeV3SurveyLanguageTag(languageCode);
|
||||
if (normalizedLanguageCode) {
|
||||
languageCodes.add(normalizedLanguageCode);
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Object.values(value).forEach((entry) => collectI18nLanguageCodes(entry, languageCodes));
|
||||
}
|
||||
|
||||
export function deriveV3SurveyLanguageRequests(input: TV3SurveyDocument): TV3SurveyLanguageRequest[] {
|
||||
const requestedLanguages = new Map<string, TV3SurveyLanguageRequest>();
|
||||
const addLanguage = (code: string, enabled = true): void => {
|
||||
requestedLanguages.set(code, {
|
||||
code,
|
||||
default: code.toLowerCase() === input.defaultLanguage.toLowerCase(),
|
||||
enabled: code.toLowerCase() === input.defaultLanguage.toLowerCase() ? true : enabled,
|
||||
});
|
||||
};
|
||||
|
||||
addLanguage(input.defaultLanguage);
|
||||
|
||||
input.languages.forEach((language) => {
|
||||
addLanguage(language.code, language.enabled);
|
||||
});
|
||||
|
||||
const contentLanguageCodes = new Set<string>();
|
||||
collectI18nLanguageCodes(input.welcomeCard, contentLanguageCodes);
|
||||
collectI18nLanguageCodes(input.blocks, contentLanguageCodes);
|
||||
collectI18nLanguageCodes(input.endings, contentLanguageCodes);
|
||||
collectI18nLanguageCodes(input.metadata, contentLanguageCodes);
|
||||
contentLanguageCodes.forEach((languageCode) => {
|
||||
if (!requestedLanguages.has(languageCode)) {
|
||||
addLanguage(languageCode);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(requestedLanguages.values()).sort((left, right) => {
|
||||
if (left.default) return -1;
|
||||
if (right.default) return 1;
|
||||
return left.code.localeCompare(right.code);
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureV3WorkspaceLanguages(
|
||||
workspaceId: string,
|
||||
languageRequests: TV3SurveyLanguageRequest[],
|
||||
requestId?: string
|
||||
): Promise<TSurveyLanguage[]> {
|
||||
const log = logger.withContext({ requestId, workspaceId });
|
||||
|
||||
try {
|
||||
const languages = await Promise.all(
|
||||
languageRequests.map((languageRequest) =>
|
||||
prisma.language.upsert({
|
||||
where: {
|
||||
workspaceId_code: {
|
||||
workspaceId,
|
||||
code: languageRequest.code,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
workspaceId,
|
||||
code: languageRequest.code,
|
||||
alias: null,
|
||||
},
|
||||
select: languageSelect,
|
||||
})
|
||||
)
|
||||
);
|
||||
const languageByCode = new Map(
|
||||
languages.map((language) => [language.code.toLowerCase(), language as TLanguage])
|
||||
);
|
||||
|
||||
return languageRequests.map((languageRequest) => {
|
||||
const language = languageByCode.get(languageRequest.code.toLowerCase());
|
||||
|
||||
if (!language) {
|
||||
throw new DatabaseError(`Failed to resolve language '${languageRequest.code}'`);
|
||||
}
|
||||
|
||||
return {
|
||||
language,
|
||||
default: languageRequest.default,
|
||||
enabled: languageRequest.enabled,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
log.error({ error }, "Error creating workspace languages for v3 survey write");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { prepareV3SurveyCreate, prepareV3SurveyCreateInput, prepareV3SurveyPatchInput } from "./prepare";
|
||||
import { ZV3CreateSurveyBody } from "./schemas";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
const workspaceId = "clxx1234567890123456789012";
|
||||
|
||||
const rawCreateBody = {
|
||||
workspaceId,
|
||||
name: "Product Feedback",
|
||||
defaultLanguage: "en-US",
|
||||
blocks: [
|
||||
{
|
||||
id: "clbk1234567890123456789012",
|
||||
name: "Main Block",
|
||||
elements: [
|
||||
{
|
||||
id: "satisfaction",
|
||||
type: "openText",
|
||||
headline: { "en-US": "What should we improve?", "de-DE": "Was sollen wir verbessern?" },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createBody = ZV3CreateSurveyBody.parse(rawCreateBody);
|
||||
|
||||
const survey = {
|
||||
id: "clsv1234567890123456789012",
|
||||
workspaceId,
|
||||
createdAt: new Date("2026-04-21T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-21T10:00:00.000Z"),
|
||||
name: "Product Feedback",
|
||||
type: "link",
|
||||
status: "draft",
|
||||
metadata: {},
|
||||
languages: [
|
||||
{
|
||||
language: {
|
||||
id: "cllangenus000000000000000",
|
||||
code: "en-US",
|
||||
alias: null,
|
||||
workspaceId,
|
||||
createdAt: new Date("2026-04-21T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-21T10:00:00.000Z"),
|
||||
},
|
||||
default: true,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
welcomeCard: { enabled: false },
|
||||
blocks: createBody.blocks,
|
||||
endings: [],
|
||||
hiddenFields: { enabled: false },
|
||||
variables: [],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
describe("v3 survey preparation", () => {
|
||||
test("prepares a valid create document and derives language side effects", () => {
|
||||
const preparation = prepareV3SurveyCreate(createBody);
|
||||
|
||||
expect(preparation.ok).toBe(true);
|
||||
if (!preparation.ok) {
|
||||
throw new Error("Expected create preparation to succeed");
|
||||
}
|
||||
expect(preparation.languageRequests).toEqual([
|
||||
{ code: "en-US", default: true, enabled: true },
|
||||
{ code: "de-DE", default: false, enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns validation results instead of throwing for invalid create input", () => {
|
||||
const preparation = prepareV3SurveyCreateInput({
|
||||
...rawCreateBody,
|
||||
blocks: [
|
||||
{
|
||||
...rawCreateBody.blocks[0],
|
||||
elements: [
|
||||
{
|
||||
...rawCreateBody.blocks[0].elements[0],
|
||||
buttonUrl: "https://example.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(preparation.ok).toBe(false);
|
||||
if (!preparation.ok) {
|
||||
expect(preparation.validation.invalidParams).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: "blocks.0.elements.0.buttonUrl" })])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("applies a patch over the current document before validating references", () => {
|
||||
const preparation = prepareV3SurveyPatchInput(survey, {
|
||||
blocks: [
|
||||
{
|
||||
...rawCreateBody.blocks[0],
|
||||
logicFallback: "clmiss12345678901234567890",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(preparation.ok).toBe(false);
|
||||
if (!preparation.ok) {
|
||||
expect(preparation.validation.invalidParams).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: "blocks.0.logicFallback" })])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects patch input with immutable fields as validation results", () => {
|
||||
const preparation = prepareV3SurveyPatchInput(survey, {
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
expect(preparation.ok).toBe(false);
|
||||
if (!preparation.ok) {
|
||||
expect(preparation.validation.invalidParams).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: "workspaceId" })])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects non-draft element id changes on non-draft surveys", () => {
|
||||
const preparation = prepareV3SurveyPatchInput(
|
||||
{
|
||||
...survey,
|
||||
status: "inProgress",
|
||||
} as TSurvey,
|
||||
{
|
||||
blocks: [
|
||||
{
|
||||
...rawCreateBody.blocks[0],
|
||||
elements: [
|
||||
{
|
||||
...rawCreateBody.blocks[0].elements[0],
|
||||
id: "renamed_satisfaction",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
expect(preparation.ok).toBe(false);
|
||||
if (!preparation.ok) {
|
||||
expect(preparation.validation.invalidParams).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "blocks.0.elements.0.id",
|
||||
reason: expect.stringContaining("cannot be changed"),
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types";
|
||||
import type { InvalidParam } from "@/app/api/v3/lib/response";
|
||||
import { getV3SurveyDefaultLanguage, getV3SurveyLanguages } from "./language";
|
||||
import { type TV3SurveyLanguageRequest, deriveV3SurveyLanguageRequests } from "./languages";
|
||||
import {
|
||||
DEFAULT_V3_SURVEY_LANGUAGE,
|
||||
type TV3CreateSurveyBody,
|
||||
type TV3PatchSurveyBody,
|
||||
type TV3SurveyDocument,
|
||||
ZV3CreateSurveyBody,
|
||||
ZV3SurveyDocumentBase,
|
||||
createZV3PatchSurveyBodySchema,
|
||||
formatV3ZodInvalidParams,
|
||||
} from "./schemas";
|
||||
import { type TV3SurveyDocumentValidationResult, validateV3SurveyDocument } from "./validation";
|
||||
|
||||
type TV3SurveyPrepareSuccess<TDocument> = {
|
||||
ok: true;
|
||||
document: TDocument;
|
||||
validation: Extract<TV3SurveyDocumentValidationResult, { valid: true }>;
|
||||
languageRequests: TV3SurveyLanguageRequest[];
|
||||
};
|
||||
|
||||
type TV3SurveyPrepareFailure = {
|
||||
ok: false;
|
||||
validation: Extract<TV3SurveyDocumentValidationResult, { valid: false }>;
|
||||
};
|
||||
|
||||
export type TV3SurveyPrepareResult<TDocument> = TV3SurveyPrepareSuccess<TDocument> | TV3SurveyPrepareFailure;
|
||||
|
||||
function invalidPreparation(invalidParams: InvalidParam[]): TV3SurveyPrepareFailure {
|
||||
return {
|
||||
ok: false,
|
||||
validation: {
|
||||
valid: false,
|
||||
invalidParams,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validPreparation<TDocument extends TV3SurveyDocument>(
|
||||
document: TDocument
|
||||
): TV3SurveyPrepareResult<TDocument> {
|
||||
const validation = validateV3SurveyDocument(document);
|
||||
|
||||
if (!validation.valid) {
|
||||
return invalidPreparation(validation.invalidParams);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
document,
|
||||
validation,
|
||||
languageRequests: deriveV3SurveyLanguageRequests(document),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDocumentFromSurvey(survey: TInternalSurvey): TV3SurveyPrepareResult<TV3SurveyDocument> {
|
||||
if (Array.isArray(survey.questions) && survey.questions.length > 0) {
|
||||
return invalidPreparation([
|
||||
{
|
||||
name: "survey",
|
||||
reason: "Legacy question-based surveys are not supported by the v3 survey management API",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const documentResult = ZV3SurveyDocumentBase.safeParse({
|
||||
name: survey.name,
|
||||
status: survey.status,
|
||||
metadata: survey.metadata ?? {},
|
||||
defaultLanguage: getV3SurveyDefaultLanguage(survey, DEFAULT_V3_SURVEY_LANGUAGE),
|
||||
languages: getV3SurveyLanguages(survey, DEFAULT_V3_SURVEY_LANGUAGE),
|
||||
welcomeCard: survey.welcomeCard,
|
||||
blocks: survey.blocks,
|
||||
endings: survey.endings,
|
||||
hiddenFields: survey.hiddenFields,
|
||||
variables: survey.variables,
|
||||
});
|
||||
|
||||
if (!documentResult.success) {
|
||||
return invalidPreparation(formatV3ZodInvalidParams(documentResult.error, "survey"));
|
||||
}
|
||||
|
||||
return validPreparation(documentResult.data);
|
||||
}
|
||||
|
||||
function mergeV3SurveyPatch(document: TV3SurveyDocument, patch: TV3PatchSurveyBody): TV3SurveyDocument {
|
||||
return {
|
||||
...document,
|
||||
...Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined)),
|
||||
};
|
||||
}
|
||||
|
||||
function getElementIds(document: TV3SurveyDocument): Set<string> {
|
||||
return new Set(document.blocks.flatMap((block) => block.elements.map((element) => element.id)));
|
||||
}
|
||||
|
||||
function getImmutableElementIdIssues(
|
||||
currentDocument: TV3SurveyDocument,
|
||||
patchedDocument: TV3SurveyDocument
|
||||
): InvalidParam[] {
|
||||
if (currentDocument.status === "draft") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const patchedElementIds = getElementIds(patchedDocument);
|
||||
const issues: InvalidParam[] = [];
|
||||
|
||||
currentDocument.blocks.forEach((currentBlock) => {
|
||||
const patchedBlockIndex = patchedDocument.blocks.findIndex((block) => block.id === currentBlock.id);
|
||||
if (patchedBlockIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const patchedBlock = patchedDocument.blocks[patchedBlockIndex];
|
||||
currentBlock.elements.forEach((currentElement, elementIndex) => {
|
||||
if (currentElement.isDraft || patchedElementIds.has(currentElement.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const patchedElement = patchedBlock.elements[elementIndex];
|
||||
if (!patchedElement || patchedElement.id === currentElement.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
issues.push({
|
||||
name: `blocks.${patchedBlockIndex}.elements.${elementIndex}.id`,
|
||||
reason: `Element id '${currentElement.id}' cannot be changed because the survey and element are no longer drafts`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function prepareV3SurveyCreate(
|
||||
document: TV3CreateSurveyBody
|
||||
): TV3SurveyPrepareResult<TV3CreateSurveyBody> {
|
||||
return validPreparation(document);
|
||||
}
|
||||
|
||||
export function prepareV3SurveyCreateInput(input: unknown): TV3SurveyPrepareResult<TV3CreateSurveyBody> {
|
||||
const parsed = ZV3CreateSurveyBody.safeParse(input);
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidPreparation(formatV3ZodInvalidParams(parsed.error, "data"));
|
||||
}
|
||||
|
||||
return prepareV3SurveyCreate(parsed.data);
|
||||
}
|
||||
|
||||
export function prepareV3SurveyPatchInput(
|
||||
survey: TInternalSurvey,
|
||||
input: unknown
|
||||
): TV3SurveyPrepareResult<TV3SurveyDocument> {
|
||||
const currentDocument = buildDocumentFromSurvey(survey);
|
||||
|
||||
if (!currentDocument.ok) {
|
||||
return currentDocument;
|
||||
}
|
||||
|
||||
const parsedPatch = createZV3PatchSurveyBodySchema(currentDocument.document.defaultLanguage).safeParse(
|
||||
input
|
||||
);
|
||||
|
||||
if (!parsedPatch.success) {
|
||||
return invalidPreparation(formatV3ZodInvalidParams(parsedPatch.error, "data"));
|
||||
}
|
||||
|
||||
const patchedDocument = mergeV3SurveyPatch(currentDocument.document, parsedPatch.data);
|
||||
const immutableElementIdIssues = getImmutableElementIdIssues(currentDocument.document, patchedDocument);
|
||||
if (immutableElementIdIssues.length > 0) {
|
||||
return invalidPreparation(immutableElementIdIssues);
|
||||
}
|
||||
|
||||
return validPreparation(patchedDocument);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { validateV3SurveyReferences } from "./reference-validation";
|
||||
import { ZV3CreateSurveyBody } from "./schemas";
|
||||
|
||||
const validSurvey = ZV3CreateSurveyBody.parse({
|
||||
workspaceId: "clxx1234567890123456789012",
|
||||
name: "Product Feedback",
|
||||
hiddenFields: {
|
||||
enabled: true,
|
||||
fieldIds: ["account_id"],
|
||||
},
|
||||
variables: [
|
||||
{
|
||||
id: "clvar123456789012345678901",
|
||||
name: "score",
|
||||
type: "number",
|
||||
value: 0,
|
||||
},
|
||||
],
|
||||
endings: [
|
||||
{
|
||||
id: "clend123456789012345678901",
|
||||
type: "endScreen",
|
||||
headline: { "en-US": "Thanks" },
|
||||
},
|
||||
],
|
||||
blocks: [
|
||||
{
|
||||
id: "clbk1234567890123456789012",
|
||||
name: "Main Block",
|
||||
logicFallback: "clend123456789012345678901",
|
||||
elements: [
|
||||
{
|
||||
id: "satisfaction",
|
||||
type: "openText",
|
||||
headline: { "en-US": "What should we improve?" },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
logic: [
|
||||
{
|
||||
id: "cllog123456789012345678901",
|
||||
conditions: {
|
||||
id: "clgrp123456789012345678901",
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: "clcon123456789012345678901",
|
||||
leftOperand: { type: "element", value: "satisfaction" },
|
||||
operator: "isSubmitted",
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: "clact123456789012345678901",
|
||||
objective: "calculate",
|
||||
variableId: "clvar123456789012345678901",
|
||||
operator: "add",
|
||||
value: { type: "static", value: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe("validateV3SurveyReferences", () => {
|
||||
test("accepts a survey with consistent stable identifiers", () => {
|
||||
expect(
|
||||
validateV3SurveyReferences({
|
||||
blocks: validSurvey.blocks,
|
||||
endings: validSurvey.endings,
|
||||
hiddenFields: validSurvey.hiddenFields,
|
||||
variables: validSurvey.variables,
|
||||
})
|
||||
).toEqual({ ok: true, invalidParams: [] });
|
||||
});
|
||||
|
||||
test("rejects duplicate block, element, variable, and hidden field identifiers", () => {
|
||||
const survey = {
|
||||
...validSurvey,
|
||||
hiddenFields: { enabled: true, fieldIds: ["account_id", "account_id"] },
|
||||
variables: [
|
||||
...validSurvey.variables,
|
||||
{
|
||||
id: "clvar123456789012345678901",
|
||||
name: "score",
|
||||
type: "number" as const,
|
||||
value: 0,
|
||||
},
|
||||
],
|
||||
blocks: [
|
||||
...validSurvey.blocks,
|
||||
{
|
||||
...validSurvey.blocks[0],
|
||||
elements: [{ ...validSurvey.blocks[0].elements[0] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = validateV3SurveyReferences({
|
||||
blocks: survey.blocks,
|
||||
endings: survey.endings,
|
||||
hiddenFields: survey.hiddenFields,
|
||||
variables: survey.variables,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.invalidParams).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "blocks.1.id" }),
|
||||
expect.objectContaining({ name: "blocks.1.elements.0.id" }),
|
||||
expect.objectContaining({ name: "variables.1.id" }),
|
||||
expect.objectContaining({ name: "hiddenFields.fieldIds.1" }),
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects cross-namespace identifier collisions", () => {
|
||||
const result = validateV3SurveyReferences({
|
||||
blocks: validSurvey.blocks,
|
||||
endings: validSurvey.endings,
|
||||
hiddenFields: { enabled: true, fieldIds: ["account_id", "satisfaction"] },
|
||||
variables: [
|
||||
{
|
||||
id: "satisfaction",
|
||||
name: "account_id",
|
||||
type: "number",
|
||||
value: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.invalidParams).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "hiddenFields.fieldIds.1" }),
|
||||
expect.objectContaining({ name: "variables.0.id" }),
|
||||
expect.objectContaining({ name: "variables.0.name" }),
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("reports dangling logic references with actionable paths", () => {
|
||||
const survey = {
|
||||
...validSurvey,
|
||||
blocks: [
|
||||
{
|
||||
...validSurvey.blocks[0],
|
||||
logicFallback: "clmiss12345678901234567890",
|
||||
logic: [
|
||||
{
|
||||
...validSurvey.blocks[0].logic![0],
|
||||
actions: [
|
||||
{
|
||||
...validSurvey.blocks[0].logic![0].actions[0],
|
||||
variableId: "clmiss12345678901234567890",
|
||||
},
|
||||
{
|
||||
id: "cljmp123456789012345678901",
|
||||
objective: "jumpToBlock" as const,
|
||||
target: "clmiss12345678901234567890",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = validateV3SurveyReferences({
|
||||
blocks: survey.blocks,
|
||||
endings: survey.endings,
|
||||
hiddenFields: survey.hiddenFields,
|
||||
variables: survey.variables,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.invalidParams).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "blocks.0.logicFallback" }),
|
||||
expect.objectContaining({ name: "blocks.0.logic.0.actions.0.variableId" }),
|
||||
expect.objectContaining({ name: "blocks.0.logic.0.actions.1.target" }),
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("reports dangling recall references with actionable paths", () => {
|
||||
const survey = {
|
||||
...validSurvey,
|
||||
blocks: [
|
||||
{
|
||||
...validSurvey.blocks[0],
|
||||
elements: [
|
||||
{
|
||||
...validSurvey.blocks[0].elements[0],
|
||||
headline: {
|
||||
default: "Please explain #recall:missing_id/fallback:your answer#",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = validateV3SurveyReferences({
|
||||
blocks: survey.blocks,
|
||||
endings: survey.endings,
|
||||
hiddenFields: survey.hiddenFields,
|
||||
variables: survey.variables,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.invalidParams).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "blocks.0.elements.0.headline.default",
|
||||
reason: expect.stringContaining("missing_id"),
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("reports dangling recall references in survey-level translatable fields", () => {
|
||||
const result = validateV3SurveyReferences({
|
||||
blocks: validSurvey.blocks,
|
||||
endings: validSurvey.endings,
|
||||
hiddenFields: validSurvey.hiddenFields,
|
||||
metadata: {
|
||||
title: {
|
||||
default: "Metadata #recall:missing_metadata_reference/fallback:value#",
|
||||
},
|
||||
},
|
||||
variables: validSurvey.variables,
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: {
|
||||
default: "Welcome #recall:missing_welcome_reference/fallback:there#",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.invalidParams).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "welcomeCard.headline.default",
|
||||
reason: expect.stringContaining("missing_welcome_reference"),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "metadata.title.default",
|
||||
reason: expect.stringContaining("missing_metadata_reference"),
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import type { TSurveyBlocks } from "@formbricks/types/surveys/blocks";
|
||||
import type { TConditionGroup, TDynamicLogicFieldValue } from "@formbricks/types/surveys/logic";
|
||||
import type { TSurveyEndings, TSurveyHiddenFields, TSurveyVariables } from "@formbricks/types/surveys/types";
|
||||
import type { InvalidParam } from "@/app/api/v3/lib/response";
|
||||
|
||||
type TReferenceValidationInput = {
|
||||
blocks: TSurveyBlocks;
|
||||
endings: TSurveyEndings;
|
||||
hiddenFields: TSurveyHiddenFields;
|
||||
metadata?: unknown;
|
||||
variables: TSurveyVariables;
|
||||
welcomeCard?: unknown;
|
||||
};
|
||||
|
||||
type TNamedReference = {
|
||||
id: string;
|
||||
path: string;
|
||||
namespace: "block" | "element" | "ending" | "hiddenField" | "variable" | "variableName";
|
||||
};
|
||||
|
||||
export class V3SurveyReferenceValidationError extends Error {
|
||||
invalidParams: InvalidParam[];
|
||||
|
||||
constructor(invalidParams: InvalidParam[]) {
|
||||
super("Survey contains invalid references");
|
||||
this.name = "V3SurveyReferenceValidationError";
|
||||
this.invalidParams = invalidParams;
|
||||
}
|
||||
}
|
||||
|
||||
export type TV3SurveyReferenceValidationResult =
|
||||
| { ok: true; invalidParams: [] }
|
||||
| { ok: false; invalidParams: InvalidParam[] };
|
||||
|
||||
function addDuplicateIdIssues(
|
||||
entries: { id: string; path: string }[],
|
||||
label: string,
|
||||
issues: InvalidParam[]
|
||||
): void {
|
||||
const firstPathById = new Map<string, string>();
|
||||
|
||||
entries.forEach(({ id, path }) => {
|
||||
const firstPath = firstPathById.get(id);
|
||||
if (firstPath !== undefined) {
|
||||
issues.push({
|
||||
name: path,
|
||||
reason: `${label} id '${id}' is duplicated; first used at ${firstPath}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
firstPathById.set(id, path);
|
||||
});
|
||||
}
|
||||
|
||||
function addDuplicateValueIssues(
|
||||
values: string[],
|
||||
pathForIndex: (index: number) => string,
|
||||
label: string,
|
||||
issues: InvalidParam[]
|
||||
): void {
|
||||
const firstIndexByValue = new Map<string, number>();
|
||||
|
||||
values.forEach((value, index) => {
|
||||
const firstIndex = firstIndexByValue.get(value);
|
||||
if (firstIndex !== undefined) {
|
||||
issues.push({
|
||||
name: pathForIndex(index),
|
||||
reason: `${label} '${value}' is duplicated; first used at ${pathForIndex(firstIndex)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
firstIndexByValue.set(value, index);
|
||||
});
|
||||
}
|
||||
|
||||
function addCrossNamespaceCollisionIssues(entries: TNamedReference[], issues: InvalidParam[]): void {
|
||||
const firstEntryById = new Map<string, TNamedReference>();
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const lookupId = entry.id.toLowerCase();
|
||||
const firstEntry = firstEntryById.get(lookupId);
|
||||
|
||||
if (!firstEntry) {
|
||||
firstEntryById.set(lookupId, entry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstEntry.namespace === entry.namespace) {
|
||||
return;
|
||||
}
|
||||
|
||||
issues.push({
|
||||
name: entry.path,
|
||||
reason: `${entry.namespace} identifier '${entry.id}' conflicts with ${firstEntry.namespace} identifier at ${firstEntry.path}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function addRecallReferenceIssues(
|
||||
value: unknown,
|
||||
path: string,
|
||||
references: {
|
||||
elementIds: Set<string>;
|
||||
variableIds: Set<string>;
|
||||
hiddenFieldIds: Set<string>;
|
||||
},
|
||||
issues: InvalidParam[]
|
||||
): void {
|
||||
if (typeof value === "string") {
|
||||
const recallPattern = /#recall:([A-Za-z0-9_-]+)/g;
|
||||
|
||||
for (const match of value.matchAll(recallPattern)) {
|
||||
const recallId = match[1];
|
||||
const isKnownReference =
|
||||
references.elementIds.has(recallId) ||
|
||||
references.variableIds.has(recallId) ||
|
||||
references.hiddenFieldIds.has(recallId);
|
||||
|
||||
if (!isKnownReference) {
|
||||
issues.push({
|
||||
name: path,
|
||||
reason: `Recall reference '${recallId}' is not defined in blocks, variables, or hiddenFields.fieldIds`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry, index) => addRecallReferenceIssues(entry, `${path}.${index}`, references, issues));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(value).forEach(([key, entry]) => {
|
||||
addRecallReferenceIssues(entry, path ? `${path}.${key}` : key, references, issues);
|
||||
});
|
||||
}
|
||||
|
||||
function validateDynamicOperand(
|
||||
operand: TDynamicLogicFieldValue,
|
||||
path: string,
|
||||
references: {
|
||||
elementIds: Set<string>;
|
||||
variableIds: Set<string>;
|
||||
hiddenFieldIds: Set<string>;
|
||||
},
|
||||
issues: InvalidParam[]
|
||||
): void {
|
||||
if (operand.type === "element" && !references.elementIds.has(operand.value)) {
|
||||
issues.push({
|
||||
name: `${path}.value`,
|
||||
reason: `Element id '${operand.value}' is not defined in blocks`,
|
||||
});
|
||||
}
|
||||
|
||||
if (operand.type === "variable" && !references.variableIds.has(operand.value)) {
|
||||
issues.push({
|
||||
name: `${path}.value`,
|
||||
reason: `Variable id '${operand.value}' is not defined in variables`,
|
||||
});
|
||||
}
|
||||
|
||||
if (operand.type === "hiddenField" && !references.hiddenFieldIds.has(operand.value)) {
|
||||
issues.push({
|
||||
name: `${path}.value`,
|
||||
reason: `Hidden field id '${operand.value}' is not defined in hiddenFields.fieldIds`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateConditionGroup(
|
||||
conditionGroup: TConditionGroup,
|
||||
path: string,
|
||||
references: {
|
||||
elementIds: Set<string>;
|
||||
variableIds: Set<string>;
|
||||
hiddenFieldIds: Set<string>;
|
||||
},
|
||||
issues: InvalidParam[]
|
||||
): void {
|
||||
conditionGroup.conditions.forEach((condition, index) => {
|
||||
const conditionPath = `${path}.conditions.${index}`;
|
||||
|
||||
if ("conditions" in condition) {
|
||||
validateConditionGroup(condition, conditionPath, references, issues);
|
||||
return;
|
||||
}
|
||||
|
||||
validateDynamicOperand(condition.leftOperand, `${conditionPath}.leftOperand`, references, issues);
|
||||
|
||||
if (condition.rightOperand?.type && condition.rightOperand.type !== "static") {
|
||||
validateDynamicOperand(condition.rightOperand, `${conditionPath}.rightOperand`, references, issues);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getV3SurveyReferenceInvalidParams(input: TReferenceValidationInput): InvalidParam[] {
|
||||
const issues: InvalidParam[] = [];
|
||||
const blockIds = input.blocks.map((block) => block.id);
|
||||
const blockEntries = input.blocks.map((block, index) => ({
|
||||
id: block.id,
|
||||
path: `blocks.${index}.id`,
|
||||
}));
|
||||
const endingIds = input.endings.map((ending) => ending.id);
|
||||
const endingEntries = input.endings.map((ending, index) => ({
|
||||
id: ending.id,
|
||||
path: `endings.${index}.id`,
|
||||
}));
|
||||
const elementEntries = input.blocks.flatMap((block, blockIndex) =>
|
||||
block.elements.map((element, elementIndex) => ({
|
||||
id: element.id,
|
||||
path: `blocks.${blockIndex}.elements.${elementIndex}.id`,
|
||||
}))
|
||||
);
|
||||
const elementIds = elementEntries.map((element) => element.id);
|
||||
const hiddenFieldIds = input.hiddenFields.fieldIds ?? [];
|
||||
const hiddenFieldEntries = hiddenFieldIds.map((id, index) => ({
|
||||
id,
|
||||
path: `hiddenFields.fieldIds.${index}`,
|
||||
}));
|
||||
const variableIds = input.variables.map((variable) => variable.id);
|
||||
const variableIdEntries = variableIds.map((id, index) => ({
|
||||
id,
|
||||
path: `variables.${index}.id`,
|
||||
}));
|
||||
const variableNames = input.variables.map((variable) => variable.name);
|
||||
const variableNameEntries = variableNames.map((id, index) => ({
|
||||
id,
|
||||
path: `variables.${index}.name`,
|
||||
}));
|
||||
const navigationTargetIds = new Set([...blockIds, ...endingIds]);
|
||||
const references = {
|
||||
elementIds: new Set(elementIds),
|
||||
variableIds: new Set(variableIds),
|
||||
hiddenFieldIds: new Set(hiddenFieldIds),
|
||||
};
|
||||
|
||||
addDuplicateIdIssues(blockEntries, "Block", issues);
|
||||
addDuplicateIdIssues(elementEntries, "Element", issues);
|
||||
addDuplicateIdIssues(variableIdEntries, "Variable", issues);
|
||||
addDuplicateValueIssues(
|
||||
hiddenFieldIds,
|
||||
(index) => `hiddenFields.fieldIds.${index}`,
|
||||
"Hidden field id",
|
||||
issues
|
||||
);
|
||||
addDuplicateValueIssues(variableNames, (index) => `variables.${index}.name`, "Variable name", issues);
|
||||
addCrossNamespaceCollisionIssues(
|
||||
[
|
||||
...blockEntries.map((entry) => ({ ...entry, namespace: "block" as const })),
|
||||
...elementEntries.map((entry) => ({ ...entry, namespace: "element" as const })),
|
||||
...endingEntries.map((entry) => ({ ...entry, namespace: "ending" as const })),
|
||||
...hiddenFieldEntries.map((entry) => ({ ...entry, namespace: "hiddenField" as const })),
|
||||
...variableIdEntries.map((entry) => ({ ...entry, namespace: "variable" as const })),
|
||||
...variableNameEntries.map((entry) => ({ ...entry, namespace: "variableName" as const })),
|
||||
],
|
||||
issues
|
||||
);
|
||||
|
||||
input.blocks.forEach((block, blockIndex) => {
|
||||
if (block.logicFallback && !navigationTargetIds.has(block.logicFallback)) {
|
||||
issues.push({
|
||||
name: `blocks.${blockIndex}.logicFallback`,
|
||||
reason: `Logic fallback target '${block.logicFallback}' is not defined in blocks or endings`,
|
||||
});
|
||||
}
|
||||
|
||||
block.logic?.forEach((logic, logicIndex) => {
|
||||
const logicPath = `blocks.${blockIndex}.logic.${logicIndex}`;
|
||||
validateConditionGroup(logic.conditions, `${logicPath}.conditions`, references, issues);
|
||||
|
||||
logic.actions.forEach((action, actionIndex) => {
|
||||
const actionPath = `${logicPath}.actions.${actionIndex}`;
|
||||
|
||||
if (action.objective === "calculate") {
|
||||
if (!references.variableIds.has(action.variableId)) {
|
||||
issues.push({
|
||||
name: `${actionPath}.variableId`,
|
||||
reason: `Variable id '${action.variableId}' is not defined in variables`,
|
||||
});
|
||||
}
|
||||
|
||||
if (action.value.type !== "static") {
|
||||
validateDynamicOperand(action.value, `${actionPath}.value`, references, issues);
|
||||
}
|
||||
}
|
||||
|
||||
if (action.objective === "requireAnswer" && !references.elementIds.has(action.target)) {
|
||||
issues.push({
|
||||
name: `${actionPath}.target`,
|
||||
reason: `Element id '${action.target}' is not defined in blocks`,
|
||||
});
|
||||
}
|
||||
|
||||
if (action.objective === "jumpToBlock" && !navigationTargetIds.has(action.target)) {
|
||||
issues.push({
|
||||
name: `${actionPath}.target`,
|
||||
reason: `Jump target '${action.target}' is not defined in blocks or endings`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
addRecallReferenceIssues(input.blocks, "blocks", references, issues);
|
||||
addRecallReferenceIssues(input.endings, "endings", references, issues);
|
||||
addRecallReferenceIssues(input.welcomeCard, "welcomeCard", references, issues);
|
||||
addRecallReferenceIssues(input.metadata, "metadata", references, issues);
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function validateV3SurveyReferences(
|
||||
input: TReferenceValidationInput
|
||||
): TV3SurveyReferenceValidationResult {
|
||||
const invalidParams = getV3SurveyReferenceInvalidParams(input);
|
||||
|
||||
if (invalidParams.length > 0) {
|
||||
return { ok: false, invalidParams };
|
||||
}
|
||||
|
||||
return { ok: true, invalidParams: [] };
|
||||
}
|
||||
|
||||
export function assertValidV3SurveyReferences(input: TReferenceValidationInput): void {
|
||||
const result = validateV3SurveyReferences(input);
|
||||
|
||||
if (!result.ok) {
|
||||
throw new V3SurveyReferenceValidationError(result.invalidParams);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,10 @@ vi.mock("@/modules/survey/list/lib/survey", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./create", () => ({
|
||||
createV3Survey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
withContext: vi.fn(() => ({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* GET /api/v3/surveys — list surveys for a workspace.
|
||||
* /api/v3/surveys — list and create block-based survey management resources.
|
||||
* Session cookie or x-api-key; scope by workspaceId only.
|
||||
*/
|
||||
import { logger } from "@formbricks/logger";
|
||||
@@ -7,6 +7,7 @@ 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,
|
||||
@@ -14,8 +15,15 @@ import {
|
||||
} from "@/app/api/v3/lib/response";
|
||||
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
|
||||
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
|
||||
import { V3SurveyCreatePermissionError, createV3Survey } from "./create";
|
||||
import { parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
|
||||
import { serializeV3SurveyListItem } from "./serializers";
|
||||
import { V3SurveyReferenceValidationError } from "./reference-validation";
|
||||
import { ZV3CreateSurveyBody } from "./schemas";
|
||||
import {
|
||||
V3SurveyUnsupportedShapeError,
|
||||
serializeV3SurveyListItem,
|
||||
serializeV3SurveyResource,
|
||||
} from "./serializers";
|
||||
|
||||
export const GET = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
@@ -80,3 +88,81 @@ export const GET = withV3ApiWrapper({
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
schemas: {
|
||||
body: ZV3CreateSurveyBody,
|
||||
},
|
||||
action: "created",
|
||||
targetType: "survey",
|
||||
handler: async ({ authentication, auditLog, parsedInput, requestId, instance }) => {
|
||||
const { body } = parsedInput;
|
||||
const log = logger.withContext({ requestId, workspaceId: body.workspaceId });
|
||||
|
||||
try {
|
||||
const authResult = await requireV3WorkspaceAccess(
|
||||
authentication,
|
||||
body.workspaceId,
|
||||
"readWrite",
|
||||
requestId,
|
||||
instance
|
||||
);
|
||||
if (authResult instanceof Response) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
const survey = await createV3Survey(
|
||||
{
|
||||
...body,
|
||||
workspaceId: authResult.workspaceId,
|
||||
},
|
||||
authentication,
|
||||
requestId,
|
||||
authResult.organizationId
|
||||
);
|
||||
const resource = serializeV3SurveyResource(survey);
|
||||
|
||||
if (auditLog) {
|
||||
auditLog.organizationId = authResult.organizationId;
|
||||
auditLog.targetId = survey.id;
|
||||
auditLog.newObject = resource;
|
||||
}
|
||||
|
||||
return createdResponse(resource, {
|
||||
requestId,
|
||||
location: `/api/v3/surveys/${survey.id}`,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof V3SurveyReferenceValidationError) {
|
||||
log.warn({ statusCode: 400, invalidParams: err.invalidParams }, "Survey reference validation failed");
|
||||
return problemBadRequest(requestId, "Invalid survey references", {
|
||||
invalid_params: err.invalidParams,
|
||||
instance,
|
||||
});
|
||||
}
|
||||
if (err instanceof V3SurveyUnsupportedShapeError) {
|
||||
log.warn({ statusCode: 400, errorCode: err.name }, "Unsupported survey shape");
|
||||
return problemBadRequest(requestId, err.message, {
|
||||
invalid_params: [{ name: "body", reason: err.message }],
|
||||
instance,
|
||||
});
|
||||
}
|
||||
if (err instanceof V3SurveyCreatePermissionError) {
|
||||
log.warn({ statusCode: 403, errorCode: err.name }, "Survey create permission check failed");
|
||||
return problemForbidden(requestId, err.message, instance);
|
||||
}
|
||||
if (err instanceof ResourceNotFoundError) {
|
||||
log.warn({ statusCode: 403, errorCode: err.name }, "Resource not found");
|
||||
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
|
||||
}
|
||||
if (err instanceof DatabaseError) {
|
||||
log.error({ error: err, statusCode: 500 }, "Database error");
|
||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
||||
}
|
||||
|
||||
log.error({ error: err, statusCode: 500 }, "V3 survey create unexpected error");
|
||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { ZV3CreateSurveyBody, ZV3PatchSurveyBody, createZV3PatchSurveyBodySchema } from "./schemas";
|
||||
|
||||
const validCreateBody = {
|
||||
workspaceId: "clxx1234567890123456789012",
|
||||
name: "Product Feedback",
|
||||
blocks: [
|
||||
{
|
||||
id: "clbk1234567890123456789012",
|
||||
name: "Main Block",
|
||||
elements: [
|
||||
{
|
||||
id: "satisfaction",
|
||||
type: "openText",
|
||||
headline: { "en-US": "What should we improve?" },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("ZV3CreateSurveyBody", () => {
|
||||
test("accepts a valid block-based create body and applies public defaults", () => {
|
||||
const parsed = ZV3CreateSurveyBody.parse(validCreateBody);
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
workspaceId: validCreateBody.workspaceId,
|
||||
name: "Product Feedback",
|
||||
type: "link",
|
||||
status: "draft",
|
||||
metadata: {},
|
||||
defaultLanguage: "en-US",
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
endings: [],
|
||||
hiddenFields: { enabled: false },
|
||||
variables: [],
|
||||
});
|
||||
expect(parsed.blocks[0].elements[0]).toMatchObject({
|
||||
headline: { default: "What should we improve?" },
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes locale maps and language codes before shared survey validation", () => {
|
||||
const parsed = ZV3CreateSurveyBody.parse({
|
||||
...validCreateBody,
|
||||
defaultLanguage: "en_us",
|
||||
languages: [{ code: "de_de" }],
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: { en_us: "Welcome", de_de: "Willkommen" },
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
...validCreateBody.blocks[0],
|
||||
elements: [
|
||||
{
|
||||
...validCreateBody.blocks[0].elements[0],
|
||||
headline: { en_us: "Hello", de_de: "Hallo" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.defaultLanguage).toBe("en-US");
|
||||
expect(parsed.languages).toEqual([{ code: "de-DE", enabled: true }]);
|
||||
expect(parsed.welcomeCard).toMatchObject({
|
||||
headline: { default: "Welcome", "de-DE": "Willkommen" },
|
||||
});
|
||||
expect(parsed.blocks[0].elements[0]).toMatchObject({
|
||||
headline: { default: "Hello", "de-DE": "Hallo" },
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects an invalid defaultLanguage instead of silently defaulting", () => {
|
||||
const result = ZV3CreateSurveyBody.safeParse({
|
||||
...validCreateBody,
|
||||
defaultLanguage: "not a locale",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues.map((issue) => issue.path.join("."))).toContain("defaultLanguage");
|
||||
});
|
||||
|
||||
test("rejects duplicate locale keys after normalization", () => {
|
||||
const result = ZV3CreateSurveyBody.safeParse({
|
||||
...validCreateBody,
|
||||
blocks: [
|
||||
{
|
||||
...validCreateBody.blocks[0],
|
||||
elements: [
|
||||
{
|
||||
...validCreateBody.blocks[0].elements[0],
|
||||
headline: { "en-US": "Hello", en_us: "Duplicate" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues.map((issue) => issue.path.join("."))).toContain(
|
||||
"blocks.0.elements.0.headline.en_us"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects unsupported top-level fields instead of silently ignoring them", () => {
|
||||
const result = ZV3CreateSurveyBody.safeParse({
|
||||
...validCreateBody,
|
||||
questions: [],
|
||||
styling: {},
|
||||
createdBy: "user_1",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues.map((issue) => issue.path.join("."))).toEqual(
|
||||
expect.arrayContaining(["questions", "styling", "createdBy"])
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects unsupported nested fields instead of stripping them", () => {
|
||||
const result = ZV3CreateSurveyBody.safeParse({
|
||||
...validCreateBody,
|
||||
blocks: [
|
||||
{
|
||||
...validCreateBody.blocks[0],
|
||||
targeting: {},
|
||||
elements: [
|
||||
{
|
||||
...validCreateBody.blocks[0].elements[0],
|
||||
analytics: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues.map((issue) => issue.path.join("."))).toEqual(
|
||||
expect.arrayContaining(["blocks.0.targeting", "blocks.0.elements.0.analytics"])
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects element fields that do not belong to the selected element type", () => {
|
||||
const result = ZV3CreateSurveyBody.safeParse({
|
||||
...validCreateBody,
|
||||
blocks: [
|
||||
{
|
||||
...validCreateBody.blocks[0],
|
||||
elements: [
|
||||
{
|
||||
...validCreateBody.blocks[0].elements[0],
|
||||
buttonUrl: "https://example.com",
|
||||
scale: "star",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues.map((issue) => issue.path.join("."))).toContain(
|
||||
"blocks.0.elements.0.buttonUrl"
|
||||
);
|
||||
expect(result.error?.issues.map((issue) => issue.path.join("."))).toContain("blocks.0.elements.0.scale");
|
||||
expect(
|
||||
result.error?.issues.find((issue) => issue.path.join(".") === "blocks.0.elements.0.buttonUrl")
|
||||
).toMatchObject({
|
||||
message: expect.stringContaining("element type 'openText'"),
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects choice fields that do not belong to the selected element type", () => {
|
||||
const result = ZV3CreateSurveyBody.safeParse({
|
||||
...validCreateBody,
|
||||
blocks: [
|
||||
{
|
||||
...validCreateBody.blocks[0],
|
||||
elements: [
|
||||
{
|
||||
id: "choices",
|
||||
type: "multipleChoiceSingle",
|
||||
headline: { "en-US": "Pick one" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "choice_1", label: { "en-US": "A" }, imageUrl: "https://example.com/a.png" },
|
||||
{ id: "choice_2", label: { "en-US": "B" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues.map((issue) => issue.path.join("."))).toContain(
|
||||
"blocks.0.elements.0.choices.0.imageUrl"
|
||||
);
|
||||
expect(
|
||||
result.error?.issues.find((issue) => issue.path.join(".") === "blocks.0.elements.0.choices.0.imageUrl")
|
||||
).toMatchObject({
|
||||
message: expect.stringContaining("Allowed fields: id, label"),
|
||||
});
|
||||
});
|
||||
|
||||
test("does not rewrite locale-shaped objects in logic metadata", () => {
|
||||
const result = ZV3CreateSurveyBody.safeParse({
|
||||
...validCreateBody,
|
||||
blocks: [
|
||||
{
|
||||
...validCreateBody.blocks[0],
|
||||
elements: [
|
||||
{
|
||||
...validCreateBody.blocks[0].elements[0],
|
||||
},
|
||||
],
|
||||
logic: [
|
||||
{
|
||||
id: "cllog123456789012345678901",
|
||||
conditions: {
|
||||
id: "clgrp123456789012345678901",
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: "clcon123456789012345678901",
|
||||
leftOperand: {
|
||||
type: "element",
|
||||
value: "satisfaction",
|
||||
meta: { "en-US": "metadata" },
|
||||
},
|
||||
operator: "isSubmitted",
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: "clact123456789012345678901",
|
||||
objective: "requireAnswer",
|
||||
target: "satisfaction",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) {
|
||||
throw new Error("Expected schema validation to pass");
|
||||
}
|
||||
expect(result.data.blocks[0].logic?.[0].conditions.conditions[0]).toMatchObject({
|
||||
leftOperand: {
|
||||
meta: { "en-US": "metadata" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects the internal default translation key in public v3 input", () => {
|
||||
const result = ZV3CreateSurveyBody.safeParse({
|
||||
...validCreateBody,
|
||||
blocks: [
|
||||
{
|
||||
...validCreateBody.blocks[0],
|
||||
elements: [
|
||||
{
|
||||
...validCreateBody.blocks[0].elements[0],
|
||||
headline: { default: "Internal key should not be public" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0].path.join(".")).toBe("blocks.0.elements.0.headline.default");
|
||||
});
|
||||
|
||||
test("preserves arbitrary metadata while normalizing known translatable metadata fields", () => {
|
||||
const parsed = ZV3CreateSurveyBody.parse({
|
||||
...validCreateBody,
|
||||
metadata: {
|
||||
cx_context: {
|
||||
"de-DE": "This is arbitrary customer metadata, not translation content",
|
||||
},
|
||||
title: {
|
||||
"en-US": "Feedback Survey",
|
||||
"de-DE": "Feedback-Umfrage",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.metadata).toMatchObject({
|
||||
cx_context: {
|
||||
"de-DE": "This is arbitrary customer metadata, not translation content",
|
||||
},
|
||||
title: {
|
||||
default: "Feedback Survey",
|
||||
"de-DE": "Feedback-Umfrage",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects non-link survey types for this survey-template endpoint", () => {
|
||||
const result = ZV3CreateSurveyBody.safeParse({
|
||||
...validCreateBody,
|
||||
type: "app",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0].path).toEqual(["type"]);
|
||||
});
|
||||
|
||||
test("rejects malformed locale maps that do not include the default language", () => {
|
||||
const result = ZV3CreateSurveyBody.safeParse({
|
||||
...validCreateBody,
|
||||
blocks: [
|
||||
{
|
||||
...validCreateBody.blocks[0],
|
||||
elements: [
|
||||
{
|
||||
...validCreateBody.blocks[0].elements[0],
|
||||
headline: { "not a locale": "Hello" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects duplicate language entries and disabled default language", () => {
|
||||
const result = ZV3CreateSurveyBody.safeParse({
|
||||
...validCreateBody,
|
||||
languages: [{ code: "en-US", enabled: false }, { code: "en_us" }],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues.map((issue) => issue.path.join("."))).toEqual(
|
||||
expect.arrayContaining(["languages.0.enabled", "languages.1.code"])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ZV3PatchSurveyBody", () => {
|
||||
test("accepts a strict top-level partial and preserves omitted defaults", () => {
|
||||
const parsed = ZV3PatchSurveyBody.parse({
|
||||
name: "Updated survey",
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({ name: "Updated survey" });
|
||||
});
|
||||
|
||||
test("rejects an empty patch body", () => {
|
||||
const result = ZV3PatchSurveyBody.safeParse({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0]).toMatchObject({
|
||||
message: "Request body must include at least one updatable field",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects immutable and out-of-scope fields", () => {
|
||||
const result = ZV3PatchSurveyBody.safeParse({
|
||||
id: "clsv1234567890123456789012",
|
||||
workspaceId: "clxx1234567890123456789012",
|
||||
type: "link",
|
||||
questions: [],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues.map((issue) => issue.path.join("."))).toEqual(
|
||||
expect.arrayContaining(["id", "workspaceId", "type", "questions"])
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizes patch translation maps using the current default language", () => {
|
||||
const parsed = createZV3PatchSurveyBodySchema("de-DE").parse({
|
||||
blocks: [
|
||||
{
|
||||
id: "clbk1234567890123456789012",
|
||||
name: "Main Block",
|
||||
elements: [
|
||||
{
|
||||
id: "satisfaction",
|
||||
type: "openText",
|
||||
headline: { de_de: "Hallo", en_us: "Hello" },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.blocks?.[0].elements[0]).toMatchObject({
|
||||
headline: { default: "Hallo", "en-US": "Hello" },
|
||||
});
|
||||
expect(parsed).not.toHaveProperty("defaultLanguage");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,16 @@
|
||||
import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types";
|
||||
import type { TSurvey as TSurveyListRecord } from "@/modules/survey/list/types/surveys";
|
||||
import { normalizeV3SurveyLanguageTag, resolveV3SurveyLanguageCode } from "./language";
|
||||
import {
|
||||
type TV3SurveyLanguage,
|
||||
getV3SurveyDefaultLanguage,
|
||||
getV3SurveyLanguages,
|
||||
normalizeV3SurveyLanguageTag,
|
||||
resolveV3SurveyLanguageCode,
|
||||
} from "./language";
|
||||
|
||||
export type TV3SurveyListItem = Omit<TSurveyListRecord, "singleUse">;
|
||||
const DEFAULT_V3_SURVEY_LANGUAGE = "en-US";
|
||||
|
||||
type TV3SurveyLanguage = {
|
||||
code: string;
|
||||
default: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type TSerializedValue =
|
||||
| string
|
||||
| number
|
||||
@@ -47,28 +47,6 @@ function toIsoString(value: Date | string): string {
|
||||
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function getSurveyLanguages(survey: TInternalSurvey): TV3SurveyLanguage[] {
|
||||
const languages = (survey.languages ?? []).map((surveyLanguage) => ({
|
||||
code: normalizeV3SurveyLanguageTag(surveyLanguage.language.code) ?? surveyLanguage.language.code,
|
||||
default: surveyLanguage.default,
|
||||
enabled: surveyLanguage.enabled,
|
||||
}));
|
||||
|
||||
if (languages.length === 0) {
|
||||
return [{ code: DEFAULT_V3_SURVEY_LANGUAGE, default: true, enabled: true }];
|
||||
}
|
||||
|
||||
return languages;
|
||||
}
|
||||
|
||||
function getDefaultLanguage(survey: TInternalSurvey): string {
|
||||
const defaultLanguageCode = survey.languages?.find((surveyLanguage) => surveyLanguage.default)?.language
|
||||
.code;
|
||||
return defaultLanguageCode
|
||||
? (normalizeV3SurveyLanguageTag(defaultLanguageCode) ?? defaultLanguageCode)
|
||||
: DEFAULT_V3_SURVEY_LANGUAGE;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -162,8 +140,8 @@ export function serializeV3SurveyResource(survey: TInternalSurvey, options?: { l
|
||||
);
|
||||
}
|
||||
|
||||
const defaultLanguage = getDefaultLanguage(survey);
|
||||
const languages = getSurveyLanguages(survey);
|
||||
const defaultLanguage = getV3SurveyDefaultLanguage(survey, DEFAULT_V3_SURVEY_LANGUAGE);
|
||||
const languages = getV3SurveyLanguages(survey, DEFAULT_V3_SURVEY_LANGUAGE);
|
||||
const configuredLanguageCodes = new Set(languages.map((language) => language.code));
|
||||
const requestedLanguages = resolveRequestedLanguages(languages, options?.lang);
|
||||
const languageCodes = requestedLanguages.length > 0 ? new Set(requestedLanguages) : configuredLanguageCodes;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
|
||||
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
|
||||
import { problemInternalError, successResponse } from "@/app/api/v3/lib/response";
|
||||
import { getAuthorizedV3Survey } from "../authorization";
|
||||
import {
|
||||
type TV3SurveyPrepareResult,
|
||||
prepareV3SurveyCreateInput,
|
||||
prepareV3SurveyPatchInput,
|
||||
} from "../prepare";
|
||||
import { type TV3SurveyDocument, ZV3EmptyQuery, ZV3SurveyValidationRequestBody } from "../schemas";
|
||||
|
||||
const createWorkspaceSchema = z.object({
|
||||
workspaceId: z.cuid2(),
|
||||
});
|
||||
|
||||
function serializeValidationResult<TDocument extends TV3SurveyDocument>(
|
||||
operation: "create" | "patch",
|
||||
preparation: TV3SurveyPrepareResult<TDocument>
|
||||
) {
|
||||
if (!preparation.ok) {
|
||||
return {
|
||||
valid: false,
|
||||
operation,
|
||||
invalid_params: preparation.validation.invalidParams,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
operation,
|
||||
invalid_params: [],
|
||||
languages: preparation.languageRequests.map((languageRequest) => ({
|
||||
...languageRequest,
|
||||
writeBehavior: "connect_or_create" as const,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export const POST = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
schemas: {
|
||||
body: ZV3SurveyValidationRequestBody,
|
||||
query: ZV3EmptyQuery,
|
||||
},
|
||||
handler: async ({ parsedInput, authentication, requestId, instance }) => {
|
||||
const { body } = parsedInput;
|
||||
const log = logger.withContext({ requestId, operation: body.operation });
|
||||
|
||||
try {
|
||||
if (body.operation === "create") {
|
||||
const workspaceResult = createWorkspaceSchema.safeParse(body.data);
|
||||
if (workspaceResult.success) {
|
||||
const authResult = await requireV3WorkspaceAccess(
|
||||
authentication,
|
||||
workspaceResult.data.workspaceId,
|
||||
"readWrite",
|
||||
requestId,
|
||||
instance
|
||||
);
|
||||
|
||||
if (authResult instanceof Response) {
|
||||
return authResult;
|
||||
}
|
||||
}
|
||||
|
||||
return successResponse(serializeValidationResult("create", prepareV3SurveyCreateInput(body.data)), {
|
||||
requestId,
|
||||
cache: "private, no-store",
|
||||
});
|
||||
}
|
||||
|
||||
const { survey, response } = await getAuthorizedV3Survey({
|
||||
surveyId: body.surveyId,
|
||||
authentication,
|
||||
access: "readWrite",
|
||||
requestId,
|
||||
instance,
|
||||
});
|
||||
|
||||
if (response) {
|
||||
log.warn(
|
||||
{ statusCode: response.status, surveyId: body.surveyId },
|
||||
"Survey not found or not accessible"
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
return successResponse(
|
||||
serializeValidationResult("patch", prepareV3SurveyPatchInput(survey, body.data)),
|
||||
{
|
||||
requestId,
|
||||
cache: "private, no-store",
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
log.error({ error, statusCode: 500 }, "Database error");
|
||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
||||
}
|
||||
|
||||
log.error({ error, statusCode: 500 }, "V3 survey validation unexpected error");
|
||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { InvalidParam } from "@/app/api/v3/lib/response";
|
||||
import { validateV3SurveyReferences } from "./reference-validation";
|
||||
import type { TV3SurveyDocument } from "./schemas";
|
||||
|
||||
export type TV3SurveyDocumentValidationResult =
|
||||
| { valid: true; invalidParams: [] }
|
||||
| { valid: false; invalidParams: InvalidParam[] };
|
||||
|
||||
export function validateV3SurveyDocument(document: TV3SurveyDocument): TV3SurveyDocumentValidationResult {
|
||||
const referenceValidation = validateV3SurveyReferences({
|
||||
blocks: document.blocks,
|
||||
endings: document.endings,
|
||||
hiddenFields: document.hiddenFields,
|
||||
metadata: document.metadata,
|
||||
variables: document.variables,
|
||||
welcomeCard: document.welcomeCard,
|
||||
});
|
||||
|
||||
if (!referenceValidation.ok) {
|
||||
return {
|
||||
valid: false,
|
||||
invalidParams: referenceValidation.invalidParams,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true, invalidParams: [] };
|
||||
}
|
||||
@@ -733,6 +733,85 @@ describe("Tests for createSurvey", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("creates survey languages from validated language inputs", async () => {
|
||||
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(mockOrganizationOutput);
|
||||
prisma.survey.create.mockResolvedValueOnce({
|
||||
...mockSurveyOutput,
|
||||
});
|
||||
|
||||
await createSurvey(mockWorkspaceId, {
|
||||
...mockCreateSurveyInput,
|
||||
languages: [
|
||||
{
|
||||
default: true,
|
||||
enabled: true,
|
||||
language: {
|
||||
id: "cllang12345678901234567890",
|
||||
code: "en-US",
|
||||
alias: null,
|
||||
workspaceId: mockWorkspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(prisma.survey.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
languages: {
|
||||
create: [
|
||||
{
|
||||
language: {
|
||||
connect: {
|
||||
id: "cllang12345678901234567890",
|
||||
},
|
||||
},
|
||||
default: true,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves an explicitly provided segment relation for existing callers", async () => {
|
||||
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(mockOrganizationOutput);
|
||||
prisma.survey.create.mockResolvedValueOnce({
|
||||
...mockSurveyOutput,
|
||||
});
|
||||
|
||||
await createSurvey(mockWorkspaceId, {
|
||||
...mockCreateSurveyInput,
|
||||
segment: {
|
||||
id: "clseg123456789012345678901",
|
||||
title: "Segment",
|
||||
description: null,
|
||||
isPrivate: false,
|
||||
filters: [],
|
||||
workspaceId: mockWorkspaceId,
|
||||
surveys: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(prisma.survey.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
segment: {
|
||||
connect: {
|
||||
id: "clseg123456789012345678901",
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
|
||||
@@ -628,9 +628,23 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat
|
||||
);
|
||||
|
||||
try {
|
||||
const { createdBy, languages, ...restSurveyBody } = parsedSurveyBody;
|
||||
const { createdBy, languages, segment, followUps, ...restSurveyBody } = parsedSurveyBody;
|
||||
const normalizedCloseOn = restSurveyBody.closeOn instanceof Date ? restSurveyBody.closeOn : null;
|
||||
const normalizedPublishOn = restSurveyBody.publishOn instanceof Date ? restSurveyBody.publishOn : null;
|
||||
const surveyLanguagesCreateData: Prisma.SurveyLanguageCreateNestedManyWithoutSurveyInput | undefined =
|
||||
languages?.length
|
||||
? {
|
||||
create: languages.map((surveyLanguage) => ({
|
||||
language: {
|
||||
connect: {
|
||||
id: surveyLanguage.language.id,
|
||||
},
|
||||
},
|
||||
default: surveyLanguage.default,
|
||||
enabled: surveyLanguage.enabled,
|
||||
})),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const actionClasses = await getActionClasses(parsedWorkspaceId);
|
||||
|
||||
@@ -641,18 +655,15 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat
|
||||
publishOn: normalizedPublishOn,
|
||||
status: restSurveyBody.status ?? "draft",
|
||||
}),
|
||||
// @ts-expect-error - languages would be undefined in case of empty array
|
||||
languages: languages?.length ? languages : undefined,
|
||||
languages: surveyLanguagesCreateData,
|
||||
segment: segment?.id ? { connect: { id: segment.id } } : undefined,
|
||||
triggers: restSurveyBody.triggers
|
||||
? handleTriggerUpdates(restSurveyBody.triggers, [], actionClasses)
|
||||
: undefined,
|
||||
attributeFilters: undefined,
|
||||
};
|
||||
const data = validateSurveyCreateDataMedia(
|
||||
attachSurveyFollowUpsToCreateData(
|
||||
attachSurveyCreatorToCreateData(baseData, createdBy),
|
||||
restSurveyBody.followUps
|
||||
)
|
||||
attachSurveyFollowUpsToCreateData(attachSurveyCreatorToCreateData(baseData, createdBy), followUps)
|
||||
);
|
||||
|
||||
const organization = await getOrganizationByWorkspaceId(parsedWorkspaceId);
|
||||
|
||||
+984
-177
File diff suppressed because it is too large
Load Diff
@@ -278,11 +278,13 @@ export const ZSurveyRecaptcha = z
|
||||
|
||||
export type TSurveyRecaptcha = z.infer<typeof ZSurveyRecaptcha>;
|
||||
|
||||
export const ZSurveyMetadata = z.object({
|
||||
title: ZI18nString.optional(),
|
||||
description: ZI18nString.optional(),
|
||||
ogImage: ZStorageUrl.optional(),
|
||||
});
|
||||
export const ZSurveyMetadata = z
|
||||
.object({
|
||||
title: ZI18nString.optional(),
|
||||
description: ZI18nString.optional(),
|
||||
ogImage: ZStorageUrl.optional(),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
export type TSurveyMetadata = z.infer<typeof ZSurveyMetadata>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user