mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-21 11:49:32 -05:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07d1d918ba | |||
| a98a0a8e73 | |||
| a5a67a05de | |||
| 4876e107f8 | |||
| 5db616ac07 | |||
| fa1ccdb2c3 | |||
| 6183ab4744 | |||
| 431a3d8a76 | |||
| 91ab958379 | |||
| e5df832653 | |||
| 0909c38eb1 | |||
| 08b0d95295 | |||
| a49e989413 | |||
| 9445b2f482 | |||
| f07d832516 | |||
| e615c692a9 | |||
| a9a910d15c | |||
| c425e7aff4 | |||
| a83a54a24a | |||
| f7890eaec3 | |||
| 8cd3187eff | |||
| 83bccc7ded | |||
| 00aa6d5247 | |||
| 0657c94ee5 | |||
| a36cef2936 | |||
| 467af8b6ef | |||
| d0e057eac1 | |||
| ef1f5a2b12 | |||
| 770041923f | |||
| 3e66ff25a1 | |||
| c979909da9 | |||
| 010d96ebcd | |||
| a0b3054f4a | |||
| 02d3cd2af3 | |||
| 2ef4eb4345 | |||
| 093757b386 | |||
| 64f8746940 | |||
| 851616078a | |||
| 06d5313629 | |||
| 7834c21d39 | |||
| f98ca39035 | |||
| 48f928b1bf | |||
| f5dfb4739c | |||
| 5a1fc01388 | |||
| 77a39c13fa | |||
| 5a12539c75 | |||
| 74e0fba757 | |||
| d9c2756185 | |||
| 88ad5c8625 | |||
| 610beee7eb | |||
| db0e2bb105 | |||
| 419ceef413 |
@@ -53,7 +53,7 @@ function {QuestionType}({
|
||||
}: {QuestionType}Props): React.JSX.Element {
|
||||
// Ensure value is always the correct type (handle undefined/null)
|
||||
const currentValue = value ?? {defaultValue};
|
||||
|
||||
|
||||
// Detect text direction from content
|
||||
const detectedDir = useTextDirection({
|
||||
dir,
|
||||
@@ -63,11 +63,11 @@ function {QuestionType}({
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
/>
|
||||
|
||||
{/* Question-specific controls */}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "eslint . --config .eslintrc.cjs --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App.tsx";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
|
||||
export const isPrismaKnownRequestError = (error: unknown): error is PrismaClientKnownRequestError =>
|
||||
export const isPrismaKnownRequestError = (error: unknown): error is Prisma.PrismaClientKnownRequestError =>
|
||||
error instanceof Prisma.PrismaClientKnownRequestError;
|
||||
|
||||
export const isSingleUseIdUniqueConstraintError = (error: PrismaClientKnownRequestError): boolean => {
|
||||
export const isSingleUseIdUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): boolean => {
|
||||
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
+2
-30
@@ -9,12 +9,12 @@ const mocks = vi.hoisted(() => ({
|
||||
getSurvey: vi.fn(),
|
||||
getValidatedResponseUpdateInput: vi.fn(),
|
||||
loggerError: vi.fn(),
|
||||
resolveClientApiIds: vi.fn(),
|
||||
sendToPipeline: vi.fn(),
|
||||
updateResponseWithQuotaEvaluation: vi.fn(),
|
||||
validateFileUploads: vi.fn(),
|
||||
validateOtherOptionLengthForMultipleChoice: vi.fn(),
|
||||
validateResponseData: vi.fn(),
|
||||
resolveClientApiIds: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
@@ -128,11 +128,11 @@ describe("putResponseHandler", () => {
|
||||
});
|
||||
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
|
||||
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
|
||||
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
|
||||
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
|
||||
mocks.validateFileUploads.mockReturnValue(true);
|
||||
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
|
||||
mocks.validateResponseData.mockReturnValue(null);
|
||||
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
|
||||
});
|
||||
|
||||
test("returns a bad request response when the response id is missing", async () => {
|
||||
@@ -245,34 +245,6 @@ describe("putResponseHandler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("returns not found when the workspace id cannot be resolved", async () => {
|
||||
mocks.resolveClientApiIds.mockResolvedValue(null);
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams({ workspaceId: "unknown_workspace_or_env" }));
|
||||
|
||||
expect(result.response.status).toBe(404);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "not_found",
|
||||
message: "Workspace not found",
|
||||
details: {
|
||||
resource_id: "unknown_workspace_or_env",
|
||||
resource_type: "Workspace",
|
||||
},
|
||||
});
|
||||
expect(mocks.getResponse).not.toHaveBeenCalled();
|
||||
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("accepts updates when the route param is a legacy environment id that resolves to the survey workspace", async () => {
|
||||
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams({ workspaceId: "legacy_environment_id" }));
|
||||
|
||||
expect(mocks.resolveClientApiIds).toHaveBeenCalledWith("legacy_environment_id");
|
||||
expect(result.response.status).toBe(200);
|
||||
expect(mocks.updateResponseWithQuotaEvaluation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("rejects updates when the response survey does not belong to the requested workspace", async () => {
|
||||
mocks.getSurvey.mockResolvedValue({
|
||||
...getBaseSurvey(),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
@@ -213,7 +212,7 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
|
||||
|
||||
// -- Composite functions --
|
||||
|
||||
const mapUniqueConstraintError = (error: PrismaClientKnownRequestError): InvalidInputError => {
|
||||
const mapUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): InvalidInputError => {
|
||||
const target = error.meta?.target;
|
||||
const targetFields = Array.isArray(target) ? (target as string[]) : [];
|
||||
if (targetFields.includes("elementId") || targetFields.includes("surveyId")) {
|
||||
|
||||
@@ -38,50 +38,6 @@ describe("convertToCsv", () => {
|
||||
|
||||
parseSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("should defang formula injection payloads in cell values", async () => {
|
||||
const payloads = [
|
||||
'=HYPERLINK("https://evil.tld","Click")',
|
||||
"+1+1",
|
||||
"-2+3",
|
||||
"@SUM(A1:A2)",
|
||||
"\tleading-tab",
|
||||
"\rleading-cr",
|
||||
];
|
||||
const rows = payloads.map((p) => ({ name: p, age: 0 }));
|
||||
const csv = await convertToCsv(["name", "age"], rows);
|
||||
const lines = csv.trim().split("\n").slice(1); // drop header
|
||||
payloads.forEach((p, i) => {
|
||||
// each value should be prefixed with a single quote so the spreadsheet
|
||||
// app treats it as text rather than a formula
|
||||
expect(lines[i].startsWith(`"'${p.charAt(0)}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("should defang formula injection in field/header names", async () => {
|
||||
const csv = await convertToCsv(["=evil", "age"], [{ "=evil": "x", age: 1 }]);
|
||||
const lines = csv.trim().split("\n");
|
||||
expect(lines[0]).toBe('"\'=evil","age"');
|
||||
expect(lines[1]).toBe('"x",1');
|
||||
});
|
||||
|
||||
test("should not alter benign strings", async () => {
|
||||
const csv = await convertToCsv(["name"], [{ name: "Alice = Bob" }]);
|
||||
const lines = csv.trim().split("\n");
|
||||
expect(lines[1]).toBe('"Alice = Bob"');
|
||||
});
|
||||
|
||||
test("should preserve distinct columns whose labels collide after sanitization", async () => {
|
||||
// "=field" and "'=field" both render as "'=field" once defanged, but the
|
||||
// underlying row keys must stay distinct so neither cell is dropped.
|
||||
const csv = await convertToCsv(
|
||||
["=field", "'=field"],
|
||||
[{ "=field": "a", "'=field": "b" }]
|
||||
);
|
||||
const lines = csv.trim().split("\n");
|
||||
expect(lines[0]).toBe('"\'=field","\'=field"');
|
||||
expect(lines[1]).toBe('"a","b"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertToXlsxBuffer", () => {
|
||||
@@ -104,54 +60,4 @@ describe("convertToXlsxBuffer", () => {
|
||||
const cleaned = raw.map(({ __rowNum__, ...rest }) => rest);
|
||||
expect(cleaned).toEqual(data);
|
||||
});
|
||||
|
||||
test("should defang formula injection payloads in xlsx cells", () => {
|
||||
const payloads = [
|
||||
'=HYPERLINK("https://evil.tld","Click")',
|
||||
"+1+1",
|
||||
"-2+3",
|
||||
"@SUM(A1:A2)",
|
||||
"\tleading-tab",
|
||||
"\rleading-cr",
|
||||
];
|
||||
const rows = payloads.map((p) => ({ name: p }));
|
||||
const buffer = convertToXlsxBuffer(["name"], rows);
|
||||
const wb = xlsx.read(buffer, { type: "buffer" });
|
||||
const sheet = wb.Sheets["Sheet1"];
|
||||
payloads.forEach((p, i) => {
|
||||
const cell = sheet[`A${i + 2}`]; // row 1 is header
|
||||
// value stored as plain text, not as a formula (no `f` property)
|
||||
expect(cell.f).toBeUndefined();
|
||||
expect(cell.v).toBe(`'${p}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("should defang formula injection in xlsx header names", () => {
|
||||
const buffer = convertToXlsxBuffer(["=evil", "name"], [{ "=evil": "x", name: "Alice" }]);
|
||||
const wb = xlsx.read(buffer, { type: "buffer" });
|
||||
const sheet = wb.Sheets["Sheet1"];
|
||||
const headerCell = sheet["A1"];
|
||||
expect(headerCell.f).toBeUndefined();
|
||||
expect(headerCell.v).toBe("'=evil");
|
||||
// benign header untouched
|
||||
expect(sheet["B1"].v).toBe("name");
|
||||
// data row mapped via original key
|
||||
expect(sheet["A2"].v).toBe("x");
|
||||
expect(sheet["B2"].v).toBe("Alice");
|
||||
});
|
||||
|
||||
test("should preserve distinct xlsx columns whose labels collide after sanitization", () => {
|
||||
// Original keys "=field" and "'=field" both render as "'=field"; ensure
|
||||
// both cells survive instead of one overwriting the other.
|
||||
const buffer = convertToXlsxBuffer(
|
||||
["=field", "'=field"],
|
||||
[{ "=field": "a", "'=field": "b" }]
|
||||
);
|
||||
const wb = xlsx.read(buffer, { type: "buffer" });
|
||||
const sheet = wb.Sheets["Sheet1"];
|
||||
expect(sheet["A1"].v).toBe("'=field");
|
||||
expect(sheet["B1"].v).toBe("'=field");
|
||||
expect(sheet["A2"].v).toBe("a");
|
||||
expect(sheet["B2"].v).toBe("b");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { expandPresetDateRanges } from "./date-presets";
|
||||
|
||||
const queryWithDateRange = (dateRange: string | [string, string]): TChartQuery => ({
|
||||
measures: ["FeedbackRecords.count"],
|
||||
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt", dateRange }],
|
||||
});
|
||||
|
||||
// Mid-month, mid-quarter date that exercises month/quarter/year boundaries cleanly.
|
||||
const NOW = new Date(2026, 4, 21, 14, 30, 0); // May 21, 2026 14:30 local
|
||||
|
||||
describe("expandPresetDateRanges", () => {
|
||||
test("includes today for 'last 7 days'", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("last 7 days"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-15", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("includes today for 'last 30 days'", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("last 30 days"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-22", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("expands 'today' to today..today", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("today"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-21", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("expands 'yesterday' to yesterday..yesterday", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("yesterday"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-20", "2026-05-20"]);
|
||||
});
|
||||
|
||||
test("'this month' runs from the 1st through today", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("this month"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-01", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("'last month' is the full previous calendar month", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("last month"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-01", "2026-04-30"]);
|
||||
});
|
||||
|
||||
test("'last month' handles year rollover", () => {
|
||||
const janFirst = new Date(2026, 0, 15, 10, 0, 0);
|
||||
const result = expandPresetDateRanges(queryWithDateRange("last month"), janFirst);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2025-12-01", "2025-12-31"]);
|
||||
});
|
||||
|
||||
test("'this quarter' starts at the first day of the calendar quarter", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("this quarter"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-01", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("'this year' starts on Jan 1", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("this year"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-01-01", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("leaves explicit [start, end] tuple unchanged", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange(["2026-01-01", "2026-01-15"]), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-01-01", "2026-01-15"]);
|
||||
});
|
||||
|
||||
test("leaves an unknown preset string unchanged so Cube can interpret it", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("from -3 days to now"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toBe("from -3 days to now");
|
||||
});
|
||||
|
||||
test("returns input unchanged when there are no time dimensions", () => {
|
||||
const q: TChartQuery = { measures: ["FeedbackRecords.count"] };
|
||||
expect(expandPresetDateRanges(q, NOW)).toEqual(q);
|
||||
});
|
||||
|
||||
test("preserves other timeDimension fields (granularity, dimension)", () => {
|
||||
const q: TChartQuery = {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
timeDimensions: [
|
||||
{ dimension: "FeedbackRecords.collectedAt", granularity: "day", dateRange: "last 7 days" },
|
||||
],
|
||||
};
|
||||
const result = expandPresetDateRanges(q, NOW);
|
||||
expect(result.timeDimensions?.[0]).toMatchObject({
|
||||
dimension: "FeedbackRecords.collectedAt",
|
||||
granularity: "day",
|
||||
dateRange: ["2026-05-15", "2026-05-21"],
|
||||
});
|
||||
});
|
||||
|
||||
test("does not mutate the input query", () => {
|
||||
const q = queryWithDateRange("last 7 days");
|
||||
const before = JSON.stringify(q);
|
||||
expandPresetDateRanges(q, NOW);
|
||||
expect(JSON.stringify(q)).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
FEEDBACK_FIELDS,
|
||||
@@ -8,17 +6,6 @@ import {
|
||||
getFilterOperatorsForType,
|
||||
} from "./schema-definition";
|
||||
|
||||
const chartCubeSchemaPath = fileURLToPath(
|
||||
new URL("../../../../../../charts/formbricks/cube/schema/FeedbackRecords.js", import.meta.url)
|
||||
);
|
||||
const dockerCubeSchemaPath = fileURLToPath(
|
||||
new URL("../../../../../../docker/cube/schema/FeedbackRecords.js", import.meta.url)
|
||||
);
|
||||
|
||||
const readChartCubeSchema = (): string => readFileSync(chartCubeSchemaPath, "utf8");
|
||||
const readDockerCubeSchema = (): string => readFileSync(dockerCubeSchemaPath, "utf8");
|
||||
const getCubeMemberName = (id: string): string => id.replace("FeedbackRecords.", "");
|
||||
|
||||
describe("schema-definition", () => {
|
||||
describe("getFilterOperatorsForType", () => {
|
||||
test("returns string operators", () => {
|
||||
@@ -107,20 +94,5 @@ describe("schema-definition", () => {
|
||||
);
|
||||
expect(ids).not.toContain("FeedbackRecords.averageScore");
|
||||
});
|
||||
|
||||
test("only exposes members present in the deployed Cube schema", () => {
|
||||
const chartCubeSchema = readChartCubeSchema();
|
||||
const exposedMembers = [...FEEDBACK_FIELDS.measures, ...FEEDBACK_FIELDS.dimensions].map(({ id }) =>
|
||||
getCubeMemberName(id)
|
||||
);
|
||||
|
||||
for (const member of exposedMembers) {
|
||||
expect(chartCubeSchema).toContain(` ${member}: {`);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the Helm and Docker Cube schemas in sync", () => {
|
||||
expect(readChartCubeSchema()).toBe(readDockerCubeSchema());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import { processResponsePipelineJob } from "./process-response-pipeline-job";
|
||||
const {
|
||||
mockFetch,
|
||||
mockCaptureSurveyResponsePostHogEvent,
|
||||
mockCreatePinnedDispatcher,
|
||||
mockDispatcherDestroy,
|
||||
mockGetIntegrations,
|
||||
mockGetResponseCountBySurveyId,
|
||||
mockHandleIntegrations,
|
||||
@@ -21,13 +23,16 @@ const {
|
||||
mockSendFollowUpsForResponse,
|
||||
mockSendResponseFinishedEmail,
|
||||
mockSendTelemetryEvents,
|
||||
mockValidateWebhookUrl,
|
||||
mockValidateAndResolveWebhookUrl,
|
||||
} = 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(),
|
||||
@@ -43,7 +48,7 @@ const {
|
||||
mockSendFollowUpsForResponse: vi.fn(),
|
||||
mockSendResponseFinishedEmail: vi.fn(),
|
||||
mockSendTelemetryEvents: vi.fn(),
|
||||
mockValidateWebhookUrl: vi.fn(),
|
||||
mockValidateAndResolveWebhookUrl: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -80,6 +85,7 @@ vi.mock(import("@/lib/constants"), async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
POSTHOG_KEY: undefined,
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -104,7 +110,8 @@ vi.mock("./posthog", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate-webhook-url", () => ({
|
||||
validateWebhookUrl: mockValidateWebhookUrl,
|
||||
validateAndResolveWebhookUrl: mockValidateAndResolveWebhookUrl,
|
||||
createPinnedDispatcher: mockCreatePinnedDispatcher,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
@@ -205,7 +212,9 @@ describe("processResponsePipelineJob", () => {
|
||||
mockPrismaUserFindMany.mockResolvedValue([]);
|
||||
mockGetResponseCountBySurveyId.mockResolvedValue(1);
|
||||
mockHandleIntegrations.mockResolvedValue(undefined);
|
||||
mockValidateWebhookUrl.mockResolvedValue(undefined);
|
||||
mockValidateAndResolveWebhookUrl.mockResolvedValue({ ip: "93.184.216.34", family: 4 });
|
||||
mockDispatcherDestroy.mockResolvedValue(undefined);
|
||||
mockCreatePinnedDispatcher.mockImplementation(() => ({ destroy: mockDispatcherDestroy }));
|
||||
mockQueueAuditEventWithoutRequest.mockResolvedValue(undefined);
|
||||
mockRecordResponseCreatedMeterEvent.mockResolvedValue(undefined);
|
||||
mockSendResponseFinishedEmail.mockResolvedValue(undefined);
|
||||
@@ -242,7 +251,7 @@ describe("processResponsePipelineJob", () => {
|
||||
triggers: { has: "responseCreated" },
|
||||
},
|
||||
});
|
||||
expect(mockValidateWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
|
||||
expect(mockValidateAndResolveWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook",
|
||||
expect.objectContaining({
|
||||
@@ -481,7 +490,7 @@ describe("processResponsePipelineJob", () => {
|
||||
url: "https://example.com/webhook",
|
||||
},
|
||||
]);
|
||||
mockValidateWebhookUrl.mockRejectedValue(webhookError);
|
||||
mockValidateAndResolveWebhookUrl.mockRejectedValue(webhookError);
|
||||
|
||||
await expect(
|
||||
processResponsePipelineJob(
|
||||
@@ -527,7 +536,7 @@ describe("processResponsePipelineJob", () => {
|
||||
locale: "en",
|
||||
},
|
||||
]);
|
||||
mockValidateWebhookUrl.mockRejectedValue(webhookError);
|
||||
mockValidateAndResolveWebhookUrl.mockRejectedValue(webhookError);
|
||||
|
||||
await expect(
|
||||
processResponsePipelineJob(
|
||||
@@ -780,6 +789,133 @@ 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 { POSTHOG_KEY } from "@/lib/constants";
|
||||
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS, POSTHOG_KEY } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { createPinnedDispatcher, validateAndResolveWebhookUrl } 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,9 +136,13 @@ 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: RequestInit,
|
||||
options: WebhookFetchOptions,
|
||||
timeoutMs: number = WEBHOOK_TIMEOUT_MS
|
||||
): Promise<Response> => {
|
||||
const abortController = new AbortController();
|
||||
@@ -153,7 +157,7 @@ const fetchWithTimeout = async (
|
||||
return await fetch(url, {
|
||||
...options,
|
||||
signal,
|
||||
});
|
||||
} as RequestInit);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
@@ -222,15 +226,47 @@ const createWebhookDeliveryTask = async ({
|
||||
);
|
||||
}
|
||||
|
||||
await validateWebhookUrl(webhook.url);
|
||||
const response = await fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
});
|
||||
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";
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Webhook delivery failed with status ${response.status}`);
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
|
||||
@@ -31,7 +31,7 @@ export const SurveyCompletedMessage = async ({
|
||||
{(!workspace || workspace.linkSurveyBranding) && (
|
||||
<div>
|
||||
<Link href="https://formbricks.com">
|
||||
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
|
||||
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -76,7 +76,7 @@ export const SurveyInactive = async ({
|
||||
{(!workspace || workspace.linkSurveyBranding) && (
|
||||
<div>
|
||||
<Link href="https://formbricks.com">
|
||||
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
|
||||
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -123,7 +123,11 @@ export const SurveyLoadingAnimation = ({
|
||||
isReadyToTransition ? "animate-surveyExit" : "animate-surveyLoading"
|
||||
)}>
|
||||
{isBrandingEnabled && (
|
||||
<Image src={Logo} alt="Logo" className={cn("w-32 transition-all duration-1000 md:w-40")} />
|
||||
<Image
|
||||
src={Logo as string}
|
||||
alt="Logo"
|
||||
className={cn("w-32 transition-all duration-1000 md:w-40")}
|
||||
/>
|
||||
)}
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@ const DropdownMenuSubContent: React.ComponentType<DropdownMenuPrimitive.Dropdown
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref as any}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-600 shadow-sm animate-in slide-in-from-left-1 hover:text-slate-700",
|
||||
"animate-in slide-in-from-left-1 z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-600 shadow-sm hover:text-slate-700",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -67,7 +67,7 @@ const DropdownMenuContent: React.ComponentType<DropdownMenuPrimitive.DropdownMen
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-700 shadow-sm animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"animate-in data-[side=right]:slide-in-from-left-2 data-[side=left]:slide-in-from-right-2 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-700 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -19,7 +19,7 @@ const PopoverContent: React.ForwardRefExoticComponent<
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border border-slate-100 bg-white p-4 shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=right]:slide-in-from-left-2 data-[side=left]:slide-in-from-right-2 z-50 w-72 rounded-md border border-slate-100 bg-white p-4 shadow-md outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -23,7 +23,7 @@ const TooltipContent: React.ComponentType<TooltipPrimitive.TooltipContentProps>
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
|
||||
"animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=top]:slide-in-from-bottom-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build",
|
||||
"build:dev": "pnpm run build",
|
||||
"start": "next start",
|
||||
"typecheck": "pnpm typegen && tsc --noEmit --project tsconfig.typecheck.json",
|
||||
"typegen": "cross-env DATABASE_URL=postgresql://postgres:postgres@localhost:5432/formbricks ENCRYPTION_KEY=example REDIS_URL=redis://localhost:6379 next typegen",
|
||||
"lint": "eslint . --fix --ext .ts,.js,.tsx,.jsx",
|
||||
"test": "dotenv -e ../../.env -- vitest run",
|
||||
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage",
|
||||
|
||||
Vendored
-8
@@ -1,8 +0,0 @@
|
||||
import "@prisma/client";
|
||||
|
||||
declare module "@prisma/client" {
|
||||
namespace Prisma {
|
||||
// Prisma exposes this error class at runtime, but the generated client types do not declare it on Prisma.
|
||||
const PrismaClientKnownRequestError: typeof import("@prisma/client/runtime/library").PrismaClientKnownRequestError;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"exclude": [
|
||||
"../../.env",
|
||||
".next",
|
||||
"node_modules",
|
||||
"playwright",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/tests/**",
|
||||
"**/__mocks__/**",
|
||||
"**/__tests__/**"
|
||||
],
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.d.ts",
|
||||
"app/**/*.ts",
|
||||
"app/**/*.tsx",
|
||||
"lib/**/*.ts",
|
||||
"lib/**/*.tsx",
|
||||
"modules/**/*.ts",
|
||||
"modules/**/*.tsx",
|
||||
"scripts/**/*.ts",
|
||||
"../../packages/types/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -30,7 +30,6 @@
|
||||
"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",
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"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"
|
||||
|
||||
Vendored
-1
@@ -30,7 +30,6 @@
|
||||
"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",
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"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": {
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
},
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"extends": "@formbricks/config-typescript/node16.json",
|
||||
"include": ["src/**/*.ts", "types/**/*.ts", "zod/**/*.ts", "migration/**/*.ts", "vite.config.ts"]
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "tsup.config.ts"]
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "email dev --port 3456",
|
||||
"build": "pnpm typecheck",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "eslint src --fix --ext .ts,.tsx",
|
||||
"clean": "rimraf .turbo node_modules dist"
|
||||
},
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"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",
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
"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,37 +1,2 @@
|
||||
import type { TWorkspaceStateSurvey } from "@/types/config";
|
||||
|
||||
export const mockSurveyId = "jgocyoxk9uifo6u381qahmes";
|
||||
|
||||
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,
|
||||
});
|
||||
export const mockSurveyName = "Test Survey";
|
||||
|
||||
@@ -709,10 +709,7 @@ describe("time on page action handling", () => {
|
||||
clearTimeOnPageTimers();
|
||||
});
|
||||
|
||||
const createConfigWithTimeOnPageAction = (
|
||||
actionName: string,
|
||||
timeInSeconds: number
|
||||
): { get: Mock; update: Mock } => ({
|
||||
const createConfigWithTimeOnPageAction = (actionName: string, timeInSeconds: number) => ({
|
||||
get: vi.fn().mockReturnValue({
|
||||
workspace: {
|
||||
data: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { SurveyStore } from "@/lib/survey/store";
|
||||
import { createMockSurvey } from "@/lib/survey/tests/__mocks__/store.mock";
|
||||
import { mockSurveyId, mockSurveyName } from "@/lib/survey/tests/__mocks__/store.mock";
|
||||
import type { TWorkspaceStateSurvey } from "@/types/config";
|
||||
|
||||
describe("SurveyStore", () => {
|
||||
let store: SurveyStore;
|
||||
@@ -26,7 +27,10 @@ describe("SurveyStore", () => {
|
||||
});
|
||||
|
||||
test("returns current survey when set", () => {
|
||||
const mockSurvey = createMockSurvey();
|
||||
const mockSurvey: TWorkspaceStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TWorkspaceStateSurvey;
|
||||
|
||||
store.setSurvey(mockSurvey);
|
||||
expect(store.getSurvey()).toBe(mockSurvey);
|
||||
@@ -36,7 +40,10 @@ describe("SurveyStore", () => {
|
||||
describe("setSurvey", () => {
|
||||
test("updates survey and notifies listeners when survey changes", () => {
|
||||
const listener = vi.fn();
|
||||
const mockSurvey = createMockSurvey();
|
||||
const mockSurvey: TWorkspaceStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TWorkspaceStateSurvey;
|
||||
|
||||
store.subscribe(listener);
|
||||
store.setSurvey(mockSurvey);
|
||||
@@ -47,7 +54,10 @@ describe("SurveyStore", () => {
|
||||
|
||||
test("does not notify listeners when setting same survey", () => {
|
||||
const listener = vi.fn();
|
||||
const mockSurvey = createMockSurvey();
|
||||
const mockSurvey: TWorkspaceStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TWorkspaceStateSurvey;
|
||||
|
||||
store.setSurvey(mockSurvey);
|
||||
store.subscribe(listener);
|
||||
@@ -60,7 +70,10 @@ describe("SurveyStore", () => {
|
||||
describe("resetSurvey", () => {
|
||||
test("resets survey to null and notifies listeners", () => {
|
||||
const listener = vi.fn();
|
||||
const mockSurvey = createMockSurvey();
|
||||
const mockSurvey: TWorkspaceStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TWorkspaceStateSurvey;
|
||||
|
||||
store.setSurvey(mockSurvey);
|
||||
store.subscribe(listener);
|
||||
@@ -83,21 +96,27 @@ describe("SurveyStore", () => {
|
||||
describe("subscribe", () => {
|
||||
test("adds listener and returns unsubscribe function", () => {
|
||||
const listener = vi.fn();
|
||||
const mockSurvey = createMockSurvey();
|
||||
const mockSurvey: TWorkspaceStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TWorkspaceStateSurvey;
|
||||
|
||||
const unsubscribe = store.subscribe(listener);
|
||||
store.setSurvey(mockSurvey);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
unsubscribe();
|
||||
store.setSurvey({ ...mockSurvey, id: "updated-survey-id" });
|
||||
store.setSurvey({ ...mockSurvey, name: "Updated Survey" } as TWorkspaceStateSurvey);
|
||||
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 = createMockSurvey();
|
||||
const mockSurvey: TWorkspaceStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TWorkspaceStateSurvey;
|
||||
|
||||
store.subscribe(listener1);
|
||||
store.subscribe(listener2);
|
||||
|
||||
@@ -69,20 +69,6 @@ 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 = "";
|
||||
@@ -153,7 +139,10 @@ describe("widget-file", () => {
|
||||
(filterSurveys as Mock).mockReturnValue([]);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -165,7 +154,7 @@ describe("widget-file", () => {
|
||||
|
||||
vi.advanceTimersByTime(mockSurvey.delay * 1000);
|
||||
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalledWith(
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
survey: mockSurvey,
|
||||
appUrl: "https://fake.app",
|
||||
@@ -313,7 +302,10 @@ describe("widget-file", () => {
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -327,7 +319,7 @@ describe("widget-file", () => {
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalledWith(
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contactId: "contact_abc",
|
||||
})
|
||||
@@ -370,7 +362,10 @@ describe("widget-file", () => {
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -383,7 +378,7 @@ describe("widget-file", () => {
|
||||
expect(mockUpdateQueue.waitForPendingWork).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalled();
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -427,7 +422,10 @@ describe("widget-file", () => {
|
||||
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -439,7 +437,7 @@ describe("widget-file", () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
// The contactId passed to renderSurvey should be read after the wait
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalledWith(
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contactId: "contact_after_identification",
|
||||
})
|
||||
@@ -454,7 +452,10 @@ describe("widget-file", () => {
|
||||
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
await widget.renderWidget({
|
||||
...mockSurvey,
|
||||
@@ -466,7 +467,7 @@ describe("widget-file", () => {
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
"User identification failed. Skipping survey with segment filters."
|
||||
);
|
||||
expect(getFormbricksSurveys().renderSurvey).not.toHaveBeenCalled();
|
||||
expect(window.formbricksSurveys.renderSurvey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("loadFormbricksSurveysExternally and waitForSurveysGlobal", () => {
|
||||
@@ -597,7 +598,8 @@ describe("widget-file", () => {
|
||||
(scriptEl.onload as () => void)();
|
||||
|
||||
// Set the global after script "loads" — simulates browser finishing execution
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
|
||||
|
||||
// Advance one polling interval for waitForSurveysGlobal to find it
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
@@ -607,8 +609,8 @@ describe("widget-file", () => {
|
||||
// Run remaining timers for survey.delay setTimeout
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(getFormbricksSurveys().setNonce).toHaveBeenCalledWith("test-nonce-123");
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalledWith(
|
||||
expect(window.formbricksSurveys.setNonce).toHaveBeenCalledWith("test-nonce-123");
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
appUrl: "https://fake.app",
|
||||
workspaceId: "env_123",
|
||||
@@ -627,11 +629,13 @@ 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");
|
||||
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -649,7 +653,7 @@ describe("widget-file", () => {
|
||||
});
|
||||
expect(scriptAppendCalls.length).toBe(0);
|
||||
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalled();
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -709,7 +713,10 @@ describe("widget-file", () => {
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -724,7 +731,7 @@ describe("widget-file", () => {
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalled();
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -239,6 +239,7 @@ 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) {
|
||||
@@ -261,6 +262,7 @@ 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);
|
||||
}
|
||||
@@ -298,6 +300,7 @@ 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;
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
"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",
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
"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";
|
||||
interface ButtonVariantProps {
|
||||
type ButtonVariantProps = {
|
||||
variant?: ButtonVariant | null;
|
||||
size?: ButtonSize | null;
|
||||
}
|
||||
};
|
||||
type ButtonVariantClassProps =
|
||||
| (ButtonVariantProps & { class?: string; className?: never })
|
||||
| (ButtonVariantProps & { class?: never; className?: string })
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
"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",
|
||||
|
||||
@@ -27,6 +27,7 @@ describe("Survey Logic", () => {
|
||||
|
||||
const mockSurvey: TJsWorkspaceStateSurvey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [], // Deprecated - using blocks instead
|
||||
blocks: [
|
||||
{
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rimraf node_modules .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
-32
@@ -15,9 +15,6 @@
|
||||
"@formbricks/ai#test:coverage": {
|
||||
"dependsOn": ["@formbricks/logger#build"]
|
||||
},
|
||||
"@formbricks/ai#typecheck": {
|
||||
"dependsOn": ["@formbricks/logger#build"]
|
||||
},
|
||||
"@formbricks/cache#build": {
|
||||
"dependsOn": ["@formbricks/logger#build"],
|
||||
"outputs": ["dist/**"]
|
||||
@@ -34,9 +31,6 @@
|
||||
"@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/**"]
|
||||
@@ -44,9 +38,6 @@
|
||||
"@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": []
|
||||
@@ -86,9 +77,6 @@
|
||||
"@formbricks/js-core#lint": {
|
||||
"dependsOn": ["@formbricks/database#build"]
|
||||
},
|
||||
"@formbricks/js-core#typecheck": {
|
||||
"dependsOn": ["@formbricks/database#build"]
|
||||
},
|
||||
"@formbricks/logger#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
@@ -111,9 +99,6 @@
|
||||
"@formbricks/storage#test:coverage": {
|
||||
"dependsOn": ["@formbricks/logger#build"]
|
||||
},
|
||||
"@formbricks/storage#typecheck": {
|
||||
"dependsOn": ["@formbricks/logger#build"]
|
||||
},
|
||||
"@formbricks/survey-ui#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
@@ -146,9 +131,6 @@
|
||||
"@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": [
|
||||
@@ -196,16 +178,6 @@
|
||||
"@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": [
|
||||
@@ -422,10 +394,6 @@
|
||||
},
|
||||
"test:coverage": {
|
||||
"outputs": []
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": ["@formbricks/database#generate", "^typecheck"],
|
||||
"outputs": []
|
||||
}
|
||||
},
|
||||
"ui": "stream"
|
||||
|
||||
Reference in New Issue
Block a user