Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 062a59efed | |||
| b577c151c8 | |||
| f39f12ec1c | |||
| 01fb38cff8 | |||
| 71e57947b9 | |||
| 5f6d6d53b2 | |||
| b79758ee49 | |||
| 25b0b89e86 | |||
| f228ce7eb6 | |||
| 67ae13a61a | |||
| 70e72ab0de | |||
| 07d1d918ba | |||
| a98a0a8e73 | |||
| a5a67a05de | |||
| 4876e107f8 | |||
| 5db616ac07 | |||
| fa1ccdb2c3 | |||
| 6183ab4744 | |||
| 431a3d8a76 | |||
| 91ab958379 | |||
| e5df832653 | |||
| 0909c38eb1 | |||
| 08b0d95295 | |||
| a49e989413 | |||
| 9445b2f482 | |||
| f07d832516 | |||
| e615c692a9 | |||
| a9a910d15c | |||
| c425e7aff4 | |||
| a83a54a24a | |||
| f7890eaec3 | |||
| 8cd3187eff | |||
| 83bccc7ded | |||
| 00aa6d5247 | |||
| 0657c94ee5 | |||
| a36cef2936 | |||
| 467af8b6ef | |||
| d0e057eac1 | |||
| ef1f5a2b12 | |||
| 770041923f | |||
| 3e66ff25a1 | |||
| c979909da9 | |||
| 010d96ebcd | |||
| a0b3054f4a | |||
| 02d3cd2af3 | |||
| 2ef4eb4345 | |||
| 093757b386 | |||
| 64f8746940 | |||
| 851616078a | |||
| 06d5313629 | |||
| 7834c21d39 | |||
| f98ca39035 | |||
| 48f928b1bf | |||
| f5dfb4739c | |||
| 5a1fc01388 | |||
| 77a39c13fa | |||
| 5a12539c75 | |||
| 74e0fba757 | |||
| d9c2756185 | |||
| 88ad5c8625 | |||
| 610beee7eb | |||
| db0e2bb105 | |||
| 419ceef413 |
@@ -53,7 +53,7 @@ function {QuestionType}({
|
||||
}: {QuestionType}Props): React.JSX.Element {
|
||||
// Ensure value is always the correct type (handle undefined/null)
|
||||
const currentValue = value ?? {defaultValue};
|
||||
|
||||
|
||||
// Detect text direction from content
|
||||
const detectedDir = useTextDirection({
|
||||
dir,
|
||||
@@ -63,11 +63,11 @@ function {QuestionType}({
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
/>
|
||||
|
||||
{/* Question-specific controls */}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "eslint . --config .eslintrc.cjs --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App.tsx";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
|
||||
export const isPrismaKnownRequestError = (error: unknown): error is PrismaClientKnownRequestError =>
|
||||
export const isPrismaKnownRequestError = (error: unknown): error is Prisma.PrismaClientKnownRequestError =>
|
||||
error instanceof Prisma.PrismaClientKnownRequestError;
|
||||
|
||||
export const isSingleUseIdUniqueConstraintError = (error: PrismaClientKnownRequestError): boolean => {
|
||||
export const isSingleUseIdUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): boolean => {
|
||||
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -313,18 +313,9 @@ describe("handleErrorResponse", () => {
|
||||
expect(body.message).toBe("bad input");
|
||||
});
|
||||
|
||||
test("returns 404 notFound for ResourceNotFoundError", async () => {
|
||||
test("returns 400 badRequest for ResourceNotFoundError", async () => {
|
||||
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
|
||||
expect(response.status).toBe(404);
|
||||
const body = await response.json();
|
||||
expect(body).toEqual({
|
||||
code: "not_found",
|
||||
message: "Survey not found",
|
||||
details: {
|
||||
resource_id: "id-1",
|
||||
resource_type: "Survey",
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test("returns 500 internalServerError for unknown errors", async () => {
|
||||
|
||||
@@ -29,10 +29,11 @@ export const handleErrorResponse = (error: any): Response => {
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
return responses.conflictResponse(error.message);
|
||||
}
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse(error.resourceType, error.resourceId);
|
||||
}
|
||||
if (error instanceof DatabaseError || error instanceof InvalidInputError) {
|
||||
if (
|
||||
error instanceof DatabaseError ||
|
||||
error instanceof InvalidInputError ||
|
||||
error instanceof ResourceNotFoundError
|
||||
) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
return responses.internalServerErrorResponse("Some error occurred");
|
||||
|
||||
@@ -9,12 +9,12 @@ const mocks = vi.hoisted(() => ({
|
||||
getSurvey: vi.fn(),
|
||||
getValidatedResponseUpdateInput: vi.fn(),
|
||||
loggerError: vi.fn(),
|
||||
resolveClientApiIds: vi.fn(),
|
||||
sendToPipeline: vi.fn(),
|
||||
updateResponseWithQuotaEvaluation: vi.fn(),
|
||||
validateFileUploads: vi.fn(),
|
||||
validateOtherOptionLengthForMultipleChoice: vi.fn(),
|
||||
validateResponseData: vi.fn(),
|
||||
resolveClientApiIds: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
@@ -128,11 +128,11 @@ describe("putResponseHandler", () => {
|
||||
});
|
||||
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
|
||||
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
|
||||
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
|
||||
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
|
||||
mocks.validateFileUploads.mockReturnValue(true);
|
||||
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
|
||||
mocks.validateResponseData.mockReturnValue(null);
|
||||
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
|
||||
});
|
||||
|
||||
test("returns a bad request response when the response id is missing", async () => {
|
||||
@@ -245,34 +245,6 @@ describe("putResponseHandler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("returns not found when the workspace id cannot be resolved", async () => {
|
||||
mocks.resolveClientApiIds.mockResolvedValue(null);
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams({ workspaceId: "unknown_workspace_or_env" }));
|
||||
|
||||
expect(result.response.status).toBe(404);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "not_found",
|
||||
message: "Workspace not found",
|
||||
details: {
|
||||
resource_id: "unknown_workspace_or_env",
|
||||
resource_type: "Workspace",
|
||||
},
|
||||
});
|
||||
expect(mocks.getResponse).not.toHaveBeenCalled();
|
||||
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("accepts updates when the route param is a legacy environment id that resolves to the survey workspace", async () => {
|
||||
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams({ workspaceId: "legacy_environment_id" }));
|
||||
|
||||
expect(mocks.resolveClientApiIds).toHaveBeenCalledWith("legacy_environment_id");
|
||||
expect(result.response.status).toBe(200);
|
||||
expect(mocks.updateResponseWithQuotaEvaluation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("rejects updates when the response survey does not belong to the requested workspace", async () => {
|
||||
mocks.getSurvey.mockResolvedValue({
|
||||
...getBaseSurvey(),
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponseInput } from "@formbricks/types/responses";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
@@ -160,16 +155,6 @@ describe("createResponse", () => {
|
||||
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(UniqueConstraintError);
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError on P2002 with displayId target (race condition)", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: { target: ["displayId"] },
|
||||
});
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
|
||||
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("should throw original error on other Prisma errors", async () => {
|
||||
const genericError = new Error("Generic database error");
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
|
||||
|
||||
@@ -2,12 +2,7 @@ import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
@@ -16,7 +11,6 @@ import {
|
||||
isSingleUseIdUniqueConstraintError,
|
||||
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
|
||||
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
||||
import { assertDisplayOwnership } from "@/lib/display/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
@@ -110,16 +104,6 @@ export const createResponse = async (
|
||||
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
|
||||
if (responseInput.displayId) {
|
||||
await assertDisplayOwnership(
|
||||
responseInput.displayId,
|
||||
workspaceId,
|
||||
responseInput.surveyId,
|
||||
contact?.id ?? null,
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
const prismaData = buildPrismaResponseData(
|
||||
{ ...responseInput, createdAt: undefined, updatedAt: undefined },
|
||||
contact,
|
||||
@@ -147,13 +131,6 @@ export const createResponse = async (
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (isPrismaKnownRequestError(error)) {
|
||||
if (
|
||||
error.code === "P2002" &&
|
||||
Array.isArray(error.meta?.target) &&
|
||||
error.meta.target.includes("displayId")
|
||||
) {
|
||||
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
|
||||
}
|
||||
if (isSingleUseIdUniqueConstraintError(error)) {
|
||||
throw new UniqueConstraintError("Response already submitted for this single-use link");
|
||||
}
|
||||
|
||||
@@ -2,12 +2,7 @@ import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
@@ -195,19 +190,7 @@ describe("createResponse V2", () => {
|
||||
).rejects.toThrow(UniqueConstraintError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on P2002 without singleUseId or displayId target", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: { target: ["someOtherField"] },
|
||||
});
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
|
||||
await expect(
|
||||
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
|
||||
).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError on P2002 with displayId target (race condition)", async () => {
|
||||
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
@@ -216,7 +199,7 @@ describe("createResponse V2", () => {
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
|
||||
await expect(
|
||||
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
|
||||
|
||||
@@ -2,12 +2,7 @@ import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
@@ -17,7 +12,6 @@ import {
|
||||
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
|
||||
import { responseSelection } from "@/app/api/v1/client/[workspaceId]/responses/lib/response";
|
||||
import { TResponseInputV2 } from "@/app/api/v2/client/[workspaceId]/responses/types/response";
|
||||
import { assertDisplayOwnership } from "@/lib/display/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
@@ -105,16 +99,6 @@ export const createResponse = async (
|
||||
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
|
||||
if (responseInput.displayId) {
|
||||
await assertDisplayOwnership(
|
||||
responseInput.displayId,
|
||||
workspaceId,
|
||||
responseInput.surveyId,
|
||||
contactId ?? null,
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
|
||||
|
||||
const prismaClient = tx ?? prisma;
|
||||
@@ -138,13 +122,6 @@ export const createResponse = async (
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (isPrismaKnownRequestError(error)) {
|
||||
if (
|
||||
error.code === "P2002" &&
|
||||
Array.isArray(error.meta?.target) &&
|
||||
error.meta.target.includes("displayId")
|
||||
) {
|
||||
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
|
||||
}
|
||||
if (isSingleUseIdUniqueConstraintError(error)) {
|
||||
throw new UniqueConstraintError("Response already submitted for this single-use link");
|
||||
}
|
||||
|
||||
@@ -415,44 +415,6 @@ describe("withV3ApiWrapper", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns 413 problem response for oversized JSON input", async () => {
|
||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "none",
|
||||
schemas: {
|
||||
body: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
},
|
||||
handler,
|
||||
});
|
||||
|
||||
const response = await wrapped(
|
||||
new NextRequest("http://localhost/api/v3/surveys", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
headers: {
|
||||
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
|
||||
"Content-Type": "application/json",
|
||||
"x-request-id": "req-payload-too-large",
|
||||
},
|
||||
}),
|
||||
{} as never
|
||||
);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
await expect(response.json()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
code: "payload_too_large",
|
||||
detail: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
|
||||
requestId: "req-payload-too-large",
|
||||
status: 413,
|
||||
title: "Payload Too Large",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("returns 400 problem response for invalid route params", async () => {
|
||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
||||
const wrapped = withV3ApiWrapper({
|
||||
|
||||
@@ -40,40 +40,6 @@ describe("parseAndValidateJsonBody", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a payload too large response when the request body exceeds the body limit", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
const result = await parseAndValidateJsonBody({
|
||||
request,
|
||||
schema: z.object({
|
||||
finished: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
expect("response" in result).toBe(true);
|
||||
|
||||
if (!("response" in result)) {
|
||||
throw new Error("Expected a response result");
|
||||
}
|
||||
|
||||
expect(result.issue).toBe("payload_too_large");
|
||||
expect(result.response.status).toBe(413);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "payload_too_large",
|
||||
message: "Payload Too Large",
|
||||
details: {
|
||||
error: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a validation response when the parsed JSON does not match the schema", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
DEFAULT_REQUEST_BODY_LIMIT_BYTES,
|
||||
RequestBodyTooLargeError,
|
||||
parseJsonBodyWithLimit,
|
||||
readRequestBodyWithLimit,
|
||||
} from "./request-body";
|
||||
|
||||
const createStreamingRequest = (chunks: string[]): Request =>
|
||||
new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
body: new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
duplex: "half",
|
||||
} as RequestInit & { duplex: "half" });
|
||||
|
||||
describe("request body parsing", () => {
|
||||
test("rejects a request when content-length exceeds the body limit", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
await expect(readRequestBodyWithLimit(request)).rejects.toMatchObject({
|
||||
actualBytes: DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1,
|
||||
limitBytes: DEFAULT_REQUEST_BODY_LIMIT_BYTES,
|
||||
name: "RequestBodyTooLargeError",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects a streamed request when the actual body exceeds the body limit", async () => {
|
||||
const request = createStreamingRequest(["a".repeat(DEFAULT_REQUEST_BODY_LIMIT_BYTES), "b"]);
|
||||
|
||||
await expect(readRequestBodyWithLimit(request)).rejects.toBeInstanceOf(RequestBodyTooLargeError);
|
||||
});
|
||||
|
||||
test("allows a body exactly at the body limit", async () => {
|
||||
const rawBody = "a".repeat(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
const body = await readRequestBodyWithLimit(request);
|
||||
|
||||
expect(body).toHaveLength(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
|
||||
expect(body).toBe(rawBody);
|
||||
});
|
||||
|
||||
test("preserves JSON parse errors for malformed bodies under the body limit", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
body: "{invalid-json",
|
||||
});
|
||||
|
||||
await expect(parseJsonBodyWithLimit(request)).rejects.toBeInstanceOf(SyntaxError);
|
||||
});
|
||||
|
||||
test("returns an empty string for requests without a body", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
await expect(readRequestBodyWithLimit(request)).resolves.toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
@@ -213,7 +212,7 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
|
||||
|
||||
// -- Composite functions --
|
||||
|
||||
const mapUniqueConstraintError = (error: PrismaClientKnownRequestError): InvalidInputError => {
|
||||
const mapUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): InvalidInputError => {
|
||||
const target = error.meta?.target;
|
||||
const targetFields = Array.isArray(target) ? (target as string[]) : [];
|
||||
if (targetFields.includes("elementId") || targetFields.includes("surveyId")) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TDisplay, TDisplayFilters, TDisplayWithContact, ZDisplayFilters } from "@formbricks/types/displays";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
export const selectDisplay = {
|
||||
@@ -146,58 +146,6 @@ export const getDisplaysBySurveyIdWithContact = reactCache(
|
||||
}
|
||||
);
|
||||
|
||||
export const getDisplayForResponseValidation = async (
|
||||
displayId: string,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<{
|
||||
surveyId: string;
|
||||
workspaceId: string;
|
||||
responseId: string | null;
|
||||
contactId: string | null;
|
||||
} | null> => {
|
||||
validateInputs([displayId, ZId]);
|
||||
const client = tx ?? prisma;
|
||||
try {
|
||||
const display = await client.display.findUnique({
|
||||
where: { id: displayId },
|
||||
select: {
|
||||
surveyId: true,
|
||||
contactId: true,
|
||||
response: { select: { id: true } },
|
||||
survey: { select: { workspaceId: true } },
|
||||
},
|
||||
});
|
||||
if (!display) return null;
|
||||
return {
|
||||
surveyId: display.surveyId,
|
||||
workspaceId: display.survey.workspaceId,
|
||||
responseId: display.response?.id ?? null,
|
||||
contactId: display.contactId,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) throw new DatabaseError(error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const assertDisplayOwnership = async (
|
||||
displayId: string,
|
||||
workspaceId: string,
|
||||
surveyId: string,
|
||||
contactId: string | null,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<void> => {
|
||||
const display = await getDisplayForResponseValidation(displayId, tx);
|
||||
if (!display) throw new InvalidInputError(`Display ${displayId} not found`);
|
||||
if (display.workspaceId !== workspaceId)
|
||||
throw new InvalidInputError(`Display ${displayId} belongs to a different workspace`);
|
||||
if (display.surveyId !== surveyId)
|
||||
throw new InvalidInputError(`Display ${displayId} is associated with a different survey`);
|
||||
if (display.responseId) throw new InvalidInputError(`Display ${displayId} is already linked to a response`);
|
||||
if (display.contactId !== null && display.contactId !== contactId)
|
||||
throw new InvalidInputError(`Display ${displayId} belongs to a different contact`);
|
||||
};
|
||||
|
||||
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
|
||||
validateInputs([displayId, ZId]);
|
||||
try {
|
||||
|
||||
@@ -3,18 +3,14 @@ import { prisma } from "@/lib/__mocks__/database";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import {
|
||||
assertDisplayOwnership,
|
||||
getDisplayCountBySurveyId,
|
||||
getDisplayForResponseValidation,
|
||||
getDisplaysByContactId,
|
||||
getDisplaysBySurveyIdWithContact,
|
||||
} from "../service";
|
||||
|
||||
const mockContactId = "clqnj99r9000008lebgf8734j";
|
||||
const mockWorkspaceId = "clqkr8dlv000308jybb08evgz";
|
||||
const mockResponseId = "clqnfg59i000208i426pb4wcv";
|
||||
const mockResponseIds = ["clqnfg59i000208i426pb4wcv", "clqnfg59i000208i426pb4wcw"];
|
||||
|
||||
const mockDisplaysForContact = [
|
||||
@@ -294,96 +290,3 @@ describe("getDisplaysBySurveyIdWithContact", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const mockDisplayRecord = {
|
||||
surveyId: mockSurveyId,
|
||||
contactId: null as string | null,
|
||||
response: null as { id: string } | null,
|
||||
survey: { workspaceId: mockWorkspaceId },
|
||||
};
|
||||
|
||||
describe("getDisplayForResponseValidation", () => {
|
||||
test("returns null when display is not found", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue(null);
|
||||
const result = await getDisplayForResponseValidation(mockDisplayId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns mapped shape when display is found", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue({
|
||||
...mockDisplayRecord,
|
||||
contactId: mockContactId,
|
||||
response: { id: mockResponseId },
|
||||
} as any);
|
||||
const result = await getDisplayForResponseValidation(mockDisplayId);
|
||||
expect(result).toEqual({
|
||||
surveyId: mockSurveyId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
responseId: mockResponseId,
|
||||
contactId: mockContactId,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
})
|
||||
);
|
||||
await expect(getDisplayForResponseValidation(mockDisplayId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertDisplayOwnership", () => {
|
||||
test("throws InvalidInputError when display is not found", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue(null);
|
||||
await expect(assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, null)).rejects.toThrow(
|
||||
InvalidInputError
|
||||
);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when workspaceId does not match", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue(mockDisplayRecord as any);
|
||||
await expect(
|
||||
assertDisplayOwnership(mockDisplayId, "wrong-workspace", mockSurveyId, null)
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when surveyId does not match", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue(mockDisplayRecord as any);
|
||||
await expect(
|
||||
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, "wrong-survey", null)
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when display is already linked to a response", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue({
|
||||
...mockDisplayRecord,
|
||||
response: { id: mockResponseId },
|
||||
} as any);
|
||||
await expect(assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, null)).rejects.toThrow(
|
||||
InvalidInputError
|
||||
);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when contactId does not match", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue({
|
||||
...mockDisplayRecord,
|
||||
contactId: "contact-a",
|
||||
} as any);
|
||||
await expect(
|
||||
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, "contact-b")
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("resolves without error when all ownership checks pass", async () => {
|
||||
vi.mocked(prisma.display.findUnique).mockResolvedValue({
|
||||
...mockDisplayRecord,
|
||||
contactId: mockContactId,
|
||||
} as any);
|
||||
await expect(
|
||||
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, mockContactId)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { updateResponse } from "./service";
|
||||
@@ -325,35 +324,5 @@ describe("updateResponse", () => {
|
||||
|
||||
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when response is deleted during update", async () => {
|
||||
const currentResponse = createMockCurrentResponse();
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
|
||||
vi.mocked(prisma.response.update).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Record to update not found", {
|
||||
code: PrismaErrorType.RelatedRecordDoesNotExist,
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
const responseInput = createMockResponseInput();
|
||||
|
||||
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when Prisma reports a missing response record", async () => {
|
||||
const currentResponse = createMockCurrentResponse();
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
|
||||
vi.mocked(prisma.response.update).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Record does not exist", {
|
||||
code: PrismaErrorType.RecordDoesNotExist,
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
const responseInput = createMockResponseInput();
|
||||
|
||||
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
@@ -570,13 +569,6 @@ export const updateResponse = async (
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
) {
|
||||
throw new ResourceNotFoundError("Response", responseId);
|
||||
}
|
||||
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,50 +38,6 @@ describe("convertToCsv", () => {
|
||||
|
||||
parseSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("should defang formula injection payloads in cell values", async () => {
|
||||
const payloads = [
|
||||
'=HYPERLINK("https://evil.tld","Click")',
|
||||
"+1+1",
|
||||
"-2+3",
|
||||
"@SUM(A1:A2)",
|
||||
"\tleading-tab",
|
||||
"\rleading-cr",
|
||||
];
|
||||
const rows = payloads.map((p) => ({ name: p, age: 0 }));
|
||||
const csv = await convertToCsv(["name", "age"], rows);
|
||||
const lines = csv.trim().split("\n").slice(1); // drop header
|
||||
payloads.forEach((p, i) => {
|
||||
// each value should be prefixed with a single quote so the spreadsheet
|
||||
// app treats it as text rather than a formula
|
||||
expect(lines[i].startsWith(`"'${p.charAt(0)}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("should defang formula injection in field/header names", async () => {
|
||||
const csv = await convertToCsv(["=evil", "age"], [{ "=evil": "x", age: 1 }]);
|
||||
const lines = csv.trim().split("\n");
|
||||
expect(lines[0]).toBe('"\'=evil","age"');
|
||||
expect(lines[1]).toBe('"x",1');
|
||||
});
|
||||
|
||||
test("should not alter benign strings", async () => {
|
||||
const csv = await convertToCsv(["name"], [{ name: "Alice = Bob" }]);
|
||||
const lines = csv.trim().split("\n");
|
||||
expect(lines[1]).toBe('"Alice = Bob"');
|
||||
});
|
||||
|
||||
test("should preserve distinct columns whose labels collide after sanitization", async () => {
|
||||
// "=field" and "'=field" both render as "'=field" once defanged, but the
|
||||
// underlying row keys must stay distinct so neither cell is dropped.
|
||||
const csv = await convertToCsv(
|
||||
["=field", "'=field"],
|
||||
[{ "=field": "a", "'=field": "b" }]
|
||||
);
|
||||
const lines = csv.trim().split("\n");
|
||||
expect(lines[0]).toBe('"\'=field","\'=field"');
|
||||
expect(lines[1]).toBe('"a","b"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertToXlsxBuffer", () => {
|
||||
@@ -104,54 +60,4 @@ describe("convertToXlsxBuffer", () => {
|
||||
const cleaned = raw.map(({ __rowNum__, ...rest }) => rest);
|
||||
expect(cleaned).toEqual(data);
|
||||
});
|
||||
|
||||
test("should defang formula injection payloads in xlsx cells", () => {
|
||||
const payloads = [
|
||||
'=HYPERLINK("https://evil.tld","Click")',
|
||||
"+1+1",
|
||||
"-2+3",
|
||||
"@SUM(A1:A2)",
|
||||
"\tleading-tab",
|
||||
"\rleading-cr",
|
||||
];
|
||||
const rows = payloads.map((p) => ({ name: p }));
|
||||
const buffer = convertToXlsxBuffer(["name"], rows);
|
||||
const wb = xlsx.read(buffer, { type: "buffer" });
|
||||
const sheet = wb.Sheets["Sheet1"];
|
||||
payloads.forEach((p, i) => {
|
||||
const cell = sheet[`A${i + 2}`]; // row 1 is header
|
||||
// value stored as plain text, not as a formula (no `f` property)
|
||||
expect(cell.f).toBeUndefined();
|
||||
expect(cell.v).toBe(`'${p}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("should defang formula injection in xlsx header names", () => {
|
||||
const buffer = convertToXlsxBuffer(["=evil", "name"], [{ "=evil": "x", name: "Alice" }]);
|
||||
const wb = xlsx.read(buffer, { type: "buffer" });
|
||||
const sheet = wb.Sheets["Sheet1"];
|
||||
const headerCell = sheet["A1"];
|
||||
expect(headerCell.f).toBeUndefined();
|
||||
expect(headerCell.v).toBe("'=evil");
|
||||
// benign header untouched
|
||||
expect(sheet["B1"].v).toBe("name");
|
||||
// data row mapped via original key
|
||||
expect(sheet["A2"].v).toBe("x");
|
||||
expect(sheet["B2"].v).toBe("Alice");
|
||||
});
|
||||
|
||||
test("should preserve distinct xlsx columns whose labels collide after sanitization", () => {
|
||||
// Original keys "=field" and "'=field" both render as "'=field"; ensure
|
||||
// both cells survive instead of one overwriting the other.
|
||||
const buffer = convertToXlsxBuffer(
|
||||
["=field", "'=field"],
|
||||
[{ "=field": "a", "'=field": "b" }]
|
||||
);
|
||||
const wb = xlsx.read(buffer, { type: "buffer" });
|
||||
const sheet = wb.Sheets["Sheet1"];
|
||||
expect(sheet["A1"].v).toBe("'=field");
|
||||
expect(sheet["B1"].v).toBe("'=field");
|
||||
expect(sheet["A2"].v).toBe("a");
|
||||
expect(sheet["B2"].v).toBe("b");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,42 +165,6 @@ describe("apiWrapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle oversized JSON input in request body", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
headers: {
|
||||
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 413 }));
|
||||
|
||||
const bodySchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn();
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas: { body: bodySchema },
|
||||
rateLimit: false,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(handleApiError).toHaveBeenCalledWith(request, {
|
||||
type: "payload_too_large",
|
||||
details: [
|
||||
{
|
||||
field: "body",
|
||||
issue: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle empty body when body schema is provided", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
|
||||
@@ -85,18 +85,6 @@ describe("utils", () => {
|
||||
expect(body.error.message).toBe("Conflict");
|
||||
});
|
||||
|
||||
test('return payload too large response for "payload_too_large" error', async () => {
|
||||
const details = [{ field: "body", issue: "Request body must not exceed 2097152 bytes" }];
|
||||
const error: ApiErrorResponseV2 = { type: "payload_too_large", details };
|
||||
|
||||
const response = handleApiError(mockRequest, error);
|
||||
expect(response.status).toBe(413);
|
||||
const body = await response.json();
|
||||
expect(body.error.code).toBe(413);
|
||||
expect(body.error.message).toBe("Payload Too Large");
|
||||
expect(body.error.details).toEqual(details);
|
||||
});
|
||||
|
||||
test('return unprocessable entity response for "unprocessable_entity" error', async () => {
|
||||
const details = [{ field: "data", issue: "malformed" }];
|
||||
const error: ApiErrorResponseV2 = { type: "unprocessable_entity", details };
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { expandPresetDateRanges } from "./date-presets";
|
||||
|
||||
const queryWithDateRange = (dateRange: string | [string, string]): TChartQuery => ({
|
||||
measures: ["FeedbackRecords.count"],
|
||||
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt", dateRange }],
|
||||
});
|
||||
|
||||
// Mid-month, mid-quarter date that exercises month/quarter/year boundaries cleanly.
|
||||
const NOW = new Date(2026, 4, 21, 14, 30, 0); // May 21, 2026 14:30 local
|
||||
|
||||
describe("expandPresetDateRanges", () => {
|
||||
test("includes today for 'last 7 days'", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("last 7 days"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-15", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("includes today for 'last 30 days'", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("last 30 days"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-22", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("expands 'today' to today..today", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("today"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-21", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("expands 'yesterday' to yesterday..yesterday", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("yesterday"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-20", "2026-05-20"]);
|
||||
});
|
||||
|
||||
test("'this month' runs from the 1st through today", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("this month"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-01", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("'last month' is the full previous calendar month", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("last month"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-01", "2026-04-30"]);
|
||||
});
|
||||
|
||||
test("'last month' handles year rollover", () => {
|
||||
const janFirst = new Date(2026, 0, 15, 10, 0, 0);
|
||||
const result = expandPresetDateRanges(queryWithDateRange("last month"), janFirst);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2025-12-01", "2025-12-31"]);
|
||||
});
|
||||
|
||||
test("'this quarter' starts at the first day of the calendar quarter", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("this quarter"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-01", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("'this year' starts on Jan 1", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("this year"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-01-01", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("leaves explicit [start, end] tuple unchanged", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange(["2026-01-01", "2026-01-15"]), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-01-01", "2026-01-15"]);
|
||||
});
|
||||
|
||||
test("leaves an unknown preset string unchanged so Cube can interpret it", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("from -3 days to now"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toBe("from -3 days to now");
|
||||
});
|
||||
|
||||
test("returns input unchanged when there are no time dimensions", () => {
|
||||
const q: TChartQuery = { measures: ["FeedbackRecords.count"] };
|
||||
expect(expandPresetDateRanges(q, NOW)).toEqual(q);
|
||||
});
|
||||
|
||||
test("preserves other timeDimension fields (granularity, dimension)", () => {
|
||||
const q: TChartQuery = {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
timeDimensions: [
|
||||
{ dimension: "FeedbackRecords.collectedAt", granularity: "day", dateRange: "last 7 days" },
|
||||
],
|
||||
};
|
||||
const result = expandPresetDateRanges(q, NOW);
|
||||
expect(result.timeDimensions?.[0]).toMatchObject({
|
||||
dimension: "FeedbackRecords.collectedAt",
|
||||
granularity: "day",
|
||||
dateRange: ["2026-05-15", "2026-05-21"],
|
||||
});
|
||||
});
|
||||
|
||||
test("does not mutate the input query", () => {
|
||||
const q = queryWithDateRange("last 7 days");
|
||||
const before = JSON.stringify(q);
|
||||
expandPresetDateRanges(q, NOW);
|
||||
expect(JSON.stringify(q)).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
FEEDBACK_FIELDS,
|
||||
@@ -8,17 +6,6 @@ import {
|
||||
getFilterOperatorsForType,
|
||||
} from "./schema-definition";
|
||||
|
||||
const chartCubeSchemaPath = fileURLToPath(
|
||||
new URL("../../../../../../charts/formbricks/cube/schema/FeedbackRecords.js", import.meta.url)
|
||||
);
|
||||
const dockerCubeSchemaPath = fileURLToPath(
|
||||
new URL("../../../../../../docker/cube/schema/FeedbackRecords.js", import.meta.url)
|
||||
);
|
||||
|
||||
const readChartCubeSchema = (): string => readFileSync(chartCubeSchemaPath, "utf8");
|
||||
const readDockerCubeSchema = (): string => readFileSync(dockerCubeSchemaPath, "utf8");
|
||||
const getCubeMemberName = (id: string): string => id.replace("FeedbackRecords.", "");
|
||||
|
||||
describe("schema-definition", () => {
|
||||
describe("getFilterOperatorsForType", () => {
|
||||
test("returns string operators", () => {
|
||||
@@ -107,20 +94,5 @@ describe("schema-definition", () => {
|
||||
);
|
||||
expect(ids).not.toContain("FeedbackRecords.averageScore");
|
||||
});
|
||||
|
||||
test("only exposes members present in the deployed Cube schema", () => {
|
||||
const chartCubeSchema = readChartCubeSchema();
|
||||
const exposedMembers = [...FEEDBACK_FIELDS.measures, ...FEEDBACK_FIELDS.dimensions].map(({ id }) =>
|
||||
getCubeMemberName(id)
|
||||
);
|
||||
|
||||
for (const member of exposedMembers) {
|
||||
expect(chartCubeSchema).toContain(` ${member}: {`);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the Helm and Docker Cube schemas in sync", () => {
|
||||
expect(readChartCubeSchema()).toBe(readDockerCubeSchema());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
createFeedbackRecord,
|
||||
createFeedbackRecordsBatch,
|
||||
deleteFeedbackRecord,
|
||||
deleteHubTenantData,
|
||||
getFeedbackRecordTenant,
|
||||
listFeedbackRecords,
|
||||
retrieveFeedbackRecord,
|
||||
@@ -345,48 +344,6 @@ describe("hub service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteHubTenantData", () => {
|
||||
test("returns config error when getHubClient returns null", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue(null);
|
||||
|
||||
const result = await deleteHubTenantData("tenant-1");
|
||||
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error?.message).toContain("HUB_API_KEY");
|
||||
});
|
||||
|
||||
test("returns mapped data when client.delete resolves", async () => {
|
||||
const deleteSpy = vi.fn().mockResolvedValue({
|
||||
tenant_id: "tenant-1",
|
||||
deleted_feedback_records: 3,
|
||||
deleted_embeddings: 5,
|
||||
deleted_webhooks: 1,
|
||||
});
|
||||
vi.mocked(getHubClient).mockReturnValue({ delete: deleteSpy } as any);
|
||||
|
||||
const result = await deleteHubTenantData("tenant-1");
|
||||
|
||||
expect(deleteSpy).toHaveBeenCalledWith("/v1/tenants/tenant-1/data");
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.data).toEqual({
|
||||
deletedFeedbackRecords: 3,
|
||||
deletedEmbeddings: 5,
|
||||
deletedWebhooks: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns error when client.delete throws", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
delete: vi.fn().mockRejectedValue(new Error("network")),
|
||||
} as any);
|
||||
|
||||
const result = await deleteHubTenantData("tenant-1");
|
||||
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error).toMatchObject({ status: 0, message: "network" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFeedbackRecordsBatch", () => {
|
||||
test("returns all errors when getHubClient returns null", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue(null);
|
||||
|
||||
@@ -31,7 +31,7 @@ export const SurveyCompletedMessage = async ({
|
||||
{(!workspace || workspace.linkSurveyBranding) && (
|
||||
<div>
|
||||
<Link href="https://formbricks.com">
|
||||
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
|
||||
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -76,7 +76,7 @@ export const SurveyInactive = async ({
|
||||
{(!workspace || workspace.linkSurveyBranding) && (
|
||||
<div>
|
||||
<Link href="https://formbricks.com">
|
||||
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
|
||||
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -123,7 +123,11 @@ export const SurveyLoadingAnimation = ({
|
||||
isReadyToTransition ? "animate-surveyExit" : "animate-surveyLoading"
|
||||
)}>
|
||||
{isBrandingEnabled && (
|
||||
<Image src={Logo} alt="Logo" className={cn("w-32 transition-all duration-1000 md:w-40")} />
|
||||
<Image
|
||||
src={Logo as string}
|
||||
alt="Logo"
|
||||
className={cn("w-32 transition-all duration-1000 md:w-40")}
|
||||
/>
|
||||
)}
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@ const DropdownMenuSubContent: React.ComponentType<DropdownMenuPrimitive.Dropdown
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref as any}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-600 shadow-sm animate-in slide-in-from-left-1 hover:text-slate-700",
|
||||
"animate-in slide-in-from-left-1 z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-600 shadow-sm hover:text-slate-700",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -67,7 +67,7 @@ const DropdownMenuContent: React.ComponentType<DropdownMenuPrimitive.DropdownMen
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-700 shadow-sm animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"animate-in data-[side=right]:slide-in-from-left-2 data-[side=left]:slide-in-from-right-2 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-700 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -19,7 +19,7 @@ const PopoverContent: React.ForwardRefExoticComponent<
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border border-slate-100 bg-white p-4 shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=right]:slide-in-from-left-2 data-[side=left]:slide-in-from-right-2 z-50 w-72 rounded-md border border-slate-100 bg-white p-4 shadow-md outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Workspace } from "@prisma/client";
|
||||
import { MotionConfig, motion } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
import { ExpandIcon, GlobeIcon, MonitorIcon, ShrinkIcon, SmartphoneIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -202,180 +202,70 @@ export const PreviewSurvey = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<div
|
||||
className="flex h-full w-full flex-col items-center justify-items-center p-2 py-4"
|
||||
id="survey-preview">
|
||||
<motion.div
|
||||
className={cn(
|
||||
"z-50 flex h-full w-fit items-center justify-center",
|
||||
isFullScreenPreview && "h-full w-full bg-zinc-500/50 backdrop-blur-md"
|
||||
)}
|
||||
style={{
|
||||
position: isFullScreenPreview ? "fixed" : "absolute",
|
||||
zIndex: 50,
|
||||
left: isFullScreenPreview ? 0 : undefined,
|
||||
top: isFullScreenPreview ? 0 : undefined,
|
||||
}}
|
||||
transition={{
|
||||
ease: "easeInOut",
|
||||
delay: 1.5,
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
layout
|
||||
style={{
|
||||
left: isFullScreenPreview ? "2.5%" : undefined,
|
||||
top: isFullScreenPreview ? 0 : undefined,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
ease: "easeInOut",
|
||||
type: "spring",
|
||||
}}
|
||||
className={cn(
|
||||
"z-50 flex h-[95%] w-full items-center justify-center overflow-hidden rounded-lg border border-slate-300",
|
||||
isFullScreenPreview && "absolute z-50 h-[95%] w-[95%]"
|
||||
)}>
|
||||
{previewMode === "mobile" && (
|
||||
<>
|
||||
<p className="absolute left-0 top-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
|
||||
{t("common.preview")}
|
||||
</p>
|
||||
<div className="absolute right-0 top-0 m-2 flex items-center gap-1">
|
||||
{showLanguageSelector && (
|
||||
<LanguageSelector
|
||||
languages={enabledLanguages}
|
||||
languageCode={languageCode}
|
||||
setLanguageCode={setLanguageCode}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
<ResetProgressButton onClick={resetProgress} />
|
||||
</div>
|
||||
<MediaBackground
|
||||
surveyType={survey.type}
|
||||
styling={styling}
|
||||
ContentRef={ContentRef as React.RefObject<HTMLDivElement>}
|
||||
isMobilePreview>
|
||||
{previewType === "modal" ? (
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
placement={placement}
|
||||
previewMode="mobile"
|
||||
overlay={overlay}
|
||||
clickOutsideClose={clickOutsideClose}
|
||||
borderRadius={styling?.roundness ?? 8}
|
||||
background={styling?.cardBackgroundColor?.light}>
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsWorkspaceStateSurvey(survey)}
|
||||
isBrandingEnabled={workspace.inAppSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
languageCode={languageCode}
|
||||
styling={styling}
|
||||
isCardBorderVisible={!styling.highlightBorderColor?.light}
|
||||
onClose={handlePreviewModalClose}
|
||||
getSetBlockId={(f: (value: string) => void) => {
|
||||
setBlockId = f;
|
||||
}}
|
||||
onFinished={onFinished}
|
||||
placement={placement}
|
||||
isSpamProtectionEnabled={isSpamProtectionEnabled}
|
||||
/>
|
||||
</Modal>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col justify-center px-1">
|
||||
<div className="absolute left-5 top-5">
|
||||
{!styling.isLogoHidden && (
|
||||
<ClientLogo workspaceLogo={workspace.logo} surveyLogo={styling.logo} previewSurvey />
|
||||
)}
|
||||
</div>
|
||||
<div className="z-10 w-full rounded-lg border border-transparent">
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
isBrandingEnabled={workspace.linkSurveyBranding}
|
||||
survey={toJsWorkspaceStateSurvey({ ...survey, type: "link" })}
|
||||
languageCode={languageCode}
|
||||
responseCount={42}
|
||||
styling={styling}
|
||||
getSetBlockId={(f: (value: string) => void) => {
|
||||
setBlockId = f;
|
||||
}}
|
||||
isSpamProtectionEnabled={isSpamProtectionEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MediaBackground>
|
||||
</>
|
||||
)}
|
||||
{previewMode === "desktop" && (
|
||||
<div className="flex h-full w-full flex-1 flex-col">
|
||||
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
|
||||
<div className="ml-6 flex space-x-2">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
|
||||
<button
|
||||
className="h-3 w-3 cursor-pointer rounded-full bg-emerald-500"
|
||||
onClick={() => {
|
||||
if (isFullScreenPreview) {
|
||||
setIsFullScreenPreview(false);
|
||||
} else {
|
||||
setIsFullScreenPreview(true);
|
||||
}
|
||||
}}
|
||||
aria-label={
|
||||
isFullScreenPreview
|
||||
? t("workspace.surveys.edit.shrink_preview")
|
||||
: t("workspace.surveys.edit.expand_preview")
|
||||
}></button>
|
||||
</div>
|
||||
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
|
||||
<p>
|
||||
{previewType === "modal" ? t("workspace.surveys.edit.your_web_app") : t("common.preview")}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center">
|
||||
{showLanguageSelector && (
|
||||
<LanguageSelector
|
||||
languages={enabledLanguages}
|
||||
languageCode={languageCode}
|
||||
setLanguageCode={setLanguageCode}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
{isFullScreenPreview ? (
|
||||
<ShrinkIcon
|
||||
className="mr-1 h-[22px] w-[22px] cursor-pointer rounded-md bg-white p-1 text-slate-500 hover:text-slate-700"
|
||||
onClick={() => {
|
||||
setIsFullScreenPreview(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ExpandIcon
|
||||
className="mr-1 h-[22px] w-[22px] cursor-pointer rounded-md bg-white p-1 text-slate-500 hover:text-slate-700"
|
||||
onClick={() => {
|
||||
setIsFullScreenPreview(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ResetProgressButton onClick={resetProgress} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex h-full w-full flex-col items-center justify-items-center p-2 py-4"
|
||||
id="survey-preview">
|
||||
<motion.div
|
||||
className={cn(
|
||||
"z-50 flex h-full w-fit items-center justify-center",
|
||||
isFullScreenPreview && "h-full w-full bg-zinc-500/50 backdrop-blur-md"
|
||||
)}
|
||||
style={{
|
||||
position: isFullScreenPreview ? "fixed" : "absolute",
|
||||
zIndex: 50,
|
||||
left: isFullScreenPreview ? 0 : undefined,
|
||||
top: isFullScreenPreview ? 0 : undefined,
|
||||
}}
|
||||
transition={{
|
||||
ease: "easeInOut",
|
||||
delay: 1.5,
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
layout
|
||||
style={{
|
||||
left: isFullScreenPreview ? "2.5%" : undefined,
|
||||
top: isFullScreenPreview ? 0 : undefined,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
ease: "easeInOut",
|
||||
type: "spring",
|
||||
}}
|
||||
className={cn(
|
||||
"z-50 flex h-[95%] w-full items-center justify-center overflow-hidden rounded-lg border border-slate-300",
|
||||
isFullScreenPreview && "absolute z-50 h-[95%] w-[95%]"
|
||||
)}>
|
||||
{previewMode === "mobile" && (
|
||||
<>
|
||||
<p className="absolute left-0 top-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
|
||||
{t("common.preview")}
|
||||
</p>
|
||||
<div className="absolute right-0 top-0 m-2 flex items-center gap-1">
|
||||
{showLanguageSelector && (
|
||||
<LanguageSelector
|
||||
languages={enabledLanguages}
|
||||
languageCode={languageCode}
|
||||
setLanguageCode={setLanguageCode}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
<ResetProgressButton onClick={resetProgress} />
|
||||
</div>
|
||||
<MediaBackground
|
||||
surveyType={survey.type}
|
||||
styling={styling}
|
||||
ContentRef={ContentRef as React.RefObject<HTMLDivElement>}
|
||||
isMobilePreview>
|
||||
{previewType === "modal" ? (
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
placement={placement}
|
||||
clickOutsideClose={clickOutsideClose}
|
||||
previewMode="mobile"
|
||||
overlay={overlay}
|
||||
previewMode="desktop"
|
||||
borderRadius={styling.roundness ?? 8}
|
||||
background={styling.cardBackgroundColor?.light}>
|
||||
clickOutsideClose={clickOutsideClose}
|
||||
borderRadius={styling?.roundness ?? 8}
|
||||
background={styling?.cardBackgroundColor?.light}>
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
@@ -390,28 +280,23 @@ export const PreviewSurvey = ({
|
||||
setBlockId = f;
|
||||
}}
|
||||
onFinished={onFinished}
|
||||
isSpamProtectionEnabled={isSpamProtectionEnabled}
|
||||
placement={placement}
|
||||
isSpamProtectionEnabled={isSpamProtectionEnabled}
|
||||
/>
|
||||
</Modal>
|
||||
) : (
|
||||
<MediaBackground
|
||||
surveyType={survey.type}
|
||||
styling={styling}
|
||||
ContentRef={ContentRef as React.RefObject<HTMLDivElement>}
|
||||
isEditorView>
|
||||
<div className="flex h-full w-full flex-col justify-center px-1">
|
||||
<div className="absolute left-5 top-5">
|
||||
{!styling.isLogoHidden && (
|
||||
<ClientLogo workspaceLogo={workspace.logo} surveyLogo={styling.logo} previewSurvey />
|
||||
)}
|
||||
</div>
|
||||
<div className="z-0 w-full max-w-4xl rounded-lg border-transparent">
|
||||
<div className="z-10 w-full rounded-lg border border-transparent">
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsWorkspaceStateSurvey({ ...survey, type: "link" })}
|
||||
isBrandingEnabled={workspace.linkSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
survey={toJsWorkspaceStateSurvey({ ...survey, type: "link" })}
|
||||
languageCode={languageCode}
|
||||
responseCount={42}
|
||||
styling={styling}
|
||||
@@ -421,27 +306,140 @@ export const PreviewSurvey = ({
|
||||
isSpamProtectionEnabled={isSpamProtectionEnabled}
|
||||
/>
|
||||
</div>
|
||||
</MediaBackground>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</MediaBackground>
|
||||
</>
|
||||
)}
|
||||
{previewMode === "desktop" && (
|
||||
<div className="flex h-full w-full flex-1 flex-col">
|
||||
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
|
||||
<div className="ml-6 flex space-x-2">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
|
||||
<button
|
||||
className="h-3 w-3 cursor-pointer rounded-full bg-emerald-500"
|
||||
onClick={() => {
|
||||
if (isFullScreenPreview) {
|
||||
setIsFullScreenPreview(false);
|
||||
} else {
|
||||
setIsFullScreenPreview(true);
|
||||
}
|
||||
}}
|
||||
aria-label={
|
||||
isFullScreenPreview
|
||||
? t("workspace.surveys.edit.shrink_preview")
|
||||
: t("workspace.surveys.edit.expand_preview")
|
||||
}></button>
|
||||
</div>
|
||||
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
|
||||
<p>
|
||||
{previewType === "modal" ? t("workspace.surveys.edit.your_web_app") : t("common.preview")}
|
||||
</p>
|
||||
|
||||
{/* for toggling between mobile and desktop mode */}
|
||||
<div className="mt-2 flex rounded-full border-2 border-slate-300 p-1">
|
||||
<TabOption
|
||||
active={previewMode === "mobile"}
|
||||
icon={<SmartphoneIcon className="mx-4 my-2 h-4 w-4 text-slate-700" />}
|
||||
onClick={() => handlePreviewModeChange("mobile")}
|
||||
/>
|
||||
<TabOption
|
||||
active={previewMode === "desktop"}
|
||||
icon={<MonitorIcon className="mx-4 my-2 h-4 w-4 text-slate-700" />}
|
||||
onClick={() => handlePreviewModeChange("desktop")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{showLanguageSelector && (
|
||||
<LanguageSelector
|
||||
languages={enabledLanguages}
|
||||
languageCode={languageCode}
|
||||
setLanguageCode={setLanguageCode}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
{isFullScreenPreview ? (
|
||||
<ShrinkIcon
|
||||
className="mr-1 h-[22px] w-[22px] cursor-pointer rounded-md bg-white p-1 text-slate-500 hover:text-slate-700"
|
||||
onClick={() => {
|
||||
setIsFullScreenPreview(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ExpandIcon
|
||||
className="mr-1 h-[22px] w-[22px] cursor-pointer rounded-md bg-white p-1 text-slate-500 hover:text-slate-700"
|
||||
onClick={() => {
|
||||
setIsFullScreenPreview(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ResetProgressButton onClick={resetProgress} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previewType === "modal" ? (
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
placement={placement}
|
||||
clickOutsideClose={clickOutsideClose}
|
||||
overlay={overlay}
|
||||
previewMode="desktop"
|
||||
borderRadius={styling.roundness ?? 8}
|
||||
background={styling.cardBackgroundColor?.light}>
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsWorkspaceStateSurvey(survey)}
|
||||
isBrandingEnabled={workspace.inAppSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
languageCode={languageCode}
|
||||
styling={styling}
|
||||
isCardBorderVisible={!styling.highlightBorderColor?.light}
|
||||
onClose={handlePreviewModalClose}
|
||||
getSetBlockId={(f: (value: string) => void) => {
|
||||
setBlockId = f;
|
||||
}}
|
||||
onFinished={onFinished}
|
||||
isSpamProtectionEnabled={isSpamProtectionEnabled}
|
||||
placement={placement}
|
||||
/>
|
||||
</Modal>
|
||||
) : (
|
||||
<MediaBackground
|
||||
surveyType={survey.type}
|
||||
styling={styling}
|
||||
ContentRef={ContentRef as React.RefObject<HTMLDivElement>}
|
||||
isEditorView>
|
||||
<div className="absolute left-5 top-5">
|
||||
{!styling.isLogoHidden && (
|
||||
<ClientLogo workspaceLogo={workspace.logo} surveyLogo={styling.logo} previewSurvey />
|
||||
)}
|
||||
</div>
|
||||
<div className="z-0 w-full max-w-4xl rounded-lg border-transparent">
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsWorkspaceStateSurvey({ ...survey, type: "link" })}
|
||||
isBrandingEnabled={workspace.linkSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
languageCode={languageCode}
|
||||
responseCount={42}
|
||||
styling={styling}
|
||||
getSetBlockId={(f: (value: string) => void) => {
|
||||
setBlockId = f;
|
||||
}}
|
||||
isSpamProtectionEnabled={isSpamProtectionEnabled}
|
||||
/>
|
||||
</div>
|
||||
</MediaBackground>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* for toggling between mobile and desktop mode */}
|
||||
<div className="mt-2 flex rounded-full border-2 border-slate-300 p-1">
|
||||
<TabOption
|
||||
active={previewMode === "mobile"}
|
||||
icon={<SmartphoneIcon className="mx-4 my-2 h-4 w-4 text-slate-700" />}
|
||||
onClick={() => handlePreviewModeChange("mobile")}
|
||||
/>
|
||||
<TabOption
|
||||
active={previewMode === "desktop"}
|
||||
icon={<MonitorIcon className="mx-4 my-2 h-4 w-4 text-slate-700" />}
|
||||
onClick={() => handlePreviewModeChange("desktop")}
|
||||
/>
|
||||
</div>
|
||||
</MotionConfig>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { MotionConfig, Variants, motion } from "framer-motion";
|
||||
import { Variants, motion } from "framer-motion";
|
||||
import { Fragment, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyType } from "@formbricks/types/surveys/types";
|
||||
@@ -131,113 +131,111 @@ export const ThemeStylingPreviewSurvey = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<div className="flex h-full w-full flex-col items-center justify-items-center overflow-hidden">
|
||||
<motion.div
|
||||
variants={previewParentContainerVariant}
|
||||
className="fixed hidden h-[95%] w-5/6"
|
||||
animate={isFullScreenPreview ? "expanded" : "shrink"}
|
||||
/>
|
||||
<motion.div
|
||||
layout
|
||||
variants={previewScreenVariants}
|
||||
animate={
|
||||
isFullScreenPreview
|
||||
? previewPosition === "relative"
|
||||
? "expanded"
|
||||
: "expanded_with_fixed_positioning"
|
||||
: "shrink"
|
||||
}
|
||||
className={cn(
|
||||
"relative z-10 flex w-5/6 flex-col rounded-lg border border-slate-300 shadow-xl",
|
||||
isAppSurvey ? "bg-slate-200" : "overflow-y-auto bg-white"
|
||||
)}>
|
||||
<div className="flex h-auto w-full items-center rounded-t-lg bg-slate-100 py-2">
|
||||
<div className="ml-6 flex space-x-2">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
|
||||
</div>
|
||||
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
|
||||
<p>{isAppSurvey ? t("workspace.surveys.edit.your_web_app") : t("common.preview")}</p>
|
||||
<div className="flex h-full w-full flex-col items-center justify-items-center overflow-hidden">
|
||||
<motion.div
|
||||
variants={previewParentContainerVariant}
|
||||
className="fixed hidden h-[95%] w-5/6"
|
||||
animate={isFullScreenPreview ? "expanded" : "shrink"}
|
||||
/>
|
||||
<motion.div
|
||||
layout
|
||||
variants={previewScreenVariants}
|
||||
animate={
|
||||
isFullScreenPreview
|
||||
? previewPosition === "relative"
|
||||
? "expanded"
|
||||
: "expanded_with_fixed_positioning"
|
||||
: "shrink"
|
||||
}
|
||||
className={cn(
|
||||
"relative z-10 flex w-5/6 flex-col rounded-lg border border-slate-300 shadow-xl",
|
||||
isAppSurvey ? "bg-slate-200" : "overflow-y-auto bg-white"
|
||||
)}>
|
||||
<div className="flex h-auto w-full items-center rounded-t-lg bg-slate-100 py-2">
|
||||
<div className="ml-6 flex space-x-2">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
|
||||
</div>
|
||||
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
|
||||
<p>{isAppSurvey ? t("workspace.surveys.edit.your_web_app") : t("common.preview")}</p>
|
||||
|
||||
<div className="flex items-center">
|
||||
<ResetProgressButton onClick={resetQuestionProgress} />
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<ResetProgressButton onClick={resetQuestionProgress} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col rounded-b-lg">
|
||||
{isAppSurvey ? (
|
||||
<Modal
|
||||
isOpen
|
||||
placement={placement}
|
||||
clickOutsideClose={clickOutsideClose}
|
||||
overlay={overlay}
|
||||
previewMode="desktop"
|
||||
background={workspace.styling.cardBackgroundColor?.light}
|
||||
borderRadius={workspace.styling.roundness ?? 8}>
|
||||
<Fragment key={surveyKey}>
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsWorkspaceStateSurvey({ ...survey, type: "app" })}
|
||||
isBrandingEnabled={workspace.inAppSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
onFileUpload={async (file) => file.name}
|
||||
styling={styling}
|
||||
isCardBorderVisible={!highlightBorderColor}
|
||||
languageCode="default"
|
||||
/>
|
||||
</Fragment>
|
||||
</Modal>
|
||||
) : (
|
||||
<MediaBackground
|
||||
surveyType={survey.type}
|
||||
styling={styling}
|
||||
ContentRef={ContentRef as React.MutableRefObject<HTMLDivElement> | null}
|
||||
isEditorView>
|
||||
{!workspace.styling?.isLogoHidden && (
|
||||
<button className="absolute left-5 top-5" onClick={scrollToEditLogoSection}>
|
||||
<ClientLogo workspaceLogo={workspace.logo} previewSurvey />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
key={surveyKey}
|
||||
className={`${!workspace.styling.isLogoHidden && !isFullScreenPreview ? "mt-12" : ""} z-0 w-full max-w-md overflow-hidden rounded-lg p-4`}>
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsWorkspaceStateSurvey({ ...survey, type: "link" })}
|
||||
isBrandingEnabled={workspace.linkSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
onFileUpload={async (file) => file.name}
|
||||
responseCount={42}
|
||||
styling={styling}
|
||||
languageCode="default"
|
||||
/>
|
||||
</div>
|
||||
</MediaBackground>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* for toggling between mobile and desktop mode */}
|
||||
<div className="mt-2 flex rounded-full border-2 border-slate-300 p-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`${previewType === "link" ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
|
||||
onClick={() => setPreviewType("link")}>
|
||||
{t("common.link_survey")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${isAppSurvey ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
|
||||
onClick={() => setPreviewType("app")}>
|
||||
{t("common.app_survey")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col rounded-b-lg">
|
||||
{isAppSurvey ? (
|
||||
<Modal
|
||||
isOpen
|
||||
placement={placement}
|
||||
clickOutsideClose={clickOutsideClose}
|
||||
overlay={overlay}
|
||||
previewMode="desktop"
|
||||
background={workspace.styling.cardBackgroundColor?.light}
|
||||
borderRadius={workspace.styling.roundness ?? 8}>
|
||||
<Fragment key={surveyKey}>
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsWorkspaceStateSurvey({ ...survey, type: "app" })}
|
||||
isBrandingEnabled={workspace.inAppSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
onFileUpload={async (file) => file.name}
|
||||
styling={styling}
|
||||
isCardBorderVisible={!highlightBorderColor}
|
||||
languageCode="default"
|
||||
/>
|
||||
</Fragment>
|
||||
</Modal>
|
||||
) : (
|
||||
<MediaBackground
|
||||
surveyType={survey.type}
|
||||
styling={styling}
|
||||
ContentRef={ContentRef as React.MutableRefObject<HTMLDivElement> | null}
|
||||
isEditorView>
|
||||
{!workspace.styling?.isLogoHidden && (
|
||||
<button className="absolute left-5 top-5" onClick={scrollToEditLogoSection}>
|
||||
<ClientLogo workspaceLogo={workspace.logo} previewSurvey />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
key={surveyKey}
|
||||
className={`${!workspace.styling.isLogoHidden && !isFullScreenPreview ? "mt-12" : ""} z-0 w-full max-w-md overflow-hidden rounded-lg p-4`}>
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsWorkspaceStateSurvey({ ...survey, type: "link" })}
|
||||
isBrandingEnabled={workspace.linkSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
onFileUpload={async (file) => file.name}
|
||||
responseCount={42}
|
||||
styling={styling}
|
||||
languageCode="default"
|
||||
/>
|
||||
</div>
|
||||
</MediaBackground>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* for toggling between mobile and desktop mode */}
|
||||
<div className="mt-2 flex rounded-full border-2 border-slate-300 p-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`${previewType === "link" ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
|
||||
onClick={() => setPreviewType("link")}>
|
||||
{t("common.link_survey")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${isAppSurvey ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
|
||||
onClick={() => setPreviewType("app")}>
|
||||
{t("common.app_survey")}
|
||||
</button>
|
||||
</div>
|
||||
</MotionConfig>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ const TooltipContent: React.ComponentType<TooltipPrimitive.TooltipContentProps>
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
|
||||
"animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=top]:slide-in-from-bottom-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build",
|
||||
"build:dev": "pnpm run build",
|
||||
"start": "next start",
|
||||
"typecheck": "pnpm typegen && tsc --noEmit --project tsconfig.typecheck.json",
|
||||
"typegen": "cross-env DATABASE_URL=postgresql://postgres:postgres@localhost:5432/formbricks ENCRYPTION_KEY=example REDIS_URL=redis://localhost:6379 next typegen",
|
||||
"lint": "eslint . --fix --ext .ts,.js,.tsx,.jsx",
|
||||
"test": "dotenv -e ../../.env -- vitest run",
|
||||
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage",
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import "@prisma/client";
|
||||
|
||||
declare module "@prisma/client" {
|
||||
namespace Prisma {
|
||||
// Prisma exposes this error class at runtime, but the generated client types do not declare it on Prisma.
|
||||
const PrismaClientKnownRequestError: typeof import("@prisma/client/runtime/library").PrismaClientKnownRequestError;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"exclude": [
|
||||
"../../.env",
|
||||
".next",
|
||||
"node_modules",
|
||||
"playwright",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/tests/**",
|
||||
"**/__mocks__/**",
|
||||
"**/__tests__/**"
|
||||
],
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.d.ts",
|
||||
"app/**/*.ts",
|
||||
"app/**/*.tsx",
|
||||
"lib/**/*.ts",
|
||||
"lib/**/*.tsx",
|
||||
"modules/**/*.ts",
|
||||
"modules/**/*.tsx",
|
||||
"scripts/**/*.ts",
|
||||
"../../packages/types/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -65,7 +65,6 @@ Cube is part of the baseline Formbricks v5 stack and is deployed by this chart b
|
||||
The chart deploys Hub API and, by default, a `hub-worker` deployment. Hub API is insert-only for River jobs; webhook dispatch and embedding jobs are processed by `hub-worker`.
|
||||
|
||||
When the Formbricks migration job is enabled, Hub waits for the `formbricks-migration` Job to complete before its own goose/river init migrations run. This keeps fresh shared-database installs from creating Hub tables before Prisma has initialized the Formbricks schema.
|
||||
If the Job has already been cleaned up, Hub only continues after all expected Prisma and data migration success markers are present in the database.
|
||||
|
||||
Self-hosted embeddings are disabled by default. Set `hub.embeddings.enabled=true` to deploy an internal Hugging Face Text Embeddings Inference (TEI) service and wire Hub API plus Hub worker to it through the OpenAI-compatible endpoint added in Hub:
|
||||
|
||||
@@ -130,7 +129,7 @@ Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If yo
|
||||
| deployment.containerSecurityContext.runAsNonRoot | bool | `true` | |
|
||||
| deployment.env | object | `{}` | |
|
||||
| deployment.envFrom | string | `nil` | |
|
||||
| deployment.image.digest | string | `""` | When set, takes precedence over tag. |
|
||||
| deployment.image.digest | string | `""` | |
|
||||
| deployment.image.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| deployment.image.repository | string | `"ghcr.io/formbricks/formbricks"` | |
|
||||
| deployment.image.tag | string | `""` | |
|
||||
@@ -225,7 +224,7 @@ Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If yo
|
||||
| hub.migration.waitForFormbricksMigration.enabled | bool | `true` | |
|
||||
| hub.migration.waitForFormbricksMigration.intervalSeconds | int | `5` | |
|
||||
| hub.migration.waitForFormbricksMigration.maxAttempts | int | `180` | |
|
||||
| hub.migration.waitForFormbricksMigration.missingJobMaxAttempts | int | `12` | Consecutive missing Job reads before using DB markers. |
|
||||
| hub.migration.waitForFormbricksMigration.missingJobMaxAttempts | int | `12` | |
|
||||
| hub.pdb.enabled | bool | `false` | |
|
||||
| hub.replicas | int | `1` | |
|
||||
| hub.resources.limits.memory | string | `"512Mi"` | |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
|
||||
{{ .Release.Name | camelcase }} with {{ include "formbricks.deploymentImage" . }} has been deployed successfully on {{ template "formbricks.namespace" .}} namespace !
|
||||
{{ .Release.Name | camelcase }} with {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag }} has been deployed successfully on {{ template "formbricks.namespace" .}} namespace !
|
||||
|
||||
Here's how you can access and manage your deployment:
|
||||
---
|
||||
|
||||
@@ -125,6 +125,10 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
|
||||
{{- printf "%s-app-secrets" (include "formbricks.name" .) -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.migrationJobName" -}}
|
||||
{{- printf "%s-migration" (include "formbricks.name" .) | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.redisName" -}}
|
||||
{{- .Values.redis.fullnameOverride | default (printf "%s-redis" (include "formbricks.name" .)) | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end }}
|
||||
@@ -153,21 +157,6 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
|
||||
{{- .Values.redis.auth.existingSecretPasswordKey | default "REDIS_PASSWORD" -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.migrationJobName" -}}
|
||||
{{- printf "%s-migration" (include "formbricks.name" .) | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Formbricks application image reference. A configured digest takes precedence over the tag.
|
||||
*/}}
|
||||
{{- define "formbricks.deploymentImage" -}}
|
||||
{{- if .Values.deployment.image.digest -}}
|
||||
{{- printf "%s@%s" .Values.deployment.image.repository .Values.deployment.image.digest -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s:%s" .Values.deployment.image.repository (.Values.deployment.image.tag | default .Chart.AppVersion | default "latest") -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.hubSecretName" -}}
|
||||
{{- default (include "formbricks.appSecretName" .) .Values.hub.existingSecret -}}
|
||||
{{- end }}
|
||||
|
||||
@@ -79,7 +79,7 @@ spec:
|
||||
terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds | default 30 }}
|
||||
containers:
|
||||
- name: {{ template "formbricks.name" . }}
|
||||
image: {{ include "formbricks.deploymentImage" . }}
|
||||
image: {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag | default .Chart.AppVersion | default "latest" }}
|
||||
imagePullPolicy: {{ .Values.deployment.image.pullPolicy }}
|
||||
{{- if .Values.deployment.command }}
|
||||
command:
|
||||
|
||||
@@ -39,35 +39,22 @@ spec:
|
||||
initContainers:
|
||||
{{- if and .Values.migration.enabled .Values.hub.migration.waitForFormbricksMigration.enabled }}
|
||||
- name: wait-for-formbricks-migration
|
||||
image: {{ include "formbricks.deploymentImage" . }}
|
||||
image: {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag | default .Chart.AppVersion | default "latest" }}
|
||||
imagePullPolicy: {{ .Values.deployment.image.pullPolicy }}
|
||||
command:
|
||||
- node
|
||||
- -e
|
||||
- |
|
||||
const fs = require("fs");
|
||||
const fsp = fs.promises;
|
||||
const https = require("https");
|
||||
const path = require("path");
|
||||
const { pathToFileURL } = require("url");
|
||||
const { Prisma, PrismaClient } = require("@prisma/client");
|
||||
|
||||
const parsePositiveInteger = (value, fallback) => {
|
||||
const parsed = Number.parseInt(value || "", 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
};
|
||||
const maxAttempts = parsePositiveInteger(process.env.FORMBRICKS_MIGRATION_WAIT_MAX_ATTEMPTS, 180);
|
||||
const missingJobMaxAttempts = parsePositiveInteger(
|
||||
process.env.FORMBRICKS_MIGRATION_WAIT_MISSING_JOB_MAX_ATTEMPTS,
|
||||
12
|
||||
);
|
||||
const intervalSeconds = parsePositiveInteger(
|
||||
process.env.FORMBRICKS_MIGRATION_WAIT_INTERVAL_SECONDS,
|
||||
5
|
||||
const maxAttempts = Number.parseInt(process.env.FORMBRICKS_MIGRATION_WAIT_MAX_ATTEMPTS || "180", 10);
|
||||
const missingJobMaxAttempts = Number.parseInt(
|
||||
process.env.FORMBRICKS_MIGRATION_WAIT_MISSING_JOB_MAX_ATTEMPTS || "12",
|
||||
10
|
||||
);
|
||||
const intervalSeconds = Number.parseInt(process.env.FORMBRICKS_MIGRATION_WAIT_INTERVAL_SECONDS || "5", 10);
|
||||
const jobName = process.env.FORMBRICKS_MIGRATION_JOB_NAME;
|
||||
const migrationsDir = path.resolve("packages/database/dist/migration");
|
||||
const prisma = new PrismaClient();
|
||||
const namespace = fs
|
||||
.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "utf8")
|
||||
.trim();
|
||||
@@ -115,125 +102,10 @@ spec:
|
||||
request.end();
|
||||
});
|
||||
|
||||
const loadExpectedMigrationMarkers = async () => {
|
||||
const entries = await fsp.readdir(migrationsDir, { withFileTypes: true });
|
||||
const schemaMigrationNames = [];
|
||||
const dataMigrationIds = [];
|
||||
|
||||
const migrationEntries = entries
|
||||
.filter((dirent) => dirent.isDirectory())
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const entry of migrationEntries) {
|
||||
const migrationPath = path.join(migrationsDir, entry.name);
|
||||
const files = await fsp.readdir(migrationPath);
|
||||
|
||||
if (files.includes("migration.sql")) {
|
||||
schemaMigrationNames.push(entry.name);
|
||||
}
|
||||
|
||||
if (files.includes("migration.js")) {
|
||||
const migrationModule = await import(
|
||||
pathToFileURL(path.join(migrationPath, "migration.js")).href
|
||||
);
|
||||
|
||||
for (const exportedValue of Object.values(migrationModule)) {
|
||||
if (
|
||||
exportedValue &&
|
||||
typeof exportedValue === "object" &&
|
||||
exportedValue.type === "data" &&
|
||||
exportedValue.id
|
||||
) {
|
||||
dataMigrationIds.push(exportedValue.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { schemaMigrationNames, dataMigrationIds };
|
||||
};
|
||||
|
||||
let expectedMigrationMarkersPromise;
|
||||
const getExpectedMigrationMarkers = () => {
|
||||
if (!expectedMigrationMarkersPromise) {
|
||||
expectedMigrationMarkersPromise = loadExpectedMigrationMarkers();
|
||||
}
|
||||
|
||||
return expectedMigrationMarkersPromise;
|
||||
};
|
||||
|
||||
const hasFormbricksMigrationSuccessMarkers = async () => {
|
||||
try {
|
||||
const { schemaMigrationNames, dataMigrationIds } = await getExpectedMigrationMarkers();
|
||||
|
||||
// apply-migrations.js persists success in these DB tables after Prisma/data migrations complete.
|
||||
if (schemaMigrationNames.length === 0) {
|
||||
console.log(
|
||||
`No schema migrations found in ${migrationsDir}; refusing missing-Job success fallback.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const appliedSchemaMigrations = await prisma.$queryRaw`
|
||||
SELECT migration_name
|
||||
FROM _prisma_migrations
|
||||
WHERE finished_at IS NOT NULL
|
||||
AND rolled_back_at IS NULL
|
||||
AND migration_name IN (${Prisma.join(schemaMigrationNames)})
|
||||
`;
|
||||
const appliedSchemaMigrationNames = new Set(
|
||||
appliedSchemaMigrations.map((migration) => migration.migration_name)
|
||||
);
|
||||
const missingSchemaMigrations = schemaMigrationNames.filter(
|
||||
(migrationName) => !appliedSchemaMigrationNames.has(migrationName)
|
||||
);
|
||||
|
||||
if (missingSchemaMigrations.length > 0) {
|
||||
console.log(
|
||||
`Prisma migration markers are incomplete; ${missingSchemaMigrations.length} schema migration(s) are missing.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dataMigrationIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const appliedDataMigrations = await prisma.$queryRaw`
|
||||
SELECT id
|
||||
FROM "DataMigration"
|
||||
WHERE status = 'applied'
|
||||
AND id IN (${Prisma.join(dataMigrationIds)})
|
||||
`;
|
||||
const appliedDataMigrationIds = new Set(
|
||||
appliedDataMigrations.map((migration) => migration.id)
|
||||
);
|
||||
const missingDataMigrations = dataMigrationIds.filter(
|
||||
(migrationId) => !appliedDataMigrationIds.has(migrationId)
|
||||
);
|
||||
|
||||
if (missingDataMigrations.length > 0) {
|
||||
console.log(
|
||||
`Data migration markers are incomplete; ${missingDataMigrations.length} data migration(s) are missing.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.log(`Migration success markers are not ready: ${message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
let missingJobAttempts = 0;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
const job = await fetchJob();
|
||||
missingJobAttempts = 0;
|
||||
const conditions = job.status?.conditions || [];
|
||||
const isComplete = conditions.some(
|
||||
(condition) => condition.type === "Complete" && condition.status === "True"
|
||||
@@ -256,30 +128,12 @@ spec:
|
||||
console.log(`Waiting for ${jobName} to complete (${attempt}/${maxAttempts})...`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (error && error.statusCode === 404) {
|
||||
missingJobAttempts += 1;
|
||||
|
||||
if (missingJobAttempts >= missingJobMaxAttempts) {
|
||||
const hasSuccessMarkers = await hasFormbricksMigrationSuccessMarkers();
|
||||
|
||||
if (hasSuccessMarkers) {
|
||||
console.log(
|
||||
`${jobName} was not found after ${missingJobAttempts} consecutive attempts, ` +
|
||||
"but all Formbricks migration success markers are present; starting Hub migrations."
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Waiting for ${jobName} to be available (${attempt}/${maxAttempts}; missing ${missingJobAttempts}/${missingJobMaxAttempts}): ${message}`
|
||||
);
|
||||
} else {
|
||||
missingJobAttempts = 0;
|
||||
console.log(
|
||||
`Waiting for ${jobName} to be available (${attempt}/${maxAttempts}): ${message}`
|
||||
);
|
||||
if (error && error.statusCode === 404 && attempt >= missingJobMaxAttempts) {
|
||||
console.log(`${jobName} was not found after ${attempt} attempts; assuming it was already cleaned up.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Waiting for ${jobName} to be available (${attempt}/${maxAttempts}): ${message}`);
|
||||
}
|
||||
|
||||
await sleep(intervalSeconds * 1000);
|
||||
@@ -291,45 +145,8 @@ spec:
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect().catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
});
|
||||
{{- if or .Values.deployment.envFrom (or (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) .Values.secret.enabled) }}
|
||||
envFrom:
|
||||
{{- if or .Values.secret.enabled (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) }}
|
||||
- secretRef:
|
||||
name: {{ template "formbricks.name" . }}-app-secrets
|
||||
{{- end }}
|
||||
{{- range $value := .Values.deployment.envFrom }}
|
||||
{{- if (eq .type "configmap") }}
|
||||
- configMapRef:
|
||||
{{- if .name }}
|
||||
name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }}
|
||||
{{- else if .nameSuffix }}
|
||||
name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }}
|
||||
{{- else }}
|
||||
name: {{ template "formbricks.name" $ }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if (eq .type "secret") }}
|
||||
- secretRef:
|
||||
{{- if .name }}
|
||||
name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }}
|
||||
{{- else if .nameSuffix }}
|
||||
name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }}
|
||||
{{- else }}
|
||||
name: {{ template "formbricks.name" $ }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- range $key, $value := .Values.deployment.env }}
|
||||
{{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 12 }}
|
||||
{{- end }}
|
||||
- name: FORMBRICKS_MIGRATION_JOB_NAME
|
||||
value: {{ include "formbricks.migrationJobName" . | quote }}
|
||||
- name: FORMBRICKS_MIGRATION_WAIT_MAX_ATTEMPTS
|
||||
|
||||
@@ -51,5 +51,5 @@ roleRef:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: {{ include "formbricks.hubMigrationWaitServiceAccountName" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
namespace: {{ include "formbricks.namespace" . }}
|
||||
{{- end }}
|
||||
|
||||
@@ -46,7 +46,7 @@ spec:
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: migration
|
||||
image: {{ include "formbricks.deploymentImage" . }}
|
||||
image: {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag | default .Chart.AppVersion | default "latest" }}
|
||||
imagePullPolicy: {{ .Values.deployment.image.pullPolicy }}
|
||||
command:
|
||||
- node
|
||||
|
||||
@@ -939,7 +939,6 @@ hub:
|
||||
waitForFormbricksMigration:
|
||||
enabled: true
|
||||
maxAttempts: 180
|
||||
# Consecutive missing Job reads before falling back to persisted Prisma/DataMigration markers.
|
||||
missingJobMaxAttempts: 12
|
||||
intervalSeconds: 5
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 174 KiB |
@@ -1,56 +0,0 @@
|
||||
---
|
||||
title: "AI Features"
|
||||
description: "How AI features are organized, hosted, and controlled in Formbricks."
|
||||
icon: "sparkles"
|
||||
---
|
||||
|
||||
<Note>
|
||||
AI features are part of the [Enterprise Edition](/self-hosting/advanced/license).
|
||||
</Note>
|
||||
|
||||
Formbricks ships a single organization-wide toggle that turns on AI-powered helpers across the app:
|
||||
**Settings → Organization → General → Smart functionality (AI)**.
|
||||
|
||||
## AI Principles
|
||||
|
||||
1. **Always optional**: AI is disabled until your organization enables. You can run
|
||||
Formbricks fully without AI.
|
||||
2. **We separate AI**: We distinguish between:
|
||||
- **Smart Functionality**: helps teams build and operate faster (for example
|
||||
[AI Survey Translation](/surveys/general-features/multi-language-surveys#translate-with-ai) or [AI Chart Creation](/unify-feedback/dashboards-charts#ai-builder)).
|
||||
- **Data AI**: features that work directly with your feedback data (creating embeddings for Unify Feedback semantic search).
|
||||
|
||||
### Privacy-first and self-hosted where possible
|
||||
|
||||
We prioritize self-hosted AI, especially for capabilities that process customer feedback data. Formbricks supports
|
||||
AI in self-hosted and on-premise environments, and we prefer open-weight models whenever feasible.
|
||||
|
||||
For Unify Feedback semantic search, we host the embeddings model ourselves. This means feedback data is not shared
|
||||
with third-party model providers for that capability, and your collected feedback/response data is never used as AI
|
||||
training input.
|
||||
|
||||
## Model Hosting Status
|
||||
|
||||
- **Current Smart Functionality model**: Gemini 3.5 Flash hosted on Google Cloud Platform in Germany.
|
||||
|
||||
- **Current Embeddings model**: Alibaba GTE embeddings hosted by Formbricks in Germany.
|
||||
|
||||
- **In progress**: evaluation of self-hosted Kimi 2.5 to replace Gemini 3.5 Flash for Smart Functionality.
|
||||
|
||||
## AI Features by Category
|
||||
|
||||
### Smart Functionality
|
||||
|
||||
- **[AI Survey Translation](/surveys/general-features/multi-language-surveys#translate-with-ai)**:
|
||||
auto-translate survey questions, options, and prompts into enabled languages.
|
||||
- **[AI Chart Creation](/unify-feedback/dashboards-charts#ai-builder)**:
|
||||
describe a chart in natural language and Formbricks generates the underlying query.
|
||||
|
||||
### Data AI
|
||||
|
||||
- **Embeddings creation**:
|
||||
create embeddings for feedback records so they can be used for semantic search, clustering, and retrieval.
|
||||
|
||||
## Permissions
|
||||
|
||||
Only **Owners** and **Managers** can change the AI toggle. Other roles see a read-only state.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
title: "AI Features"
|
||||
description: "Enable AI-powered helpers like survey translation and AI chart creation."
|
||||
icon: "sparkles"
|
||||
sidebarTitle: "AI Features"
|
||||
---
|
||||
|
||||
A single organization toggle unlocks AI-assisted survey translation and AI chart creation across the app. Requires `AI_PROVIDER`, `AI_MODEL`, and the matching provider credentials on the instance.
|
||||
|
||||
Read the full guide: [AI Features](/platform/features/ai-features).
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
title: "Dashboards & Charts"
|
||||
description: "Visualize Feedback Records with charts and group them onto shareable dashboards."
|
||||
icon: "chart-line"
|
||||
sidebarTitle: "Dashboards & Charts"
|
||||
---
|
||||
|
||||
Build Area, Bar, Line, Pie, and Big Number charts on top of any Feedback Directory, then arrange them on dashboards to share with your team.
|
||||
|
||||
Read the full guide: [Dashboards & Charts](/unify-feedback/dashboards-charts).
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
title: "Unify Feedback"
|
||||
description: "Consolidate feedback from surveys, CSVs, and APIs into one normalized store."
|
||||
icon: "layer-group"
|
||||
sidebarTitle: "Unify Feedback"
|
||||
---
|
||||
|
||||
Unify Feedback brings survey responses, CSV uploads, and API-ingested records into the same normalized model under organization-scoped Feedback Directories. Workspaces can be granted access to specific directories.
|
||||
|
||||
Read the full guide: [Unify Feedback overview](/unify-feedback/overview).
|
||||
@@ -30,7 +30,7 @@ This confirms the Google identity for the current deletion attempt, but it does
|
||||
|
||||
### How to connect your Formbricks instance to Google
|
||||
|
||||
{/* prettier-ignore-start */}
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
<Steps>
|
||||
<Step title="Create a GCP Project">
|
||||
@@ -95,4 +95,4 @@ This confirms the Google identity for the current deletion attempt, but it does
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
{/* prettier-ignore-end */}
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
@@ -15,7 +15,7 @@ These variables are present inside your machine's docker-compose file. Restart t
|
||||
|
||||
For `AI_PROVIDER=google`, use a Gemini model ID such as `gemini-2.5-flash` together with Google Cloud credentials. Formbricks uses Google Cloud naming here, even though the underlying SDK still talks to Vertex AI endpoints for Gemini model access.
|
||||
|
||||
{/* prettier-ignore-start */}
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
| Variable | Description | Required | Default |
|
||||
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
@@ -144,6 +144,6 @@ For Helm deployments, the chart deploys Cube by default (`cube.enabled: true`).
|
||||
cluster instead, set `cube.enabled: false`, point `CUBEJS_API_URL` at your endpoint, and supply
|
||||
`CUBEJS_API_SECRET` through your existing secret management setup.
|
||||
|
||||
{/* prettier-ignore-end */}
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
Note: If you want to configure something that is not possible via above, please open an issue on our GitHub repo here or reach out to us on Github Discussions and we'll try our best to work out a solution with you.
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
title: "Surveys"
|
||||
description: "Design, distribute, and analyze surveys with Formbricks."
|
||||
icon: "tablet-screen"
|
||||
---
|
||||
|
||||
Formbricks surveys let you collect feedback from customers, users, and employees through link surveys, website surveys, and in-app surveys.
|
||||
|
||||
## Build
|
||||
|
||||
A drag-and-drop builder with conditional logic, recall, variables, hidden fields, multi-language support, and over a dozen question types. Surveys can be fully styled to match your brand.
|
||||
|
||||
## Distribute
|
||||
|
||||
Share surveys via a public link, embed them in your website, or trigger them in your web or mobile app. Use targeting, recontact rules, and quotas to control who sees a survey and when.
|
||||
|
||||
## Analyze
|
||||
|
||||
Each survey has a built-in summary view and response table. For cross-survey analytics, pipe responses into [Dashboards & Charts](/unify-feedback/dashboards-charts) to build custom visualizations and KPIs. Export to CSV/XLSX, or stream responses out via webhooks and the REST API.
|
||||
|
||||
## Next steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Link Surveys" icon="link" href="/surveys/link-surveys/quickstart">
|
||||
Share standalone survey links by email, chat, or QR code.
|
||||
</Card>
|
||||
<Card title="Website & App Surveys" icon="mobile" href="/surveys/website-app-surveys/quickstart">
|
||||
Trigger surveys inside your website or app.
|
||||
</Card>
|
||||
<Card title="Question Types" icon="question" href="/surveys/question-type/free-text">
|
||||
Browse every available question type.
|
||||
</Card>
|
||||
<Card title="Best Practices" icon="lightbulb" href="/surveys/best-practices/understanding-survey-types">
|
||||
Templates and proven survey patterns.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
title: "Dashboards & Charts"
|
||||
description: "Visualize Feedback Records and group charts onto shareable dashboards."
|
||||
icon: "chart-line"
|
||||
---
|
||||
|
||||
Dashboards & Charts let you turn Feedback Records into visual analytics. A **Chart** is a single visualization scoped to one Feedback Directory. A **Dashboard** is a grid of charts you can share with your team.
|
||||
|
||||
## Charts
|
||||
|
||||
Charts live under **Workspace → Charts**. Each chart is a query plus a visualization config.
|
||||
|
||||
### Available chart types
|
||||
|
||||
| Type | When to use |
|
||||
| --- | --- |
|
||||
| **Area Chart** | Trend over time with magnitude emphasis (cumulative volume, NPS trend with shaded area). Default. |
|
||||
| **Bar Chart** | Compare a measure across categories (responses per source, NPS by segment). |
|
||||
| **Line Chart** | Trend over time without area fill (CSAT week-over-week, response rate). |
|
||||
| **Pie Chart** | Share-of-total across a small set of categories (channel mix, sentiment split). |
|
||||
| **Big Number** | Headline KPI for a dashboard (total responses, avg NPS, % positive). |
|
||||
|
||||
You can build a chart in two ways:
|
||||
|
||||
### Manual builder
|
||||
|
||||
Pick a Feedback Directory, choose dimensions and measures (count of records, average NPS, ...), apply filters, and select a chart type. Live preview updates as you tweak.
|
||||
|
||||
### AI builder
|
||||
|
||||
Describe what you want in natural language ("Average NPS by month over the last 90 days") and Formbricks generates the Cube query for you. You can edit the result in the manual builder afterwards.
|
||||
|
||||
The AI builder requires **Smart functionality (AI)** to be enabled at the organization level and a configured AI provider. See [AI Features](/platform/features/ai-features).
|
||||
|
||||
## Dashboards
|
||||
|
||||
Dashboards live under **Workspace → Dashboards**. Each dashboard is a grid you can resize and arrange.
|
||||
|
||||
From a dashboard you can:
|
||||
|
||||
- **Create** a new chart inline and have it added automatically.
|
||||
- **Add existing** charts.
|
||||
- **Reorder and resize** widgets.
|
||||
- **Duplicate** a chart for a quick variation.
|
||||
- **Delete** a widget (the underlying chart stays in the workspace).
|
||||
|
||||
## Permissions
|
||||
|
||||
- Owners, Managers, and members with **Manage** or **Read & Write** access can create and edit dashboards and charts.
|
||||
- Members with **Read** access can view dashboards and charts but cannot edit them.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A Feedback Directory with records.
|
||||
- Workspace access to that directory.
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
title: "Feedback Directories"
|
||||
description: "Org-level containers that group related Feedback Records and their sources."
|
||||
icon: "folder-tree"
|
||||
---
|
||||
|
||||
A **Feedback Directory** is the top-level container for feedback inside an organization. Every Feedback Record belongs to exactly one directory, and every source writes into a single directory.
|
||||
|
||||
## When to create a new directory
|
||||
|
||||
Create one directory per logically separate dataset. Common patterns:
|
||||
|
||||
- **By product line** (e.g. "Mobile devices", "Web apps")
|
||||
- **By stakeholder group** (e.g. "Customers", "Employees")
|
||||
- **By region** (e.g. "Europe", "North America")
|
||||
|
||||
## Workspace access
|
||||
|
||||
Directories live at the **organization** level but are exposed to **workspaces** through an access list. Each workspace can **only access one directory.**
|
||||
|
||||
Manage directory access from **Organization Settings → Feedback Directories**:
|
||||
|
||||
- Create new directories
|
||||
- Rename or archive directories
|
||||
- Add or remove workspace access
|
||||
|
||||
Only **Owners** and **Managers** can manage directories. Workspace members see the directories their workspace has access to inside the Unify section.
|
||||
|
||||
## Archiving
|
||||
|
||||
Archiving a directory hides it from default views but does not delete its records. Use it for one-off programs that have ended.
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
title: "Feedback Records"
|
||||
description: "The normalized unit of feedback inside a Feedback Directory."
|
||||
icon: "list-check"
|
||||
---
|
||||
|
||||
A **Feedback Record** is one piece of feedback expressed in a normalized schema. Whether it came from a survey response, a CSV row, or an API push, it lands in the same shape so you can query everything together.
|
||||
|
||||
## The Feedback Record schema
|
||||
|
||||
Every record has the following fields. Required fields must be mapped by every source.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `submission_id` | string | Yes | Stable ID for the submission (e.g. `response_id`, `ticket_id`, `order_id`). Used for idempotent re-imports. |
|
||||
| `collected_at` | timestamp | Yes | When the feedback was originally collected. |
|
||||
| `source_type` | string | Yes | The kind of source (e.g. `survey`, `csv`, `review`). |
|
||||
| `field_id` | string | Yes | Stable identifier for the question/field. |
|
||||
| `field_type` | enum | Yes | One of `text`, `categorical`, `nps`, `csat`, `ces`, `rating`, `number`, `boolean`, `date`. |
|
||||
| `tenant_id` | string | No | Feedback Directory ID. Set automatically when ingesting. |
|
||||
| `source_id` | string | No | Reference to the survey/form/ticket/review ID. |
|
||||
| `source_name` | string | No | Human-readable source name for display. |
|
||||
| `field_label` | string | No | The question text or field label. |
|
||||
| `field_group_id` | string | No | Groups related fields (matrix, ranking, grid questions). |
|
||||
| `field_group_label` | string | No | Human-readable group label. |
|
||||
| `value_text` | string | No | Text responses. |
|
||||
| `value_number` | float64 | No | Numeric responses (ratings, NPS, CSAT). |
|
||||
| `value_boolean` | boolean | No | Yes/no responses. |
|
||||
| `value_date` | timestamp | No | Date responses. |
|
||||
| `metadata` | jsonb | No | Free-form context (device, campaign, custom fields). |
|
||||
| `language` | string | No | ISO 639-1 language code (`en`, `de`, `fr`, ...). |
|
||||
| `user_id` | string | No | Anonymous user ID. Never store PII here. |
|
||||
|
||||
The right `value_*` field is set based on `field_type`. For example a `nps` field uses `value_number`, an open-text comment uses `value_text`.
|
||||
|
||||
## Viewing and managing records
|
||||
|
||||
Inside a workspace, navigate to **Unify → Feedback Records**. You'll see the latest records across every directory the workspace has access to, sorted by `collected_at`.
|
||||
|
||||
From the table you can:
|
||||
|
||||
- **Filter** by directory, source, field type, or date range.
|
||||
- **Open** a record drawer to see the full field set and metadata.
|
||||
- **Edit** values inline for cleanup (e.g. relabel a categorical answer).
|
||||
- **Delete** a record.
|
||||
- **Add** a record manually via the "+ Add" button.
|
||||
|
||||
## Idempotent imports
|
||||
|
||||
Sources that re-ingest data (CSV uploads, API ingestions) use `submission_id` as the dedup key. Re-importing the same `submission_id` updates the existing record instead of creating a duplicate.
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
title: "Feedback Sources"
|
||||
description: "Sources that bring feedback data into a Feedback Directory."
|
||||
icon: "plug"
|
||||
---
|
||||
|
||||
A **Source** defines how external data is mapped into Feedback Records inside a Feedback Directory. Manage them from **Unify → Sources**.
|
||||
|
||||
## Source types
|
||||
|
||||
Formbricks supports three source types:
|
||||
|
||||
### 1. Formbricks Surveys
|
||||
|
||||
Pipe responses from a Formbricks survey directly into a Feedback Directory. Pick a survey, select the questions you want to ingest - that's it. Formbricks automatically maps each question to its `field_type`. Optionally create Feedback Records of existing responses on connect.
|
||||
|
||||
### 2. CSV Import
|
||||
|
||||
Upload a CSV (up to **2 MB** and **1,000 rows**) and Formbricks auto-suggests a column mapping based on common header names (`timestamp`, `response_id`, `rating`, `feedback_text`, ...). Required columns: `submission_id`, `field_id`, `field_type`, and the feedback value.
|
||||
|
||||
<Note>
|
||||
Re-uploading a CSV with the same `submission_id` updates existing records instead of creating duplicates.
|
||||
</Note>
|
||||
|
||||
A sample CSV is available from the source creation dialog.
|
||||
|
||||
### 3. API Ingestion
|
||||
|
||||
Push records into a directory programmatically from your own systems. Best for server-to-server ingestion. API reference docs are coming soon.
|
||||
|
||||
## Field mapping
|
||||
|
||||
For CSV and API sources, you map each source column or question to a Feedback Record field. CSV mapping suggests matches automatically with high/medium/low confidence based on header names.
|
||||
|
||||
For Formbricks Surveys, we handle the mapping internally - each question type is translated into the matching Hub `field_type`:
|
||||
|
||||
- Single-select, multi-select, dropdown → `categorical`
|
||||
- NPS → `nps`
|
||||
- Rating → `rating`
|
||||
- CSAT → `csat`
|
||||
- CES → `ces`
|
||||
- Free text → `text`
|
||||
- Number → `number`
|
||||
- Date → `date`
|
||||
- Boolean / consent → `boolean`
|
||||
|
||||
## Managing sources
|
||||
|
||||
From the Sources page you can:
|
||||
|
||||
- **Create** a new source for any source type.
|
||||
- **Edit** the mapping for an existing source.
|
||||
- **Pause** or **resume** ingestion.
|
||||
- **Delete** a source. Existing records stay in the directory.
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
title: "Formbricks Hub"
|
||||
description: "The data layer that powers Unify Feedback."
|
||||
icon: "database"
|
||||
---
|
||||
|
||||
**Formbricks Hub** is Formbricks' unified feedback data layer. It stores normalized feedback from multiple input channels so teams can query, analyze, and act on it in one place.
|
||||
|
||||
Unify Feedback is powered by Hub under the hood: Feedback Sources ingest data into Hub, Feedback Records are stored in Hub, and dashboards, charts, and topics build on that shared model.
|
||||
|
||||
Hub is built for **the age of AI**: each open-text feedback record is vectorized so semantic search, clustering, and retrieval work out of the box. Its event-based architecture also lets you enrich records with any model, provider, and custom metadata of your choice.
|
||||
|
||||
The Hub is fully open-source (Apache 2.0) and can be self-hosted.
|
||||
|
||||
To learn more, visit the Hub docs at [hub.formbricks.com](https://hub.formbricks.com) and the [Hub API Reference](https://hub.formbricks.com/api).
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
title: "Unify Feedback"
|
||||
description: "Bring feedback from every source into one place and turn it into insights."
|
||||
icon: "layer-group"
|
||||
---
|
||||
|
||||
Unify Feedback is the part of Formbricks that consolidates feedback from across your stack into a single, queryable store. Survey responses, CSV imports, API ingestions, and tool-generated records all land in the same model so you can analyze them together.
|
||||
|
||||
## Why Unify Feedback
|
||||
|
||||
Most companies collect feedback in many places: surveys, support tickets, app store reviews, NPS tools, sales calls. Each lives in its own silo with its own schema. Unify Feedback normalizes all of these into **Feedback Records** grouped under **Feedback Directories**, so they can be filtered, visualized, and acted on as one dataset.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Create a Feedback Directory.** A directory is a tenant-scoped bucket for related feedback (for example, "Product Feedback" or "Support 2026").
|
||||
2. **Connect Sources.** Pull data from Formbricks surveys, upload CSVs, or push records via the API.
|
||||
3. **Explore Records.** Browse, filter, edit, and tag individual Feedback Records.
|
||||
4. **Discover Topics.** Use vector based Topics & Subtopics (Preview) to cluster open-text feedback.
|
||||
5. **Visualize.** Build Charts and group them on Dashboards to share insights with your team.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Feedback Directories" icon="folder-tree" href="/unify-feedback/feedback-directories">
|
||||
Org-level buckets that group your feedback.
|
||||
</Card>
|
||||
<Card title="Feedback Records" icon="list-check" href="/unify-feedback/feedback-records">
|
||||
The normalized unit of feedback inside a directory.
|
||||
</Card>
|
||||
<Card title="Feedback Sources" icon="plug" href="/unify-feedback/feedback-sources">
|
||||
Connectors that bring data into a directory.
|
||||
</Card>
|
||||
<Card title="Dashboards & Charts" icon="chart-line" href="/unify-feedback/dashboards-charts">
|
||||
Visualize and share feedback insights.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Note>
|
||||
Unify Feedback is an enterprise feature. Enable it on Formbricks Cloud with a paid plan, or self-host with a license.
|
||||
</Note>
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
title: "Topics & Subtopics (Preview)"
|
||||
description: "Vector clustering of open-text feedback into Topics and Subtopics."
|
||||
icon: "tags"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
Topics & Subtopics is a **Preview** feature. The schema and UI may change.
|
||||
</Warning>
|
||||
|
||||
Open-text feedback ("Why did you give this score?", support tickets, app reviews) is rich but hard to count. Topics & Subtopics uses AI to cluster free text into a two-level taxonomy you can filter, count, and trend over time.
|
||||
|
||||
## How it works
|
||||
|
||||
1. Pick a Feedback Directory under **Unify → Topics & Subtopics**.
|
||||
2. Formbricks scans `value_text` across the directory and proposes a set of **Topics** (broad categories) and **Subtopics** (specific themes within a topic).
|
||||
3. Each record can be assigned to one Topic and one Subtopic.
|
||||
|
||||
## What you can do today
|
||||
|
||||
- **Browse** the proposed Topic / Subtopic tree for a directory.
|
||||
- **Inspect** which records cluster under each Topic.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Manual edits to topic labels and assignments.
|
||||
- Topic filters on Charts and Dashboards.
|
||||
- Per-source topic confidence scoring.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A Feedback Directory with records.
|
||||
- Smart functionality (AI) enabled at the organization level. See [AI Features](/platform/features/ai-features).
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: "XM & Surveys"
|
||||
description: "Learn how Formbricks helps you gather, analyse, and report experience data."
|
||||
icon: "tablet-screen"
|
||||
---
|
||||
|
||||
Experience Management is the practice of measuring and managing how a stakeholder group of an organization (customers, employees, patients, citizens, etc...) experience the products or services of the organization.
|
||||
|
||||
Historically, Experience Management has three steps:
|
||||
|
||||
1. **Gather** data
|
||||
|
||||
2. **Analyze** and report on the data
|
||||
|
||||
3. **Integrate** and automate to measure experiences at scale
|
||||
|
||||
## Gather data
|
||||
|
||||
The heart of Formbricks data gathering is a powerful yet user-friendly survey builder. With a simple drag-and-drop interface, you can add questions, set response options, handle variables, set up complex logic and manage quotas. Our surveys have a modern look & feel, can be fully customized to match your brand - all while keeping respondent data safe.
|
||||
|
||||
## Analytics & Report
|
||||
|
||||
Formbricks gives you clear analytics and insights to understand user responses. It organizes survey results into easy-to-read formats, helping you spot trends, identify issues, and find opportunities for improvement. You can export your data to .csv or .xlsx or pipe it to your data lake via API.
|
||||
|
||||
We're working on a fully compliant way to leverage AI to harvest insights from unstructured data as well as a comprehensive reporting feature.
|
||||
|
||||
## Integrate & Automate
|
||||
|
||||
Experience Management scales best, when it is automated. Webhooks and the comprehensive REST API make it fast and easy to build integrations into your existing tech stack. Formbricks also powers integrations for n8n, ActivePieces, Zapier and Make.com to build any flow that you need.
|
||||
@@ -132,30 +132,6 @@ For link surveys, the translation delivery is dependent on the `lang` URL parame
|
||||
|
||||
---
|
||||
|
||||
## Translate with AI
|
||||
|
||||
Translating every question, option, and label by hand can take a while. If your organization has AI enabled, you can fill in missing translations in one click.
|
||||
|
||||
<Steps>
|
||||
<Step title="Open the Manage Translations modal">
|
||||
Inside the survey editor, switch to the language you want to translate into and open the **Manage Translations** modal.
|
||||
</Step>
|
||||
|
||||
<Step title="Click 'Translate with AI'">
|
||||
The button is enabled when there are empty fields in the selected target language. Formbricks translates all empty headlines, descriptions, choices, and button labels from the default language into the target language.
|
||||
</Step>
|
||||
|
||||
<Step title="Review and edit">
|
||||
AI-translated strings are filled into the editor like manual translations. Review them before publishing and tweak anything that needs a different tone or wording.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
AI translation is an [Enterprise feature](/self-hosting/advanced/license) and requires **Smart functionality (AI)** to be enabled at the organization level. See [AI Features](/platform/features/ai-features).
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## RTL Language Support
|
||||
|
||||
Formbricks fully supports Right-to-Left (RTL) languages such as Arabic, Hebrew, Persian, and Urdu. When you add an RTL language to your survey, the survey interface automatically adjusts to display content from right to left.
|
||||