Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6955d7c99f | |||
| be5beaeed7 | |||
| bc56f99fd8 | |||
| 0f38627627 | |||
| a878bdff42 | |||
| d757e12c76 | |||
| 629febb2f7 | |||
| 40b93cc834 | |||
| f41d2c14f1 | |||
| af51414b03 | |||
| a9e39dd4ab | |||
| c8b0bb2225 | |||
| f6aa27ba8c | |||
| 82765f7dd7 | |||
| d5bbafcf90 | |||
| db87a588b5 | |||
| c834587c8d | |||
| ef18aacfa2 | |||
| 025a766c57 | |||
| f476db3128 | |||
| 37023275ca | |||
| 9266f64588 | |||
| 032066194b | |||
| 0bef023302 | |||
| aa83ee336c | |||
| 4357f497a1 | |||
| 526c17af23 | |||
| a0ddadebad | |||
| bc0d04f5e8 | |||
| f0967c2e23 | |||
| 13c9677edd | |||
| c0bf2ab7cc | |||
| 65d0f4ac0e | |||
| 655c0b5e47 |
@@ -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,6 +5,7 @@
|
||||
"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,10 +1,11 @@
|
||||
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 Prisma.PrismaClientKnownRequestError =>
|
||||
export const isPrismaKnownRequestError = (error: unknown): error is PrismaClientKnownRequestError =>
|
||||
error instanceof Prisma.PrismaClientKnownRequestError;
|
||||
|
||||
export const isSingleUseIdUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): boolean => {
|
||||
export const isSingleUseIdUniqueConstraintError = (error: PrismaClientKnownRequestError): boolean => {
|
||||
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -313,9 +313,18 @@ describe("handleErrorResponse", () => {
|
||||
expect(body.message).toBe("bad input");
|
||||
});
|
||||
|
||||
test("returns 400 badRequest for ResourceNotFoundError", async () => {
|
||||
test("returns 404 notFound for ResourceNotFoundError", async () => {
|
||||
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
|
||||
expect(response.status).toBe(400);
|
||||
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",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 500 internalServerError for unknown errors", async () => {
|
||||
|
||||
@@ -29,11 +29,10 @@ export const handleErrorResponse = (error: any): Response => {
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
return responses.conflictResponse(error.message);
|
||||
}
|
||||
if (
|
||||
error instanceof DatabaseError ||
|
||||
error instanceof InvalidInputError ||
|
||||
error instanceof ResourceNotFoundError
|
||||
) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse(error.resourceType, error.resourceId);
|
||||
}
|
||||
if (error instanceof DatabaseError || error instanceof InvalidInputError) {
|
||||
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,6 +245,34 @@ 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,7 +1,12 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponseInput } from "@formbricks/types/responses";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
@@ -155,6 +160,16 @@ 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,7 +2,12 @@ import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
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";
|
||||
@@ -11,6 +16,7 @@ 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";
|
||||
@@ -104,6 +110,16 @@ 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,
|
||||
@@ -131,6 +147,13 @@ 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,7 +2,12 @@ import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
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";
|
||||
@@ -190,7 +195,19 @@ describe("createResponse V2", () => {
|
||||
).rejects.toThrow(UniqueConstraintError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
|
||||
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 () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
@@ -199,7 +216,7 @@ describe("createResponse V2", () => {
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
|
||||
await expect(
|
||||
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
|
||||
).rejects.toThrow(DatabaseError);
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
|
||||
|
||||
@@ -2,7 +2,12 @@ import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
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";
|
||||
@@ -12,6 +17,7 @@ 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";
|
||||
@@ -99,6 +105,16 @@ 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;
|
||||
@@ -122,6 +138,13 @@ 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,6 +415,44 @@ 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,6 +40,40 @@ 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",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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,5 +1,6 @@
|
||||
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";
|
||||
@@ -212,7 +213,7 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
|
||||
|
||||
// -- Composite functions --
|
||||
|
||||
const mapUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): InvalidInputError => {
|
||||
const mapUniqueConstraintError = (error: 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 } from "@formbricks/types/errors";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
export const selectDisplay = {
|
||||
@@ -146,6 +146,58 @@ 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,14 +3,18 @@ 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, ValidationError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, InvalidInputError, 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 = [
|
||||
@@ -290,3 +294,96 @@ 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,6 +1,7 @@
|
||||
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";
|
||||
@@ -324,5 +325,35 @@ 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,6 +3,7 @@ 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";
|
||||
@@ -569,6 +570,13 @@ 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,6 +38,50 @@ 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", () => {
|
||||
@@ -60,4 +104,54 @@ 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,6 +165,42 @@ 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,6 +85,18 @@ 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 };
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
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,3 +1,5 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
FEEDBACK_FIELDS,
|
||||
@@ -6,6 +8,17 @@ 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", () => {
|
||||
@@ -94,5 +107,20 @@ 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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,7 +133,7 @@ export const EditContactAttributesModal = ({
|
||||
const errorFieldId = `attribute-key-${firstErrorIndex}`;
|
||||
const errorElement = document.getElementById(errorFieldId);
|
||||
if (errorElement) {
|
||||
setTimeout(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
errorElement.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
// Try to focus the input inside the combobox if it exists
|
||||
const inputElement = errorElement.querySelector("input") as HTMLInputElement;
|
||||
@@ -143,6 +143,7 @@ export const EditContactAttributesModal = ({
|
||||
errorElement.focus();
|
||||
}
|
||||
}, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,9 +337,10 @@ export const UploadContactsCSVButton = ({
|
||||
useEffect(() => {
|
||||
if (error && errorContainerRef.current) {
|
||||
// Small delay to ensure DOM has updated and the alert is visible
|
||||
setTimeout(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
errorContainerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createFeedbackRecord,
|
||||
createFeedbackRecordsBatch,
|
||||
deleteFeedbackRecord,
|
||||
deleteHubTenantData,
|
||||
getFeedbackRecordTenant,
|
||||
listFeedbackRecords,
|
||||
retrieveFeedbackRecord,
|
||||
@@ -344,6 +345,48 @@ 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 as string} alt="Brand logo" className="mx-auto w-40" />
|
||||
<Image src={footerLogo} 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 as string} alt="Brand logo" className="mx-auto w-40" />
|
||||
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -123,11 +123,7 @@ export const SurveyLoadingAnimation = ({
|
||||
isReadyToTransition ? "animate-surveyExit" : "animate-surveyLoading"
|
||||
)}>
|
||||
{isBrandingEnabled && (
|
||||
<Image
|
||||
src={Logo as string}
|
||||
alt="Logo"
|
||||
className={cn("w-32 transition-all duration-1000 md:w-40")}
|
||||
/>
|
||||
<Image src={Logo} 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(
|
||||
"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",
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -67,7 +67,7 @@ const DropdownMenuContent: React.ComponentType<DropdownMenuPrimitive.DropdownMen
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"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",
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -19,7 +19,7 @@ const PopoverContent: React.ForwardRefExoticComponent<
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"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",
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -23,7 +23,7 @@ const TooltipContent: React.ComponentType<TooltipPrimitive.TooltipContentProps>
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"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",
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"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,6 +65,7 @@ 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:
|
||||
|
||||
@@ -129,7 +130,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 | `""` | |
|
||||
| deployment.image.digest | string | `""` | When set, takes precedence over tag. |
|
||||
| deployment.image.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| deployment.image.repository | string | `"ghcr.io/formbricks/formbricks"` | |
|
||||
| deployment.image.tag | string | `""` | |
|
||||
@@ -224,7 +225,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` | |
|
||||
| hub.migration.waitForFormbricksMigration.missingJobMaxAttempts | int | `12` | Consecutive missing Job reads before using DB markers. |
|
||||
| hub.pdb.enabled | bool | `false` | |
|
||||
| hub.replicas | int | `1` | |
|
||||
| hub.resources.limits.memory | string | `"512Mi"` | |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
|
||||
{{ .Release.Name | camelcase }} with {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag }} has been deployed successfully on {{ template "formbricks.namespace" .}} namespace !
|
||||
{{ .Release.Name | camelcase }} with {{ include "formbricks.deploymentImage" . }} has been deployed successfully on {{ template "formbricks.namespace" .}} namespace !
|
||||
|
||||
Here's how you can access and manage your deployment:
|
||||
---
|
||||
|
||||
@@ -125,10 +125,6 @@ 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 }}
|
||||
@@ -157,6 +153,21 @@ 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: {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag | default .Chart.AppVersion | default "latest" }}
|
||||
image: {{ include "formbricks.deploymentImage" . }}
|
||||
imagePullPolicy: {{ .Values.deployment.image.pullPolicy }}
|
||||
{{- if .Values.deployment.command }}
|
||||
command:
|
||||
|
||||
@@ -39,22 +39,35 @@ spec:
|
||||
initContainers:
|
||||
{{- if and .Values.migration.enabled .Values.hub.migration.waitForFormbricksMigration.enabled }}
|
||||
- name: wait-for-formbricks-migration
|
||||
image: {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag | default .Chart.AppVersion | default "latest" }}
|
||||
image: {{ include "formbricks.deploymentImage" . }}
|
||||
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 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 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 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();
|
||||
@@ -102,10 +115,125 @@ 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"
|
||||
@@ -128,12 +256,30 @@ 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 && attempt >= missingJobMaxAttempts) {
|
||||
console.log(`${jobName} was not found after ${attempt} attempts; assuming it was already cleaned up.`);
|
||||
return;
|
||||
}
|
||||
if (error && error.statusCode === 404) {
|
||||
missingJobAttempts += 1;
|
||||
|
||||
console.log(`Waiting for ${jobName} to be available (${attempt}/${maxAttempts}): ${message}`);
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(intervalSeconds * 1000);
|
||||
@@ -145,8 +291,45 @@ 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: {{ include "formbricks.namespace" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
{{- end }}
|
||||
|
||||
@@ -46,7 +46,7 @@ spec:
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: migration
|
||||
image: {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag | default .Chart.AppVersion | default "latest" }}
|
||||
image: {{ include "formbricks.deploymentImage" . }}
|
||||
imagePullPolicy: {{ .Values.deployment.image.pullPolicy }}
|
||||
command:
|
||||
- node
|
||||
|
||||
@@ -939,6 +939,7 @@ hub:
|
||||
waitForFormbricksMigration:
|
||||
enabled: true
|
||||
maxAttempts: 180
|
||||
# Consecutive missing Job reads before falling back to persisted Prisma/DataMigration markers.
|
||||
missingJobMaxAttempts: 12
|
||||
intervalSeconds: 5
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
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.
|
||||
|
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 |
@@ -0,0 +1,10 @@
|
||||
---
|
||||
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).
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
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).
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
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.
|
||||
|
||||
@@ -132,6 +132,30 @@ 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.
|
||||