mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-20 03:07:53 -05:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7f83c42a4 | |||
| 9e1a8e96c8 |
+4
-4
@@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => ({
|
||||
loggerError: vi.fn(),
|
||||
sendToPipeline: vi.fn(),
|
||||
updateResponseWithQuotaEvaluation: vi.fn(),
|
||||
validateFileUploads: vi.fn(),
|
||||
validateClientFileUploads: vi.fn(),
|
||||
validateOtherOptionLengthForMultipleChoice: vi.fn(),
|
||||
validateResponseData: vi.fn(),
|
||||
}));
|
||||
@@ -44,7 +44,7 @@ vi.mock("@/modules/api/v2/lib/element", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/storage/utils", () => ({
|
||||
validateFileUploads: mocks.validateFileUploads,
|
||||
validateClientFileUploads: mocks.validateClientFileUploads,
|
||||
}));
|
||||
|
||||
vi.mock("./response", () => ({
|
||||
@@ -124,7 +124,7 @@ describe("putResponseHandler", () => {
|
||||
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
|
||||
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
|
||||
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
|
||||
mocks.validateFileUploads.mockReturnValue(true);
|
||||
mocks.validateClientFileUploads.mockReturnValue(true);
|
||||
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
|
||||
mocks.validateResponseData.mockReturnValue(null);
|
||||
});
|
||||
@@ -278,7 +278,7 @@ describe("putResponseHandler", () => {
|
||||
});
|
||||
|
||||
test("rejects invalid file upload updates", async () => {
|
||||
mocks.validateFileUploads.mockReturnValue(false);
|
||||
mocks.validateClientFileUploads.mockReturnValue(false);
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams());
|
||||
|
||||
|
||||
+13
-4
@@ -11,7 +11,7 @@ import { getSurvey } from "@/lib/survey/service";
|
||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
import { validateClientFileUploads } from "@/modules/storage/utils";
|
||||
import { updateResponseWithQuotaEvaluation } from "./response";
|
||||
import { getValidatedResponseUpdateInput } from "./validated-response-update-input";
|
||||
|
||||
@@ -126,7 +126,8 @@ const getSurveyForResponse = async (
|
||||
const validateUpdateRequest = (
|
||||
existingResponse: TResponse,
|
||||
survey: TSurvey,
|
||||
responseUpdateInput: TResponseUpdateInput
|
||||
responseUpdateInput: TResponseUpdateInput,
|
||||
workspaceId: string
|
||||
): TRouteResult | undefined => {
|
||||
if (existingResponse.finished) {
|
||||
return {
|
||||
@@ -134,7 +135,15 @@ const validateUpdateRequest = (
|
||||
};
|
||||
}
|
||||
|
||||
if (!validateFileUploads(responseUpdateInput.data, survey.questions)) {
|
||||
if (
|
||||
!validateClientFileUploads({
|
||||
data: responseUpdateInput.data,
|
||||
workspaceId,
|
||||
surveyId: survey.id,
|
||||
blocks: survey.blocks,
|
||||
questions: survey.questions,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid file upload response", undefined, true),
|
||||
};
|
||||
@@ -241,7 +250,7 @@ export const putResponseHandler = async ({
|
||||
};
|
||||
}
|
||||
|
||||
const validationResult = validateUpdateRequest(existingResponse, survey, responseUpdateInput);
|
||||
const validationResult = validateUpdateRequest(existingResponse, survey, responseUpdateInput, workspaceId);
|
||||
if (validationResult) {
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
|
||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
import { validateClientFileUploads } from "@/modules/storage/utils";
|
||||
import { createResponseWithQuotaEvaluation } from "./lib/response";
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
@@ -147,7 +147,15 @@ export const POST = withV1ApiWrapper({
|
||||
responseInputData.singleUseId = singleUseValidationResult.singleUseId;
|
||||
}
|
||||
|
||||
if (!validateFileUploads(responseInputData.data, survey.questions)) {
|
||||
if (
|
||||
!validateClientFileUploads({
|
||||
data: responseInputData.data,
|
||||
workspaceId,
|
||||
surveyId: survey.id,
|
||||
blocks: survey.blocks,
|
||||
questions: survey.questions,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid file upload response"),
|
||||
};
|
||||
|
||||
@@ -66,7 +66,7 @@ export const POST = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const { fileName, fileType, surveyId } = parsedInputResult.data;
|
||||
const { fileName, fileType, surveyId, questionId } = parsedInputResult.data;
|
||||
|
||||
const [survey, organizationId] = await Promise.all([
|
||||
getSurvey(surveyId),
|
||||
@@ -109,6 +109,7 @@ export const POST = withV1ApiWrapper({
|
||||
|
||||
const fileUploadPermission = validateSurveyAllowsFileUpload({
|
||||
fileName,
|
||||
questionId,
|
||||
blocks: survey.blocks,
|
||||
questions: survey.questions,
|
||||
});
|
||||
@@ -118,7 +119,9 @@ export const POST = withV1ApiWrapper({
|
||||
response: responses.badRequestResponse(
|
||||
fileUploadPermission.reason === "no_file_upload_question"
|
||||
? "Survey does not allow file uploads"
|
||||
: "File extension is not allowed for this survey",
|
||||
: fileUploadPermission.reason === "file_upload_question_not_found"
|
||||
? "Question does not allow file uploads"
|
||||
: "File extension is not allowed for this question",
|
||||
undefined
|
||||
),
|
||||
};
|
||||
@@ -134,7 +137,8 @@ export const POST = withV1ApiWrapper({
|
||||
workspaceId,
|
||||
fileType,
|
||||
"private",
|
||||
maxFileUploadSize
|
||||
maxFileUploadSize,
|
||||
["surveys", surveyId, "questions", questionId]
|
||||
);
|
||||
|
||||
if (!signedUrlResponse.ok) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
import { validateClientFileUploads } from "@/modules/storage/utils";
|
||||
import { createResponseWithQuotaEvaluation } from "./lib/response";
|
||||
import { TResponseInputV2, ZResponseInputV2 } from "./types/response";
|
||||
|
||||
@@ -89,6 +90,18 @@ const validateResponseSubmission = async (
|
||||
return surveyCheckResult;
|
||||
}
|
||||
|
||||
if (
|
||||
!validateClientFileUploads({
|
||||
data: responseInputData.data,
|
||||
workspaceId,
|
||||
surveyId: survey.id,
|
||||
blocks: survey.blocks,
|
||||
questions: survey.questions,
|
||||
})
|
||||
) {
|
||||
return responses.badRequestResponse("Invalid file upload response", undefined, true);
|
||||
}
|
||||
|
||||
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
|
||||
responseData: responseInputData.data,
|
||||
surveyQuestions: getElementsFromBlocks(survey.blocks),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
noContentResponse,
|
||||
problemBadRequest,
|
||||
problemForbidden,
|
||||
problemInternalError,
|
||||
@@ -119,13 +118,3 @@ describe("successResponse", () => {
|
||||
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
|
||||
});
|
||||
});
|
||||
|
||||
describe("noContentResponse", () => {
|
||||
test("returns 204 without a body", async () => {
|
||||
const res = noContentResponse({ requestId: "req-empty" });
|
||||
expect(res.status).toBe(204);
|
||||
expect(res.headers.get("X-Request-Id")).toBe("req-empty");
|
||||
expect(res.headers.get("Cache-Control")).toContain("no-store");
|
||||
expect(await res.text()).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,18 +171,3 @@ export function successResponse<T>(
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function noContentResponse(options?: { requestId?: string; cache?: string }): Response {
|
||||
const headers: Record<string, string> = {
|
||||
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
|
||||
};
|
||||
|
||||
if (options?.requestId) {
|
||||
headers["X-Request-Id"] = options.requestId;
|
||||
}
|
||||
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import { ApiKeyPermission } from "@prisma/client";
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { deleteSurvey } from "@/modules/survey/lib/surveys";
|
||||
import { DELETE } from "./route";
|
||||
|
||||
const { mockAuthenticateRequest } = vi.hoisted(() => ({
|
||||
mockAuthenticateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
|
||||
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
|
||||
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
|
||||
action,
|
||||
targetType,
|
||||
userId: "unknown",
|
||||
targetId: "unknown",
|
||||
organizationId: "unknown",
|
||||
status: "failure",
|
||||
oldObject: undefined,
|
||||
newObject: undefined,
|
||||
userType: "api",
|
||||
apiUrl,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
|
||||
return { ...actual, authenticateRequest: mockAuthenticateRequest };
|
||||
});
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyRateLimit: vi.fn().mockResolvedValue(undefined),
|
||||
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
return { ...actual, AUDIT_LOG_ENABLED: false };
|
||||
});
|
||||
|
||||
vi.mock("@/app/api/v3/lib/auth", () => ({
|
||||
requireV3WorkspaceAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
getSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/surveys", () => ({
|
||||
deleteSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
queueAuditEvent: mockQueueAuditEvent,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/api/with-api-logging", () => ({
|
||||
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
withContext: vi.fn(() => ({
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
|
||||
const queueAuditEvent = vi.mocked((await import("@/modules/ee/audit-logs/lib/handler")).queueAuditEvent);
|
||||
|
||||
const surveyId = "clxx1234567890123456789012";
|
||||
const workspaceId = "clzz9876543210987654321098";
|
||||
|
||||
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
|
||||
const headers: Record<string, string> = { ...extraHeaders };
|
||||
if (requestId) {
|
||||
headers["x-request-id"] = requestId;
|
||||
}
|
||||
|
||||
return new NextRequest(url, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
const apiKeyAuth = {
|
||||
type: "apiKey" as const,
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: {
|
||||
accessControl: { read: true, write: true },
|
||||
},
|
||||
workspacePermissions: [
|
||||
{
|
||||
workspaceId,
|
||||
workspaceName: "W",
|
||||
permission: ApiKeyPermission.write,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("DELETE /api/v3/surveys/[surveyId]", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: "user_1", name: "User", email: "u@example.com" },
|
||||
expires: "2026-01-01",
|
||||
} as any);
|
||||
mockAuthenticateRequest.mockResolvedValue(null);
|
||||
vi.mocked(getSurvey).mockResolvedValue({
|
||||
id: surveyId,
|
||||
name: "Delete me",
|
||||
workspaceId: workspaceId,
|
||||
type: "link",
|
||||
status: "draft",
|
||||
createdAt: new Date("2026-04-15T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
|
||||
responseCount: 0,
|
||||
creator: { name: "User" },
|
||||
singleUse: null,
|
||||
} as any);
|
||||
vi.mocked(deleteSurvey).mockResolvedValue({
|
||||
id: surveyId,
|
||||
workspaceId,
|
||||
type: "link",
|
||||
segment: null,
|
||||
triggers: [],
|
||||
} as any);
|
||||
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
|
||||
workspaceId,
|
||||
organizationId: "org_1",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns 401 when no session and no API key", async () => {
|
||||
getServerSession.mockResolvedValue(null);
|
||||
mockAuthenticateRequest.mockResolvedValue(null);
|
||||
|
||||
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
|
||||
params: Promise.resolve({ surveyId }),
|
||||
} as never);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 200 with session auth and deletes the survey", async () => {
|
||||
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-delete"), {
|
||||
params: Promise.resolve({ surveyId }),
|
||||
} as never);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user: expect.any(Object) }),
|
||||
workspaceId,
|
||||
"readWrite",
|
||||
"req-delete",
|
||||
`/api/v3/surveys/${surveyId}`
|
||||
);
|
||||
expect(deleteSurvey).toHaveBeenCalledWith(surveyId);
|
||||
expect(await res.json()).toEqual({
|
||||
data: {
|
||||
id: surveyId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 200 with x-api-key when the key can delete in the survey workspace", async () => {
|
||||
getServerSession.mockResolvedValue(null);
|
||||
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
|
||||
|
||||
const res = await DELETE(
|
||||
createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-api-key", {
|
||||
"x-api-key": "fbk_test",
|
||||
}),
|
||||
{
|
||||
params: Promise.resolve({ surveyId }),
|
||||
} as never
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ apiKeyId: "key_1" }),
|
||||
workspaceId,
|
||||
"readWrite",
|
||||
"req-api-key",
|
||||
`/api/v3/surveys/${surveyId}`
|
||||
);
|
||||
});
|
||||
|
||||
test("returns 400 when surveyId is invalid", async () => {
|
||||
const res = await DELETE(createRequest("http://localhost/api/v3/surveys/not-a-cuid"), {
|
||||
params: Promise.resolve({ surveyId: "not-a-cuid" }),
|
||||
} as never);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 403 when the survey does not exist", async () => {
|
||||
vi.mocked(getSurvey).mockResolvedValueOnce(null);
|
||||
|
||||
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
|
||||
params: Promise.resolve({ surveyId }),
|
||||
} as never);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(deleteSurvey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 403 when the user lacks readWrite workspace access", async () => {
|
||||
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
title: "Forbidden",
|
||||
status: 403,
|
||||
detail: "You are not authorized to access this resource",
|
||||
requestId: "req-forbidden",
|
||||
}),
|
||||
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
|
||||
)
|
||||
);
|
||||
|
||||
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-forbidden"), {
|
||||
params: Promise.resolve({ surveyId }),
|
||||
} as never);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(deleteSurvey).not.toHaveBeenCalled();
|
||||
expect(queueAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "deleted",
|
||||
targetType: "survey",
|
||||
targetId: "unknown",
|
||||
organizationId: "unknown",
|
||||
userId: "user_1",
|
||||
userType: "user",
|
||||
status: "failure",
|
||||
oldObject: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("returns 500 when survey deletion fails", async () => {
|
||||
vi.mocked(deleteSurvey).mockRejectedValueOnce(new DatabaseError("db down"));
|
||||
|
||||
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-db"), {
|
||||
params: Promise.resolve({ surveyId }),
|
||||
} as never);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("internal_server_error");
|
||||
});
|
||||
|
||||
test("returns 403 when the survey is deleted after authorization succeeds", async () => {
|
||||
vi.mocked(deleteSurvey).mockRejectedValueOnce(new ResourceNotFoundError("Survey", surveyId));
|
||||
|
||||
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-race"), {
|
||||
params: Promise.resolve({ surveyId }),
|
||||
} as never);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("forbidden");
|
||||
expect(queueAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "deleted",
|
||||
targetType: "survey",
|
||||
targetId: surveyId,
|
||||
organizationId: "org_1",
|
||||
userId: "user_1",
|
||||
userType: "user",
|
||||
status: "failure",
|
||||
oldObject: expect.objectContaining({
|
||||
id: surveyId,
|
||||
workspaceId: workspaceId,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("queues an audit log with target, actor, organization, and old object", async () => {
|
||||
await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-audit"), {
|
||||
params: Promise.resolve({ surveyId }),
|
||||
} as never);
|
||||
|
||||
expect(queueAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "deleted",
|
||||
targetType: "survey",
|
||||
targetId: surveyId,
|
||||
organizationId: "org_1",
|
||||
userId: "user_1",
|
||||
userType: "user",
|
||||
status: "success",
|
||||
oldObject: expect.objectContaining({
|
||||
id: surveyId,
|
||||
workspaceId: workspaceId,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,173 +3,41 @@ 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,
|
||||
problemForbidden,
|
||||
problemInternalError,
|
||||
successResponse,
|
||||
} from "@/app/api/v3/lib/response";
|
||||
import {
|
||||
V3SurveyLanguageError,
|
||||
V3SurveyUnsupportedShapeError,
|
||||
serializeV3SurveyResource,
|
||||
} from "@/app/api/v3/surveys/serializers";
|
||||
import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { deleteSurvey } from "@/modules/survey/lib/surveys";
|
||||
import { parseV3SurveyLanguageQuery } from "../language";
|
||||
|
||||
const surveyParamsSchema = z.object({
|
||||
surveyId: z.cuid2(),
|
||||
});
|
||||
|
||||
const surveyQuerySchema = z
|
||||
.object({
|
||||
lang: z
|
||||
.union([z.string(), z.array(z.string())])
|
||||
.transform((value, ctx) => {
|
||||
const parsedLanguageQuery = parseV3SurveyLanguageQuery(value);
|
||||
|
||||
if (!parsedLanguageQuery.ok) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: parsedLanguageQuery.message,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
return parsedLanguageQuery.languages;
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.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: {
|
||||
params: surveyParamsSchema,
|
||||
query: surveyQuerySchema,
|
||||
},
|
||||
handler: async ({ parsedInput, authentication, requestId, instance }) => {
|
||||
const surveyId = parsedInput.params.surveyId;
|
||||
const log = logger.withContext({ requestId, surveyId });
|
||||
|
||||
try {
|
||||
const { survey, response } = await getAuthorizedSurvey({
|
||||
surveyId,
|
||||
authentication,
|
||||
access: "read",
|
||||
requestId,
|
||||
instance,
|
||||
});
|
||||
|
||||
if (response) {
|
||||
log.warn({ statusCode: response.status }, "Survey not found or not accessible");
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
return successResponse(serializeV3SurveyResource(survey, { lang: parsedInput.query.lang }), {
|
||||
requestId,
|
||||
cache: "private, no-store",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof V3SurveyLanguageError) {
|
||||
log.warn({ statusCode: 400, lang: parsedInput.query.lang }, "Invalid survey language selector");
|
||||
return problemBadRequest(requestId, error.message, {
|
||||
instance,
|
||||
invalid_params: [
|
||||
{
|
||||
name: "lang",
|
||||
reason: error.message,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof V3SurveyUnsupportedShapeError) {
|
||||
log.warn({ statusCode: 400 }, "Unsupported v3 survey shape");
|
||||
return problemBadRequest(requestId, error.message, {
|
||||
instance,
|
||||
invalid_params: [
|
||||
{
|
||||
name: "survey",
|
||||
reason: error.message,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
} 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 get unexpected error");
|
||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const DELETE = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
action: "deleted",
|
||||
targetType: "survey",
|
||||
schemas: {
|
||||
params: surveyParamsSchema,
|
||||
params: z.object({
|
||||
surveyId: z.cuid2(),
|
||||
}),
|
||||
},
|
||||
handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => {
|
||||
const surveyId = parsedInput.params.surveyId;
|
||||
const log = logger.withContext({ requestId, surveyId });
|
||||
|
||||
try {
|
||||
const { survey, authResult, response } = await getAuthorizedSurvey({
|
||||
surveyId,
|
||||
authentication,
|
||||
access: "readWrite",
|
||||
requestId,
|
||||
instance,
|
||||
});
|
||||
const survey = await getSurvey(surveyId);
|
||||
|
||||
if (response) {
|
||||
if (!survey) {
|
||||
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
|
||||
return response;
|
||||
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
|
||||
}
|
||||
|
||||
const authResult = await requireV3WorkspaceAccess(
|
||||
authentication,
|
||||
survey.workspaceId,
|
||||
"readWrite",
|
||||
requestId,
|
||||
instance
|
||||
);
|
||||
|
||||
if (authResult instanceof Response) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
if (auditLog) {
|
||||
@@ -178,9 +46,14 @@ export const DELETE = withV3ApiWrapper({
|
||||
auditLog.oldObject = survey;
|
||||
}
|
||||
|
||||
await deleteSurvey(surveyId);
|
||||
const deletedSurvey = await deleteSurvey(surveyId);
|
||||
|
||||
return noContentResponse({ requestId });
|
||||
return successResponse(
|
||||
{
|
||||
id: deletedSurvey.id,
|
||||
},
|
||||
{ requestId }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible");
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
normalizeV3SurveyLanguageTag,
|
||||
parseV3SurveyLanguageQuery,
|
||||
resolveV3SurveyLanguageCode,
|
||||
} from "./language";
|
||||
|
||||
const languages = [
|
||||
{ code: "en-US", enabled: true },
|
||||
{ code: "de-DE", enabled: true },
|
||||
{ code: "fr-FR", enabled: false },
|
||||
];
|
||||
|
||||
describe("normalizeV3SurveyLanguageTag", () => {
|
||||
test.each([
|
||||
["EN_us", "en-US"],
|
||||
["en-us", "en-US"],
|
||||
["de", "de"],
|
||||
["zh_hans_cn", "zh-Hans-CN"],
|
||||
])("normalizes %s to %s", (input, expected) => {
|
||||
expect(normalizeV3SurveyLanguageTag(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("returns null for invalid language tags", () => {
|
||||
expect(normalizeV3SurveyLanguageTag("not a locale")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseV3SurveyLanguageQuery", () => {
|
||||
test("parses comma-separated language selectors", () => {
|
||||
expect(parseV3SurveyLanguageQuery("de-DE, pt_PT, EN_us")).toEqual({
|
||||
ok: true,
|
||||
languages: ["de-DE", "pt-PT", "en-US"],
|
||||
});
|
||||
});
|
||||
|
||||
test("parses repeated language selectors", () => {
|
||||
expect(parseV3SurveyLanguageQuery(["de-DE", "pt_PT,en_us"])).toEqual({
|
||||
ok: true,
|
||||
languages: ["de-DE", "pt-PT", "en-US"],
|
||||
});
|
||||
});
|
||||
|
||||
test("deduplicates language selectors case-insensitively", () => {
|
||||
expect(parseV3SurveyLanguageQuery("de-DE,DE_de")).toEqual({
|
||||
ok: true,
|
||||
languages: ["de-DE"],
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects empty language selectors", () => {
|
||||
expect(parseV3SurveyLanguageQuery("de-DE,")).toEqual({
|
||||
ok: false,
|
||||
message: "Language selector must contain valid comma-separated locale codes",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects invalid language selectors", () => {
|
||||
expect(parseV3SurveyLanguageQuery("not a locale")).toEqual({
|
||||
ok: false,
|
||||
message: "Language 'not a locale' is not a valid locale code",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveV3SurveyLanguageCode", () => {
|
||||
test("matches configured languages case-insensitively and normalizes underscores", () => {
|
||||
expect(resolveV3SurveyLanguageCode("DE_de", languages)).toEqual({ ok: true, code: "de-DE" });
|
||||
});
|
||||
|
||||
test("resolves language-only tags when exactly one configured language matches", () => {
|
||||
expect(resolveV3SurveyLanguageCode("de", languages)).toEqual({ ok: true, code: "de-DE" });
|
||||
});
|
||||
|
||||
test("resolves disabled configured languages for management reads", () => {
|
||||
expect(resolveV3SurveyLanguageCode("fr", languages)).toEqual({ ok: true, code: "fr-FR" });
|
||||
});
|
||||
|
||||
test("returns ambiguous when language-only tags match multiple configured languages", () => {
|
||||
expect(
|
||||
resolveV3SurveyLanguageCode("pt", [
|
||||
{ code: "pt-BR", enabled: true },
|
||||
{ code: "pt-PT", enabled: true },
|
||||
])
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "ambiguous",
|
||||
message: "Language 'pt' is ambiguous for this survey; use one of pt-BR, pt-PT",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns unknown for languages not configured on the survey", () => {
|
||||
expect(resolveV3SurveyLanguageCode("es-ES", languages)).toEqual({
|
||||
ok: false,
|
||||
reason: "unknown",
|
||||
message: "Language 'es-ES' is not configured for this survey",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves the implicit default language for surveys without configured languages", () => {
|
||||
expect(resolveV3SurveyLanguageCode("en", [{ code: "en-US", enabled: true }])).toEqual({
|
||||
ok: true,
|
||||
code: "en-US",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
type TV3SurveyLanguageInput = {
|
||||
code: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type TV3SurveyLanguageQueryInput = string | string[];
|
||||
|
||||
type TResolveV3SurveyLanguageCodeResult =
|
||||
| { ok: true; code: string }
|
||||
| { ok: false; reason: "invalid" | "unknown" | "ambiguous"; message: string };
|
||||
|
||||
type TParseV3SurveyLanguageQueryResult = { ok: true; languages: string[] } | { ok: false; message: string };
|
||||
|
||||
export function normalizeV3SurveyLanguageTag(value: string): string | null {
|
||||
const normalizedSeparators = value.trim().replaceAll("_", "-");
|
||||
|
||||
try {
|
||||
return Intl.getCanonicalLocales(normalizedSeparators)[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseV3SurveyLanguageQuery(
|
||||
value: TV3SurveyLanguageQueryInput
|
||||
): TParseV3SurveyLanguageQueryResult {
|
||||
const requestedLanguages = (Array.isArray(value) ? value : [value])
|
||||
.flatMap((entry) => entry.split(","))
|
||||
.map((entry) => entry.trim());
|
||||
|
||||
if (requestedLanguages.some((entry) => entry.length === 0)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Language selector must contain valid comma-separated locale codes",
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedLanguages: string[] = [];
|
||||
|
||||
for (const language of requestedLanguages) {
|
||||
const normalizedLanguage = normalizeV3SurveyLanguageTag(language);
|
||||
|
||||
if (!normalizedLanguage) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Language '${language}' is not a valid locale code`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!normalizedLanguages.some((entry) => entry.toLowerCase() === normalizedLanguage.toLowerCase())) {
|
||||
normalizedLanguages.push(normalizedLanguage);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, languages: normalizedLanguages };
|
||||
}
|
||||
|
||||
function getLanguageSubtag(languageTag: string): string {
|
||||
return languageTag.split("-")[0]?.toLowerCase() ?? languageTag.toLowerCase();
|
||||
}
|
||||
|
||||
export function resolveV3SurveyLanguageCode(
|
||||
requestedLanguage: string,
|
||||
languages: TV3SurveyLanguageInput[]
|
||||
): TResolveV3SurveyLanguageCodeResult {
|
||||
const normalizedRequestedLanguage = normalizeV3SurveyLanguageTag(requestedLanguage);
|
||||
|
||||
if (!normalizedRequestedLanguage) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "invalid",
|
||||
message: `Language '${requestedLanguage}' is not a valid locale code`,
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedLanguages = languages.map((language) => ({
|
||||
...language,
|
||||
code: normalizeV3SurveyLanguageTag(language.code) ?? language.code,
|
||||
}));
|
||||
const exactMatch = normalizedLanguages.find(
|
||||
(language) => language.code.toLowerCase() === normalizedRequestedLanguage.toLowerCase()
|
||||
);
|
||||
|
||||
if (exactMatch) {
|
||||
return { ok: true, code: exactMatch.code };
|
||||
}
|
||||
|
||||
const requestedSubtag = getLanguageSubtag(normalizedRequestedLanguage);
|
||||
const hasRegionOrScript = normalizedRequestedLanguage.includes("-");
|
||||
const matchingLanguages = hasRegionOrScript
|
||||
? []
|
||||
: normalizedLanguages.filter((language) => getLanguageSubtag(language.code) === requestedSubtag);
|
||||
|
||||
if (matchingLanguages.length > 1) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "ambiguous",
|
||||
message: `Language '${normalizedRequestedLanguage}' is ambiguous for this survey; use one of ${matchingLanguages.map((language) => language.code).join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
const languageMatch = matchingLanguages[0];
|
||||
if (languageMatch) {
|
||||
return { ok: true, code: languageMatch.code };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason: "unknown",
|
||||
message: `Language '${normalizedRequestedLanguage}' is not configured for this survey`,
|
||||
};
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { V3SurveyUnsupportedShapeError, serializeV3SurveyResource } from "./serializers";
|
||||
|
||||
const baseSurvey = {
|
||||
id: "survey_1",
|
||||
workspaceId: "workspace_1",
|
||||
createdAt: new Date("2026-04-21T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-21T11:00:00.000Z"),
|
||||
name: "Product Feedback",
|
||||
type: "link",
|
||||
status: "draft",
|
||||
metadata: { cx: "enterprise" },
|
||||
languages: [
|
||||
{
|
||||
default: true,
|
||||
enabled: true,
|
||||
language: { id: "lang_1", code: "en-US", alias: "en", createdAt: new Date(), updatedAt: new Date() },
|
||||
},
|
||||
{
|
||||
default: false,
|
||||
enabled: true,
|
||||
language: { id: "lang_2", code: "de-DE", alias: "de", createdAt: new Date(), updatedAt: new Date() },
|
||||
},
|
||||
{
|
||||
default: false,
|
||||
enabled: false,
|
||||
language: { id: "lang_3", code: "fr-FR", alias: "fr", createdAt: new Date(), updatedAt: new Date() },
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: { default: "Welcome", "de-DE": "Willkommen", "fr-FR": "Bienvenue" },
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
id: "block_1",
|
||||
name: "Intro",
|
||||
elements: [
|
||||
{
|
||||
id: "satisfaction",
|
||||
type: "openText",
|
||||
headline: { default: "What should we improve?", "de-DE": "Was sollen wir verbessern?" },
|
||||
subheader: { default: "Tell us more" },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
endings: [],
|
||||
hiddenFields: { enabled: false, fieldIds: [] },
|
||||
variables: [],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
describe("serializeV3SurveyResource", () => {
|
||||
test("returns canonical multilingual fields using real locale codes", () => {
|
||||
const resource = serializeV3SurveyResource(baseSurvey);
|
||||
|
||||
expect(resource.defaultLanguage).toBe("en-US");
|
||||
expect(resource).not.toHaveProperty("language");
|
||||
expect(resource.languages).toEqual([
|
||||
{ code: "en-US", default: true, enabled: true },
|
||||
{ code: "de-DE", default: false, enabled: true },
|
||||
{ code: "fr-FR", default: false, enabled: false },
|
||||
]);
|
||||
expect(resource).toMatchObject({
|
||||
welcomeCard: {
|
||||
headline: {
|
||||
"en-US": "Welcome",
|
||||
"de-DE": "Willkommen",
|
||||
"fr-FR": "Bienvenue",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(resource).toMatchObject({
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
headline: {
|
||||
"en-US": "What should we improve?",
|
||||
"de-DE": "Was sollen wir verbessern?",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("does not expose the internal default pseudo-locale for surveys without configured languages", () => {
|
||||
const survey = {
|
||||
...baseSurvey,
|
||||
languages: [],
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: { default: "Welcome" },
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
id: "block_1",
|
||||
name: "Intro",
|
||||
elements: [
|
||||
{
|
||||
id: "satisfaction",
|
||||
type: "openText",
|
||||
headline: { default: "What should we improve?" },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const resource = serializeV3SurveyResource(survey);
|
||||
|
||||
expect(resource.defaultLanguage).toBe("en-US");
|
||||
expect(resource.languages).toEqual([{ code: "en-US", default: true, enabled: true }]);
|
||||
expect(resource).toMatchObject({
|
||||
welcomeCard: { headline: { "en-US": "Welcome" } },
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
headline: { "en-US": "What should we improve?" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("filters the implicit default language for surveys without configured languages", () => {
|
||||
const survey = {
|
||||
...baseSurvey,
|
||||
languages: [],
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: { default: "Welcome" },
|
||||
},
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const resource = serializeV3SurveyResource(survey, { lang: ["en"] });
|
||||
|
||||
expect(resource).not.toHaveProperty("language");
|
||||
expect(resource).toMatchObject({ welcomeCard: { headline: { "en-US": "Welcome" } } });
|
||||
});
|
||||
|
||||
test("preserves stored locale variants when their keys use non-canonical casing or separators", () => {
|
||||
const survey = {
|
||||
...baseSurvey,
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: { default: "Welcome", de_de: "Willkommen" },
|
||||
},
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const resource = serializeV3SurveyResource(survey);
|
||||
|
||||
expect(resource).toMatchObject({
|
||||
welcomeCard: {
|
||||
headline: {
|
||||
"en-US": "Welcome",
|
||||
"de-DE": "Willkommen",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("filters fields for case-insensitive underscore language selectors while preserving maps", () => {
|
||||
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["DE_de"] });
|
||||
|
||||
expect(resource).not.toHaveProperty("language");
|
||||
expect(resource).toMatchObject({
|
||||
welcomeCard: { headline: { "de-DE": "Willkommen" } },
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
headline: { "de-DE": "Was sollen wir verbessern?" },
|
||||
subheader: { "de-DE": "Tell us more" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves language-only selectors against configured survey languages", () => {
|
||||
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["de"] });
|
||||
|
||||
expect(resource).toMatchObject({ welcomeCard: { headline: { "de-DE": "Willkommen" } } });
|
||||
});
|
||||
|
||||
test("filters disabled configured languages for management reads", () => {
|
||||
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["fr"] });
|
||||
|
||||
expect(resource).toMatchObject({ welcomeCard: { headline: { "fr-FR": "Bienvenue" } } });
|
||||
});
|
||||
|
||||
test("filters multiple requested languages while preserving maps", () => {
|
||||
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["en-US", "de"] });
|
||||
|
||||
expect(resource).not.toHaveProperty("language");
|
||||
expect(resource).toMatchObject({
|
||||
welcomeCard: {
|
||||
headline: {
|
||||
"en-US": "Welcome",
|
||||
"de-DE": "Willkommen",
|
||||
},
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
headline: {
|
||||
"en-US": "What should we improve?",
|
||||
"de-DE": "Was sollen wir verbessern?",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects ambiguous language-only selectors", () => {
|
||||
const survey = {
|
||||
...baseSurvey,
|
||||
languages: [
|
||||
{
|
||||
default: true,
|
||||
enabled: true,
|
||||
language: {
|
||||
id: "lang_1",
|
||||
code: "pt-BR",
|
||||
alias: "br",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
{
|
||||
default: false,
|
||||
enabled: true,
|
||||
language: {
|
||||
id: "lang_2",
|
||||
code: "pt-PT",
|
||||
alias: "pt",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
expect(() => serializeV3SurveyResource(survey, { lang: ["pt"] })).toThrow(
|
||||
"Language 'pt' is ambiguous for this survey; use one of pt-BR, pt-PT"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects legacy question-based survey shapes instead of returning an incomplete block resource", () => {
|
||||
const survey = {
|
||||
...baseSurvey,
|
||||
questions: [{ id: "legacy_question", type: "openText", headline: { default: "Legacy question" } }],
|
||||
blocks: [],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
expect(() => serializeV3SurveyResource(survey)).toThrow(V3SurveyUnsupportedShapeError);
|
||||
expect(() => serializeV3SurveyResource(survey)).toThrow(
|
||||
"Legacy question-based surveys are not supported by the v3 survey management API"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,192 +1,13 @@
|
||||
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 { TSurvey } from "@/modules/survey/list/types/surveys";
|
||||
|
||||
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
|
||||
| boolean
|
||||
| null
|
||||
| TSerializedValue[]
|
||||
| { [key: string]: TSerializedValue };
|
||||
|
||||
export class V3SurveyLanguageError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "V3SurveyLanguageError";
|
||||
}
|
||||
}
|
||||
|
||||
export class V3SurveyUnsupportedShapeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "V3SurveyUnsupportedShapeError";
|
||||
}
|
||||
}
|
||||
export type TV3SurveyListItem = Omit<TSurvey, "singleUse">;
|
||||
|
||||
/**
|
||||
* Keep the v3 API contract isolated from internal persistence naming.
|
||||
* Surveys are scoped by workspaceId.
|
||||
*/
|
||||
export function serializeV3SurveyListItem(survey: TSurveyListRecord): TV3SurveyListItem {
|
||||
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
|
||||
const { singleUse: _omitSingleUse, ...rest } = survey;
|
||||
|
||||
return rest;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function isI18nString(value: unknown): value is Record<string, string> {
|
||||
return (
|
||||
isPlainObject(value) &&
|
||||
typeof value.default === "string" &&
|
||||
Object.values(value).every((entry) => typeof entry === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function getI18nValueForLanguage(value: Record<string, string>, languageCode: string): string | undefined {
|
||||
if (typeof value[languageCode] === "string") {
|
||||
return value[languageCode];
|
||||
}
|
||||
|
||||
const matchingKey = Object.keys(value).find(
|
||||
(key) => normalizeV3SurveyLanguageTag(key)?.toLowerCase() === languageCode.toLowerCase()
|
||||
);
|
||||
return matchingKey ? value[matchingKey] : undefined;
|
||||
}
|
||||
|
||||
function serializeCanonicalValue(
|
||||
value: unknown,
|
||||
defaultLanguage: string,
|
||||
languageCodes: Set<string>,
|
||||
options?: { fallbackMissingTranslations?: boolean }
|
||||
): TSerializedValue {
|
||||
if (isI18nString(value)) {
|
||||
const result: Record<string, string> = {
|
||||
[defaultLanguage]: value.default,
|
||||
};
|
||||
|
||||
for (const languageCode of languageCodes) {
|
||||
const translatedValue = getI18nValueForLanguage(value, languageCode);
|
||||
if (languageCode !== defaultLanguage) {
|
||||
if (translatedValue !== undefined) {
|
||||
result[languageCode] = translatedValue;
|
||||
} else if (options?.fallbackMissingTranslations) {
|
||||
result[languageCode] = value.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!languageCodes.has(defaultLanguage)) {
|
||||
delete result[defaultLanguage];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => serializeCanonicalValue(entry, defaultLanguage, languageCodes, options));
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [
|
||||
key,
|
||||
serializeCanonicalValue(entry, defaultLanguage, languageCodes, options),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
return value as TSerializedValue;
|
||||
}
|
||||
|
||||
function resolveRequestedLanguage(languages: TV3SurveyLanguage[], language: string): string {
|
||||
const result = resolveV3SurveyLanguageCode(language, languages);
|
||||
|
||||
if (!result.ok) {
|
||||
throw new V3SurveyLanguageError(result.message);
|
||||
}
|
||||
|
||||
return result.code;
|
||||
}
|
||||
|
||||
function resolveRequestedLanguages(languages: TV3SurveyLanguage[], requestedLanguages?: string[]): string[] {
|
||||
if (!requestedLanguages) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return requestedLanguages.map((language) => resolveRequestedLanguage(languages, language));
|
||||
}
|
||||
|
||||
export function serializeV3SurveyResource(survey: TInternalSurvey, options?: { lang?: string[] }) {
|
||||
if (Array.isArray(survey.questions) && survey.questions.length > 0) {
|
||||
throw new V3SurveyUnsupportedShapeError(
|
||||
"Legacy question-based surveys are not supported by the v3 survey management API"
|
||||
);
|
||||
}
|
||||
|
||||
const defaultLanguage = getDefaultLanguage(survey);
|
||||
const languages = getSurveyLanguages(survey);
|
||||
const configuredLanguageCodes = new Set(languages.map((language) => language.code));
|
||||
const requestedLanguages = resolveRequestedLanguages(languages, options?.lang);
|
||||
const languageCodes = requestedLanguages.length > 0 ? new Set(requestedLanguages) : configuredLanguageCodes;
|
||||
const serializeValue = (value: unknown) =>
|
||||
serializeCanonicalValue(value, defaultLanguage, languageCodes, {
|
||||
fallbackMissingTranslations: requestedLanguages.length > 0,
|
||||
});
|
||||
|
||||
return {
|
||||
id: survey.id,
|
||||
workspaceId: survey.workspaceId,
|
||||
createdAt: toIsoString(survey.createdAt),
|
||||
updatedAt: toIsoString(survey.updatedAt),
|
||||
name: survey.name,
|
||||
type: survey.type,
|
||||
status: survey.status,
|
||||
metadata: survey.metadata,
|
||||
defaultLanguage,
|
||||
languages,
|
||||
welcomeCard: serializeValue(survey.welcomeCard),
|
||||
blocks: serializeValue(survey.blocks),
|
||||
endings: serializeValue(survey.endings),
|
||||
hiddenFields: survey.hiddenFields,
|
||||
variables: survey.variables,
|
||||
};
|
||||
}
|
||||
|
||||
+29
-7
@@ -4,7 +4,7 @@ import { logger } from "@formbricks/logger";
|
||||
import { ZDeleteFileRequest, ZDownloadFileRequest } from "@formbricks/types/storage";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { authorizePrivateDownload } from "@/app/storage/[workspaceId]/[accessType]/[fileName]/lib/auth";
|
||||
import { authorizePrivateDownload } from "@/app/storage/[workspaceId]/[accessType]/[...filePath]/lib/auth";
|
||||
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
@@ -15,10 +15,14 @@ import { logFileDeletion } from "./lib/audit-logs";
|
||||
|
||||
export const GET = async (
|
||||
request: NextRequest,
|
||||
props: { params: Promise<{ workspaceId: string; accessType: string; fileName: string }> }
|
||||
props: { params: Promise<{ workspaceId: string; accessType: string; filePath: string[] }> }
|
||||
): Promise<Response> => {
|
||||
const params = await props.params;
|
||||
const paramValidation = ZDownloadFileRequest.safeParse(params);
|
||||
const fileName = params.filePath.join("/");
|
||||
const paramValidation = ZDownloadFileRequest.safeParse({
|
||||
accessType: params.accessType,
|
||||
fileName,
|
||||
});
|
||||
|
||||
if (!paramValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
@@ -28,7 +32,7 @@ export const GET = async (
|
||||
);
|
||||
}
|
||||
|
||||
const { accessType, fileName } = paramValidation.data;
|
||||
const { accessType } = paramValidation.data;
|
||||
const idParam = params.workspaceId;
|
||||
|
||||
// Resolve: the URL param may be an environmentId (old uploads) or workspaceId (new uploads)
|
||||
@@ -72,10 +76,14 @@ export const GET = async (
|
||||
|
||||
export const DELETE = async (
|
||||
request: NextRequest,
|
||||
props: { params: Promise<{ workspaceId: string; accessType: string; fileName: string }> }
|
||||
props: { params: Promise<{ workspaceId: string; accessType: string; filePath: string[] }> }
|
||||
): Promise<Response> => {
|
||||
const params = await props.params;
|
||||
const paramValidation = ZDeleteFileRequest.safeParse(params);
|
||||
const fileName = params.filePath.join("/");
|
||||
const paramValidation = ZDeleteFileRequest.safeParse({
|
||||
accessType: params.accessType,
|
||||
fileName,
|
||||
});
|
||||
if (!paramValidation.success) {
|
||||
const errorDetails = transformErrorToDetails(paramValidation.error);
|
||||
|
||||
@@ -88,7 +96,7 @@ export const DELETE = async (
|
||||
return responses.badRequestResponse("Fields are missing or incorrectly formatted", errorDetails, true);
|
||||
}
|
||||
|
||||
const { accessType, fileName } = paramValidation.data;
|
||||
const { accessType } = paramValidation.data;
|
||||
const idParam = params.workspaceId;
|
||||
|
||||
// Resolve: the URL param may be an environmentId (old uploads) or workspaceId (new uploads)
|
||||
@@ -140,6 +148,20 @@ export const DELETE = async (
|
||||
);
|
||||
|
||||
if (!deleteResult.ok) {
|
||||
if (!("error" in deleteResult)) {
|
||||
logger.error({ deleteResult }, "Unknown delete failure result shape");
|
||||
|
||||
await logFileDeletion({
|
||||
failureReason: "unknown_delete_failure",
|
||||
accessType,
|
||||
userId: session?.user?.id,
|
||||
workspaceId: resolved.workspaceId,
|
||||
apiUrl: request.url,
|
||||
});
|
||||
|
||||
return responses.internalServerErrorResponse("Failed to delete file", true);
|
||||
}
|
||||
|
||||
const { error } = deleteResult;
|
||||
|
||||
logger.error({ error }, "Error deleting file");
|
||||
@@ -22,7 +22,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
|
||||
import { deleteFile } from "@/modules/storage/service";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { parseStorageFileUrl, resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/modules/survey/lib/organization";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
@@ -589,14 +589,18 @@ const findAndDeleteUploadedFilesInResponse = async (response: TResponse, survey:
|
||||
|
||||
const deletionPromises = fileUrls.map(async (fileUrl) => {
|
||||
try {
|
||||
const { pathname } = new URL(fileUrl);
|
||||
const [, storageId, accessType, fileName] = pathname.split("/").filter(Boolean);
|
||||
const storageFile = parseStorageFileUrl(fileUrl);
|
||||
|
||||
if (!storageId || !accessType || !fileName) {
|
||||
throw new Error(`Invalid file path: ${pathname}`);
|
||||
if (!storageFile) {
|
||||
throw new Error(`Invalid storage file URL: ${fileUrl}`);
|
||||
}
|
||||
|
||||
return deleteFile(storageId, accessType as "private" | "public", fileName, survey.workspaceId);
|
||||
return deleteFile(
|
||||
storageFile.storageId,
|
||||
storageFile.accessType,
|
||||
storageFile.fileName,
|
||||
survey.workspaceId
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to delete file ${fileUrl}`);
|
||||
}
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ export const fileUploadQuestion: Survey["questions"][number] = {
|
||||
export const responseData: Response["data"] = {
|
||||
[openTextQuestion.id]: "Open Text Answer",
|
||||
[fileUploadQuestion.id]: [
|
||||
`https://example.com/dummy/${workspaceId}/private/file1.png`,
|
||||
`https://example.com/dummy/${workspaceId}/private/file2.pdf`,
|
||||
`https://example.com/storage/${workspaceId}/private/file1.png`,
|
||||
`https://example.com/storage/${workspaceId}/private/file2.pdf`,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Result, okVoid } from "@formbricks/types/error-handlers";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { deleteFile } from "@/modules/storage/service";
|
||||
import { parseStorageFileUrl } from "@/modules/storage/utils";
|
||||
|
||||
export const findAndDeleteUploadedFilesInResponse = async (
|
||||
responseData: Response["data"],
|
||||
@@ -24,13 +25,12 @@ export const findAndDeleteUploadedFilesInResponse = async (
|
||||
|
||||
const deletionPromises = fileUrls.map(async (fileUrl) => {
|
||||
try {
|
||||
const { pathname } = new URL(fileUrl);
|
||||
const [, storageId, accessType, fileName] = pathname.split("/").filter(Boolean);
|
||||
const storageFile = parseStorageFileUrl(fileUrl);
|
||||
|
||||
if (!storageId || !accessType || !fileName) {
|
||||
throw new Error(`Invalid file path: ${pathname}`);
|
||||
if (!storageFile) {
|
||||
throw new Error(`Invalid storage file URL: ${fileUrl}`);
|
||||
}
|
||||
return deleteFile(storageId, accessType as "private" | "public", fileName, workspaceId);
|
||||
return deleteFile(storageFile.storageId, storageFile.accessType, storageFile.fileName, workspaceId);
|
||||
} catch (error) {
|
||||
logger.error({ error, fileUrl }, "Failed to delete file");
|
||||
}
|
||||
|
||||
@@ -117,6 +117,61 @@ describe("storage service", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("should generate scoped private upload URL when path segments are provided", async () => {
|
||||
const mockSignedUrlResponse = {
|
||||
ok: true,
|
||||
data: {
|
||||
signedUrl: "https://s3.example.com/upload",
|
||||
presignedFields: { key: "value" },
|
||||
},
|
||||
} as MockedSignedUploadReturn;
|
||||
|
||||
vi.mocked(getSignedUploadUrl).mockResolvedValue(mockSignedUrlResponse);
|
||||
|
||||
const result = await getSignedUrlForUpload(
|
||||
"test-doc.pdf",
|
||||
"ws-123",
|
||||
"application/pdf",
|
||||
"private" as TAccessType,
|
||||
1024 * 1024 * 10,
|
||||
["surveys", "survey-123", "questions", "question-123"]
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.fileUrl).toBe(
|
||||
`/storage/ws-123/private/surveys/survey-123/questions/question-123/test-doc--fid--${mockUUID}.pdf`
|
||||
);
|
||||
}
|
||||
|
||||
expect(getSignedUploadUrl).toHaveBeenCalledWith(
|
||||
`test-doc--fid--${mockUUID}.pdf`,
|
||||
"application/pdf",
|
||||
"ws-123/private/surveys/survey-123/questions/question-123",
|
||||
1024 * 1024 * 10
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["", ".", "..", "bad segment", "bad/segment", "bad\\segment", "bad?segment", "bad#segment"])(
|
||||
"should reject unsafe scoped private upload path segment %s",
|
||||
async (unsafeSegment) => {
|
||||
const result = await getSignedUrlForUpload(
|
||||
"test-doc.pdf",
|
||||
"ws-123",
|
||||
"application/pdf",
|
||||
"private" as TAccessType,
|
||||
1024 * 1024 * 10,
|
||||
["surveys", unsafeSegment]
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe(StorageErrorCode.InvalidInput);
|
||||
}
|
||||
expect(getSignedUploadUrl).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
test("should properly sanitize filenames with special characters like # in URL", async () => {
|
||||
const mockSignedUrlResponse = {
|
||||
ok: true,
|
||||
|
||||
@@ -10,15 +10,18 @@ import {
|
||||
getSignedUploadUrl,
|
||||
} from "@formbricks/storage";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { TAccessType } from "@formbricks/types/storage";
|
||||
import { type TAccessType } from "@formbricks/types/storage";
|
||||
import { sanitizeFileName } from "./utils";
|
||||
|
||||
const SAFE_FILE_PATH_SEGMENT = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
export const getSignedUrlForUpload = async (
|
||||
fileName: string,
|
||||
workspaceId: string,
|
||||
fileType: string,
|
||||
accessType: TAccessType,
|
||||
maxFileUploadSize: number = 1024 * 1024 * 10 // 10MB
|
||||
maxFileUploadSize: number = 1024 * 1024 * 10, // 10MB
|
||||
filePathSegments: string[] = []
|
||||
): Promise<
|
||||
Result<
|
||||
{
|
||||
@@ -34,17 +37,19 @@ export const getSignedUrlForUpload = async (
|
||||
if (!safeFileName) {
|
||||
return err({ code: StorageErrorCode.InvalidInput });
|
||||
}
|
||||
|
||||
if (filePathSegments.some((segment) => !SAFE_FILE_PATH_SEGMENT.test(segment))) {
|
||||
return err({ code: StorageErrorCode.InvalidInput });
|
||||
}
|
||||
|
||||
const encodedFilePathSegments = filePathSegments.map((segment) => encodeURIComponent(segment));
|
||||
const fileNameWithoutExtension = safeFileName.split(".").slice(0, -1).join(".");
|
||||
const fileExtension = safeFileName.split(".").pop();
|
||||
|
||||
const updatedFileName = `${fileNameWithoutExtension}--fid--${randomUUID()}.${fileExtension}`;
|
||||
const filePath = [workspaceId, accessType, ...filePathSegments].join("/");
|
||||
|
||||
const signedUrlResult = await getSignedUploadUrl(
|
||||
updatedFileName,
|
||||
fileType,
|
||||
`${workspaceId}/${accessType}`,
|
||||
maxFileUploadSize
|
||||
);
|
||||
const signedUrlResult = await getSignedUploadUrl(updatedFileName, fileType, filePath, maxFileUploadSize);
|
||||
|
||||
if (!signedUrlResult.ok) {
|
||||
return signedUrlResult;
|
||||
@@ -54,7 +59,10 @@ export const getSignedUrlForUpload = async (
|
||||
return ok({
|
||||
signedUrl: signedUrlResult.data.signedUrl,
|
||||
presignedFields: signedUrlResult.data.presignedFields,
|
||||
fileUrl: `/storage/${workspaceId}/${accessType}/${encodeURIComponent(updatedFileName)}`,
|
||||
fileUrl: `/storage/${workspaceId}/${accessType}/${[
|
||||
...encodedFilePathSegments,
|
||||
encodeURIComponent(updatedFileName),
|
||||
].join("/")}`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Error getting signed url for upload");
|
||||
|
||||
@@ -7,10 +7,12 @@ import { TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
isAllowedFileExtension,
|
||||
isValidImageFile,
|
||||
parseStorageFileUrl,
|
||||
resolveStorageUrl,
|
||||
resolveStorageUrlAuto,
|
||||
resolveStorageUrlsInObject,
|
||||
sanitizeFileName,
|
||||
validateClientFileUploads,
|
||||
validateFileUploads,
|
||||
validateSingleFile,
|
||||
validateSurveyAllowsFileUpload,
|
||||
@@ -369,7 +371,11 @@ describe("storage utils", () => {
|
||||
},
|
||||
] as unknown as TSurveyBlock[];
|
||||
|
||||
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true });
|
||||
expect(
|
||||
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "element1", blocks })
|
||||
).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should allow a matching extension from a legacy file upload question", () => {
|
||||
@@ -381,7 +387,9 @@ describe("storage utils", () => {
|
||||
},
|
||||
] as TSurveyQuestion[];
|
||||
|
||||
expect(validateSurveyAllowsFileUpload({ fileName: "image.png", questions })).toEqual({ ok: true });
|
||||
expect(
|
||||
validateSurveyAllowsFileUpload({ fileName: "image.png", questionId: "question1", questions })
|
||||
).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test("should allow any globally safe extension when a file upload has no survey restriction", () => {
|
||||
@@ -398,7 +406,11 @@ describe("storage utils", () => {
|
||||
},
|
||||
] as unknown as TSurveyBlock[];
|
||||
|
||||
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true });
|
||||
expect(
|
||||
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "element1", blocks })
|
||||
).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should reject surveys without file upload blocks or questions", () => {
|
||||
@@ -421,7 +433,9 @@ describe("storage utils", () => {
|
||||
},
|
||||
] as TSurveyQuestion[];
|
||||
|
||||
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks, questions })).toEqual({
|
||||
expect(
|
||||
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "question1", blocks, questions })
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "no_file_upload_question",
|
||||
});
|
||||
@@ -447,7 +461,9 @@ describe("storage utils", () => {
|
||||
},
|
||||
] as unknown as TSurveyBlock[];
|
||||
|
||||
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({
|
||||
expect(
|
||||
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "element2", blocks })
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "file_extension_not_allowed",
|
||||
});
|
||||
@@ -473,7 +489,11 @@ describe("storage utils", () => {
|
||||
},
|
||||
] as unknown as TSurveyBlock[];
|
||||
|
||||
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true });
|
||||
expect(
|
||||
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "element2", blocks })
|
||||
).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should reject files without a globally safe extension even when the survey has an unrestricted upload", () => {
|
||||
@@ -484,15 +504,148 @@ describe("storage utils", () => {
|
||||
},
|
||||
] as TSurveyQuestion[];
|
||||
|
||||
expect(validateSurveyAllowsFileUpload({ fileName: "report", questions })).toEqual({
|
||||
expect(
|
||||
validateSurveyAllowsFileUpload({ fileName: "report", questionId: "question1", questions })
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "file_extension_not_allowed",
|
||||
});
|
||||
expect(validateSurveyAllowsFileUpload({ fileName: "malware.exe", questions })).toEqual({
|
||||
expect(
|
||||
validateSurveyAllowsFileUpload({ fileName: "malware.exe", questionId: "question1", questions })
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "file_extension_not_allowed",
|
||||
});
|
||||
});
|
||||
|
||||
test("should reject a question id that is not the file upload element", () => {
|
||||
const blocks = [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "element1",
|
||||
type: "fileUpload" as const,
|
||||
allowedFileExtensions: ["pdf"],
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as TSurveyBlock[];
|
||||
|
||||
expect(
|
||||
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "element2", blocks })
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "file_upload_question_not_found",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateClientFileUploads", () => {
|
||||
const workspaceId = "clxworkspace123";
|
||||
const surveyId = "clxsurvey123";
|
||||
const questionId = "file_question";
|
||||
const blocks = [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: questionId,
|
||||
type: "fileUpload" as const,
|
||||
allowedFileExtensions: ["pdf"],
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as TSurveyBlock[];
|
||||
|
||||
test("should accept scoped private storage URLs for the matching survey and question", () => {
|
||||
const responseData = {
|
||||
[questionId]: [
|
||||
`/storage/${workspaceId}/private/surveys/${surveyId}/questions/${questionId}/report--fid--abc.pdf`,
|
||||
],
|
||||
};
|
||||
|
||||
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(true);
|
||||
});
|
||||
|
||||
test("should reject unscoped legacy storage URLs for new client submissions", () => {
|
||||
const responseData = {
|
||||
[questionId]: [`/storage/${workspaceId}/private/report--fid--abc.pdf`],
|
||||
};
|
||||
|
||||
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false);
|
||||
});
|
||||
|
||||
test("should reject scoped URLs for a different survey", () => {
|
||||
const responseData = {
|
||||
[questionId]: [
|
||||
`/storage/${workspaceId}/private/surveys/otherSurvey/questions/${questionId}/report--fid--abc.pdf`,
|
||||
],
|
||||
};
|
||||
|
||||
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false);
|
||||
});
|
||||
|
||||
test("should reject scoped URLs for a different question", () => {
|
||||
const responseData = {
|
||||
[questionId]: [
|
||||
`/storage/${workspaceId}/private/surveys/${surveyId}/questions/otherQuestion/report--fid--abc.pdf`,
|
||||
],
|
||||
};
|
||||
|
||||
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false);
|
||||
});
|
||||
|
||||
test("should reject external URLs", () => {
|
||||
const responseData = {
|
||||
[questionId]: ["https://example.com/report--fid--abc.pdf"],
|
||||
};
|
||||
|
||||
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false);
|
||||
});
|
||||
|
||||
test("should reject file extensions not allowed by the matching upload question", () => {
|
||||
const responseData = {
|
||||
[questionId]: [
|
||||
`/storage/${workspaceId}/private/surveys/${surveyId}/questions/${questionId}/image--fid--abc.png`,
|
||||
],
|
||||
};
|
||||
|
||||
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseStorageFileUrl", () => {
|
||||
test("should parse nested relative storage URLs", () => {
|
||||
expect(
|
||||
parseStorageFileUrl(
|
||||
"/storage/workspace-123/private/surveys/survey-123/questions/question-123/report.pdf"
|
||||
)
|
||||
).toEqual({
|
||||
storageId: "workspace-123",
|
||||
accessType: "private",
|
||||
fileName: "surveys/survey-123/questions/question-123/report.pdf",
|
||||
});
|
||||
});
|
||||
|
||||
test("should parse absolute storage URLs", () => {
|
||||
expect(parseStorageFileUrl("https://example.com/storage/workspace-123/public/report.pdf")).toEqual({
|
||||
storageId: "workspace-123",
|
||||
accessType: "public",
|
||||
fileName: "report.pdf",
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
"https://example.com/not-storage/workspace-123/private/report.pdf",
|
||||
"/storage/workspace-123/internal/report.pdf",
|
||||
"/storage/workspace-123/private",
|
||||
"not a url",
|
||||
])("should reject invalid storage URL %s", (fileUrl) => {
|
||||
expect(parseStorageFileUrl(fileUrl)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidImageFile", () => {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import "server-only";
|
||||
import { type StorageError, StorageErrorCode } from "@formbricks/storage";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/storage";
|
||||
import {
|
||||
type TAccessType,
|
||||
type TAllowedFileExtension,
|
||||
ZAllowedFileExtension,
|
||||
} from "@formbricks/types/storage";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElementTypeEnum, TSurveyFileUploadElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
@@ -120,7 +124,7 @@ export type TSurveyFileUploadPermissionResult =
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: "no_file_upload_question" | "file_extension_not_allowed";
|
||||
reason: "no_file_upload_question" | "file_upload_question_not_found" | "file_extension_not_allowed";
|
||||
};
|
||||
|
||||
const getAllowedFileExtensionFromFileName = (fileName: string): TAllowedFileExtension | null => {
|
||||
@@ -132,21 +136,33 @@ const getAllowedFileExtensionFromFileName = (fileName: string): TAllowedFileExte
|
||||
return extensionValidation.success ? extensionValidation.data : null;
|
||||
};
|
||||
|
||||
export const validateSurveyAllowsFileUpload = ({
|
||||
fileName,
|
||||
const getSurveyFileUploadConfigs = ({
|
||||
blocks,
|
||||
questions,
|
||||
}: {
|
||||
fileName: string;
|
||||
blocks?: TSurveyBlock[] | null;
|
||||
questions?: TSurveyQuestion[] | null;
|
||||
}): TSurveyFileUploadPermissionResult => {
|
||||
const fileUploadConfigs = [
|
||||
}): TSurveyFileUploadElement[] => {
|
||||
return [
|
||||
...(blocks ?? [])
|
||||
.flatMap((block) => block.elements)
|
||||
.filter((element) => element.type === TSurveyElementTypeEnum.FileUpload),
|
||||
...(questions ?? []).filter((question) => question.type === TSurveyQuestionTypeEnum.FileUpload),
|
||||
] as TSurveyFileUploadElement[];
|
||||
};
|
||||
|
||||
export const validateSurveyAllowsFileUpload = ({
|
||||
fileName,
|
||||
questionId,
|
||||
blocks,
|
||||
questions,
|
||||
}: {
|
||||
fileName: string;
|
||||
questionId: string;
|
||||
blocks?: TSurveyBlock[] | null;
|
||||
questions?: TSurveyQuestion[] | null;
|
||||
}): TSurveyFileUploadPermissionResult => {
|
||||
const fileUploadConfigs = getSurveyFileUploadConfigs({ blocks, questions });
|
||||
|
||||
if (fileUploadConfigs.length === 0) {
|
||||
return {
|
||||
@@ -155,6 +171,15 @@ export const validateSurveyAllowsFileUpload = ({
|
||||
};
|
||||
}
|
||||
|
||||
const fileUploadConfig = fileUploadConfigs.find((config) => config.id === questionId);
|
||||
|
||||
if (!fileUploadConfig) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "file_upload_question_not_found",
|
||||
};
|
||||
}
|
||||
|
||||
const fileExtension = getAllowedFileExtensionFromFileName(fileName);
|
||||
|
||||
if (!fileExtension) {
|
||||
@@ -164,11 +189,9 @@ export const validateSurveyAllowsFileUpload = ({
|
||||
};
|
||||
}
|
||||
|
||||
const isFileExtensionAllowed = fileUploadConfigs.some((fileUploadConfig) => {
|
||||
const { allowedFileExtensions } = fileUploadConfig;
|
||||
|
||||
return allowedFileExtensions === undefined || allowedFileExtensions.includes(fileExtension);
|
||||
});
|
||||
const { allowedFileExtensions } = fileUploadConfig;
|
||||
const isFileExtensionAllowed =
|
||||
allowedFileExtensions === undefined || allowedFileExtensions.includes(fileExtension);
|
||||
|
||||
return isFileExtensionAllowed
|
||||
? { ok: true }
|
||||
@@ -178,6 +201,127 @@ export const validateSurveyAllowsFileUpload = ({
|
||||
};
|
||||
};
|
||||
|
||||
const getStorageUrlPathSegments = (fileUrl: string): string[] | null => {
|
||||
if (!fileUrl.startsWith("/storage/")) return null;
|
||||
|
||||
const pathWithoutSearch = fileUrl.split(/[?#]/)[0];
|
||||
return pathWithoutSearch.split("/").filter(Boolean);
|
||||
};
|
||||
|
||||
type TParsedStorageFileUrl = {
|
||||
storageId: string;
|
||||
accessType: TAccessType;
|
||||
fileName: string;
|
||||
};
|
||||
|
||||
export const parseStorageFileUrl = (fileUrl: string): TParsedStorageFileUrl | null => {
|
||||
let pathname: string;
|
||||
|
||||
try {
|
||||
pathname = fileUrl.startsWith("/storage/") ? fileUrl : new URL(fileUrl).pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathWithoutSearch = pathname.split(/[?#]/)[0];
|
||||
if (!pathWithoutSearch.startsWith("/storage/")) return null;
|
||||
|
||||
const [storageSegment, storageId, accessType, ...fileNameSegments] = pathWithoutSearch
|
||||
.split("/")
|
||||
.filter(Boolean);
|
||||
const fileName = fileNameSegments.join("/");
|
||||
|
||||
if (
|
||||
storageSegment !== "storage" ||
|
||||
!storageId ||
|
||||
!fileName ||
|
||||
(accessType !== "private" && accessType !== "public")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { storageId, accessType, fileName };
|
||||
};
|
||||
|
||||
const isScopedPrivateUploadUrl = ({
|
||||
fileUrl,
|
||||
workspaceId,
|
||||
surveyId,
|
||||
questionId,
|
||||
}: {
|
||||
fileUrl: string;
|
||||
workspaceId: string;
|
||||
surveyId: string;
|
||||
questionId: string;
|
||||
}): boolean => {
|
||||
const segments = getStorageUrlPathSegments(fileUrl);
|
||||
|
||||
if (!segments || segments.length !== 8) return false;
|
||||
|
||||
const [
|
||||
storageSegment,
|
||||
storageWorkspaceId,
|
||||
accessType,
|
||||
surveysSegment,
|
||||
storageSurveyId,
|
||||
questionsSegment,
|
||||
storageQuestionId,
|
||||
fileName,
|
||||
] = segments;
|
||||
|
||||
return (
|
||||
storageSegment === "storage" &&
|
||||
storageWorkspaceId === workspaceId &&
|
||||
accessType === "private" &&
|
||||
surveysSegment === "surveys" &&
|
||||
storageSurveyId === surveyId &&
|
||||
questionsSegment === "questions" &&
|
||||
storageQuestionId === questionId &&
|
||||
Boolean(fileName)
|
||||
);
|
||||
};
|
||||
|
||||
export const validateClientFileUploads = ({
|
||||
data,
|
||||
workspaceId,
|
||||
surveyId,
|
||||
blocks,
|
||||
questions,
|
||||
}: {
|
||||
data?: TResponseData;
|
||||
workspaceId: string;
|
||||
surveyId: string;
|
||||
blocks?: TSurveyBlock[] | null;
|
||||
questions?: TSurveyQuestion[] | null;
|
||||
}): boolean => {
|
||||
if (!data) return true;
|
||||
|
||||
const fileUploadConfigs = getSurveyFileUploadConfigs({ blocks, questions });
|
||||
|
||||
for (const fileUploadConfig of fileUploadConfigs) {
|
||||
const fileUrls = data[fileUploadConfig.id];
|
||||
|
||||
if (fileUrls === undefined) continue;
|
||||
if (!Array.isArray(fileUrls) || !fileUrls.every((url) => typeof url === "string")) return false;
|
||||
|
||||
for (const fileUrl of fileUrls) {
|
||||
if (!validateSingleFile(fileUrl, fileUploadConfig.allowedFileExtensions)) return false;
|
||||
if (
|
||||
!isScopedPrivateUploadUrl({
|
||||
fileUrl,
|
||||
workspaceId,
|
||||
surveyId,
|
||||
questionId: fileUploadConfig.id,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const isValidImageFile = (fileUrl: string): boolean => {
|
||||
const fileName = getOriginalFileNameFromUrl(fileUrl);
|
||||
if (!fileName || fileName.endsWith(".")) return false;
|
||||
|
||||
@@ -99,7 +99,12 @@ describe("useDeleteSurvey", () => {
|
||||
0
|
||||
);
|
||||
|
||||
resolveFetch?.(new Response(null, { status: 204 }));
|
||||
resolveFetch?.(
|
||||
new Response(JSON.stringify({ data: { id: "survey_1" } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: surveyKeys.lists() });
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type { V3ApiError } from "@/modules/api/lib/v3-client";
|
||||
import { buildSurveyListSearchParams, deleteSurvey } from "./v3-surveys-client";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { buildSurveyListSearchParams } from "./v3-surveys-client";
|
||||
|
||||
describe("buildSurveyListSearchParams", () => {
|
||||
test("emits only supported v3 params using normalized filter values", () => {
|
||||
@@ -44,39 +39,3 @@ describe("buildSurveyListSearchParams", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteSurvey", () => {
|
||||
test("treats 204 No Content as a successful delete", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(deleteSurvey("survey_1")).resolves.toBeUndefined();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/v3/surveys/survey_1", {
|
||||
method: "DELETE",
|
||||
cache: "no-store",
|
||||
});
|
||||
});
|
||||
|
||||
test("maps v3 problem responses to V3ApiError", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
Response.json(
|
||||
{
|
||||
status: 403,
|
||||
detail: "You are not authorized to access this resource",
|
||||
code: "forbidden",
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
await expect(deleteSurvey("survey_1")).rejects.toMatchObject<V3ApiError>({
|
||||
status: 403,
|
||||
detail: "You are not authorized to access this resource",
|
||||
code: "forbidden",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,12 @@ type TV3SurveyListResponse = {
|
||||
meta: TSurveyListPage["meta"];
|
||||
};
|
||||
|
||||
type TV3DeleteSurveyResponse = {
|
||||
data: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TSurveyListPage = {
|
||||
data: TSurveyListItem[];
|
||||
meta: {
|
||||
@@ -116,7 +122,7 @@ export async function listSurveys({
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteSurvey(surveyId: string): Promise<void> {
|
||||
export async function deleteSurvey(surveyId: string): Promise<{ id: string }> {
|
||||
const response = await fetch(`/api/v3/surveys/${surveyId}`, {
|
||||
method: "DELETE",
|
||||
cache: "no-store",
|
||||
@@ -125,4 +131,7 @@ export async function deleteSurvey(surveyId: string): Promise<void> {
|
||||
if (!response.ok) {
|
||||
throw await parseV3ApiError(response);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as TV3DeleteSurveyResponse;
|
||||
return body.data;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
|
||||
import { CreateSurveyParams, CreateSurveyWithLogicParams } from "@/playwright/utils/mock";
|
||||
|
||||
const MOCK_STORAGE_UPLOAD_PATH = "/__playwright__/mock-storage-upload";
|
||||
const MOCK_STORAGE_FILE_PATH = "/storage/playwright-mock";
|
||||
|
||||
type MockStorageFileFixture = {
|
||||
name: string;
|
||||
@@ -44,11 +43,19 @@ const DEFAULT_MOCK_STORAGE_FILE_FIXTURE: MockStorageFileFixture = {
|
||||
),
|
||||
};
|
||||
|
||||
const getMockStorageFileUrl = (
|
||||
appOrigin: string,
|
||||
fileName: string,
|
||||
accessType: "public" | "private"
|
||||
): string => {
|
||||
const getMockStorageFileUrl = ({
|
||||
appOrigin,
|
||||
fileName,
|
||||
accessType,
|
||||
storageId = "playwright-mock",
|
||||
filePathSegments = [],
|
||||
}: {
|
||||
appOrigin: string;
|
||||
fileName: string;
|
||||
accessType: "public" | "private";
|
||||
storageId?: string;
|
||||
filePathSegments?: string[];
|
||||
}): string => {
|
||||
if (accessType === "public") {
|
||||
const fixture = PLAYWRIGHT_STORAGE_FILE_FIXTURES.get(fileName);
|
||||
|
||||
@@ -57,7 +64,7 @@ const getMockStorageFileUrl = (
|
||||
}
|
||||
}
|
||||
|
||||
return `${MOCK_STORAGE_FILE_PATH}/${accessType}/${encodeURIComponent(fileName)}`;
|
||||
return `/storage/${storageId}/${accessType}/${[...filePathSegments, encodeURIComponent(fileName)].join("/")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -86,7 +93,7 @@ export const mockStorageUploads = async (page: Page): Promise<void> => {
|
||||
presignedFields: {
|
||||
key: fileName,
|
||||
},
|
||||
fileUrl: getMockStorageFileUrl(appOrigin, fileName, "public"),
|
||||
fileUrl: getMockStorageFileUrl({ appOrigin, fileName, accessType: "public" }),
|
||||
signingData: null,
|
||||
updatedFileName: fileName,
|
||||
},
|
||||
@@ -113,9 +120,17 @@ export const mockStorageUploads = async (page: Page): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = route.request().postDataJSON() as { fileName?: string } | undefined;
|
||||
const payload = route.request().postDataJSON() as
|
||||
| { fileName?: string; surveyId?: string; questionId?: string }
|
||||
| undefined;
|
||||
const fileName = payload?.fileName ?? "uploaded-file.bin";
|
||||
const appOrigin = new URL(route.request().url()).origin;
|
||||
const requestUrl = new URL(route.request().url());
|
||||
const appOrigin = requestUrl.origin;
|
||||
const workspaceId = requestUrl.pathname.split("/").filter(Boolean)[3] ?? "playwright-mock";
|
||||
const filePathSegments =
|
||||
payload?.surveyId && payload?.questionId
|
||||
? ["surveys", payload.surveyId, "questions", payload.questionId]
|
||||
: [];
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -126,7 +141,13 @@ export const mockStorageUploads = async (page: Page): Promise<void> => {
|
||||
presignedFields: {
|
||||
key: fileName,
|
||||
},
|
||||
fileUrl: getMockStorageFileUrl(appOrigin, fileName, "private"),
|
||||
fileUrl: getMockStorageFileUrl({
|
||||
appOrigin,
|
||||
fileName,
|
||||
accessType: "private",
|
||||
storageId: workspaceId,
|
||||
filePathSegments,
|
||||
}),
|
||||
signingData: null,
|
||||
updatedFileName: fileName,
|
||||
},
|
||||
@@ -148,7 +169,7 @@ export const mockStorageUploads = async (page: Page): Promise<void> => {
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**${MOCK_STORAGE_FILE_PATH}/**`, async (route) => {
|
||||
await page.route("**/storage/**", async (route) => {
|
||||
if (!["GET", "HEAD"].includes(route.request().method())) {
|
||||
await route.fallback();
|
||||
return;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
# V3 API — Surveys (hand-maintained; not generated by generate-api-specs).
|
||||
# Implementation: apps/web/app/api/v3/surveys/route.ts and apps/web/app/api/v3/surveys/[surveyId]/route.ts
|
||||
# See apps/web/app/api/v3/README.md and docs/Survey-Server-Actions.md (Part III) for full context.
|
||||
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Formbricks API v3
|
||||
description: |
|
||||
**GET /api/v3/surveys**, **GET /api/v3/surveys/{surveyId}**, and **DELETE /api/v3/surveys/{surveyId}** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace).
|
||||
**GET /api/v3/surveys** and **DELETE /api/v3/surveys/{surveyId}** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace).
|
||||
|
||||
**Spec location:** `docs/api-v3-reference/openapi.yml` (alongside v2 at `docs/api-v2-reference/openapi.yml`).
|
||||
|
||||
@@ -33,7 +34,7 @@ info:
|
||||
The v3-backed survey overview page intentionally removes actions that are not yet exposed by this contract: `Created by` filtering, `Duplicate`, `Copy...`, `Preview`, and `Copy link`.
|
||||
|
||||
**Next steps (out of scope for this spec)**
|
||||
Additional v3 survey write endpoints, optional ETag/304, field selection, and survey version history.
|
||||
Additional v3 survey endpoints, optional ETag/304, field selection — see Survey-Server-Actions.md Part III.
|
||||
version: 0.1.0
|
||||
x-implementation-notes:
|
||||
route: apps/web/app/api/v3/surveys/route.ts
|
||||
@@ -197,174 +198,6 @@ paths:
|
||||
- sessionAuth: []
|
||||
- apiKeyAuth: []
|
||||
/api/v3/surveys/{surveyId}:
|
||||
get:
|
||||
operationId: getSurveyV3
|
||||
summary: Retrieve a survey
|
||||
description: |
|
||||
Returns the public v3 survey management resource for one survey. By default, translatable
|
||||
fields are returned as canonical multilingual maps keyed by real locale codes. Use `lang`
|
||||
to filter those maps to one or more requested locale codes.
|
||||
tags:
|
||||
- V3 Surveys
|
||||
parameters:
|
||||
- in: path
|
||||
name: surveyId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: cuid2
|
||||
description: Survey identifier.
|
||||
- in: query
|
||||
name: lang
|
||||
required: false
|
||||
style: form
|
||||
explode: false
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
examples:
|
||||
- [de-DE]
|
||||
- [de-DE, pt-PT]
|
||||
description: |
|
||||
Comma-separated locale code filter for translatable fields, for example `?lang=de-DE,pt-PT`.
|
||||
The response shape stays stable: translatable fields are always maps keyed by locale code, never
|
||||
strings. The parser is case-insensitive, accepts `_` or `-` separators, and normalizes to canonical
|
||||
BCP 47 casing (`de_DE`, `DE-de` → `de-DE`). A language-only selector (`de`) resolves to the matching
|
||||
configured survey language when exactly one exists; otherwise it returns `400`. Disabled-but-configured
|
||||
languages are readable in the management API so unfinished translations can be completed. Aliases are
|
||||
not accepted.
|
||||
responses:
|
||||
"200":
|
||||
description: Survey retrieved successfully
|
||||
headers:
|
||||
X-Request-Id:
|
||||
schema: { type: string }
|
||||
description: Request correlation ID
|
||||
Cache-Control:
|
||||
schema: { type: string }
|
||||
example: "private, no-store"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/SurveyResource"
|
||||
examples:
|
||||
canonical:
|
||||
summary: Canonical multilingual authoring resource
|
||||
value:
|
||||
data:
|
||||
id: clseedsurveycsat000000
|
||||
workspaceId: clseedworkspace000000000
|
||||
createdAt: "2026-05-18T09:24:54.014Z"
|
||||
updatedAt: "2026-05-18T09:24:54.014Z"
|
||||
name: CSAT Survey
|
||||
type: link
|
||||
status: inProgress
|
||||
metadata: {}
|
||||
defaultLanguage: en-US
|
||||
languages:
|
||||
- code: en-US
|
||||
default: true
|
||||
enabled: true
|
||||
- code: de-DE
|
||||
default: false
|
||||
enabled: false
|
||||
welcomeCard:
|
||||
enabled: false
|
||||
blocks:
|
||||
- id: e0tfwzqk63op37y14z95qq3k
|
||||
name: Main Block
|
||||
elements:
|
||||
- id: nzte4cm8836hgjw63pesziht
|
||||
type: rating
|
||||
range: 5
|
||||
scale: smiley
|
||||
headline:
|
||||
en-US: How satisfied are you with our product?
|
||||
de-DE: Wie zufrieden sind Sie mit unserem Produkt?
|
||||
required: true
|
||||
endings: []
|
||||
hiddenFields:
|
||||
enabled: false
|
||||
variables: []
|
||||
filtered:
|
||||
summary: Language-filtered projection with ?lang=de-DE
|
||||
value:
|
||||
data:
|
||||
id: clseedsurveycsat000000
|
||||
workspaceId: clseedworkspace000000000
|
||||
createdAt: "2026-05-18T09:24:54.014Z"
|
||||
updatedAt: "2026-05-18T09:24:54.014Z"
|
||||
name: CSAT Survey
|
||||
type: link
|
||||
status: inProgress
|
||||
metadata: {}
|
||||
defaultLanguage: en-US
|
||||
languages:
|
||||
- code: en-US
|
||||
default: true
|
||||
enabled: true
|
||||
- code: de-DE
|
||||
default: false
|
||||
enabled: false
|
||||
welcomeCard:
|
||||
enabled: false
|
||||
blocks:
|
||||
- id: e0tfwzqk63op37y14z95qq3k
|
||||
name: Main Block
|
||||
elements:
|
||||
- id: nzte4cm8836hgjw63pesziht
|
||||
type: rating
|
||||
range: 5
|
||||
scale: smiley
|
||||
headline:
|
||||
de-DE: Wie zufrieden sind Sie mit unserem Produkt?
|
||||
required: true
|
||||
endings: []
|
||||
hiddenFields:
|
||||
enabled: false
|
||||
variables: []
|
||||
"400":
|
||||
description: Invalid survey id, unsupported query parameter, unknown language, or unsupported legacy survey shape
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
"401":
|
||||
description: Not authenticated (no valid session or API key)
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
"403":
|
||||
description: Forbidden — no access, or survey does not exist (404 not used; avoids existence leak)
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
"429":
|
||||
description: Rate limit exceeded
|
||||
headers:
|
||||
Retry-After:
|
||||
schema: { type: integer }
|
||||
description: Seconds until the current rate-limit window resets
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
security:
|
||||
- sessionAuth: []
|
||||
- apiKeyAuth: []
|
||||
delete:
|
||||
operationId: deleteSurveyV3
|
||||
summary: Delete a survey
|
||||
@@ -380,7 +213,7 @@ paths:
|
||||
format: cuid2
|
||||
description: Survey identifier.
|
||||
responses:
|
||||
"204":
|
||||
"200":
|
||||
description: Survey deleted successfully
|
||||
headers:
|
||||
X-Request-Id:
|
||||
@@ -389,6 +222,10 @@ paths:
|
||||
Cache-Control:
|
||||
schema: { type: string }
|
||||
example: "private, no-store"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SurveyDeleteResponse"
|
||||
"400":
|
||||
description: Bad Request
|
||||
content:
|
||||
@@ -467,694 +304,15 @@ components:
|
||||
properties:
|
||||
enabled: { type: boolean }
|
||||
isEncrypted: { type: boolean }
|
||||
TranslatableText:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/TranslatableTextMap"
|
||||
description: |
|
||||
Survey authoring text. `GET /api/v3/surveys/{surveyId}` always returns locale maps keyed by
|
||||
real locale codes such as `en-US` and `de-DE`. Use `?lang=` to filter which locale keys are included.
|
||||
The internal storage key `default` is never exposed by v3.
|
||||
examples:
|
||||
- en-US: What should we improve?
|
||||
de-DE: Was sollten wir verbessern?
|
||||
TranslatableTextMap:
|
||||
SurveyDeleteResponse:
|
||||
type: object
|
||||
description: Canonical multilingual text map keyed by real locale codes.
|
||||
propertyNames:
|
||||
type: string
|
||||
description: BCP 47 locale code, for example `en-US`, `de-DE`, or `pt-BR`.
|
||||
additionalProperties:
|
||||
type: string
|
||||
SurveyLanguage:
|
||||
type: object
|
||||
description: |
|
||||
Language configured for this survey. Aliases/display names are intentionally not exposed in v3.
|
||||
Disabled languages can still be read by the management API so unfinished translations can be completed.
|
||||
required: [code, default, enabled]
|
||||
required: [data]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: Canonical locale code.
|
||||
example: en-US
|
||||
default:
|
||||
type: boolean
|
||||
description: Whether this is the default authoring language.
|
||||
enabled:
|
||||
type: boolean
|
||||
description: Whether this language is enabled for respondent-facing delivery.
|
||||
SurveyWelcomeCard:
|
||||
type: object
|
||||
description: Optional card shown before the first survey block.
|
||||
required: [enabled]
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
headline:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
subheader:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
buttonLabel:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
fileUrl:
|
||||
type: string
|
||||
videoUrl:
|
||||
type: string
|
||||
timeToFinish:
|
||||
type: boolean
|
||||
showResponseCount:
|
||||
type: boolean
|
||||
additionalProperties: true
|
||||
SurveyHiddenFields:
|
||||
type: object
|
||||
description: |
|
||||
Hidden fields, sometimes called embedded data in other survey products. Field ids are stable
|
||||
public identifiers and may be referenced by logic, recall, quotas, integrations, and response data.
|
||||
Use only letters, numbers, underscores, and hyphens; avoid spaces and reserved ids.
|
||||
required: [enabled]
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
fieldIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
pattern: "^[a-zA-Z0-9_-]+$"
|
||||
uniqueItems: true
|
||||
additionalProperties: false
|
||||
SurveyVariable:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SurveyNumberVariable"
|
||||
- $ref: "#/components/schemas/SurveyTextVariable"
|
||||
description: |
|
||||
Survey variable. Variable ids are stable references used by logic and calculation actions.
|
||||
Variable names are human-readable labels and must be unique within the survey.
|
||||
SurveyNumberVariable:
|
||||
type: object
|
||||
description: |
|
||||
Number variable. Used by `calculate` logic actions with numeric operators such as `add`,
|
||||
`subtract`, `multiply`, `divide`, or `assign`.
|
||||
required: [id, name, type, value]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
description: Stable variable id referenced from logic.
|
||||
name:
|
||||
type: string
|
||||
pattern: "^[a-z0-9_]+$"
|
||||
description: Unique variable name. Lowercase letters, numbers, and underscores only.
|
||||
type:
|
||||
type: string
|
||||
enum: [number]
|
||||
value:
|
||||
type: number
|
||||
description: Default numeric value.
|
||||
additionalProperties: false
|
||||
SurveyTextVariable:
|
||||
type: object
|
||||
description: |
|
||||
Text variable. Used by `calculate` logic actions with text operators such as `assign` or `concat`.
|
||||
required: [id, name, type, value]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
description: Stable variable id referenced from logic.
|
||||
name:
|
||||
type: string
|
||||
pattern: "^[a-z0-9_]+$"
|
||||
description: Unique variable name. Lowercase letters, numbers, and underscores only.
|
||||
type:
|
||||
type: string
|
||||
enum: [text]
|
||||
value:
|
||||
type: string
|
||||
description: Default text value.
|
||||
additionalProperties: false
|
||||
SurveyEnding:
|
||||
type: object
|
||||
description: Ending reached after the last block or a jump action.
|
||||
required: [id, type]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
description: Stable ending id. `jumpToBlock.target` may point to this id.
|
||||
type:
|
||||
type: string
|
||||
enum: [endScreen, redirectToUrl]
|
||||
headline:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
subheader:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
buttonLabel:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
buttonLink:
|
||||
type: string
|
||||
imageUrl:
|
||||
type: string
|
||||
videoUrl:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
description: Redirect URL for `redirectToUrl` endings.
|
||||
label:
|
||||
type: string
|
||||
description: Optional internal label for redirect endings.
|
||||
additionalProperties: true
|
||||
SurveyBlock:
|
||||
type: object
|
||||
description: |
|
||||
Block-based survey section. Block ids are stable public identifiers. Logic and fallbacks can
|
||||
jump to block ids or ending ids, so clients and agents should preserve ids unless intentionally
|
||||
creating/deleting a block.
|
||||
required: [id, name, elements]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
description: Stable block id.
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
elements:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyElement"
|
||||
logic:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyBlockLogic"
|
||||
logicFallback:
|
||||
type: string
|
||||
format: cuid2
|
||||
description: Block or ending id used when no logic condition matches.
|
||||
buttonLabel:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
backButtonLabel:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
additionalProperties: true
|
||||
SurveyElement:
|
||||
type: object
|
||||
description: |
|
||||
Survey element/question inside a block. Element ids are stable public identifiers used by
|
||||
logic, recall strings, response data, quotas, integrations, and analysis. The schema lists
|
||||
the fields used by all current element types; type-specific fields are present only when relevant.
|
||||
required: [id, type, headline, required]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
pattern: "^[a-zA-Z0-9_-]+$"
|
||||
description: Stable element id. Avoid spaces and reserved ids.
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- openText
|
||||
- multipleChoiceSingle
|
||||
- multipleChoiceMulti
|
||||
- nps
|
||||
- rating
|
||||
- csat
|
||||
- ces
|
||||
- consent
|
||||
- pictureSelection
|
||||
- cta
|
||||
- date
|
||||
- fileUpload
|
||||
- cal
|
||||
- matrix
|
||||
- address
|
||||
- ranking
|
||||
- contactInfo
|
||||
headline:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
subheader:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
required:
|
||||
type: boolean
|
||||
imageUrl:
|
||||
type: string
|
||||
videoUrl:
|
||||
type: string
|
||||
isDraft:
|
||||
type: boolean
|
||||
description: Draft marker used by the editor and future update rules.
|
||||
placeholder:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
longAnswer:
|
||||
type: boolean
|
||||
description: "`openText` only."
|
||||
inputType:
|
||||
type: string
|
||||
enum: [text, email, url, number, phone]
|
||||
description: "`openText` only."
|
||||
charLimit:
|
||||
data:
|
||||
type: object
|
||||
description: "`openText` character limit configuration."
|
||||
required: [id]
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
min:
|
||||
type: number
|
||||
max:
|
||||
type: number
|
||||
additionalProperties: false
|
||||
choices:
|
||||
type: array
|
||||
description: Choice list for multiple choice, ranking, and picture selection elements.
|
||||
items:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SurveyChoice"
|
||||
- $ref: "#/components/schemas/SurveyPictureChoice"
|
||||
shuffleOption:
|
||||
type: string
|
||||
enum: [none, all, exceptLast, reverseOrderOccasionally, reverseOrderExceptLast]
|
||||
displayType:
|
||||
type: string
|
||||
enum: [list, dropdown]
|
||||
description: Multiple choice display style.
|
||||
otherOptionPlaceholder:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
lowerLabel:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
upperLabel:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
isColorCodingEnabled:
|
||||
type: boolean
|
||||
scale:
|
||||
type: string
|
||||
enum: [number, smiley, star]
|
||||
description: Rating, CSAT, CES, or NPS scale display.
|
||||
range:
|
||||
type: integer
|
||||
enum: [3, 4, 5, 6, 7, 10]
|
||||
description: Rating range. CSAT is always 5; CES is 5 or 7.
|
||||
label:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
description: Consent checkbox label.
|
||||
allowMulti:
|
||||
type: boolean
|
||||
description: "`pictureSelection` only."
|
||||
buttonExternal:
|
||||
type: boolean
|
||||
description: "`cta` only."
|
||||
buttonUrl:
|
||||
type: string
|
||||
description: "`cta` only."
|
||||
ctaButtonLabel:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
html:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
description: "`date` helper copy."
|
||||
format:
|
||||
type: string
|
||||
enum: [M-d-y, d-M-y, y-M-d]
|
||||
description: "`date` only."
|
||||
allowMultipleFiles:
|
||||
type: boolean
|
||||
description: "`fileUpload` only."
|
||||
maxSizeInMB:
|
||||
type: number
|
||||
description: "`fileUpload` only."
|
||||
allowedFileExtensions:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: "`fileUpload` only."
|
||||
calUserName:
|
||||
type: string
|
||||
description: "`cal` only."
|
||||
calHost:
|
||||
type: string
|
||||
description: "`cal` only."
|
||||
rows:
|
||||
type: array
|
||||
description: Matrix rows.
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyChoice"
|
||||
columns:
|
||||
type: array
|
||||
description: Matrix columns.
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyChoice"
|
||||
addressLine1:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
addressLine2:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
city:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
state:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
zip:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
country:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
firstName:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
lastName:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
email:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
phone:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
company:
|
||||
$ref: "#/components/schemas/SurveyToggleInputConfig"
|
||||
validation:
|
||||
$ref: "#/components/schemas/SurveyValidation"
|
||||
additionalProperties: true
|
||||
SurveyChoice:
|
||||
type: object
|
||||
required: [id, label]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Stable choice id.
|
||||
label:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
additionalProperties: false
|
||||
SurveyPictureChoice:
|
||||
type: object
|
||||
required: [id, imageUrl]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Stable picture choice id.
|
||||
imageUrl:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
SurveyToggleInputConfig:
|
||||
type: object
|
||||
description: Field config for address and contact info elements.
|
||||
required: [show, required, placeholder]
|
||||
properties:
|
||||
show:
|
||||
type: boolean
|
||||
required:
|
||||
type: boolean
|
||||
placeholder:
|
||||
$ref: "#/components/schemas/TranslatableText"
|
||||
additionalProperties: false
|
||||
SurveyValidation:
|
||||
type: object
|
||||
description: Optional element-level validation rules.
|
||||
required: [rules]
|
||||
properties:
|
||||
logic:
|
||||
type: string
|
||||
enum: [and, or]
|
||||
default: and
|
||||
rules:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyValidationRule"
|
||||
additionalProperties: false
|
||||
SurveyValidationRule:
|
||||
type: object
|
||||
required: [id, type, params]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- minLength
|
||||
- maxLength
|
||||
- pattern
|
||||
- email
|
||||
- url
|
||||
- phone
|
||||
- equals
|
||||
- doesNotEqual
|
||||
- contains
|
||||
- doesNotContain
|
||||
- minValue
|
||||
- maxValue
|
||||
- isGreaterThan
|
||||
- isLessThan
|
||||
- minSelections
|
||||
- maxSelections
|
||||
- minRanked
|
||||
- rankAll
|
||||
- minRowsAnswered
|
||||
- answerAllRows
|
||||
- isLaterThan
|
||||
- isEarlierThan
|
||||
- isBetween
|
||||
- isNotBetween
|
||||
- fileExtensionIs
|
||||
- fileExtensionIsNot
|
||||
params:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
field:
|
||||
type: string
|
||||
enum:
|
||||
[
|
||||
addressLine1,
|
||||
addressLine2,
|
||||
city,
|
||||
state,
|
||||
zip,
|
||||
country,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phone,
|
||||
company,
|
||||
]
|
||||
additionalProperties: false
|
||||
SurveyBlockLogic:
|
||||
type: object
|
||||
description: Conditional logic rule evaluated at block level.
|
||||
required: [id, conditions, actions]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
conditions:
|
||||
$ref: "#/components/schemas/SurveyConditionGroup"
|
||||
actions:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyLogicAction"
|
||||
additionalProperties: false
|
||||
SurveyConditionGroup:
|
||||
type: object
|
||||
required: [id, connector, conditions]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
connector:
|
||||
type: string
|
||||
enum: [and, or]
|
||||
conditions:
|
||||
type: array
|
||||
items:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SurveyCondition"
|
||||
- $ref: "#/components/schemas/SurveyConditionGroup"
|
||||
additionalProperties: false
|
||||
SurveyCondition:
|
||||
type: object
|
||||
description: |
|
||||
Single condition. Operators such as `isSubmitted`, `isSkipped`, `isClicked`, `isAccepted`,
|
||||
`isBooked`, `isSet`, and `isEmpty` do not use `rightOperand`; comparison operators do.
|
||||
required: [id, leftOperand, operator]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
leftOperand:
|
||||
$ref: "#/components/schemas/SurveyDynamicReference"
|
||||
operator:
|
||||
type: string
|
||||
enum:
|
||||
- equals
|
||||
- doesNotEqual
|
||||
- contains
|
||||
- doesNotContain
|
||||
- startsWith
|
||||
- doesNotStartWith
|
||||
- endsWith
|
||||
- doesNotEndWith
|
||||
- isSubmitted
|
||||
- isSkipped
|
||||
- isGreaterThan
|
||||
- isLessThan
|
||||
- isGreaterThanOrEqual
|
||||
- isLessThanOrEqual
|
||||
- equalsOneOf
|
||||
- includesAllOf
|
||||
- includesOneOf
|
||||
- doesNotIncludeOneOf
|
||||
- doesNotIncludeAllOf
|
||||
- isClicked
|
||||
- isNotClicked
|
||||
- isAccepted
|
||||
- isBefore
|
||||
- isAfter
|
||||
- isBooked
|
||||
- isPartiallySubmitted
|
||||
- isCompletelySubmitted
|
||||
- isSet
|
||||
- isNotSet
|
||||
- isEmpty
|
||||
- isNotEmpty
|
||||
- isAnyOf
|
||||
rightOperand:
|
||||
$ref: "#/components/schemas/SurveyLogicOperand"
|
||||
additionalProperties: false
|
||||
SurveyLogicOperand:
|
||||
oneOf:
|
||||
- type: object
|
||||
required: [type, value]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [static]
|
||||
value:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
- type: array
|
||||
items:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
- $ref: "#/components/schemas/SurveyDynamicReference"
|
||||
SurveyDynamicReference:
|
||||
type: object
|
||||
description: Dynamic reference to another value in the survey document.
|
||||
required: [type, value]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [element, variable, hiddenField]
|
||||
value:
|
||||
type: string
|
||||
description: Element id, variable id, or hidden field id depending on `type`.
|
||||
meta:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
SurveyLogicAction:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SurveyCalculateAction"
|
||||
- $ref: "#/components/schemas/SurveyRequireAnswerAction"
|
||||
- $ref: "#/components/schemas/SurveyJumpToBlockAction"
|
||||
description: |
|
||||
Logic action. Keep referenced ids stable: `calculate.variableId` points to a variable id,
|
||||
`requireAnswer.target` points to an element id, and `jumpToBlock.target` points to a block id
|
||||
or ending id.
|
||||
SurveyCalculateAction:
|
||||
type: object
|
||||
description: Updates a survey variable when the logic rule matches.
|
||||
required: [id, objective, variableId, operator, value]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
objective:
|
||||
type: string
|
||||
enum: [calculate]
|
||||
variableId:
|
||||
type: string
|
||||
format: cuid2
|
||||
description: Variable id for `calculate`.
|
||||
operator:
|
||||
type: string
|
||||
enum: [assign, concat, add, subtract, multiply, divide]
|
||||
value:
|
||||
$ref: "#/components/schemas/SurveyLogicOperand"
|
||||
additionalProperties: false
|
||||
SurveyRequireAnswerAction:
|
||||
type: object
|
||||
description: Requires an element/question to be answered before continuing.
|
||||
required: [id, objective, target]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
objective:
|
||||
type: string
|
||||
enum: [requireAnswer]
|
||||
target:
|
||||
type: string
|
||||
description: Target element id.
|
||||
additionalProperties: false
|
||||
SurveyJumpToBlockAction:
|
||||
type: object
|
||||
description: Jumps to another block or ending when the logic rule matches.
|
||||
required: [id, objective, target]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
objective:
|
||||
type: string
|
||||
enum: [jumpToBlock]
|
||||
target:
|
||||
type: string
|
||||
format: cuid2
|
||||
description: Target block id or ending id.
|
||||
additionalProperties: false
|
||||
SurveyResource:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- workspaceId
|
||||
- createdAt
|
||||
- updatedAt
|
||||
- name
|
||||
- type
|
||||
- status
|
||||
- metadata
|
||||
- defaultLanguage
|
||||
- languages
|
||||
- welcomeCard
|
||||
- blocks
|
||||
- endings
|
||||
- hiddenFields
|
||||
- variables
|
||||
properties:
|
||||
id: { type: string }
|
||||
workspaceId: { type: string }
|
||||
createdAt: { type: string, format: date-time }
|
||||
updatedAt: { type: string, format: date-time }
|
||||
name: { type: string }
|
||||
type: { type: string, enum: [link, app, website, web] }
|
||||
status:
|
||||
type: string
|
||||
enum: [draft, inProgress, paused, completed]
|
||||
metadata:
|
||||
type: object
|
||||
nullable: true
|
||||
additionalProperties: true
|
||||
defaultLanguage:
|
||||
type: string
|
||||
description: Real locale code for the survey default language. The internal `default` translation key is never exposed.
|
||||
languages:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyLanguage"
|
||||
welcomeCard:
|
||||
$ref: "#/components/schemas/SurveyWelcomeCard"
|
||||
blocks:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyBlock"
|
||||
endings:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyEnding"
|
||||
hiddenFields:
|
||||
$ref: "#/components/schemas/SurveyHiddenFields"
|
||||
variables:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyVariable"
|
||||
id: { type: string }
|
||||
Problem:
|
||||
type: object
|
||||
description: RFC 9457 Problem Details for HTTP APIs (`application/problem+json`). Responses typically include a machine-readable `code` field alongside `title`, `status`, `detail`, and `requestId`.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface TUploadFileConfig {
|
||||
allowedFileExtensions?: string[] | undefined;
|
||||
surveyId?: string | undefined;
|
||||
questionId?: string | undefined;
|
||||
}
|
||||
|
||||
export interface TUploadFileResponse {
|
||||
|
||||
@@ -259,6 +259,7 @@ export function FileUploadElement({
|
||||
{
|
||||
allowedFileExtensions: element.allowedFileExtensions,
|
||||
surveyId,
|
||||
questionId: element.id,
|
||||
}
|
||||
);
|
||||
return { name: file.name, url: uploadedUrl };
|
||||
|
||||
@@ -183,6 +183,45 @@ describe("ApiClient", () => {
|
||||
expect(fileUrl).toBe("https://fake-file-url.com");
|
||||
});
|
||||
|
||||
test("includes surveyId and questionId in the upload signing request", async () => {
|
||||
vi.mocked(global.fetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
signedUrl: "https://fake-s3-url.com",
|
||||
fileUrl: "/storage/ws-test/private/surveys/survey123/questions/question123/test.jpg",
|
||||
presignedFields: { policy: "test" },
|
||||
signingData: null,
|
||||
updatedFileName: "test.jpg",
|
||||
},
|
||||
}),
|
||||
} as unknown as Response)
|
||||
.mockResolvedValueOnce({ ok: true } as unknown as Response);
|
||||
|
||||
await client.uploadFile(
|
||||
{
|
||||
base64: "data:image/jpeg;base64,abcd",
|
||||
name: "test.jpg",
|
||||
type: "image/jpeg",
|
||||
},
|
||||
{
|
||||
allowedFileExtensions: ["jpg"],
|
||||
surveyId: "survey123",
|
||||
questionId: "question123",
|
||||
}
|
||||
);
|
||||
|
||||
const requestInit = vi.mocked(global.fetch).mock.calls[0][1] as RequestInit;
|
||||
expect(JSON.parse(requestInit.body as string)).toEqual({
|
||||
fileName: "test.jpg",
|
||||
fileType: "image/jpeg",
|
||||
allowedFileExtensions: ["jpg"],
|
||||
surveyId: "survey123",
|
||||
questionId: "question123",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws an error if file is invalid", async () => {
|
||||
await expect(() => client.uploadFile({ base64: "", name: "", type: "" } as any)).rejects.toThrow(
|
||||
"Invalid file object"
|
||||
|
||||
@@ -116,7 +116,7 @@ export class ApiClient {
|
||||
name: string;
|
||||
base64: string;
|
||||
},
|
||||
{ allowedFileExtensions, surveyId }: TUploadFileConfig | undefined = {}
|
||||
{ allowedFileExtensions, surveyId, questionId }: TUploadFileConfig | undefined = {}
|
||||
): Promise<string> {
|
||||
if (!file.name || !file.type || !file.base64) {
|
||||
throw new Error(`Invalid file object`);
|
||||
@@ -127,6 +127,7 @@ export class ApiClient {
|
||||
fileType: file.type,
|
||||
allowedFileExtensions,
|
||||
surveyId,
|
||||
questionId,
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.appUrl}/api/v1/client/${this.workspaceId}/storage`, {
|
||||
|
||||
@@ -114,6 +114,7 @@ export const ZDeleteFileRequest = ZDownloadFileRequest;
|
||||
export const ZUploadFileConfig = z.object({
|
||||
allowedFileExtensions: z.array(z.string()).optional(),
|
||||
surveyId: z.string().optional(),
|
||||
questionId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TUploadFileConfig = z.infer<typeof ZUploadFileConfig>;
|
||||
@@ -124,6 +125,14 @@ export const ZUploadPrivateFileRequest = z
|
||||
fileType: z.string().trim().min(1),
|
||||
allowedFileExtensions: z.array(ZAllowedFileExtension).optional(),
|
||||
surveyId: z.cuid2(),
|
||||
questionId: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.regex(
|
||||
/^[a-zA-Z0-9_-]+$/,
|
||||
"Question id must contain only alphanumeric characters, hyphens, or underscores"
|
||||
),
|
||||
workspaceId: z.cuid2(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
|
||||
Reference in New Issue
Block a user