Compare commits

..

27 Commits

Author SHA1 Message Date
Javi Aguilar cb82ca7ff4 fix preact not supporting important in styles 2026-05-21 17:29:16 +02:00
Javi Aguilar 6ef5b48590 declare cva config outside 2026-05-21 17:09:13 +02:00
Javi Aguilar fb4bf7ca6d fix: back button not readable in dark backgrounds - fixes #7922 2026-05-21 16:28:31 +02:00
Javi Aguilar 11b4d34b79 refactor: allow arrays and falsy values in cn util 2026-05-21 16:27:51 +02:00
Johannes c8b0bb2225 fix: reserve future contact keys and improve segment errors (ENG-1037, ENG-994) (#8101)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 11:41:23 +00:00
Dhruwang Jariwala f6aa27ba8c fix: chart date range type switch + presets include today (ENG-1034, ENG-1035) (#8096)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-21 11:05:10 +00:00
Johannes 82765f7dd7 fix: allow enterprise oauth display names (#8099)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-21 10:59:35 +00:00
Dhruwang Jariwala d5bbafcf90 fix: remount AI translation editor on value change, not disabled transition (#8084) 2026-05-21 10:09:57 +00:00
Anshuman Pandey db87a588b5 fix: adds close button on response error screen (#8093) 2026-05-21 09:26:47 +00:00
Javi Aguilar c834587c8d chore: add typecheck command and fix format and type issues (#7999) 2026-05-21 08:13:46 +00:00
Anshuman Pandey ef18aacfa2 fix: fixes responseId client api issue with legacy environmentId (#8079) 2026-05-21 06:15:27 +00:00
Dhruwang Jariwala 025a766c57 fix: show copy icon on legacy environmentId, reintroduce duplicate survey action (ENG-978, ENG-987) (#8061)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:21:33 +00:00
Bhagya Amarasinghe f476db3128 fix: update Helm chart default image tag (#8072) 2026-05-21 05:11:20 +00:00
Bhagya Amarasinghe 37023275ca fix: require Cube API secret in compose (#8071) 2026-05-21 05:07:57 +00:00
Bhagya Amarasinghe 9266f64588 fix: harden Helm env value rendering (#8070) 2026-05-21 05:01:10 +00:00
Dhruwang Jariwala 032066194b fix: render scheduled-plan-change description placeholders correctly (#8064)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:58:39 +00:00
Dhruwang Jariwala 0bef023302 fix: gate AI chart generation on smartTools, not dataAnalysis (ENG-1001) (#8060)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:53:42 +00:00
Dhruwang Jariwala aa83ee336c fix: route Manage Teams and integration OAuth callbacks to settings (ENG-988) (#8059)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:51:47 +00:00
Anshuman Pandey 4357f497a1 fix: sanitize CSV/XLSX exports against formula injection (#8045) 2026-05-21 04:49:50 +00:00
Bhagya Amarasinghe 526c17af23 fix: wire Cube API secret into Helm defaults (#8068) 2026-05-21 04:47:15 +00:00
Matti Nannt a0ddadebad fix: scope display contact lookup to workspace (ENG-818) (#8048)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 04:41:48 +00:00
Bhagya Amarasinghe bc0d04f5e8 fix: staging AI chart Cube schema (#8057) 2026-05-20 14:22:23 +00:00
Anshuman Pandey f0967c2e23 fix: preserve legacy SDK shape with placeholder segment data (#8067) 2026-05-20 16:21:13 +02:00
Johannes 13c9677edd fix: correct settings sidebar back navigation behavior (#8052)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 11:18:12 +00:00
Johannes c0bf2ab7cc fix: enforce billing-only settings access (#8053)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 11:14:43 +00:00
Johannes 65d0f4ac0e fix: add CSAT and CES summary filter icons (#8056)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 09:44:10 +00:00
Matti Nannt 655c0b5e47 fix: strip client-provided timestamps in client response API (ENG-828) (#8047)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 06:53:42 +00:00
53 changed files with 520 additions and 524 deletions
+6 -6
View File
@@ -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 */}
+1
View File
@@ -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 -1
View File
@@ -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;
}
@@ -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(),
+2 -1
View File
@@ -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")) {
@@ -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");
});
});
@@ -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());
});
});
});
@@ -6,8 +6,6 @@ import { processResponsePipelineJob } from "./process-response-pipeline-job";
const {
mockFetch,
mockCaptureSurveyResponsePostHogEvent,
mockCreatePinnedDispatcher,
mockDispatcherDestroy,
mockGetIntegrations,
mockGetResponseCountBySurveyId,
mockHandleIntegrations,
@@ -23,16 +21,13 @@ const {
mockSendFollowUpsForResponse,
mockSendResponseFinishedEmail,
mockSendTelemetryEvents,
mockValidateAndResolveWebhookUrl,
mockValidateWebhookUrl,
} = vi.hoisted(() => {
process.env.HUB_API_URL ??= "https://hub.test";
const dispatcherDestroy = vi.fn().mockResolvedValue(undefined);
return {
mockFetch: vi.fn(),
mockCaptureSurveyResponsePostHogEvent: vi.fn(),
mockCreatePinnedDispatcher: vi.fn(() => ({ destroy: dispatcherDestroy })),
mockDispatcherDestroy: dispatcherDestroy,
mockGetIntegrations: vi.fn(),
mockGetResponseCountBySurveyId: vi.fn(),
mockHandleIntegrations: vi.fn(),
@@ -48,7 +43,7 @@ const {
mockSendFollowUpsForResponse: vi.fn(),
mockSendResponseFinishedEmail: vi.fn(),
mockSendTelemetryEvents: vi.fn(),
mockValidateAndResolveWebhookUrl: vi.fn(),
mockValidateWebhookUrl: vi.fn(),
};
});
@@ -85,7 +80,6 @@ vi.mock(import("@/lib/constants"), async (importOriginal) => {
return {
...actual,
POSTHOG_KEY: undefined,
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: false,
};
});
@@ -110,8 +104,7 @@ vi.mock("./posthog", () => ({
}));
vi.mock("@/lib/utils/validate-webhook-url", () => ({
validateAndResolveWebhookUrl: mockValidateAndResolveWebhookUrl,
createPinnedDispatcher: mockCreatePinnedDispatcher,
validateWebhookUrl: mockValidateWebhookUrl,
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
@@ -212,9 +205,7 @@ describe("processResponsePipelineJob", () => {
mockPrismaUserFindMany.mockResolvedValue([]);
mockGetResponseCountBySurveyId.mockResolvedValue(1);
mockHandleIntegrations.mockResolvedValue(undefined);
mockValidateAndResolveWebhookUrl.mockResolvedValue({ ip: "93.184.216.34", family: 4 });
mockDispatcherDestroy.mockResolvedValue(undefined);
mockCreatePinnedDispatcher.mockImplementation(() => ({ destroy: mockDispatcherDestroy }));
mockValidateWebhookUrl.mockResolvedValue(undefined);
mockQueueAuditEventWithoutRequest.mockResolvedValue(undefined);
mockRecordResponseCreatedMeterEvent.mockResolvedValue(undefined);
mockSendResponseFinishedEmail.mockResolvedValue(undefined);
@@ -251,7 +242,7 @@ describe("processResponsePipelineJob", () => {
triggers: { has: "responseCreated" },
},
});
expect(mockValidateAndResolveWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
expect(mockValidateWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
expect(mockFetch).toHaveBeenCalledWith(
"https://example.com/webhook",
expect.objectContaining({
@@ -490,7 +481,7 @@ describe("processResponsePipelineJob", () => {
url: "https://example.com/webhook",
},
]);
mockValidateAndResolveWebhookUrl.mockRejectedValue(webhookError);
mockValidateWebhookUrl.mockRejectedValue(webhookError);
await expect(
processResponsePipelineJob(
@@ -536,7 +527,7 @@ describe("processResponsePipelineJob", () => {
locale: "en",
},
]);
mockValidateAndResolveWebhookUrl.mockRejectedValue(webhookError);
mockValidateWebhookUrl.mockRejectedValue(webhookError);
await expect(
processResponsePipelineJob(
@@ -789,133 +780,6 @@ describe("processResponsePipelineJob", () => {
);
});
test("pins fetch to the resolved webhook IP via undici dispatcher", async () => {
mockPrismaWebhookFindMany.mockResolvedValue([
{
id: "webhook_123",
secret: null,
url: "https://example.com/webhook",
},
]);
const pinnedDispatcher = { destroy: mockDispatcherDestroy };
mockValidateAndResolveWebhookUrl.mockResolvedValue({ ip: "203.0.113.10", family: 4 });
mockCreatePinnedDispatcher.mockReturnValue(pinnedDispatcher);
await expect(processResponsePipelineJob(baseData, baseContext)).resolves.toBeUndefined();
expect(mockValidateAndResolveWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
expect(mockCreatePinnedDispatcher).toHaveBeenCalledWith({ ip: "203.0.113.10", family: 4 });
expect(mockFetch).toHaveBeenCalledWith(
"https://example.com/webhook",
expect.objectContaining({
dispatcher: pinnedDispatcher,
redirect: "manual",
})
);
});
test("blocks 3xx redirects from webhook endpoints as delivery failures", async () => {
mockPrismaWebhookFindMany.mockResolvedValue([
{
id: "webhook_123",
secret: null,
url: "https://example.com/webhook",
},
]);
mockFetch.mockResolvedValue({
ok: false,
status: 302,
});
await expect(processResponsePipelineJob(baseData, baseContext)).rejects.toThrow(
"Webhook delivery blocked: redirect status 302"
);
expect(mockFetch).toHaveBeenCalledWith(
"https://example.com/webhook",
expect.objectContaining({ redirect: "manual" })
);
expect(mockLoggerError).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
webhookId: "webhook_123",
}),
"Response pipeline webhook delivery failed"
);
});
test("destroys the pinned dispatcher after a successful webhook delivery", async () => {
mockPrismaWebhookFindMany.mockResolvedValue([
{
id: "webhook_123",
secret: null,
url: "https://example.com/webhook",
},
]);
await expect(processResponsePipelineJob(baseData, baseContext)).resolves.toBeUndefined();
expect(mockDispatcherDestroy).toHaveBeenCalledTimes(1);
});
test("logs dispatcher cleanup failures without failing a successful webhook delivery", async () => {
const cleanupError = new Error("destroy failed");
mockPrismaWebhookFindMany.mockResolvedValue([
{
id: "webhook_123",
secret: null,
url: "https://example.com/webhook",
},
]);
mockDispatcherDestroy.mockRejectedValue(cleanupError);
await expect(processResponsePipelineJob(baseData, baseContext)).resolves.toBeUndefined();
expect(mockLoggerWarn).toHaveBeenCalledWith(
expect.objectContaining({
err: cleanupError,
webhookId: "webhook_123",
webhookUrl: "https://example.com/webhook",
}),
"Response pipeline webhook dispatcher cleanup failed"
);
});
test("destroys the pinned dispatcher when the webhook fetch throws", async () => {
mockPrismaWebhookFindMany.mockResolvedValue([
{
id: "webhook_123",
secret: null,
url: "https://example.com/webhook",
},
]);
mockFetch.mockRejectedValue(new Error("connect refused"));
await expect(processResponsePipelineJob(baseData, baseContext)).rejects.toThrow("connect refused");
expect(mockDispatcherDestroy).toHaveBeenCalledTimes(1);
});
test("does not pin a dispatcher when the resolver returns null (internal URL flag)", async () => {
mockPrismaWebhookFindMany.mockResolvedValue([
{
id: "webhook_123",
secret: null,
url: "http://localhost:3000/webhook",
},
]);
mockValidateAndResolveWebhookUrl.mockResolvedValue(null);
await expect(processResponsePipelineJob(baseData, baseContext)).resolves.toBeUndefined();
expect(mockCreatePinnedDispatcher).not.toHaveBeenCalled();
expect(mockDispatcherDestroy).not.toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:3000/webhook",
expect.objectContaining({ dispatcher: undefined })
);
});
test("classifies database pool exhaustion as retryable and logs a warning", async () => {
const poolExhaustionError = new Error("Timed out fetching a new connection from the connection pool");
mockPrismaSurveyFindUnique.mockRejectedValue(poolExhaustionError);
@@ -7,11 +7,11 @@ import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { type TUserLocale, ZUserLocale } from "@formbricks/types/user";
import { handleConnectorPipeline } from "@/lib/connector/pipeline-handler";
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS, POSTHOG_KEY } from "@/lib/constants";
import { POSTHOG_KEY } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { createPinnedDispatcher, validateAndResolveWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { queueAuditEventWithoutRequest } from "@/modules/ee/audit-logs/lib/handler";
import { type TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering";
@@ -136,13 +136,9 @@ const createWebhookMessageId = ({
webhookId: string;
}): string => createHash("sha256").update(`${jobId}:${webhookId}:${event}`).digest("hex");
type WebhookFetchOptions = RequestInit & {
dispatcher?: ReturnType<typeof createPinnedDispatcher>;
};
const fetchWithTimeout = async (
url: string,
options: WebhookFetchOptions,
options: RequestInit,
timeoutMs: number = WEBHOOK_TIMEOUT_MS
): Promise<Response> => {
const abortController = new AbortController();
@@ -157,7 +153,7 @@ const fetchWithTimeout = async (
return await fetch(url, {
...options,
signal,
} as RequestInit);
});
} finally {
clearTimeout(timeoutId);
}
@@ -226,47 +222,15 @@ const createWebhookDeliveryTask = async ({
);
}
const address = await validateAndResolveWebhookUrl(webhook.url);
// Pin TCP connect to the validated IP — closes DNS-rebinding TOCTOU between
// validation and fetch. Skip pinning when address is null (DANGEROUSLY flag +
// blocked name resolved via /etc/hosts).
const dispatcher = address ? createPinnedDispatcher(address) : undefined;
// `redirect: "manual"` blocks 30x-based SSRF to private/internal hosts.
// Gated on the same env var as URL validation for self-hosters who opted in.
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
await validateWebhookUrl(webhook.url);
const response = await fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
});
try {
const response = await fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
redirect: redirectMode,
dispatcher,
});
// With `redirect: "manual"`, undici returns the actual 30x (not opaqueredirect).
// Treat as delivery failure so redirect-based SSRF cannot silently succeed.
if (response.status >= 300 && response.status < 400) {
throw new Error(`Webhook delivery blocked: redirect status ${response.status}`);
}
if (!response.ok) {
throw new Error(`Webhook delivery failed with status ${response.status}`);
}
} finally {
try {
await dispatcher?.destroy();
} catch (cleanupError) {
logger.warn(
{
...logContext,
err: cleanupError,
webhookId: webhook.id,
webhookUrl: webhook.url,
},
"Response pipeline webhook dispatcher cleanup failed"
);
}
if (!response.ok) {
throw new Error(`Webhook delivery failed with status ${response.status}`);
}
} catch (error) {
logger.error(
@@ -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}
+2
View File
@@ -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",
+8
View File
@@ -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;
}
}
+26
View File
@@ -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"
]
}
-6
View File
@@ -65,8 +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.
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:
```yaml
@@ -222,10 +220,6 @@ Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If yo
| hub.migration.activeDeadlineSeconds | int | `900` | |
| hub.migration.backoffLimit | int | `3` | |
| hub.migration.ttlSecondsAfterFinished | int | `300` | |
| 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.pdb.enabled | bool | `false` | |
| hub.replicas | int | `1` | |
| hub.resources.limits.memory | string | `"512Mi"` | |
-8
View File
@@ -125,18 +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.hubSecretName" -}}
{{- default (include "formbricks.appSecretName" .) .Values.hub.existingSecret -}}
{{- end }}
{{- define "formbricks.hubMigrationWaitServiceAccountName" -}}
{{- printf "%s-migration-wait" (include "formbricks.hubname" .) | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{/*
Hub image reference. Pin by digest in production (hub.image.digest = "sha256:..."); falls back to
hub.image.tag for local/dev. All Hub workloads (deployment, init container, migration job, future
@@ -32,134 +32,7 @@ spec:
imagePullSecrets:
{{- toYaml .Values.deployment.imagePullSecrets | nindent 8 }}
{{- end }}
{{- if and .Values.migration.enabled .Values.hub.migration.waitForFormbricksMigration.enabled }}
serviceAccountName: {{ include "formbricks.hubMigrationWaitServiceAccountName" . }}
automountServiceAccountToken: true
{{- end }}
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" }}
imagePullPolicy: {{ .Values.deployment.image.pullPolicy }}
command:
- node
- -e
- |
const fs = require("fs");
const https = require("https");
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 namespace = fs
.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "utf8")
.trim();
const token = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8");
const ca = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt");
const host = process.env.KUBERNETES_SERVICE_HOST;
const port = process.env.KUBERNETES_SERVICE_PORT || "443";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const jobPath = `/apis/batch/v1/namespaces/${namespace}/jobs/${jobName}`;
const fetchJob = () =>
new Promise((resolve, reject) => {
const request = https.request(
{
host,
port,
path: jobPath,
method: "GET",
ca,
headers: {
Authorization: `Bearer ${token}`,
},
},
(response) => {
let body = "";
response.setEncoding("utf8");
response.on("data", (chunk) => {
body += chunk;
});
response.on("end", () => {
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(JSON.parse(body));
return;
}
const error = new Error(`Kubernetes API returned ${response.statusCode}: ${body}`);
error.statusCode = response.statusCode;
reject(error);
});
}
);
request.on("error", reject);
request.end();
});
(async () => {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const job = await fetchJob();
const conditions = job.status?.conditions || [];
const isComplete = conditions.some(
(condition) => condition.type === "Complete" && condition.status === "True"
);
const isFailed = conditions.some(
(condition) => condition.type === "Failed" && condition.status === "True"
);
if (isComplete) {
console.log(`${jobName} completed; starting Hub migrations.`);
return;
}
if (isFailed) {
console.error(`${jobName} failed; refusing to start Hub migrations.`);
process.exitCode = 1;
return;
}
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;
}
console.log(`Waiting for ${jobName} to be available (${attempt}/${maxAttempts}): ${message}`);
}
await sleep(intervalSeconds * 1000);
}
console.error(`Timed out waiting for ${jobName} after ${maxAttempts} attempts.`);
process.exitCode = 1;
})()
.catch((error) => {
console.error(error);
process.exitCode = 1;
});
env:
- name: FORMBRICKS_MIGRATION_JOB_NAME
value: {{ include "formbricks.migrationJobName" . | quote }}
- name: FORMBRICKS_MIGRATION_WAIT_MAX_ATTEMPTS
value: {{ .Values.hub.migration.waitForFormbricksMigration.maxAttempts | quote }}
- name: FORMBRICKS_MIGRATION_WAIT_MISSING_JOB_MAX_ATTEMPTS
value: {{ .Values.hub.migration.waitForFormbricksMigration.missingJobMaxAttempts | quote }}
- name: FORMBRICKS_MIGRATION_WAIT_INTERVAL_SECONDS
value: {{ .Values.hub.migration.waitForFormbricksMigration.intervalSeconds | quote }}
{{- if .Values.migration.resources }}
resources:
{{- toYaml .Values.migration.resources | nindent 12 }}
{{- end }}
{{- end }}
- name: hub-migrate
image: {{ include "formbricks.hubImage" . }}
imagePullPolicy: {{ .Values.hub.image.pullPolicy }}
@@ -1,55 +0,0 @@
{{- if and .Values.migration.enabled .Values.hub.migration.waitForFormbricksMigration.enabled }}
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "formbricks.hubMigrationWaitServiceAccountName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-migration-wait
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: {{ include "formbricks.hubMigrationWaitServiceAccountName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-migration-wait
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
rules:
- apiGroups:
- batch
resources:
- jobs
resourceNames:
- {{ include "formbricks.migrationJobName" . }}
verbs:
- get
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ include "formbricks.hubMigrationWaitServiceAccountName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-migration-wait
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: {{ include "formbricks.hubMigrationWaitServiceAccountName" . }}
subjects:
- kind: ServiceAccount
name: {{ include "formbricks.hubMigrationWaitServiceAccountName" . }}
namespace: {{ include "formbricks.namespace" . }}
{{- end }}
@@ -3,7 +3,7 @@
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "formbricks.migrationJobName" . }}
name: {{ include "formbricks.name" . }}-migration
labels:
{{- include "formbricks.labels" . | nindent 4 }}
annotations:
-5
View File
@@ -909,11 +909,6 @@ hub:
ttlSecondsAfterFinished: 300
backoffLimit: 3
activeDeadlineSeconds: 900
waitForFormbricksMigration:
enabled: true
maxAttempts: 180
missingJobMaxAttempts: 12
intervalSeconds: 5
resources:
limits:
+1
View File
@@ -30,6 +30,7 @@
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"generate": "turbo run generate",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"test": "turbo run test --no-cache",
"test:coverage": "turbo run test:coverage --no-cache",
"test:e2e": "playwright test",
+1
View File
@@ -30,6 +30,7 @@
"lint": "eslint . --ext .ts,.js",
"lint:fix": "eslint . --ext .ts,.js --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/ai.json",
"typecheck": "tsc --noEmit",
"build": "rimraf dist && vite build && tsc --project tsconfig.build.json",
"test": "vitest run",
"test:coverage": "vitest run --coverage"
+1
View File
@@ -30,6 +30,7 @@
"lint": "eslint . --ext .ts,.js",
"lint:fix": "eslint . --ext .ts,.js --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/cache.json",
"typecheck": "tsc --noEmit",
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
+1
View File
@@ -46,6 +46,7 @@
"generate": "prisma generate",
"lint": "eslint ./src --fix",
"generate-data-migration": "tsx ./src/scripts/generate-data-migration.ts",
"typecheck": "tsc --noEmit",
"create-migration": "dotenv -e ../../.env -- tsx ./src/scripts/create-migration.ts"
},
"dependencies": {
+1 -1
View File
@@ -5,5 +5,5 @@
},
"exclude": ["node_modules", "dist"],
"extends": "@formbricks/config-typescript/node16.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "tsup.config.ts"]
"include": ["src/**/*.ts", "types/**/*.ts", "zod/**/*.ts", "migration/**/*.ts", "vite.config.ts"]
}
+2 -1
View File
@@ -8,7 +8,8 @@
"types": "src/index.ts",
"scripts": {
"dev": "email dev --port 3456",
"build": "tsc --noEmit",
"build": "pnpm typecheck",
"typecheck": "tsc --noEmit",
"lint": "eslint src --fix --ext .ts,.tsx",
"clean": "rimraf .turbo node_modules dist"
},
+1
View File
@@ -28,6 +28,7 @@
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json",
"typecheck": "tsc --noEmit",
"build": "tsc && vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
+1
View File
@@ -36,6 +36,7 @@
"build": "vite build",
"build:dev": "vite build --mode dev",
"go": "vite build --watch --mode dev",
"typecheck": "tsc --noEmit",
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"clean": "rimraf .turbo node_modules dist coverage",
"test": "vitest run",
@@ -1,2 +1,37 @@
import type { TWorkspaceStateSurvey } from "@/types/config";
export const mockSurveyId = "jgocyoxk9uifo6u381qahmes";
export const mockSurveyName = "Test Survey";
export const mockSurvey: TWorkspaceStateSurvey = {
id: mockSurveyId,
welcomeCard: {
enabled: false,
timeToFinish: false,
showResponseCount: false,
headline: { en: "Welcome" },
},
questions: [],
variables: [],
type: "app",
showLanguageSwitch: false,
endings: [],
autoClose: null,
status: "inProgress",
recontactDays: null,
displayLimit: null,
displayOption: "displayMultiple",
hiddenFields: { enabled: false },
delay: 0,
workspaceOverwrites: {},
isBackButtonHidden: false,
isAutoProgressingEnabled: false,
recaptcha: { enabled: false, threshold: 0.5 },
languages: [],
triggers: [],
displayPercentage: 100,
};
export const createMockSurvey = (id = mockSurveyId): TWorkspaceStateSurvey => ({
...mockSurvey,
id,
});
@@ -709,7 +709,10 @@ describe("time on page action handling", () => {
clearTimeOnPageTimers();
});
const createConfigWithTimeOnPageAction = (actionName: string, timeInSeconds: number) => ({
const createConfigWithTimeOnPageAction = (
actionName: string,
timeInSeconds: number
): { get: Mock; update: Mock } => ({
get: vi.fn().mockReturnValue({
workspace: {
data: {
@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { SurveyStore } from "@/lib/survey/store";
import { mockSurveyId, mockSurveyName } from "@/lib/survey/tests/__mocks__/store.mock";
import type { TWorkspaceStateSurvey } from "@/types/config";
import { createMockSurvey } from "@/lib/survey/tests/__mocks__/store.mock";
describe("SurveyStore", () => {
let store: SurveyStore;
@@ -27,10 +26,7 @@ describe("SurveyStore", () => {
});
test("returns current survey when set", () => {
const mockSurvey: TWorkspaceStateSurvey = {
id: mockSurveyId,
name: mockSurveyName,
} as TWorkspaceStateSurvey;
const mockSurvey = createMockSurvey();
store.setSurvey(mockSurvey);
expect(store.getSurvey()).toBe(mockSurvey);
@@ -40,10 +36,7 @@ describe("SurveyStore", () => {
describe("setSurvey", () => {
test("updates survey and notifies listeners when survey changes", () => {
const listener = vi.fn();
const mockSurvey: TWorkspaceStateSurvey = {
id: mockSurveyId,
name: mockSurveyName,
} as TWorkspaceStateSurvey;
const mockSurvey = createMockSurvey();
store.subscribe(listener);
store.setSurvey(mockSurvey);
@@ -54,10 +47,7 @@ describe("SurveyStore", () => {
test("does not notify listeners when setting same survey", () => {
const listener = vi.fn();
const mockSurvey: TWorkspaceStateSurvey = {
id: mockSurveyId,
name: mockSurveyName,
} as TWorkspaceStateSurvey;
const mockSurvey = createMockSurvey();
store.setSurvey(mockSurvey);
store.subscribe(listener);
@@ -70,10 +60,7 @@ describe("SurveyStore", () => {
describe("resetSurvey", () => {
test("resets survey to null and notifies listeners", () => {
const listener = vi.fn();
const mockSurvey: TWorkspaceStateSurvey = {
id: mockSurveyId,
name: mockSurveyName,
} as TWorkspaceStateSurvey;
const mockSurvey = createMockSurvey();
store.setSurvey(mockSurvey);
store.subscribe(listener);
@@ -96,27 +83,21 @@ describe("SurveyStore", () => {
describe("subscribe", () => {
test("adds listener and returns unsubscribe function", () => {
const listener = vi.fn();
const mockSurvey: TWorkspaceStateSurvey = {
id: mockSurveyId,
name: mockSurveyName,
} as TWorkspaceStateSurvey;
const mockSurvey = createMockSurvey();
const unsubscribe = store.subscribe(listener);
store.setSurvey(mockSurvey);
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
store.setSurvey({ ...mockSurvey, name: "Updated Survey" } as TWorkspaceStateSurvey);
store.setSurvey({ ...mockSurvey, id: "updated-survey-id" });
expect(listener).toHaveBeenCalledTimes(1); // Still 1, not called after unsubscribe
});
test("multiple listeners receive updates", () => {
const listener1 = vi.fn();
const listener2 = vi.fn();
const mockSurvey: TWorkspaceStateSurvey = {
id: mockSurveyId,
name: mockSurveyName,
} as TWorkspaceStateSurvey;
const mockSurvey = createMockSurvey();
store.subscribe(listener1);
store.subscribe(listener2);
@@ -69,6 +69,20 @@ describe("widget-file", () => {
configure: vi.fn(),
};
const createMockFormbricksSurveys = (): NonNullable<Window["formbricksSurveys"]> => ({
renderSurvey: vi.fn(),
setNonce: vi.fn(),
});
const getFormbricksSurveys = (): NonNullable<Window["formbricksSurveys"]> => {
const formbricksSurveys = window.formbricksSurveys;
if (!formbricksSurveys) {
throw new Error("window.formbricksSurveys is not set");
}
return formbricksSurveys;
};
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = "";
@@ -139,10 +153,7 @@ describe("widget-file", () => {
(filterSurveys as Mock).mockReturnValue([]);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
window.formbricksSurveys = createMockFormbricksSurveys();
vi.useFakeTimers();
@@ -154,7 +165,7 @@ describe("widget-file", () => {
vi.advanceTimersByTime(mockSurvey.delay * 1000);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
survey: mockSurvey,
appUrl: "https://fake.app",
@@ -302,10 +313,7 @@ describe("widget-file", () => {
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
window.formbricksSurveys = createMockFormbricksSurveys();
vi.useFakeTimers();
@@ -319,7 +327,7 @@ describe("widget-file", () => {
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
contactId: "contact_abc",
})
@@ -362,10 +370,7 @@ describe("widget-file", () => {
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
window.formbricksSurveys = createMockFormbricksSurveys();
vi.useFakeTimers();
@@ -378,7 +383,7 @@ describe("widget-file", () => {
expect(mockUpdateQueue.waitForPendingWork).not.toHaveBeenCalled();
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalled();
vi.useRealTimers();
});
@@ -422,10 +427,7 @@ describe("widget-file", () => {
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
window.formbricksSurveys = createMockFormbricksSurveys();
vi.useFakeTimers();
@@ -437,7 +439,7 @@ describe("widget-file", () => {
vi.advanceTimersByTime(0);
// The contactId passed to renderSurvey should be read after the wait
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
contactId: "contact_after_identification",
})
@@ -452,10 +454,7 @@ describe("widget-file", () => {
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
window.formbricksSurveys = createMockFormbricksSurveys();
await widget.renderWidget({
...mockSurvey,
@@ -467,7 +466,7 @@ describe("widget-file", () => {
expect(mockLogger.debug).toHaveBeenCalledWith(
"User identification failed. Skipping survey with segment filters."
);
expect(window.formbricksSurveys.renderSurvey).not.toHaveBeenCalled();
expect(getFormbricksSurveys().renderSurvey).not.toHaveBeenCalled();
});
describe("loadFormbricksSurveysExternally and waitForSurveysGlobal", () => {
@@ -598,8 +597,7 @@ describe("widget-file", () => {
(scriptEl.onload as () => void)();
// Set the global after script "loads" — simulates browser finishing execution
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
window.formbricksSurveys = createMockFormbricksSurveys();
// Advance one polling interval for waitForSurveysGlobal to find it
await vi.advanceTimersByTimeAsync(200);
@@ -609,8 +607,8 @@ describe("widget-file", () => {
// Run remaining timers for survey.delay setTimeout
vi.runAllTimers();
expect(window.formbricksSurveys.setNonce).toHaveBeenCalledWith("test-nonce-123");
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect(getFormbricksSurveys().setNonce).toHaveBeenCalledWith("test-nonce-123");
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
appUrl: "https://fake.app",
workspaceId: "env_123",
@@ -629,13 +627,11 @@ describe("widget-file", () => {
// After the previous successful test, surveysLoadPromise holds a resolved promise.
// Calling renderWidget again (without formbricksSurveys on window, but with cached promise)
// should reuse the cached promise rather than creating a new script element.
// @ts-expect-error -- cleaning up mock to force dedup path
delete window.formbricksSurveys;
const appendChildSpy = vi.spyOn(document.head, "appendChild");
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
window.formbricksSurveys = createMockFormbricksSurveys();
vi.useFakeTimers();
@@ -653,7 +649,7 @@ describe("widget-file", () => {
});
expect(scriptAppendCalls.length).toBe(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalled();
vi.useRealTimers();
});
@@ -713,10 +709,7 @@ describe("widget-file", () => {
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
window.formbricksSurveys = createMockFormbricksSurveys();
vi.useFakeTimers();
@@ -731,7 +724,7 @@ describe("widget-file", () => {
);
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalled();
vi.useRealTimers();
});
@@ -239,7 +239,6 @@ const waitForSurveysGlobal = (): Promise<TFormbricksSurveys> => {
const startTime = Date.now();
const check = (): void => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
if (globalThis.window.formbricksSurveys) {
const storedNonce = globalThis.window.__formbricksNonce;
if (storedNonce) {
@@ -262,7 +261,6 @@ const waitForSurveysGlobal = (): Promise<TFormbricksSurveys> => {
};
const loadFormbricksSurveysExternally = (): Promise<TFormbricksSurveys> => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
if (globalThis.window.formbricksSurveys) {
return Promise.resolve(globalThis.window.formbricksSurveys);
}
@@ -300,7 +298,6 @@ let isPreloaded = false;
export const preloadSurveysScript = (appUrl: string): void => {
// Don't preload if already loaded or already preloading
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
if (globalThis.window.formbricksSurveys) return;
if (isPreloaded) return;
+1
View File
@@ -30,6 +30,7 @@
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json",
"typecheck": "tsc --noEmit",
"build": "rimraf dist && vite build && tsc --project tsconfig.build.json",
"test": "vitest run"
},
+1
View File
@@ -29,6 +29,7 @@
"lint": "eslint . --ext .ts,.js",
"lint:fix": "eslint . --ext .ts,.js --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json",
"typecheck": "tsc --noEmit",
"build": "vite build && tsc --project tsconfig.build.json",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
+1
View File
@@ -55,6 +55,7 @@
"build": "vite build",
"build:dev": "vite build --mode dev",
"go": "vite build --watch --mode dev",
"typecheck": "tsc --noEmit",
"lint": "eslint src --fix --ext .ts,.js,.tsx,.jsx",
"preview": "vite preview",
"clean": "rimraf .turbo node_modules dist coverage",
@@ -5,10 +5,10 @@ import { cn } from "@/lib/utils";
export type ButtonVariant = "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "custom";
export type ButtonSize = "default" | "custom" | "sm" | "lg" | "icon";
type ButtonVariantProps = {
interface ButtonVariantProps {
variant?: ButtonVariant | null;
size?: ButtonSize | null;
};
}
type ButtonVariantClassProps =
| (ButtonVariantProps & { class?: string; className?: never })
| (ButtonVariantProps & { class?: never; className?: string })
+1
View File
@@ -35,6 +35,7 @@
"build:analyze": "cross-env NODE_OPTIONS=--max-old-space-size=8192 ANALYZE=true vite build && cross-env NODE_OPTIONS=--max-old-space-size=8192 tsc --project tsconfig.build.json",
"build:dev": "cross-env NODE_OPTIONS=--max-old-space-size=8192 vite build --mode dev && cross-env NODE_OPTIONS=--max-old-space-size=8192 tsc --project tsconfig.build.json",
"go": "concurrently -n \"ESM,UMD\" \"vite build --watch --mode dev\" \"BUILD_UMD=true vite build --watch --mode dev\"",
"typecheck": "tsc --noEmit",
"lint": "eslint src --fix --ext .ts,.js,.tsx,.jsx",
"preview": "vite preview",
"clean": "rimraf .turbo node_modules dist",
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Button } from "./button";
interface BackButtonProps {
onClick: () => void;
@@ -7,18 +7,11 @@ interface BackButtonProps {
tabIndex?: number;
}
export function BackButton({ onClick, backButtonLabel, tabIndex = 2 }: BackButtonProps) {
export function BackButton({ onClick, backButtonLabel, tabIndex = 2 }: Readonly<BackButtonProps>) {
const { t } = useTranslation();
return (
<button
dir="auto"
tabIndex={tabIndex}
type="button"
className={cn(
"hover:bg-input-bg text-heading focus:ring-focus rounded-custom mb-1 flex items-center px-3 py-3 text-base leading-4 font-medium focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
)}
onClick={onClick}>
<Button dir="auto" type="button" variant="secondary" tabIndex={tabIndex} onClick={onClick}>
{backButtonLabel || t("common.back")}
</button>
</Button>
);
}
@@ -0,0 +1,41 @@
import { ButtonHTMLAttributes } from "preact";
import { cn } from "@/lib/utils";
export type ButtonProps = {
variant: "primary" | "secondary";
} & ButtonHTMLAttributes<HTMLButtonElement>;
export function Button({ variant, className, children, ...rest }: Readonly<ButtonProps>) {
return (
<button
dir="auto"
type="button"
className={cn([
"border-border mb-1 flex items-center justify-center border leading-4",
"focus:ring-focus shadow-xs focus:ring-2 focus:ring-offset-2 focus:outline-hidden",
"enabled:hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60",
[
// secondary uses primary's bg color as text color and vice-versa
'data-[variant="secondary"]:bg-(--fb-button-text-color)!',
'data-[variant="secondary"]:text-(--fb-button-bg-color)!',
],
String(className || ""),
])}
data-variant={variant}
style={{
backgroundColor: "var(--fb-button-bg-color)",
color: "var(--fb-button-text-color)",
borderRadius: "var(--fb-button-border-radius)",
height: "var(--fb-button-height)",
fontSize: "var(--fb-button-font-size)",
fontWeight: "var(--fb-button-font-weight)",
paddingLeft: "var(--fb-button-padding-x)",
paddingRight: "var(--fb-button-padding-x)",
paddingTop: "var(--fb-button-padding-y)",
paddingBottom: "var(--fb-button-padding-y)",
}}
{...rest}>
{children}
</button>
);
}
@@ -1,7 +1,8 @@
import { ButtonHTMLAttributes, useRef } from "preact/compat";
import { ButtonHTMLAttributes } from "preact";
import { useRef } from "preact/compat";
import { useCallback, useEffect, useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Button } from "./button";
interface SubmitButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
buttonLabel?: string;
@@ -66,32 +67,18 @@ export function SubmitButton({
}, [focus]);
return (
<button
<Button
{...props}
dir="auto"
variant="primary"
className="button-custom border-submit-button-border"
ref={buttonRef}
type={type}
tabIndex={tabIndex}
autoFocus={focus}
className={cn(
"border-submit-button-border focus:ring-focus mb-1 flex items-center justify-center border leading-4 shadow-xs focus:ring-2 focus:ring-offset-2 focus:outline-hidden enabled:hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60",
"button-custom"
)}
style={{
borderRadius: "var(--fb-button-border-radius)",
backgroundColor: "var(--fb-button-bg-color)",
color: "var(--fb-button-text-color)",
height: "var(--fb-button-height)",
fontSize: "var(--fb-button-font-size)",
fontWeight: "var(--fb-button-font-weight)",
paddingLeft: "var(--fb-button-padding-x)",
paddingRight: "var(--fb-button-padding-x)",
paddingTop: "var(--fb-button-padding-y)",
paddingBottom: "var(--fb-button-padding-y)",
}}
onClick={onClick}
disabled={disabled}>
{buttonLabel || (isLastQuestion ? t("common.finish") : t("common.next"))}
</button>
</Button>
);
}
-1
View File
@@ -27,7 +27,6 @@ describe("Survey Logic", () => {
const mockSurvey: TJsWorkspaceStateSurvey = {
id: "survey1",
name: "Test Survey",
questions: [], // Deprecated - using blocks instead
blocks: [
{
+8
View File
@@ -552,4 +552,12 @@ describe("cn", () => {
test("handles all undefined", () => {
expect(cn(undefined, undefined)).toBe("");
});
test("handles nested arrays of classes", () => {
expect(cn(["foo", ["foo", ["foo", "bar"]]])).toBe("foo foo foo bar");
});
test("handles nulls, booleans and undefined values", () => {
expect(cn(null, true, false, undefined, [null, true, false, undefined])).toBe("");
});
});
+8 -2
View File
@@ -12,8 +12,14 @@ import { type TSurveyElement, type TSurveyElementChoice } from "@formbricks/type
import { type TShuffleOption } from "@formbricks/types/surveys/types";
import { ApiResponse, ApiSuccessResponse } from "@/types/api";
export const cn = (...classes: (string | undefined)[]) => {
return twMerge(classes.filter(Boolean).join(" "));
type ClassValue = string | boolean | null | undefined | ClassValue[];
export const cn = (...classes: ClassValue[]): string => {
return twMerge(
classes
.map((c) => (Array.isArray(c) ? cn(...c) : c))
.filter((c): c is string => typeof c === "string" && c.length > 0)
.join(" ")
);
};
export const getSecureRandom = (): number => {
+1
View File
@@ -11,6 +11,7 @@
"sideEffects": false,
"scripts": {
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"typecheck": "tsc --noEmit",
"clean": "rimraf node_modules .turbo"
},
"dependencies": {
+1
View File
@@ -12,6 +12,7 @@
"sideEffects": false,
"scripts": {
"clean": "rimraf .turbo node_modules dist",
"typecheck": "tsc --noEmit",
"lint": "eslint . --ext .ts,.js,.tsx,.jsx"
},
"devDependencies": {
+32
View File
@@ -15,6 +15,9 @@
"@formbricks/ai#test:coverage": {
"dependsOn": ["@formbricks/logger#build"]
},
"@formbricks/ai#typecheck": {
"dependsOn": ["@formbricks/logger#build"]
},
"@formbricks/cache#build": {
"dependsOn": ["@formbricks/logger#build"],
"outputs": ["dist/**"]
@@ -31,6 +34,9 @@
"@formbricks/cache#test:coverage": {
"dependsOn": ["@formbricks/logger#build"]
},
"@formbricks/cache#typecheck": {
"dependsOn": ["@formbricks/logger#build"]
},
"@formbricks/database#build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", "../../node_modules/.prisma/client/**"]
@@ -38,6 +44,9 @@
"@formbricks/database#lint": {
"dependsOn": ["@formbricks/logger#build", "@formbricks/database#build"]
},
"@formbricks/database#typecheck": {
"dependsOn": ["@formbricks/logger#build", "@formbricks/database#generate"]
},
"@formbricks/email#build": {
"dependsOn": ["^build"],
"outputs": []
@@ -77,6 +86,9 @@
"@formbricks/js-core#lint": {
"dependsOn": ["@formbricks/database#build"]
},
"@formbricks/js-core#typecheck": {
"dependsOn": ["@formbricks/database#build"]
},
"@formbricks/logger#build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
@@ -99,6 +111,9 @@
"@formbricks/storage#test:coverage": {
"dependsOn": ["@formbricks/logger#build"]
},
"@formbricks/storage#typecheck": {
"dependsOn": ["@formbricks/logger#build"]
},
"@formbricks/survey-ui#build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
@@ -131,6 +146,9 @@
"@formbricks/surveys#test:coverage": {
"dependsOn": ["@formbricks/survey-ui#build"]
},
"@formbricks/surveys#typecheck": {
"dependsOn": ["@formbricks/i18n-utils#build", "@formbricks/survey-ui#build"]
},
"@formbricks/web#dev": {
"cache": false,
"dependsOn": [
@@ -178,6 +196,16 @@
"@formbricks/surveys#build"
]
},
"@formbricks/web#typecheck": {
"dependsOn": [
"@formbricks/ai#build",
"@formbricks/cache#build",
"@formbricks/database#build",
"@formbricks/logger#build",
"@formbricks/storage#build",
"@formbricks/surveys#build"
]
},
"build": {
"dependsOn": ["^build"],
"env": [
@@ -394,6 +422,10 @@
},
"test:coverage": {
"outputs": []
},
"typecheck": {
"dependsOn": ["@formbricks/database#generate", "^typecheck"],
"outputs": []
}
},
"ui": "stream"