Compare commits

...

9 Commits

Author SHA1 Message Date
Jakob Schott e9dbaa3c28 fix: survey id in summary (#5056) 2025-03-26 02:24:58 +00:00
dependabot[bot] d352d03071 chore(deps-dev): bump the npm_and_yarn group across 2 directories with 1 update (#5062)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-03-26 02:09:46 +00:00
victorvhs017 ebefe775bb fix: updated ttl property on cache-handler (#5065) 2025-03-26 01:53:59 +00:00
Anshuman Pandey 0852a961cc fix: adds unit tests for tsx files (#5001) 2025-03-25 16:58:47 +00:00
victorvhs017 46f06f4c0e feat: Added Webhooks in Management API V2 (#4949)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-03-25 14:28:44 +00:00
Matti Nannt afb39e4aba docs: update license page (#5061) 2025-03-25 15:09:13 +01:00
Anshuman Pandey 2c6a90f82b fix: storage api endpoint openapi spec (#5057) 2025-03-25 12:16:35 +00:00
Dhruwang Jariwala e35f732e48 fix: Remove hardcoded pg url (#4878) 2025-03-25 08:36:18 +00:00
Matti Nannt ec8b17dee2 chore: use github bug report form instead of formbricks form (#5055) 2025-03-25 08:44:32 +01:00
117 changed files with 5846 additions and 1115 deletions
@@ -56,10 +56,6 @@ runs:
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
shell: bash
+1 -1
View File
@@ -35,6 +35,6 @@
"prop-types": "15.8.1",
"storybook": "8.4.7",
"tsup": "8.3.5",
"vite": "6.0.9"
"vite": "6.0.12"
}
}
-6
View File
@@ -24,12 +24,6 @@ RUN corepack enable
# Install necessary build tools and compilers
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
# Set hardcoded environment variables
ENV DATABASE_URL="postgresql://placeholder:for@build:5432/gets_overwritten_at_runtime?schema=public"
ENV NEXTAUTH_SECRET="placeholder_for_next_auth_of_64_chars_get_overwritten_at_runtime"
ENV ENCRYPTION_KEY="placeholder_for_build_key_of_64_chars_get_overwritten_at_runtime"
ENV CRON_SECRET="placeholder_for_cron_secret_of_64_chars_get_overwritten_at_runtime"
ARG NEXT_PUBLIC_SENTRY_DSN
ARG SENTRY_AUTH_TOKEN
@@ -0,0 +1,103 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
// Mock react-hot-toast so we can assert that a success message is shown
vi.mock("react-hot-toast", () => ({
__esModule: true,
default: {
success: vi.fn(),
},
}));
// Set up a spy for navigator.clipboard.writeText so it becomes a ViTest spy.
beforeAll(() => {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
writable: true,
value: {
// Using a mockResolvedValue resolves the promise as writeText is async.
writeText: vi.fn().mockResolvedValue(undefined),
},
});
});
describe("OnboardingSetupInstructions", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
// Provide some default props for testing
const defaultProps = {
environmentId: "env-123",
webAppUrl: "https://example.com",
channel: "app" as const, // Assuming channel is either "app" or "website"
widgetSetupCompleted: false,
};
test("renders HTML tab content by default", () => {
render(<OnboardingSetupInstructions {...defaultProps} />);
// Since the default active tab is "html", we check for a unique text
expect(
screen.getByText(/environments.connect.insert_this_code_into_the_head_tag_of_your_website/i)
).toBeInTheDocument();
// The HTML snippet contains a marker comment
expect(screen.getByText("START")).toBeInTheDocument();
// Verify the "Copy Code" button is present
expect(screen.getByRole("button", { name: /common.copy_code/i })).toBeInTheDocument();
});
test("renders NPM tab content when selected", async () => {
render(<OnboardingSetupInstructions {...defaultProps} />);
const user = userEvent.setup();
// Click on the "NPM" tab to switch views.
const npmTab = screen.getByText("NPM");
await user.click(npmTab);
// Check that the install commands are present
expect(screen.getByText(/npm install @formbricks\/js/)).toBeInTheDocument();
expect(screen.getByText(/yarn add @formbricks\/js/)).toBeInTheDocument();
// Verify the "Read Docs" link has the correct URL (based on channel prop)
const readDocsLink = screen.getByRole("link", { name: /common.read_docs/i });
expect(readDocsLink).toHaveAttribute("href", "https://formbricks.com/docs/app-surveys/framework-guides");
});
test("copies HTML snippet to clipboard and shows success toast when Copy Code button is clicked", async () => {
render(<OnboardingSetupInstructions {...defaultProps} />);
const user = userEvent.setup();
const writeTextSpy = vi.spyOn(navigator.clipboard, "writeText");
// Click the "Copy Code" button
const copyButton = screen.getByRole("button", { name: /common.copy_code/i });
await user.click(copyButton);
// Ensure navigator.clipboard.writeText was called.
expect(writeTextSpy).toHaveBeenCalled();
const writtenText = (navigator.clipboard.writeText as any).mock.calls[0][0] as string;
// Check that the pasted snippet contains the expected environment values
expect(writtenText).toContain('var appUrl = "https://example.com"');
expect(writtenText).toContain('var environmentId = "env-123"');
// Verify that a success toast was shown
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
});
test("renders step-by-step manual link with correct URL in HTML tab", () => {
render(<OnboardingSetupInstructions {...defaultProps} />);
const manualLink = screen.getByRole("link", { name: /common.step_by_step_manual/i });
expect(manualLink).toHaveAttribute(
"href",
"https://formbricks.com/docs/app-surveys/framework-guides#html"
);
});
});
@@ -0,0 +1,77 @@
import { render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import formbricks from "@formbricks/js";
import { FormbricksClient } from "./FormbricksClient";
// Mock next/navigation hooks.
vi.mock("next/navigation", () => ({
usePathname: () => "/test-path",
useSearchParams: () => new URLSearchParams("foo=bar"),
}));
// Mock the environment variables.
vi.mock("@formbricks/lib/env", () => ({
env: {
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: "env-test",
NEXT_PUBLIC_FORMBRICKS_API_HOST: "https://api.test.com",
},
}));
// Mock the flag that enables Formbricks.
vi.mock("@/app/lib/formbricks", () => ({
formbricksEnabled: true,
}));
// Mock the Formbricks SDK module.
vi.mock("@formbricks/js", () => ({
__esModule: true,
default: {
setup: vi.fn(),
setUserId: vi.fn(),
setEmail: vi.fn(),
registerRouteChange: vi.fn(),
},
}));
describe("FormbricksClient", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => {
const mockSetup = vi.spyOn(formbricks, "setup");
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(<FormbricksClient userId="user-123" email="test@example.com" />);
// Expect the first effect to call setup and assign the provided user details.
expect(mockSetup).toHaveBeenCalledWith({
environmentId: "env-test",
appUrl: "https://api.test.com",
});
expect(mockSetUserId).toHaveBeenCalledWith("user-123");
expect(mockSetEmail).toHaveBeenCalledWith("test@example.com");
// And the second effect should always register the route change when Formbricks is enabled.
expect(mockRegisterRouteChange).toHaveBeenCalled();
});
test("does not call setup, setUserId, or setEmail if userId is not provided yet still calls registerRouteChange", () => {
const mockSetup = vi.spyOn(formbricks, "setup");
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(<FormbricksClient userId="" email="test@example.com" />);
// Since userId is falsy, the first effect should not call setup or assign user details.
expect(mockSetup).not.toHaveBeenCalled();
expect(mockSetUserId).not.toHaveBeenCalled();
expect(mockSetEmail).not.toHaveBeenCalled();
// The second effect only checks formbricksEnabled, so registerRouteChange should be called.
expect(mockRegisterRouteChange).toHaveBeenCalled();
});
});
@@ -1,6 +1,5 @@
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
@@ -24,7 +23,6 @@ export const TopControlBar = ({
<TopControlButtons
environment={environment}
environments={environments}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
membershipRole={membershipRole}
projectPermission={projectPermission}
/>
@@ -6,9 +6,9 @@ import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { CircleUserIcon, MessageCircleQuestionIcon, PlusIcon } from "lucide-react";
import { BugIcon, CircleUserIcon, PlusIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import formbricks from "@formbricks/js";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
@@ -16,7 +16,6 @@ import { TOrganizationRole } from "@formbricks/types/memberships";
interface TopControlButtonsProps {
environment: TEnvironment;
environments: TEnvironment[];
isFormbricksCloud: boolean;
membershipRole?: TOrganizationRole;
projectPermission: TTeamPermission | null;
}
@@ -24,7 +23,6 @@ interface TopControlButtonsProps {
export const TopControlButtons = ({
environment,
environments,
isFormbricksCloud,
membershipRole,
projectPermission,
}: TopControlButtonsProps) => {
@@ -38,19 +36,15 @@ export const TopControlButtons = ({
return (
<div className="z-50 flex items-center space-x-2">
{!isBilling && <EnvironmentSwitch environment={environment} environments={environments} />}
{isFormbricksCloud && (
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
<Button
variant="ghost"
size="icon"
className="h-fit w-fit bg-slate-50 p-1"
onClick={() => {
formbricks.track("Top Menu: Product Feedback");
}}>
<MessageCircleQuestionIcon />
</Button>
</TooltipRenderer>
)}
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
<Link href="https://github.com/formbricks/formbricks/issues/new/choose" target="_blank">
<BugIcon />
</Link>
</Button>
</TooltipRenderer>
<TooltipRenderer tooltipContent={t("common.account")}>
<Button
variant="ghost"
@@ -88,7 +88,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard>
)}
<SettingsId title={t("common.organization")} id={organization.id}></SettingsId>
<SettingsId title={t("common.organization_id")} id={organization.id}></SettingsId>
</PageContentWrapper>
);
};
@@ -7,6 +7,7 @@ import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
import { notFound } from "next/navigation";
import {
@@ -93,6 +94,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
isReadOnly={isReadOnly}
locale={user.locale ?? DEFAULT_LOCALE}
/>
<SettingsId title={t("common.survey_id")} id={surveyId}></SettingsId>
</PageContentWrapper>
);
};
@@ -0,0 +1,390 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { CRON_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { mockSurveyOutput } from "@formbricks/lib/survey/tests/__mock__/survey.mock";
import { doesSurveyHasOpenTextQuestion } from "@formbricks/lib/survey/utils";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
doesResponseHasAnyOpenTextAnswer,
generateInsightsEnabledForSurveyQuestions,
generateInsightsForSurvey,
} from "./utils";
// Mock all dependencies
vi.mock("@formbricks/lib/constants", () => ({
CRON_SECRET: vi.fn(() => "mocked-cron-secret"),
WEBAPP_URL: "https://mocked-webapp-url.com",
}));
vi.mock("@formbricks/lib/survey/cache", () => ({
surveyCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@formbricks/lib/survey/service", () => ({
getSurvey: vi.fn(),
updateSurvey: vi.fn(),
}));
vi.mock("@formbricks/lib/survey/utils", () => ({
doesSurveyHasOpenTextQuestion: vi.fn(),
}));
vi.mock("@formbricks/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
// Mock global fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe("Insights Utils", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("generateInsightsForSurvey", () => {
test("should call fetch with correct parameters", () => {
const surveyId = "survey-123";
mockFetch.mockResolvedValueOnce({ ok: true });
generateInsightsForSurvey(surveyId);
expect(mockFetch).toHaveBeenCalledWith(`${WEBAPP_URL}/api/insights`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": CRON_SECRET,
},
body: JSON.stringify({
surveyId,
}),
});
});
test("should handle errors and return error object", () => {
const surveyId = "survey-123";
mockFetch.mockImplementationOnce(() => {
throw new Error("Network error");
});
const result = generateInsightsForSurvey(surveyId);
expect(result).toEqual({
ok: false,
error: new Error("Error while generating insights for survey: Network error"),
});
});
test("should throw error if CRON_SECRET is not set", async () => {
// Reset modules to ensure clean state
vi.resetModules();
// Mock CRON_SECRET as undefined
vi.doMock("@formbricks/lib/constants", () => ({
CRON_SECRET: undefined,
WEBAPP_URL: "https://mocked-webapp-url.com",
}));
// Re-import the utils module to get the mocked CRON_SECRET
const { generateInsightsForSurvey } = await import("./utils");
expect(() => generateInsightsForSurvey("survey-123")).toThrow("CRON_SECRET is not set");
// Reset modules after test
vi.resetModules();
});
});
describe("generateInsightsEnabledForSurveyQuestions", () => {
test("should return success=false when survey has no open text questions", async () => {
// Mock data
const surveyId = "survey-123";
const mockSurvey: TSurvey = {
...mockSurveyOutput,
type: "link",
segment: null,
displayPercentage: null,
questions: [
{
id: "cm8cjnse3000009jxf20v91ic",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 1" },
required: true,
choices: [
{
id: "cm8cjnse3000009jxf20v91ic",
label: { default: "Choice 1" },
},
],
},
{
id: "cm8cjo19c000109jx6znygc0u",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Question 2" },
required: true,
scale: "number",
range: 5,
isColorCodingEnabled: false,
},
],
};
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(false);
// Execute function
const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
// Verify results
expect(result).toEqual({ success: false });
expect(updateSurvey).not.toHaveBeenCalled();
});
test("should return success=true when survey is updated with insights enabled", async () => {
vi.clearAllMocks();
// Mock data
const surveyId = "cm8ckvchx000008lb710n0gdn";
// Mock survey with open text questions that have no insightsEnabled property
const mockSurveyWithOpenTextQuestions: TSurvey = {
...mockSurveyOutput,
id: surveyId,
type: "link",
segment: null,
displayPercentage: null,
questions: [
{
id: "cm8cjnse3000009jxf20v91ic",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
inputType: "text",
charLimit: {},
},
{
id: "cm8cjo19c000109jx6znygc0u",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 2" },
required: true,
inputType: "text",
charLimit: {},
},
],
};
// Define the updated survey that should be returned after updateSurvey
const mockUpdatedSurveyWithOpenTextQuestions: TSurvey = {
...mockSurveyWithOpenTextQuestions,
questions: mockSurveyWithOpenTextQuestions.questions.map((q) => ({
...q,
insightsEnabled: true, // Updated property
})),
};
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurveyWithOpenTextQuestions);
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
vi.mocked(updateSurvey).mockResolvedValueOnce(mockUpdatedSurveyWithOpenTextQuestions);
// Execute function
const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
expect(result).toEqual({
success: true,
survey: mockUpdatedSurveyWithOpenTextQuestions,
});
});
test("should return success=false when all open text questions already have insightsEnabled defined", async () => {
// Mock data
const surveyId = "survey-123";
const mockSurvey: TSurvey = {
...mockSurveyOutput,
type: "link",
segment: null,
displayPercentage: null,
questions: [
{
id: "cm8cjnse3000009jxf20v91ic",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
inputType: "text",
charLimit: {},
insightsEnabled: true,
},
{
id: "cm8cjo19c000109jx6znygc0u",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 2" },
required: true,
choices: [
{
id: "cm8cjnse3000009jxf20v91ic",
label: { default: "Choice 1" },
},
],
},
],
};
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
// Execute function
const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
// Verify results
expect(result).toEqual({ success: false });
expect(updateSurvey).not.toHaveBeenCalled();
});
test("should throw ResourceNotFoundError if survey is not found", async () => {
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(null);
// Execute and verify function
await expect(generateInsightsEnabledForSurveyQuestions("survey-123")).rejects.toThrow(
new ResourceNotFoundError("Survey", "survey-123")
);
});
test("should throw ResourceNotFoundError if updateSurvey returns null", async () => {
// Mock data
const surveyId = "survey-123";
const mockSurvey: TSurvey = {
...mockSurveyOutput,
type: "link",
segment: null,
displayPercentage: null,
questions: [
{
id: "cm8cjnse3000009jxf20v91ic",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
inputType: "text",
charLimit: {},
},
],
};
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
// Type assertion to handle the null case
vi.mocked(updateSurvey).mockResolvedValueOnce(null as unknown as TSurvey);
// Execute and verify function
await expect(generateInsightsEnabledForSurveyQuestions(surveyId)).rejects.toThrow(
new ResourceNotFoundError("Survey", surveyId)
);
});
test("should return success=false when no questions have insights enabled after update", async () => {
// Mock data
const surveyId = "survey-123";
const mockSurvey: TSurvey = {
...mockSurveyOutput,
type: "link",
segment: null,
displayPercentage: null,
questions: [
{
id: "cm8cjnse3000009jxf20v91ic",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
inputType: "text",
charLimit: {},
insightsEnabled: false,
},
],
};
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
vi.mocked(updateSurvey).mockResolvedValueOnce(mockSurvey);
// Execute function
const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
// Verify results
expect(result).toEqual({ success: false });
});
test("should propagate any errors that occur", async () => {
// Setup mocks
const testError = new Error("Test error");
vi.mocked(getSurvey).mockRejectedValueOnce(testError);
// Execute and verify function
await expect(generateInsightsEnabledForSurveyQuestions("survey-123")).rejects.toThrow(testError);
});
});
describe("doesResponseHasAnyOpenTextAnswer", () => {
test("should return true when at least one open text question has an answer", () => {
const openTextQuestionIds = ["q1", "q2", "q3"];
const response = {
q1: "",
q2: "This is an answer",
q3: "",
q4: "This is not an open text answer",
};
const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
expect(result).toBe(true);
});
test("should return false when no open text questions have answers", () => {
const openTextQuestionIds = ["q1", "q2", "q3"];
const response = {
q1: "",
q2: "",
q3: "",
q4: "This is not an open text answer",
};
const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
expect(result).toBe(false);
});
test("should return false when response does not contain any open text question IDs", () => {
const openTextQuestionIds = ["q1", "q2", "q3"];
const response = {
q4: "This is not an open text answer",
q5: "Another answer",
};
const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
expect(result).toBe(false);
});
test("should return false for non-string answers", () => {
const openTextQuestionIds = ["q1", "q2", "q3"];
const response = {
q1: "",
q2: 123,
q3: true,
} as any; // Use type assertion to handle mixed types in the test
const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
expect(result).toBe(false);
});
});
});
@@ -11,6 +11,10 @@ import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
export const generateInsightsForSurvey = (surveyId: string) => {
if (!CRON_SECRET) {
throw new Error("CRON_SECRET is not set");
}
try {
return fetch(`${WEBAPP_URL}/api/insights`, {
method: "POST",
@@ -31,6 +31,9 @@ export const OPTIONS = async (): Promise<Response> => {
};
export const POST = async (req: NextRequest, context: Context): Promise<Response> => {
if (!ENCRYPTION_KEY) {
return responses.internalServerErrorResponse("Encryption key is not set");
}
const params = await context.params;
const environmentId = params.environmentId;
@@ -12,6 +12,10 @@ import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { putFileToLocalStorage } from "@formbricks/lib/storage/service";
export const POST = async (req: NextRequest): Promise<Response> => {
if (!ENCRYPTION_KEY) {
return responses.internalServerErrorResponse("Encryption key is not set");
}
const accessType = "public"; // public files are accessible by anyone
const headersList = await headers();
@@ -1,6 +1,7 @@
import { webhookCache } from "@/lib/cache/webhook";
import { Prisma, Webhook } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { cache } from "@formbricks/lib/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId } from "@formbricks/types/common";
@@ -25,7 +26,10 @@ export const deleteWebhook = async (id: string): Promise<Webhook> => {
return deletedWebhook;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
throw new ResourceNotFoundError("Webhook", id);
}
throw new DatabaseError(`Database error when deleting webhook with ID ${id}`);
@@ -0,0 +1,3 @@
import { DELETE, GET, PUT } from "@/modules/api/v2/management/webhooks/[webhookId]/route";
export { GET, PUT, DELETE };
@@ -0,0 +1,3 @@
import { GET, POST } from "@/modules/api/v2/management/webhooks/route";
export { GET, POST };
+113
View File
@@ -0,0 +1,113 @@
import { TPipelineInput } from "@/app/lib/types/pipelines";
import { PipelineTriggers } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TResponse } from "@formbricks/types/responses";
import { sendToPipeline } from "./pipelines";
// Mock the constants module
vi.mock("@formbricks/lib/constants", () => ({
CRON_SECRET: "mocked-cron-secret",
WEBAPP_URL: "https://test.formbricks.com",
}));
// Mock the logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
// Mock global fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe("pipelines", () => {
// Reset mocks before each test
beforeEach(() => {
vi.clearAllMocks();
});
// Clean up after each test
afterEach(() => {
vi.clearAllMocks();
});
test("sendToPipeline should call fetch with correct parameters", async () => {
// Mock the fetch implementation to return a successful response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
});
// Create sample data for testing
const testData: TPipelineInput = {
event: PipelineTriggers.responseCreated,
surveyId: "cm8ckvchx000008lb710n0gdn",
environmentId: "cm8cmp9hp000008jf7l570ml2",
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
};
// Call the function with test data
await sendToPipeline(testData);
// Check that fetch was called with the correct arguments
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith("https://test.formbricks.com/api/pipeline", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": "mocked-cron-secret",
},
body: JSON.stringify({
environmentId: testData.environmentId,
surveyId: testData.surveyId,
event: testData.event,
response: testData.response,
}),
});
});
test("sendToPipeline should handle fetch errors", async () => {
// Mock fetch to throw an error
const testError = new Error("Network error");
mockFetch.mockRejectedValueOnce(testError);
// Create sample data for testing
const testData: TPipelineInput = {
event: PipelineTriggers.responseCreated,
surveyId: "cm8ckvchx000008lb710n0gdn",
environmentId: "cm8cmp9hp000008jf7l570ml2",
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
};
// Call the function
await sendToPipeline(testData);
// Check that the error was logged using logger
expect(logger.error).toHaveBeenCalledWith(testError, "Error sending event to pipeline");
});
test("sendToPipeline should throw error if CRON_SECRET is not set", async () => {
// For this test, we need to mock CRON_SECRET as undefined
// Let's use a more compatible approach to reset the mocks
const originalModule = await import("@formbricks/lib/constants");
const mockConstants = { ...originalModule, CRON_SECRET: undefined };
vi.doMock("@formbricks/lib/constants", () => mockConstants);
// Re-import the module to get the new mocked values
const { sendToPipeline: sendToPipelineNoSecret } = await import("./pipelines");
// Create sample data for testing
const testData: TPipelineInput = {
event: PipelineTriggers.responseCreated,
surveyId: "cm8ckvchx000008lb710n0gdn",
environmentId: "cm8cmp9hp000008jf7l570ml2",
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
};
// Expect the function to throw an error
await expect(sendToPipelineNoSecret(testData)).rejects.toThrow("CRON_SECRET is not set");
});
});
+4
View File
@@ -3,6 +3,10 @@ import { CRON_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import { logger } from "@formbricks/logger";
export const sendToPipeline = async ({ event, surveyId, environmentId, response }: TPipelineInput) => {
if (!CRON_SECRET) {
throw new Error("CRON_SECRET is not set");
}
return fetch(`${WEBAPP_URL}/api/pipeline`, {
method: "POST",
headers: {
+120
View File
@@ -0,0 +1,120 @@
import cuid2 from "@paralleldrive/cuid2";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as crypto from "@formbricks/lib/crypto";
import { generateSurveySingleUseId, validateSurveySingleUseId } from "./singleUseSurveys";
// Mock the crypto module
vi.mock("@formbricks/lib/crypto", () => ({
symmetricEncrypt: vi.fn(),
symmetricDecrypt: vi.fn(),
decryptAES128: vi.fn(),
}));
// Mock constants
vi.mock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: "test-encryption-key",
FORMBRICKS_ENCRYPTION_KEY: "test-formbricks-encryption-key",
}));
// Mock cuid2
vi.mock("@paralleldrive/cuid2", () => {
const createIdMock = vi.fn();
const isCuidMock = vi.fn();
return {
default: {
createId: createIdMock,
isCuid: isCuidMock,
},
createId: createIdMock,
isCuid: isCuidMock,
};
});
describe("generateSurveySingleUseId", () => {
const mockCuid = "test-cuid-123";
const mockEncryptedCuid = "encrypted-cuid-123";
beforeEach(() => {
// Setup mocks
vi.mocked(cuid2.createId).mockReturnValue(mockCuid);
vi.mocked(crypto.symmetricEncrypt).mockReturnValue(mockEncryptedCuid);
});
afterEach(() => {
vi.resetAllMocks();
});
it("returns unencrypted cuid when isEncrypted is false", () => {
const result = generateSurveySingleUseId(false);
expect(result).toBe(mockCuid);
expect(crypto.symmetricEncrypt).not.toHaveBeenCalled();
});
it("returns encrypted cuid when isEncrypted is true", () => {
const result = generateSurveySingleUseId(true);
expect(result).toBe(mockEncryptedCuid);
expect(crypto.symmetricEncrypt).toHaveBeenCalledWith(mockCuid, "test-encryption-key");
});
it("returns undefined when cuid is not valid", () => {
vi.mocked(cuid2.isCuid).mockReturnValue(false);
const result = validateSurveySingleUseId(mockEncryptedCuid);
expect(result).toBeUndefined();
});
it("returns undefined when decryption fails", () => {
vi.mocked(crypto.symmetricDecrypt).mockImplementation(() => {
throw new Error("Decryption failed");
});
const result = validateSurveySingleUseId(mockEncryptedCuid);
expect(result).toBeUndefined();
});
it("throws error when ENCRYPTION_KEY is not set in generateSurveySingleUseId", async () => {
// Temporarily mock ENCRYPTION_KEY as undefined
vi.doMock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: undefined,
FORMBRICKS_ENCRYPTION_KEY: "test-formbricks-encryption-key",
}));
// Re-import to get the new mock values
const { generateSurveySingleUseId: generateSurveySingleUseIdNoKey } = await import("./singleUseSurveys");
expect(() => generateSurveySingleUseIdNoKey(true)).toThrow("ENCRYPTION_KEY is not set");
});
it("throws error when ENCRYPTION_KEY is not set in validateSurveySingleUseId for symmetric encryption", async () => {
// Temporarily mock ENCRYPTION_KEY as undefined
vi.doMock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: undefined,
FORMBRICKS_ENCRYPTION_KEY: "test-formbricks-encryption-key",
}));
// Re-import to get the new mock values
const { validateSurveySingleUseId: validateSurveySingleUseIdNoKey } = await import("./singleUseSurveys");
expect(() => validateSurveySingleUseIdNoKey(mockEncryptedCuid)).toThrow("ENCRYPTION_KEY is not set");
});
it("throws error when FORMBRICKS_ENCRYPTION_KEY is not set in validateSurveySingleUseId for AES128", async () => {
// Temporarily mock FORMBRICKS_ENCRYPTION_KEY as undefined
vi.doMock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: "test-encryption-key",
FORMBRICKS_ENCRYPTION_KEY: undefined,
}));
// Re-import to get the new mock values
const { validateSurveySingleUseId: validateSurveySingleUseIdNoKey } = await import("./singleUseSurveys");
expect(() =>
validateSurveySingleUseIdNoKey("M(.Bob=dS1!wUSH2lb,E7hxO=He1cnnitmXrG|Su/DKYZrPy~zgS)u?dgI53sfs/")
).toThrow("FORMBRICKS_ENCRYPTION_KEY is not defined");
});
});
+25 -14
View File
@@ -9,31 +9,42 @@ export const generateSurveySingleUseId = (isEncrypted: boolean): string => {
return cuid;
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedCuid = symmetricEncrypt(cuid, ENCRYPTION_KEY);
return encryptedCuid;
};
// validate the survey single use id
export const validateSurveySingleUseId = (surveySingleUseId: string): string | undefined => {
try {
let decryptedCuid: string | null = null;
let decryptedCuid: string | null = null;
if (surveySingleUseId.length === 64) {
if (!FORMBRICKS_ENCRYPTION_KEY) {
throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined");
}
decryptedCuid = decryptAES128(FORMBRICKS_ENCRYPTION_KEY!, surveySingleUseId);
} else {
decryptedCuid = symmetricDecrypt(surveySingleUseId, ENCRYPTION_KEY);
if (surveySingleUseId.length === 64) {
if (!FORMBRICKS_ENCRYPTION_KEY) {
throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined");
}
if (cuid2.isCuid(decryptedCuid)) {
return decryptedCuid;
} else {
try {
decryptedCuid = decryptAES128(FORMBRICKS_ENCRYPTION_KEY, surveySingleUseId);
} catch (error) {
return undefined;
}
} catch (error) {
} else {
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
try {
decryptedCuid = symmetricDecrypt(surveySingleUseId, ENCRYPTION_KEY);
} catch (error) {
return undefined;
}
}
if (cuid2.isCuid(decryptedCuid)) {
return decryptedCuid;
} else {
return undefined;
}
};
+5 -2
View File
@@ -57,8 +57,6 @@ CacheHandler.onCreation(async () => {
timeoutMs: 1000,
};
redisHandlerOptions.ttl = Number(process.env.REDIS_DEFAULT_TTL) || 86400; // 1 day
// Create the `redis-stack` Handler if the client is available and connected.
handler = await createRedisHandler(redisHandlerOptions);
} else {
@@ -70,6 +68,11 @@ CacheHandler.onCreation(async () => {
return {
handlers: [handler],
ttl: {
// We set the stale and the expire age to the same value, because the stale age is determined by the unstable_cache revalidation.
defaultStaleAge: (process.env.REDIS_URL && Number(process.env.REDIS_DEFAULT_TTL)) || 86400,
estimateExpireAge: (staleAge) => staleAge,
},
};
});
@@ -43,7 +43,7 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
}): Promise<Response> => {
try {
const authentication = await authenticateRequest(request);
if (!authentication.ok) throw authentication.error;
if (!authentication.ok) return handleApiError(request, authentication.error);
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
@@ -53,7 +53,7 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
if (!bodyResult.success) {
throw err({
type: "forbidden",
type: "bad_request",
details: formatZodError(bodyResult.error),
});
}
@@ -100,6 +100,6 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
request,
});
} catch (err) {
return handleApiError(request, err);
return handleApiError(request, err.error);
}
};
@@ -8,6 +8,7 @@ export const authenticateRequest = async (
request: Request
): Promise<Result<TAuthenticationApiKey, ApiErrorResponseV2>> => {
const apiKey = request.headers.get("x-api-key");
if (apiKey) {
const environmentIdResult = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentIdResult.ok) {
@@ -1,4 +1,7 @@
import { fetchEnvironmentId } from "@/modules/api/v2/management/lib/services";
import {
fetchEnvironmentId,
fetchEnvironmentIdFromSurveyIds,
} from "@/modules/api/v2/management/lib/services";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Result, ok } from "@formbricks/types/error-handlers";
@@ -14,3 +17,31 @@ export const getEnvironmentId = async (
return ok(result.data.environmentId);
};
/**
* Validates that all surveys are in the same environment and return the environment id
* @param surveyIds array of survey ids from the same environment
* @returns the common environment id
*/
export const getEnvironmentIdFromSurveyIds = async (
surveyIds: string[]
): Promise<Result<string, ApiErrorResponseV2>> => {
const result = await fetchEnvironmentIdFromSurveyIds(surveyIds);
if (!result.ok) {
return result;
}
// Check if all items in the array are the same
if (new Set(result.data).size !== 1) {
return {
ok: false,
error: {
type: "bad_request",
details: [{ field: "surveyIds", issue: "not all surveys are in the same environment" }],
},
};
}
return ok(result.data[0]);
};
@@ -1,22 +0,0 @@
import {
deleteResponseEndpoint,
getResponseEndpoint,
updateResponseEndpoint,
} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
import {
createResponseEndpoint,
getResponsesEndpoint,
} from "@/modules/api/v2/management/responses/lib/openapi";
import { ZodOpenApiPathsObject } from "zod-openapi";
export const responsePaths: ZodOpenApiPathsObject = {
"/responses": {
get: getResponsesEndpoint,
post: createResponseEndpoint,
},
"/responses/{id}": {
get: getResponseEndpoint,
put: updateResponseEndpoint,
delete: deleteResponseEndpoint,
},
};
@@ -41,3 +41,36 @@ export const fetchEnvironmentId = reactCache(async (id: string, isResponseId: bo
}
)()
);
export const fetchEnvironmentIdFromSurveyIds = reactCache(async (surveyIds: string[]) =>
cache(
async (): Promise<Result<string[], ApiErrorResponseV2>> => {
try {
const results = await prisma.survey.findMany({
where: { id: { in: surveyIds } },
select: {
environmentId: true,
},
});
if (results.length !== surveyIds.length) {
return err({
type: "not_found",
details: [{ field: "survey", issue: "not found" }],
});
}
return ok(results.map((result) => result.environmentId));
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "survey", issue: error.message }],
});
}
},
[`services-fetchEnvironmentIdFromSurveyIds-${surveyIds.join("-")}`],
{
tags: surveyIds.map((surveyId) => surveyCache.tag.byId(surveyId)),
}
)()
);
@@ -1,14 +1,17 @@
import { fetchEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/services";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { createId } from "@paralleldrive/cuid2";
import { describe, expect, it, vi } from "vitest";
import { err, ok } from "@formbricks/types/error-handlers";
import { getEnvironmentId } from "../helper";
import { getEnvironmentId, getEnvironmentIdFromSurveyIds } from "../helper";
import { fetchEnvironmentId } from "../services";
vi.mock("../services", () => ({
fetchEnvironmentId: vi.fn(),
fetchEnvironmentIdFromSurveyIds: vi.fn(),
}));
describe("Helper Functions", () => {
describe("Tests for getEnvironmentId", () => {
it("should return environmentId for surveyId", async () => {
vi.mocked(fetchEnvironmentId).mockResolvedValue(ok({ environmentId: "env-id" }));
@@ -41,3 +44,42 @@ describe("Helper Functions", () => {
}
});
});
describe("getEnvironmentIdFromSurveyIds", () => {
const envId1 = createId();
const envId2 = createId();
it("returns the common environment id when all survey ids are in the same environment", async () => {
vi.mocked(fetchEnvironmentIdFromSurveyIds).mockResolvedValueOnce({
ok: true,
data: [envId1, envId1],
});
const result = await getEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
expect(result).toEqual(ok(envId1));
});
it("returns error when surveys are not in the same environment", async () => {
vi.mocked(fetchEnvironmentIdFromSurveyIds).mockResolvedValueOnce({
ok: true,
data: [envId1, envId2],
});
const result = await getEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "bad_request",
details: [{ field: "surveyIds", issue: "not all surveys are in the same environment" }],
});
}
});
it("returns error when API call fails", async () => {
const apiError = {
type: "server_error",
details: [{ field: "api", issue: "failed" }],
} as unknown as ApiErrorResponseV2;
vi.mocked(fetchEnvironmentIdFromSurveyIds).mockResolvedValueOnce({ ok: false, error: apiError });
const result = await getEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
expect(result).toEqual({ ok: false, error: apiError });
});
});
@@ -1,18 +1,17 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { fetchEnvironmentId } from "../services";
import { fetchEnvironmentId, fetchEnvironmentIdFromSurveyIds } from "../services";
vi.mock("@formbricks/database", () => ({
prisma: {
survey: { findFirst: vi.fn() },
survey: {
findFirst: vi.fn(),
findMany: vi.fn(),
},
},
}));
describe("Services", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getSurveyAndEnvironmentId", () => {
test("should return surveyId and environmentId for responseId", async () => {
vi.mocked(prisma.survey.findFirst).mockResolvedValue({
@@ -80,4 +79,36 @@ describe("Services", () => {
}
});
});
describe("fetchEnvironmentIdFromSurveyIds", () => {
test("should return an array of environmentIds if all surveys exist", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue([
{ environmentId: "env-1" },
{ environmentId: "env-2" },
]);
const result = await fetchEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(["env-1", "env-2"]);
}
});
test("should return not_found error if any survey is missing", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue([{ environmentId: "env-1" }]);
const result = await fetchEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("not_found");
}
});
test("should return internal_server_error if prisma query fails", async () => {
vi.mocked(prisma.survey.findMany).mockRejectedValue(new Error("Query failed"));
const result = await fetchEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
});
@@ -1,5 +1,7 @@
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
import { Prisma } from "@prisma/client";
import { describe, expect, test } from "vitest";
import { hashApiKey } from "../utils";
import { buildCommonFilterQuery, hashApiKey, pickCommonFilter } from "../utils";
describe("hashApiKey", () => {
test("generate the correct sha256 hash for a given input", () => {
@@ -15,3 +17,72 @@ describe("hashApiKey", () => {
expect(result).toHaveLength(9); // mocked on the vitestSetup.ts file;;
});
});
describe("pickCommonFilter", () => {
test("picks the common filter fields correctly", () => {
const params = {
limit: 10,
skip: 5,
sortBy: "createdAt",
order: "asc",
startDate: new Date("2023-01-01"),
endDate: new Date("2023-12-31"),
} as TGetFilter;
const result = pickCommonFilter(params);
expect(result).toEqual(params);
});
test("handles missing fields gracefully", () => {
const params = { limit: 10 } as TGetFilter;
const result = pickCommonFilter(params);
expect(result).toEqual({
limit: 10,
skip: undefined,
sortBy: undefined,
order: undefined,
startDate: undefined,
endDate: undefined,
});
});
describe("buildCommonFilterQuery", () => {
test("applies startDate and endDate when provided", () => {
const query: Prisma.WebhookFindManyArgs = { where: {} };
const params = {
startDate: new Date("2023-01-01"),
endDate: new Date("2023-12-31"),
} as TGetFilter;
const result = buildCommonFilterQuery(query, params);
expect(result.where?.createdAt?.gte).toEqual(params.startDate);
expect(result.where?.createdAt?.lte).toEqual(params.endDate);
});
test("applies sortBy and order when provided", () => {
const query: Prisma.WebhookFindManyArgs = { where: {} };
const params = { sortBy: "createdAt", order: "desc" } as TGetFilter;
const result = buildCommonFilterQuery(query, params);
expect(result.orderBy).toEqual({ createdAt: "desc" });
});
test("applies limit (take) when provided", () => {
const query: Prisma.WebhookFindManyArgs = { where: {} };
const params = { limit: 5 } as TGetFilter;
const result = buildCommonFilterQuery(query, params);
expect(result.take).toBe(5);
});
test("applies skip when provided", () => {
const query: Prisma.WebhookFindManyArgs = { where: {} };
const params = { skip: 10 } as TGetFilter;
const result = buildCommonFilterQuery(query, params);
expect(result.skip).toBe(10);
});
test("handles missing fields gracefully", () => {
const query = {};
const params = {} as TGetFilter;
const result = buildCommonFilterQuery(query, params);
expect(result).toEqual({});
});
});
});
@@ -1,3 +1,65 @@
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
import { Prisma } from "@prisma/client";
import { createHash } from "crypto";
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
export function pickCommonFilter<T extends TGetFilter>(params: T) {
const { limit, skip, sortBy, order, startDate, endDate } = params;
return { limit, skip, sortBy, order, startDate, endDate };
}
type HasFindMany = Prisma.WebhookFindManyArgs | Prisma.ResponseFindManyArgs;
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
const { limit, skip, sortBy, order, startDate, endDate } = params || {};
let filteredQuery = {
...query,
};
if (startDate) {
filteredQuery = {
...filteredQuery,
where: {
...filteredQuery.where,
createdAt: {
...((filteredQuery.where?.createdAt as Prisma.DateTimeFilter) ?? {}),
gte: startDate,
},
},
};
}
if (endDate) {
filteredQuery = {
...filteredQuery,
where: {
...filteredQuery.where,
createdAt: {
...((filteredQuery.where?.createdAt as Prisma.DateTimeFilter) ?? {}),
lte: endDate,
},
},
};
}
if (sortBy) {
filteredQuery = {
...filteredQuery,
orderBy: {
[sortBy]: order,
},
};
}
if (limit) {
filteredQuery = { ...filteredQuery, take: limit };
}
if (skip) {
filteredQuery = { ...filteredQuery, skip };
}
return filteredQuery;
}
@@ -1,6 +1,7 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { displayCache } from "@formbricks/lib/display/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
@@ -26,7 +27,10 @@ export const deleteDisplay = async (displayId: string): Promise<Result<boolean,
return ok(true);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error.code === "P2016" || error.code === "P2025") {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "display", issue: "not found" }],
@@ -1,4 +1,5 @@
import { responseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZResponse } from "@formbricks/database/zod/responses";
@@ -19,7 +20,7 @@ export const getResponseEndpoint: ZodOpenApiOperationObject = {
description: "Response retrieved successfully.",
content: {
"application/json": {
schema: ZResponse,
schema: makePartialSchema(ZResponse),
},
},
},
@@ -41,7 +42,7 @@ export const deleteResponseEndpoint: ZodOpenApiOperationObject = {
description: "Response deleted successfully.",
content: {
"application/json": {
schema: ZResponse,
schema: makePartialSchema(ZResponse),
},
},
},
@@ -72,7 +73,7 @@ export const updateResponseEndpoint: ZodOpenApiOperationObject = {
description: "Response updated successfully.",
content: {
"application/json": {
schema: ZResponse,
schema: makePartialSchema(ZResponse),
},
},
},
@@ -8,6 +8,7 @@ import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { cache } from "@formbricks/lib/cache";
import { responseCache } from "@formbricks/lib/response/cache";
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
@@ -77,7 +78,10 @@ export const deleteResponse = async (responseId: string): Promise<Result<Respons
return ok(deletedResponse);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error.code === "P2016" || error.code === "P2025") {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "response", issue: "not found" }],
@@ -116,7 +120,10 @@ export const updateResponse = async (
return ok(updatedResponse);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error.code === "P2016" || error.code === "P2025") {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "response", issue: "not found" }],
@@ -2,6 +2,7 @@ import { displayId, mockDisplay } from "./__mocks__/display.mock";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { deleteDisplay } from "../display";
vi.mock("@formbricks/database", () => ({
@@ -39,7 +40,7 @@ describe("Display Lib", () => {
test("return a not_found error when the display is not found", async () => {
vi.mocked(prisma.display.delete).mockRejectedValue(
new PrismaClientKnownRequestError("Display not found", {
code: "P2025",
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "1.0.0",
meta: {
cause: "Display not found",
@@ -2,6 +2,7 @@ import { response, responseId, responseInput, survey } from "./__mocks__/respons
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ok, okVoid } from "@formbricks/types/error-handlers";
import { deleteDisplay } from "../display";
import { deleteResponse, getResponse, updateResponse } from "../response";
@@ -154,7 +155,7 @@ describe("Response Lib", () => {
test("handle prisma client error code P2025", async () => {
vi.mocked(prisma.response.delete).mockRejectedValue(
new PrismaClientKnownRequestError("Response not found", {
code: "P2025",
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "1.0.0",
meta: {
cause: "Response not found",
@@ -192,7 +193,7 @@ describe("Response Lib", () => {
test("return a not_found error when the response is not found", async () => {
vi.mocked(prisma.response.update).mockRejectedValue(
new PrismaClientKnownRequestError("Response not found", {
code: "P2025",
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "1.0.0",
meta: {
cause: "Response not found",
@@ -47,7 +47,7 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI
return handleApiError(request, response.error);
}
return responses.successResponse({ data: response.data });
return responses.successResponse(response);
},
});
@@ -88,7 +88,7 @@ export const DELETE = async (request: Request, props: { params: Promise<{ respon
return handleApiError(request, response.error);
}
return responses.successResponse({ data: response.data });
return responses.successResponse(response);
},
});
@@ -130,6 +130,6 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
return handleApiError(request, response.error);
}
return responses.successResponse({ data: response.data });
return responses.successResponse(response);
},
});
@@ -3,10 +3,11 @@ import {
getResponseEndpoint,
updateResponseEndpoint,
} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
import { ZGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZResponse, ZResponseInput } from "@formbricks/types/responses";
import { ZResponse } from "@formbricks/database/zod/responses";
export const getResponsesEndpoint: ZodOpenApiOperationObject = {
operationId: "getResponses",
@@ -21,7 +22,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
description: "Responses retrieved successfully.",
content: {
"application/json": {
schema: z.array(ZResponse),
schema: z.array(responseWithMetaSchema(makePartialSchema(ZResponse))),
},
},
},
@@ -47,7 +48,7 @@ export const createResponseEndpoint: ZodOpenApiOperationObject = {
description: "Response created successfully.",
content: {
"application/json": {
schema: ZResponse,
schema: makePartialSchema(ZResponse),
},
},
},
@@ -48,7 +48,7 @@ export const getOrganizationIdFromEnvironmentId = reactCache(async (environmentI
export const getOrganizationBilling = reactCache(async (organizationId: string) =>
cache(
async (): Promise<Result<Pick<Organization, "billing">, ApiErrorResponseV2>> => {
async (): Promise<Result<Organization["billing"], ApiErrorResponseV2>> => {
try {
const organization = await prisma.organization.findFirst({
where: {
@@ -62,7 +62,8 @@ export const getOrganizationBilling = reactCache(async (organizationId: string)
if (!organization) {
return err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] });
}
return ok(organization);
return ok(organization.billing);
} catch (error) {
return err({
type: "internal_server_error",
@@ -126,26 +127,27 @@ export const getMonthlyOrganizationResponseCount = reactCache(async (organizatio
cache(
async (): Promise<Result<number, ApiErrorResponseV2>> => {
try {
const organization = await getOrganizationBilling(organizationId);
if (!organization.ok) {
return err(organization.error);
const billing = await getOrganizationBilling(organizationId);
if (!billing.ok) {
return err(billing.error);
}
// Determine the start date based on the plan type
let startDate: Date;
if (organization.data.billing.plan === "free") {
if (billing.data.plan === "free") {
// For free plans, use the first day of the current calendar month
const now = new Date();
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
} else {
// For other plans, use the periodStart from billing
if (!organization.data.billing.periodStart) {
if (!billing.data.periodStart) {
return err({
type: "internal_server_error",
details: [{ field: "organization", issue: "billing period start is not set" }],
});
}
startDate = organization.data.billing.periodStart;
startDate = billing.data.periodStart;
}
// Get all environment IDs for the organization
@@ -41,7 +41,14 @@ export const createResponse = async (
} = responseInput;
try {
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
let ttc = {};
if (initialTtc) {
if (finished) {
ttc = calculateTtcTotal(initialTtc);
} else {
ttc = initialTtc;
}
}
const prismaData: Prisma.ResponseCreateInput = {
survey: {
@@ -67,11 +74,11 @@ export const createResponse = async (
return err(organizationIdResult.error);
}
const organizationResult = await getOrganizationBilling(organizationIdResult.data);
if (!organizationResult.ok) {
return err(organizationResult.error);
const billing = await getOrganizationBilling(organizationIdResult.data);
if (!billing.ok) {
return err(billing.error);
}
const organization = organizationResult.data;
const billingData = billing.data;
const response = await prisma.response.create({
data: prismaData,
@@ -95,12 +102,12 @@ export const createResponse = async (
}
const responsesCount = responsesCountResult.data;
const responsesLimit = organization.billing.limits.monthly.responses;
const responsesLimit = billingData.limits?.monthly.responses;
if (responsesLimit && responsesCount >= responsesLimit) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organization.billing.plan,
plan: billingData.plan,
limits: {
projects: null,
monthly: {
@@ -85,7 +85,7 @@ describe("Organization Lib", () => {
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.billing).toEqual(organizationBilling);
expect(result.data).toEqual(organizationBilling);
}
});
@@ -55,7 +55,7 @@ describe("Response Lib", () => {
vi.mocked(prisma.response.create).mockResolvedValue(response);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
const result = await createResponse(environmentId, responseInput);
@@ -70,7 +70,7 @@ describe("Response Lib", () => {
vi.mocked(prisma.response.create).mockResolvedValue(response);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
const result = await createResponse(environmentId, responseInputNotFinished);
@@ -85,7 +85,7 @@ describe("Response Lib", () => {
vi.mocked(prisma.response.create).mockResolvedValue(response);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
const result = await createResponse(environmentId, responseInputWithoutTtc);
@@ -100,7 +100,7 @@ describe("Response Lib", () => {
vi.mocked(prisma.response.create).mockResolvedValue(response);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
const result = await createResponse(environmentId, responseInputWithoutDisplay);
@@ -145,7 +145,7 @@ describe("Response Lib", () => {
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
@@ -165,7 +165,7 @@ describe("Response Lib", () => {
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(
err({ type: "internal_server_error", details: [{ field: "organization", issue: "Aggregate error" }] })
@@ -186,7 +186,7 @@ describe("Response Lib", () => {
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
@@ -1,97 +1,40 @@
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
import { describe, expect, test } from "vitest";
import { Prisma } from "@prisma/client";
import { describe, expect, it, vi } from "vitest";
import { getResponsesQuery } from "../utils";
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
pickCommonFilter: vi.fn(),
buildCommonFilterQuery: vi.fn(),
}));
describe("getResponsesQuery", () => {
const environmentId = "env_1";
const filters: TGetResponsesFilter = {
limit: 10,
skip: 0,
sortBy: "createdAt",
order: "asc",
};
test("return the base query when no params are provided", () => {
const query = getResponsesQuery(environmentId);
expect(query).toEqual({
where: {
survey: { environmentId },
},
});
it("adds surveyId to where clause if provided", () => {
const result = getResponsesQuery("env-id", { surveyId: "survey123" } as TGetResponsesFilter);
expect(result?.where?.surveyId).toBe("survey123");
});
test("add surveyId to the query when provided", () => {
const query = getResponsesQuery(environmentId, { ...filters, surveyId: "survey_1" });
expect(query.where).toEqual({
survey: { environmentId },
surveyId: "survey_1",
});
it("adds contactId to where clause if provided", () => {
const result = getResponsesQuery("env-id", { contactId: "contact123" } as TGetResponsesFilter);
expect(result?.where?.contactId).toBe("contact123");
});
test("add startDate filter to the query", () => {
const startDate = new Date("2023-01-01");
const query = getResponsesQuery(environmentId, { ...filters, startDate });
expect(query.where).toEqual({
survey: { environmentId },
createdAt: { gte: startDate },
});
});
it("calls pickCommonFilter & buildCommonFilterQuery with correct arguments", () => {
vi.mocked(pickCommonFilter).mockReturnValueOnce({ someFilter: true } as any);
vi.mocked(buildCommonFilterQuery).mockReturnValueOnce({ where: { combined: true } as any });
test("add endDate filter to the query", () => {
const endDate = new Date("2023-01-31");
const query = getResponsesQuery(environmentId, { ...filters, endDate });
expect(query.where).toEqual({
survey: { environmentId },
createdAt: { lte: endDate },
});
});
test("add sortBy and order to the query", () => {
const query = getResponsesQuery(environmentId, { ...filters, sortBy: "createdAt", order: "desc" });
expect(query.orderBy).toEqual({
createdAt: "desc",
});
});
test("add limit (take) to the query", () => {
const query = getResponsesQuery(environmentId, { ...filters, limit: 10 });
expect(query.take).toBe(10);
});
test("add skip to the query", () => {
const query = getResponsesQuery(environmentId, { ...filters, skip: 5 });
expect(query.skip).toBe(5);
});
test("add contactId to the query", () => {
const query = getResponsesQuery(environmentId, { ...filters, contactId: "contact_1" });
expect(query.where).toEqual({
survey: { environmentId },
contactId: "contact_1",
});
});
test("combine multiple filters correctly", () => {
const params = {
...filters,
surveyId: "survey_1",
startDate: new Date("2023-01-01"),
endDate: new Date("2023-01-31"),
limit: 20,
skip: 10,
contactId: "contact_1",
};
const query = getResponsesQuery(environmentId, params);
expect(query.where).toEqual({
survey: { environmentId },
surveyId: "survey_1",
createdAt: { lte: params.endDate, gte: params.startDate },
contactId: "contact_1",
});
expect(query.orderBy).toEqual({
createdAt: "asc",
});
expect(query.take).toBe(20);
expect(query.skip).toBe(10);
const result = getResponsesQuery("env-id", { surveyId: "test" } as TGetResponsesFilter);
expect(pickCommonFilter).toHaveBeenCalledWith({ surveyId: "test" });
expect(buildCommonFilterQuery).toHaveBeenCalledWith(
expect.objectContaining<Prisma.ResponseFindManyArgs>({
where: {
survey: { environmentId: "env-id" },
surveyId: "test",
},
}),
{ someFilter: true }
);
expect(result).toEqual({ where: { combined: true } });
});
});
@@ -1,9 +1,8 @@
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
import { Prisma } from "@prisma/client";
export const getResponsesQuery = (environmentId: string, params?: TGetResponsesFilter) => {
const { surveyId, limit, skip, sortBy, order, startDate, endDate, contactId } = params || {};
let query: Prisma.ResponseFindManyArgs = {
where: {
survey: {
@@ -12,6 +11,10 @@ export const getResponsesQuery = (environmentId: string, params?: TGetResponsesF
},
};
if (!params) return query;
const { surveyId, contactId } = params || {};
if (surveyId) {
query = {
...query,
@@ -22,55 +25,6 @@ export const getResponsesQuery = (environmentId: string, params?: TGetResponsesF
};
}
if (startDate) {
query = {
...query,
where: {
...query.where,
createdAt: {
...(query.where?.createdAt as Prisma.DateTimeFilter<"Response">),
gte: startDate,
},
},
};
}
if (endDate) {
query = {
...query,
where: {
...query.where,
createdAt: {
...(query.where?.createdAt as Prisma.DateTimeFilter<"Response">),
lte: endDate,
},
},
};
}
if (sortBy) {
query = {
...query,
orderBy: {
[sortBy]: order,
},
};
}
if (limit) {
query = {
...query,
take: limit,
};
}
if (skip) {
query = {
...query,
skip: skip,
};
}
if (contactId) {
query = {
...query,
@@ -81,5 +35,11 @@ export const getResponsesQuery = (environmentId: string, params?: TGetResponsesF
};
}
const baseFilter = pickCommonFilter(params);
if (baseFilter) {
query = buildCommonFilterQuery<Prisma.ResponseFindManyArgs>(query, baseFilter);
}
return query;
};
@@ -1,28 +1,21 @@
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
import { z } from "zod";
import { ZResponse } from "@formbricks/database/zod/responses";
export const ZGetResponsesFilter = z
.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
skip: z.coerce.number().nonnegative().optional().default(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
order: z.enum(["asc", "desc"]).optional().default("desc"),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
surveyId: z.string().cuid2().optional(),
contactId: z.string().optional(),
})
.refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
return false;
}
return true;
},
{
message: "startDate must be before endDate",
export const ZGetResponsesFilter = ZGetFilter.extend({
surveyId: z.string().cuid2().optional(),
contactId: z.string().optional(),
}).refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
return false;
}
);
return true;
},
{
message: "startDate must be before endDate",
}
);
export type TGetResponsesFilter = z.infer<typeof ZGetResponsesFilter>;
@@ -39,21 +32,16 @@ export const ZResponseInput = ZResponse.pick({
variables: true,
ttc: true,
meta: true,
})
.partial({
displayId: true,
singleUseId: true,
endingId: true,
language: true,
variables: true,
ttc: true,
meta: true,
createdAt: true,
updatedAt: true,
})
.openapi({
ref: "responseCreate",
description: "A response to create",
});
}).partial({
displayId: true,
singleUseId: true,
endingId: true,
language: true,
variables: true,
ttc: true,
meta: true,
createdAt: true,
updatedAt: true,
});
export type TResponseInput = z.infer<typeof ZResponseInput>;
@@ -0,0 +1,81 @@
import { webhookIdSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
import { ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
export const getWebhookEndpoint: ZodOpenApiOperationObject = {
operationId: "getWebhook",
summary: "Get a webhook",
description: "Gets a webhook from the database.",
requestParams: {
path: z.object({
webhookId: webhookIdSchema,
}),
},
tags: ["Management API > Webhooks"],
responses: {
"200": {
description: "Webhook retrieved successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZWebhook),
},
},
},
},
};
export const deleteWebhookEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteWebhook",
summary: "Delete a webhook",
description: "Deletes a webhook from the database.",
tags: ["Management API > Webhooks"],
requestParams: {
path: z.object({
webhookId: webhookIdSchema,
}),
},
responses: {
"200": {
description: "Webhook deleted successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZWebhook),
},
},
},
},
};
export const updateWebhookEndpoint: ZodOpenApiOperationObject = {
operationId: "updateWebhook",
summary: "Update a webhook",
description: "Updates a webhook in the database.",
tags: ["Management API > Webhooks"],
requestParams: {
path: z.object({
webhookId: webhookIdSchema,
}),
},
requestBody: {
required: true,
description: "The webhook to update",
content: {
"application/json": {
schema: ZWebhookInput,
},
},
},
responses: {
"200": {
description: "Webhook updated successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZWebhook),
},
},
},
},
};
@@ -0,0 +1,20 @@
import { WebhookSource } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { PrismaErrorType } from "@formbricks/database/types/error";
export const mockedPrismaWebhookUpdateReturn = {
id: "123",
url: "",
name: null,
createdAt: new Date("2025-03-24T07:27:36.850Z"),
updatedAt: new Date("2025-03-24T07:27:36.850Z"),
source: "user" as WebhookSource,
environmentId: "",
triggers: [],
surveyIds: [],
};
export const prismaNotFoundError = new PrismaClientKnownRequestError("Record does not exist", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "PrismaClient 4.0.0",
});
@@ -0,0 +1,126 @@
import { webhookCache } from "@/lib/cache/webhook";
import {
mockedPrismaWebhookUpdateReturn,
prismaNotFoundError,
} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock";
import { webhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
import { describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { deleteWebhook, getWebhook, updateWebhook } from "../webhook";
vi.mock("@formbricks/database", () => ({
prisma: {
webhook: {
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}));
vi.mock("@/lib/cache/webhook", () => ({
webhookCache: {
tag: {
byId: () => "mockTag",
},
revalidate: vi.fn(),
},
}));
describe("getWebhook", () => {
test("returns ok if webhook is found", async () => {
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce({ id: "123" });
const result = await getWebhook("123");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({ id: "123" });
}
});
test("returns err if webhook not found", async () => {
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce(null);
const result = await getWebhook("999");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toBe("not_found");
}
});
test("returns err on Prisma error", async () => {
vi.mocked(prisma.webhook.findUnique).mockRejectedValueOnce(new Error("DB error"));
const result = await getWebhook("error");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
describe("updateWebhook", () => {
const mockedWebhookUpdateReturn = { url: "https://example.com" } as z.infer<typeof webhookUpdateSchema>;
test("returns ok on successful update", async () => {
vi.mocked(prisma.webhook.update).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn);
const result = await updateWebhook("123", mockedWebhookUpdateReturn);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockedPrismaWebhookUpdateReturn);
}
expect(webhookCache.revalidate).toHaveBeenCalled();
});
test("returns not_found if record does not exist", async () => {
vi.mocked(prisma.webhook.update).mockRejectedValueOnce(prismaNotFoundError);
const result = await updateWebhook("999", mockedWebhookUpdateReturn);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toBe("not_found");
}
});
test("returns internal_server_error if other error occurs", async () => {
vi.mocked(prisma.webhook.update).mockRejectedValueOnce(new Error("Unknown error"));
const result = await updateWebhook("abc", mockedWebhookUpdateReturn);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toBe("internal_server_error");
}
});
});
describe("deleteWebhook", () => {
test("returns ok on successful delete", async () => {
vi.mocked(prisma.webhook.delete).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn);
const result = await deleteWebhook("123");
expect(result.ok).toBe(true);
expect(webhookCache.revalidate).toHaveBeenCalled();
});
test("returns not_found if record does not exist", async () => {
vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(prismaNotFoundError);
const result = await deleteWebhook("999");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toBe("not_found");
}
});
test("returns internal_server_error on other errors", async () => {
vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(new Error("Delete error"));
const result = await deleteWebhook("abc");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toBe("internal_server_error");
}
});
});
@@ -0,0 +1,111 @@
import { webhookCache } from "@/lib/cache/webhook";
import { webhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Webhook } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { cache } from "@formbricks/lib/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getWebhook = async (webhookId: string) =>
cache(
async (): Promise<Result<Webhook, ApiErrorResponseV2>> => {
try {
const webhook = await prisma.webhook.findUnique({
where: {
id: webhookId,
},
});
if (!webhook) {
return err({
type: "not_found",
details: [{ field: "webhook", issue: "not found" }],
});
}
return ok(webhook);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "webhook", issue: error.message }],
});
}
},
[`management-getWebhook-${webhookId}`],
{
tags: [webhookCache.tag.byId(webhookId)],
}
)();
export const updateWebhook = async (
webhookId: string,
webhookInput: z.infer<typeof webhookUpdateSchema>
): Promise<Result<Webhook, ApiErrorResponseV2>> => {
try {
const updatedWebhook = await prisma.webhook.update({
where: {
id: webhookId,
},
data: webhookInput,
});
webhookCache.revalidate({
id: webhookId,
});
return ok(updatedWebhook);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "webhook", issue: "not found" }],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "webhook", issue: error.message }],
});
}
};
export const deleteWebhook = async (webhookId: string): Promise<Result<Webhook, ApiErrorResponseV2>> => {
try {
const deletedWebhook = await prisma.webhook.delete({
where: {
id: webhookId,
},
});
webhookCache.revalidate({
id: deletedWebhook.id,
environmentId: deletedWebhook.environmentId,
source: deletedWebhook.source,
});
return ok(deletedWebhook);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "webhook", issue: "not found" }],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "webhook", issue: error.message }],
});
}
};
@@ -0,0 +1,156 @@
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
import { getEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/helper";
import {
deleteWebhook,
getWebhook,
updateWebhook,
} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/webhook";
import {
webhookIdSchema,
webhookUpdateSchema,
} from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
import { NextRequest } from "next/server";
import { z } from "zod";
export const GET = async (request: NextRequest, props: { params: Promise<{ webhookId: string }> }) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ webhookId: webhookIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
const { params } = parsedInput;
if (!params) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
});
}
const webhook = await getWebhook(params.webhookId);
if (!webhook.ok) {
return handleApiError(request, webhook.error);
}
const checkAuthorizationResult = await checkAuthorization({
authentication,
environmentId: webhook.ok ? webhook.data.environmentId : "",
});
if (!checkAuthorizationResult.ok) {
return handleApiError(request, checkAuthorizationResult.error);
}
return responses.successResponse(webhook);
},
});
export const PUT = async (request: NextRequest, props: { params: Promise<{ webhookId: string }> }) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ webhookId: webhookIdSchema }),
body: webhookUpdateSchema,
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
const { params, body } = parsedInput;
if (!body || !params) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: !body ? "body" : "params", issue: "missing" }],
});
}
// get surveys environment
const surveysEnvironmentId = await getEnvironmentIdFromSurveyIds(body.surveyIds);
if (!surveysEnvironmentId.ok) {
return handleApiError(request, surveysEnvironmentId.error);
}
// get webhook environment
const webhook = await getWebhook(params.webhookId);
if (!webhook.ok) {
return handleApiError(request, webhook.error);
}
// check webhook environment against the api key environment
const checkAuthorizationResult = await checkAuthorization({
authentication,
environmentId: webhook.ok ? webhook.data.environmentId : "",
});
if (!checkAuthorizationResult.ok) {
return handleApiError(request, checkAuthorizationResult.error);
}
// check if webhook environment matches the surveys environment
if (webhook.data.environmentId !== surveysEnvironmentId.data) {
return handleApiError(request, {
type: "bad_request",
details: [
{ field: "surveys id", issue: "webhook environment does not match the surveys environment" },
],
});
}
const updatedWebhook = await updateWebhook(params.webhookId, body);
if (!updatedWebhook.ok) {
return handleApiError(request, updatedWebhook.error);
}
return responses.successResponse(updatedWebhook);
},
});
export const DELETE = async (request: NextRequest, props: { params: Promise<{ webhookId: string }> }) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ webhookId: webhookIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
const { params } = parsedInput;
if (!params) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
});
}
const webhook = await getWebhook(params.webhookId);
if (!webhook.ok) {
return handleApiError(request, webhook.error);
}
const checkAuthorizationResult = await checkAuthorization({
authentication,
environmentId: webhook.ok ? webhook.data.environmentId : "",
});
if (!checkAuthorizationResult.ok) {
return handleApiError(request, checkAuthorizationResult.error);
}
const deletedWebhook = await deleteWebhook(params.webhookId);
if (!deletedWebhook.ok) {
return handleApiError(request, deletedWebhook.error);
}
return responses.successResponse(deletedWebhook);
},
});
@@ -0,0 +1,27 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
extendZodWithOpenApi(z);
export const webhookIdSchema = z
.string()
.cuid2()
.openapi({
ref: "webhookId",
description: "The ID of the webhook",
param: {
name: "id",
in: "path",
},
});
export const webhookUpdateSchema = ZWebhook.omit({
id: true,
createdAt: true,
updatedAt: true,
environmentId: true,
}).openapi({
ref: "webhookUpdate",
description: "A webhook to update.",
});
@@ -0,0 +1,68 @@
import {
deleteWebhookEndpoint,
getWebhookEndpoint,
updateWebhookEndpoint,
} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/openapi";
import { ZGetWebhooksFilter, ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
export const getWebhooksEndpoint: ZodOpenApiOperationObject = {
operationId: "getWebhooks",
summary: "Get webhooks",
description: "Gets webhooks from the database.",
requestParams: {
query: ZGetWebhooksFilter.sourceType().required(),
},
tags: ["Management API > Webhooks"],
responses: {
"200": {
description: "Webhooks retrieved successfully.",
content: {
"application/json": {
schema: z.array(responseWithMetaSchema(makePartialSchema(ZWebhook))),
},
},
},
},
};
export const createWebhookEndpoint: ZodOpenApiOperationObject = {
operationId: "createWebhook",
summary: "Create a webhook",
description: "Creates a webhook in the database.",
tags: ["Management API > Webhooks"],
requestBody: {
required: true,
description: "The webhook to create",
content: {
"application/json": {
schema: ZWebhookInput,
},
},
},
responses: {
"201": {
description: "Webhook created successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZWebhook),
},
},
},
},
};
export const webhookPaths: ZodOpenApiPathsObject = {
"/webhooks": {
get: getWebhooksEndpoint,
post: createWebhookEndpoint,
},
"/webhooks/{webhookId}": {
get: getWebhookEndpoint,
put: updateWebhookEndpoint,
delete: deleteWebhookEndpoint,
},
};
@@ -0,0 +1,36 @@
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { TGetWebhooksFilter } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { describe, expect, it, vi } from "vitest";
import { getWebhooksQuery } from "../utils";
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
pickCommonFilter: vi.fn(),
buildCommonFilterQuery: vi.fn(),
}));
describe("getWebhooksQuery", () => {
const environmentId = "env-123";
it("adds surveyIds condition when provided", () => {
const params = { surveyIds: ["survey1"] } as TGetWebhooksFilter;
const result = getWebhooksQuery(environmentId, params);
expect(result).toBeDefined();
expect(result?.where).toMatchObject({
environmentId,
surveyIds: { hasSome: ["survey1"] },
});
});
it("calls pickCommonFilter and buildCommonFilterQuery when baseFilter is present", () => {
vi.mocked(pickCommonFilter).mockReturnValue({ someFilter: "test" } as any);
getWebhooksQuery(environmentId, { surveyIds: ["survey1"] } as TGetWebhooksFilter);
expect(pickCommonFilter).toHaveBeenCalled();
expect(buildCommonFilterQuery).toHaveBeenCalled();
});
it("buildCommonFilterQuery is not called if no baseFilter is picked", () => {
vi.mocked(pickCommonFilter).mockReturnValue(undefined as any);
getWebhooksQuery(environmentId, {} as any);
expect(buildCommonFilterQuery).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,117 @@
import { webhookCache } from "@/lib/cache/webhook";
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { WebhookSource } from "@prisma/client";
import { describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { createWebhook, getWebhooks } from "../webhook";
vi.mock("@formbricks/database", () => ({
prisma: {
$transaction: vi.fn(),
webhook: {
findMany: vi.fn(),
count: vi.fn(),
create: vi.fn(),
},
},
}));
vi.mock("@/lib/cache/webhook", () => ({
webhookCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@formbricks/lib/telemetry", () => ({
captureTelemetry: vi.fn(),
}));
describe("getWebhooks", () => {
const environmentId = "env1";
const params = {
limit: 10,
skip: 0,
};
const fakeWebhooks = [
{ id: "w1", environmentId, name: "Webhook One" },
{ id: "w2", environmentId, name: "Webhook Two" },
];
const count = fakeWebhooks.length;
it("returns ok response with webhooks and meta", async () => {
vi.mocked(prisma.$transaction).mockResolvedValueOnce([fakeWebhooks, count]);
const result = await getWebhooks(environmentId, params as TGetWebhooksFilter);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.data).toEqual(fakeWebhooks);
expect(result.data.meta).toEqual({
total: count,
limit: params.limit,
offset: params.skip,
});
}
});
it("returns error when prisma.$transaction throws", async () => {
vi.mocked(prisma.$transaction).mockRejectedValueOnce(new Error("Test error"));
const result = await getWebhooks(environmentId, params as TGetWebhooksFilter);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toEqual("internal_server_error");
}
});
});
describe("createWebhook", () => {
const inputWebhook = {
environmentId: "env1",
name: "New Webhook",
url: "http://example.com",
source: "user" as WebhookSource,
triggers: ["trigger1"],
surveyIds: ["s1", "s2"],
} as unknown as TWebhookInput;
const createdWebhook = {
id: "w100",
environmentId: inputWebhook.environmentId,
name: inputWebhook.name,
url: inputWebhook.url,
source: inputWebhook.source,
triggers: inputWebhook.triggers,
surveyIds: inputWebhook.surveyIds,
createdAt: new Date(),
updatedAt: new Date(),
};
it("creates a webhook and revalidates cache", async () => {
vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook);
const result = await createWebhook(inputWebhook);
expect(captureTelemetry).toHaveBeenCalledWith("webhook_created");
expect(prisma.webhook.create).toHaveBeenCalled();
expect(webhookCache.revalidate).toHaveBeenCalledWith({
environmentId: createdWebhook.environmentId,
source: createdWebhook.source,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(createdWebhook);
}
});
it("returns error when creation fails", async () => {
vi.mocked(prisma.webhook.create).mockRejectedValueOnce(new Error("Creation failed"));
const result = await createWebhook(inputWebhook);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toEqual("internal_server_error");
}
});
});
@@ -0,0 +1,35 @@
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { TGetWebhooksFilter } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { Prisma } from "@prisma/client";
export const getWebhooksQuery = (environmentId: string, params?: TGetWebhooksFilter) => {
let query: Prisma.WebhookFindManyArgs = {
where: {
environmentId,
},
};
if (!params) return query;
const { surveyIds } = params || {};
if (surveyIds) {
query = {
...query,
where: {
...query.where,
surveyIds: {
hasSome: surveyIds,
},
},
};
}
const baseFilter = pickCommonFilter(params);
if (baseFilter) {
query = buildCommonFilterQuery<Prisma.WebhookFindManyArgs>(query, baseFilter);
}
return query;
};
@@ -0,0 +1,83 @@
import { webhookCache } from "@/lib/cache/webhook";
import { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils";
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
import { Prisma, Webhook } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getWebhooks = async (
environmentId: string,
params: TGetWebhooksFilter
): Promise<Result<ApiResponseWithMeta<Webhook[]>, ApiErrorResponseV2>> => {
try {
const [webhooks, count] = await prisma.$transaction([
prisma.webhook.findMany({
...getWebhooksQuery(environmentId, params),
}),
prisma.webhook.count({
where: getWebhooksQuery(environmentId, params).where,
}),
]);
if (!webhooks) {
return err({
type: "not_found",
details: [{ field: "webhooks", issue: "not_found" }],
});
}
return ok({
data: webhooks,
meta: {
total: count,
limit: params?.limit,
offset: params?.skip,
},
});
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "webhooks", issue: error.message }],
});
}
};
export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webhook, ApiErrorResponseV2>> => {
captureTelemetry("webhook_created");
const { environmentId, name, url, source, triggers, surveyIds } = webhook;
try {
const prismaData: Prisma.WebhookCreateInput = {
environment: {
connect: {
id: environmentId,
},
},
name,
url,
source,
triggers,
surveyIds,
};
const createdWebhook = await prisma.webhook.create({
data: prismaData,
});
webhookCache.revalidate({
environmentId: createdWebhook.environmentId,
source: createdWebhook.source,
});
return ok(createdWebhook);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "webhook", issue: error.message }],
});
}
};
@@ -0,0 +1,86 @@
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
import { getEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/helper";
import { createWebhook, getWebhooks } from "@/modules/api/v2/management/webhooks/lib/webhook";
import { ZGetWebhooksFilter, ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { NextRequest } from "next/server";
export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
query: ZGetWebhooksFilter.sourceType(),
},
handler: async ({ authentication, parsedInput }) => {
const { query } = parsedInput;
if (!query) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "query", issue: "missing" }],
});
}
const environmentId = authentication.environmentId;
const res = await getWebhooks(environmentId, query);
if (res.ok) {
return responses.successResponse(res.data);
}
return handleApiError(request, res.error);
},
});
export const POST = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
body: ZWebhookInput,
},
handler: async ({ authentication, parsedInput }) => {
const { body } = parsedInput;
if (!body) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "body", issue: "missing" }],
});
}
const environmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error);
}
const environmentId = environmentIdResult.data;
if (body.environmentId !== environmentId) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "environmentId", issue: "does not match the surveys environment" }],
});
}
const checkAuthorizationResult = await checkAuthorization({
authentication,
environmentId,
});
if (!checkAuthorizationResult.ok) {
return handleApiError(request, checkAuthorizationResult.error);
}
const createWebhookResult = await createWebhook(body);
if (!createWebhookResult.ok) {
return handleApiError(request, createWebhookResult.error);
}
return responses.successResponse(createWebhookResult);
},
});
@@ -0,0 +1,30 @@
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
import { z } from "zod";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
export const ZGetWebhooksFilter = ZGetFilter.extend({
surveyIds: z.array(z.string().cuid2()).optional(),
}).refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
return false;
}
return true;
},
{
message: "startDate must be before endDate",
}
);
export type TGetWebhooksFilter = z.infer<typeof ZGetWebhooksFilter>;
export const ZWebhookInput = ZWebhook.pick({
name: true,
url: true,
source: true,
environmentId: true,
triggers: true,
surveyIds: true,
});
export type TWebhookInput = z.infer<typeof ZWebhookInput>;
@@ -3,6 +3,7 @@ import { contactAttributePaths } from "@/modules/api/v2/management/contact-attri
import { contactPaths } from "@/modules/api/v2/management/contacts/lib/openapi";
import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi";
import { surveyPaths } from "@/modules/api/v2/management/surveys/lib/openapi";
import { webhookPaths } from "@/modules/api/v2/management/webhooks/lib/openapi";
import * as yaml from "yaml";
import { z } from "zod";
import { createDocument, extendZodWithOpenApi } from "zod-openapi";
@@ -11,6 +12,7 @@ import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute
import { ZContactAttribute } from "@formbricks/database/zod/contact-attributes";
import { ZResponse } from "@formbricks/database/zod/responses";
import { ZSurveyWithoutQuestionType } from "@formbricks/database/zod/surveys";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
extendZodWithOpenApi(z);
@@ -27,6 +29,7 @@ const document = createDocument({
...contactAttributePaths,
...contactAttributeKeyPaths,
...surveyPaths,
...webhookPaths,
},
servers: [
{
@@ -55,6 +58,10 @@ const document = createDocument({
name: "Management API > Surveys",
description: "Operations for managing surveys.",
},
{
name: "Management API > Webhooks",
description: "Operations for managing webhooks.",
},
],
components: {
securitySchemes: {
@@ -71,6 +78,7 @@ const document = createDocument({
contactAttribute: ZContactAttribute,
contactAttributeKey: ZContactAttributeKey,
survey: ZSurveyWithoutQuestionType,
webhook: ZWebhook,
},
},
security: [
@@ -0,0 +1,12 @@
import { z } from "zod";
export const ZGetFilter = z.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
skip: z.coerce.number().nonnegative().optional().default(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
order: z.enum(["asc", "desc"]).optional().default("desc"),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
});
export type TGetFilter = z.infer<typeof ZGetFilter>;
@@ -0,0 +1,19 @@
import { z } from "zod";
export function responseWithMetaSchema<T extends z.ZodTypeAny>(contentSchema: T) {
return z.object({
data: z.array(contentSchema).optional(),
meta: z
.object({
total: z.number().optional(),
limit: z.number().optional(),
offset: z.number().optional(),
})
.optional(),
});
}
// We use the partial method to make all properties optional so we don't show the response fields as required in the OpenAPI documentation
export function makePartialSchema<T extends z.ZodObject<any>>(schema: T) {
return schema.partial();
}
+3 -2
View File
@@ -1,6 +1,7 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { userCache } from "@formbricks/lib/user/cache";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { mockUser } from "./mock-data";
@@ -57,7 +58,7 @@ describe("User Management", () => {
it("throws InvalidInputError when email already exists", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: "P2002",
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.user.create).mockRejectedValueOnce(errToThrow);
@@ -86,7 +87,7 @@ describe("User Management", () => {
it("throws ResourceNotFoundError when user doesn't exist", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: "P2016",
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "0.0.1",
});
vi.mocked(prisma.user.update).mockRejectedValueOnce(errToThrow);
+9 -2
View File
@@ -1,6 +1,7 @@
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { cache } from "@formbricks/lib/cache";
import { userCache } from "@formbricks/lib/user/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
@@ -32,7 +33,10 @@ export const updateUser = async (id: string, data: TUserUpdateInput) => {
return updatedUser;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.RecordDoesNotExist
) {
throw new ResourceNotFoundError("User", id);
}
throw error;
@@ -129,7 +133,10 @@ export const createUser = async (data: TUserCreateInput) => {
return user;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.UniqueConstraintViolation
) {
throw new InvalidInputError("User with this email already exists");
}
@@ -0,0 +1,164 @@
// InsightView.test.jsx
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, test, vi } from "vitest";
import { TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { InsightView } from "./insights-view";
// --- Mocks ---
// Stub out the translation hook so that keys are returned as-is.
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key) => key,
}),
}));
// Spy on formbricks.track
vi.mock("@formbricks/js", () => ({
default: {
track: vi.fn(),
},
}));
// A simple implementation for classnames.
vi.mock("@formbricks/lib/cn", () => ({
cn: (...classes) => classes.join(" "),
}));
// Mock CategoryBadge to render a simple button.
vi.mock("../experience/components/category-select", () => ({
default: ({ category, insightId, onCategoryChange }) => (
<button data-testid="category-badge" onClick={() => onCategoryChange(insightId, category)}>
CategoryBadge: {category}
</button>
),
}));
// Mock InsightSheet to display its open/closed state and the insight title.
vi.mock("@/modules/ee/insights/components/insight-sheet", () => ({
InsightSheet: ({ isOpen, insight }) => (
<div data-testid="insight-sheet">
{isOpen ? "InsightSheet Open" : "InsightSheet Closed"}
{insight && ` - ${insight.title}`}
</div>
),
}));
// Create an array of 15 dummy insights.
// Even-indexed insights will have the category "complaint"
// and odd-indexed insights will have "praise".
const dummyInsights = Array.from({ length: 15 }, (_, i) => ({
id: `insight-${i}`,
_count: { documentInsights: i },
title: `Insight Title ${i}`,
description: `Insight Description ${i}`,
category: i % 2 === 0 ? "complaint" : "praise",
updatedAt: new Date(),
createdAt: new Date(),
environmentId: "environment-1",
})) as TSurveyQuestionSummaryOpenText["insights"];
// Helper function to render the component with default props.
const renderComponent = (props = {}) => {
const defaultProps = {
insights: dummyInsights,
questionId: "question-1",
surveyId: "survey-1",
documentsFilter: {},
isFetching: false,
documentsPerPage: 5,
locale: "en" as TUserLocale,
};
return render(<InsightView {...defaultProps} {...props} />);
};
// --- Tests ---
describe("InsightView Component", () => {
test("renders table headers", () => {
renderComponent();
expect(screen.getByText("#")).toBeInTheDocument();
expect(screen.getByText("common.title")).toBeInTheDocument();
expect(screen.getByText("common.description")).toBeInTheDocument();
expect(screen.getByText("environments.experience.category")).toBeInTheDocument();
});
test('shows "no insights found" when insights array is empty', () => {
renderComponent({ insights: [] });
expect(screen.getByText("environments.experience.no_insights_found")).toBeInTheDocument();
});
test("does not render insights when isFetching is true", () => {
renderComponent({ isFetching: true, insights: [] });
expect(screen.getByText("environments.experience.no_insights_found")).toBeInTheDocument();
});
test("filters insights based on selected tab", async () => {
renderComponent();
// Click on the "complaint" tab.
const complaintTab = screen.getAllByText("environments.experience.complaint")[0];
fireEvent.click(complaintTab);
// Grab all table rows from the table body.
const rows = await screen.findAllByRole("row");
// Check that none of the rows include text from a "praise" insight.
rows.forEach((row) => {
expect(row.textContent).not.toEqual(/Insight Title 1/);
});
});
test("load more button increases visible insights count", () => {
renderComponent();
// Initially, "Insight Title 10" should not be visible because only 10 items are shown.
expect(screen.queryByText("Insight Title 10")).not.toBeInTheDocument();
// Get all buttons with the text "common.load_more" and filter for those that are visible.
const loadMoreButtons = screen.getAllByRole("button", { name: /common\.load_more/i });
expect(loadMoreButtons.length).toBeGreaterThan(0);
// Click the first visible "load more" button.
fireEvent.click(loadMoreButtons[0]);
// Now, "Insight Title 10" should be visible.
expect(screen.getByText("Insight Title 10")).toBeInTheDocument();
});
test("opens insight sheet when a row is clicked", () => {
renderComponent();
// Get all elements that display "Insight Title 0" and use the first one to find its table row
const cells = screen.getAllByText("Insight Title 0");
expect(cells.length).toBeGreaterThan(0);
const rowElement = cells[0].closest("tr");
expect(rowElement).not.toBeNull();
// Simulate a click on the table row
fireEvent.click(rowElement!);
// Get all instances of the InsightSheet component
const sheets = screen.getAllByTestId("insight-sheet");
// Filter for the one that contains the expected text
const matchingSheet = sheets.find((sheet) =>
sheet.textContent?.includes("InsightSheet Open - Insight Title 0")
);
expect(matchingSheet).toBeDefined();
expect(matchingSheet).toHaveTextContent("InsightSheet Open - Insight Title 0");
});
test("category badge calls onCategoryChange and updates the badge (even if value remains the same)", () => {
renderComponent();
// Get the first category badge. For index 0, the category is "complaint".
const categoryBadge = screen.getAllByTestId("category-badge")[0];
// It should display "complaint" initially.
expect(categoryBadge).toHaveTextContent("CategoryBadge: complaint");
// Click the category badge to trigger onCategoryChange.
fireEvent.click(categoryBadge);
// After clicking, the badge should still display "complaint" (since our mock simply passes the current value).
expect(categoryBadge).toHaveTextContent("CategoryBadge: complaint");
});
});
@@ -0,0 +1,215 @@
import { TInsightWithDocumentCount } from "@/modules/ee/insights/experience/types/insights";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TUserLocale } from "@formbricks/types/user";
import { InsightView } from "./insight-view";
// Mock the translation hook to simply return the key.
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock the action that fetches insights.
const mockGetEnvironmentInsightsAction = vi.fn();
vi.mock("../actions", () => ({
getEnvironmentInsightsAction: (...args: any[]) => mockGetEnvironmentInsightsAction(...args),
}));
// Mock InsightSheet so we can assert on its open state.
vi.mock("@/modules/ee/insights/components/insight-sheet", () => ({
InsightSheet: ({
isOpen,
insight,
}: {
isOpen: boolean;
insight: any;
setIsOpen: any;
handleFeedback: any;
documentsFilter: any;
documentsPerPage: number;
locale: string;
}) => (
<div data-testid="insight-sheet">
{isOpen ? `InsightSheet Open${insight ? ` - ${insight.title}` : ""}` : "InsightSheet Closed"}
</div>
),
}));
// Mock InsightLoading.
vi.mock("./insight-loading", () => ({
InsightLoading: () => <div data-testid="insight-loading">Loading...</div>,
}));
// For simplicity, we wont mock CategoryBadge so it renders normally.
// If needed, you can also mock it similar to InsightSheet.
// --- Dummy Data ---
const dummyInsight1 = {
id: "1",
title: "Insight 1",
description: "Description 1",
category: "featureRequest",
_count: { documentInsights: 5 },
};
const dummyInsight2 = {
id: "2",
title: "Insight 2",
description: "Description 2",
category: "featureRequest",
_count: { documentInsights: 3 },
};
const dummyInsightComplaint = {
id: "3",
title: "Complaint Insight",
description: "Complaint Description",
category: "complaint",
_count: { documentInsights: 10 },
};
const dummyInsightPraise = {
id: "4",
title: "Praise Insight",
description: "Praise Description",
category: "praise",
_count: { documentInsights: 8 },
};
// A helper to render the component with required props.
const renderComponent = (props = {}) => {
const defaultProps = {
statsFrom: new Date("2023-01-01"),
environmentId: "env-1",
insightsPerPage: 2,
documentsPerPage: 5,
locale: "en-US" as TUserLocale,
};
return render(<InsightView {...defaultProps} {...props} />);
};
// --- Tests ---
describe("InsightView Component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('renders "no insights found" message when insights array is empty', async () => {
// Set up the mock to return an empty array.
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [] });
renderComponent();
// Wait for the useEffect to complete.
await waitFor(() => {
expect(screen.getByText("environments.experience.no_insights_found")).toBeInTheDocument();
});
});
test("renders table rows when insights are fetched", async () => {
// Return two insights for the initial fetch.
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsight1, dummyInsight2] });
renderComponent();
// Wait until the insights are rendered.
await waitFor(() => {
expect(screen.getByText("Insight 1")).toBeInTheDocument();
expect(screen.getByText("Insight 2")).toBeInTheDocument();
});
});
test("opens insight sheet when a table row is clicked", async () => {
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsight1] });
renderComponent();
// Wait for the insight to appear.
await waitFor(() => {
expect(screen.getAllByText("Insight 1").length).toBeGreaterThan(0);
});
// Instead of grabbing the first "Insight 1" cell,
// get all table rows (they usually have role="row") and then find the row that contains "Insight 1".
const rows = screen.getAllByRole("row");
const targetRow = rows.find((row) => row.textContent?.includes("Insight 1"));
console.log(targetRow?.textContent);
expect(targetRow).toBeTruthy();
// Click the entire row.
fireEvent.click(targetRow!);
// Wait for the InsightSheet to update.
await waitFor(() => {
const sheet = screen.getAllByTestId("insight-sheet");
const matchingSheet = sheet.find((s) => s.textContent?.includes("InsightSheet Open - Insight 1"));
expect(matchingSheet).toBeInTheDocument();
});
});
test("clicking load more fetches next page of insights", async () => {
// First fetch returns two insights.
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsight1, dummyInsight2] });
// Second fetch returns one additional insight.
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsightPraise] });
renderComponent();
// Wait for the initial insights to be rendered.
await waitFor(() => {
expect(screen.getAllByText("Insight 1").length).toBeGreaterThan(0);
expect(screen.getAllByText("Insight 2").length).toBeGreaterThan(0);
});
// The load more button should be visible because hasMore is true.
const loadMoreButton = screen.getAllByText("common.load_more")[0];
fireEvent.click(loadMoreButton);
// Wait for the new insight to be appended.
await waitFor(() => {
expect(screen.getAllByText("Praise Insight").length).toBeGreaterThan(0);
});
});
test("changes filter tab and re-fetches insights", async () => {
// For initial active tab "featureRequest", return a featureRequest insight.
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsight1] });
renderComponent();
await waitFor(() => {
expect(screen.getAllByText("Insight 1")[0]).toBeInTheDocument();
});
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({
data: [dummyInsightComplaint as TInsightWithDocumentCount],
});
renderComponent();
// Find the complaint tab and click it.
const complaintTab = screen.getAllByText("environments.experience.complaint")[0];
fireEvent.click(complaintTab);
// Wait until the new complaint insight is rendered.
await waitFor(() => {
expect(screen.getAllByText("Complaint Insight")[0]).toBeInTheDocument();
});
});
test("shows loading indicator when fetching insights", async () => {
// Make the mock return a promise that doesn't resolve immediately.
let resolveFetch: any;
const fetchPromise = new Promise((resolve) => {
resolveFetch = resolve;
});
mockGetEnvironmentInsightsAction.mockReturnValueOnce(fetchPromise);
renderComponent();
// While fetching, the loading indicator should be visible.
expect(screen.getByTestId("insight-loading")).toBeInTheDocument();
// Resolve the fetch.
resolveFetch({ data: [dummyInsight1] });
await waitFor(() => {
// After fetching, the loading indicator should disappear.
expect(screen.queryByTestId("insight-loading")).not.toBeInTheDocument();
// Instead of getByText, use getAllByText to assert at least one instance of "Insight 1" exists.
expect(screen.getAllByText("Insight 1").length).toBeGreaterThan(0);
});
});
});
@@ -2,6 +2,7 @@ import { inviteCache } from "@/lib/cache/invite";
import { type TInviteUpdateInput } from "@/modules/ee/role-management/types/invites";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ResourceNotFoundError } from "@formbricks/types/errors";
export const updateInvite = async (inviteId: string, data: TInviteUpdateInput): Promise<boolean> => {
@@ -22,7 +23,10 @@ export const updateInvite = async (inviteId: string, data: TInviteUpdateInput):
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.RecordDoesNotExist
) {
throw new ResourceNotFoundError("Invite", inviteId);
} else {
throw error; // Re-throw any other errors
@@ -3,6 +3,7 @@ import { membershipCache } from "@/lib/cache/membership";
import { teamCache } from "@/lib/cache/team";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { organizationCache } from "@formbricks/lib/organization/cache";
import { projectCache } from "@formbricks/lib/project/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
@@ -91,7 +92,11 @@ export const updateMembership = async (
return membership;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
(error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist)
) {
throw new ResourceNotFoundError("Membership", `userId: ${userId}, organizationId: ${organizationId}`);
}
@@ -2,6 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { cache } from "@formbricks/lib/cache";
import { organizationCache } from "@formbricks/lib/organization/cache";
import { projectCache } from "@formbricks/lib/project/cache";
@@ -64,7 +65,10 @@ export const updateOrganizationEmailLogoUrl = async (
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.RecordDoesNotExist
) {
throw new ResourceNotFoundError("Organization", organizationId);
}
@@ -125,7 +129,10 @@ export const removeOrganizationEmailLogoUrl = async (organizationId: string): Pr
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.RecordDoesNotExist
) {
throw new ResourceNotFoundError("Organization", organizationId);
}
@@ -2,6 +2,7 @@ import { webhookCache } from "@/lib/cache/webhook";
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
import { Prisma, Webhook } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { cache } from "@formbricks/lib/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId } from "@formbricks/types/common";
@@ -62,7 +63,10 @@ export const deleteWebhook = async (id: string): Promise<boolean> => {
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
throw new ResourceNotFoundError("Webhook", id);
}
throw new DatabaseError(`Database error when deleting webhook with ID ${id}`);
@@ -0,0 +1,93 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { SetupInstructions } from "./setup-instructions";
// Mock the translation hook to simply return the key.
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock the TabBar component.
vi.mock("@/modules/ui/components/tab-bar", () => ({
TabBar: ({ tabs, setActiveId }: any) => (
<div>
{tabs.map((tab: any) => (
<button key={tab.id} onClick={() => setActiveId(tab.id)}>
{tab.label}
</button>
))}
</div>
),
}));
// Mock the CodeBlock component.
vi.mock("@/modules/ui/components/code-block", () => ({
CodeBlock: ({ children }: { children: React.ReactNode; language?: string }) => (
<pre data-testid="code-block">{children}</pre>
),
}));
// Mock Next.js Link to simply render an anchor.
vi.mock("next/link", () => {
return {
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
};
});
describe("SetupInstructions Component", () => {
const environmentId = "env123";
const webAppUrl = "https://example.com";
beforeEach(() => {
// Optionally reset mocks if needed
vi.clearAllMocks();
});
test("renders npm instructions by default", () => {
render(<SetupInstructions environmentId={environmentId} webAppUrl={webAppUrl} />);
// Verify that the npm tab is active by default by checking for a code block with npm install instructions.
expect(screen.getByText("pnpm install @formbricks/js")).toBeInTheDocument();
// Verify that the TabBar renders both "NPM" and "HTML" buttons.
expect(screen.getByRole("button", { name: /NPM/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /HTML/i })).toBeInTheDocument();
});
test("switches to html tab and displays html instructions", async () => {
render(<SetupInstructions environmentId="env123" webAppUrl="https://example.com" />);
// Instead of getByRole (which finds multiple buttons), use getAllByRole and select the first HTML tab.
const htmlTabButtons = screen.getAllByRole("button", { name: /HTML/i });
expect(htmlTabButtons.length).toBeGreaterThan(0);
const htmlTabButton = htmlTabButtons[0];
fireEvent.click(htmlTabButton);
// Wait for the HTML instructions to appear.
await waitFor(() => {
expect(screen.getByText(/<!-- START Formbricks Surveys -->/i)).toBeInTheDocument();
});
});
test("npm instructions code block contains environmentId and webAppUrl", async () => {
render(<SetupInstructions environmentId={environmentId} webAppUrl={webAppUrl} />);
// The NPM tab is the default view.
// Find all code block elements.
const codeBlocks = screen.getAllByTestId("code-block");
// The setup code block (language "js") should include the environmentId and webAppUrl.
// We filter for the one containing 'formbricks.setup' and our environment values.
const setupCodeBlock = codeBlocks.find(
(block) => block.textContent?.includes("formbricks.setup") && block.textContent?.includes(environmentId)
);
expect(setupCodeBlock).toBeDefined();
expect(setupCodeBlock?.textContent).toContain(environmentId);
expect(setupCodeBlock?.textContent).toContain(webAppUrl);
});
});
@@ -2,6 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { isS3Configured } from "@formbricks/lib/constants";
import { environmentCache } from "@formbricks/lib/environment/cache";
import { createEnvironment } from "@formbricks/lib/environment/service";
@@ -140,12 +141,15 @@ export const createProject = async (
return updatedProject;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.UniqueConstraintViolation
) {
throw new InvalidInputError("A project with this name already exists in your organization");
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError("A project with this name already exists in this organization");
}
throw new DatabaseError(error.message);
@@ -1,9 +1,9 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { userCache } from "@formbricks/lib/user/cache";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TUser } from "@formbricks/types/user";
import { TUserUpdateInput } from "@formbricks/types/user";
import { TUser, TUserUpdateInput } from "@formbricks/types/user";
// function to update a user's user
export const updateUser = async (personId: string, data: TUserUpdateInput): Promise<TUser> => {
@@ -37,7 +37,10 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
return updatedUser;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.RecordDoesNotExist
) {
throw new ResourceNotFoundError("User", personId);
}
throw error; // Re-throw any other errors
@@ -1,5 +1,6 @@
import { ActionClass, Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { actionClassCache } from "@formbricks/lib/actionClass/cache";
import { TActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors";
@@ -28,7 +29,10 @@ export const createActionClass = async (
return actionClassPrisma;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.UniqueConstraintViolation
) {
throw new DatabaseError(
`Action with ${error.meta?.target?.[0]} ${actionClass[error.meta?.target?.[0]]} already exists`
);
@@ -0,0 +1,217 @@
import * as utils from "@/modules/survey/link/lib/utils";
import { render, screen, waitFor } from "@testing-library/react";
import * as navigation from "next/navigation";
import React from "react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { TResponseData } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { LinkSurvey } from "./link-survey";
// Allow tests to control search params via a module-level variable.
let searchParamsValue = new URLSearchParams();
vi.mock("next/navigation", () => ({
useSearchParams: () => searchParamsValue,
}));
// Stub getPrefillValue to return a dummy prefill value.
vi.mock("@/modules/survey/link/lib/utils", () => ({
getPrefillValue: vi.fn(() => ({ prefilled: "dummy" })),
}));
// Mock LinkSurveyWrapper as a simple wrapper that renders its children.
vi.mock("@/modules/survey/link/components/link-survey-wrapper", () => ({
LinkSurveyWrapper: ({ children }: { children: React.ReactNode }) => (
<div data-testid="link-survey-wrapper">{children}</div>
),
}));
// Mock SurveyLinkUsed to render a div with a test id.
vi.mock("@/modules/survey/link/components/survey-link-used", () => ({
SurveyLinkUsed: ({ singleUseMessage }: { singleUseMessage: string }) => (
<div data-testid="survey-link-used">SurveyLinkUsed: {singleUseMessage}</div>
),
}));
// Mock VerifyEmail to render a div that indicates if it's an error or not.
vi.mock("@/modules/survey/link/components/verify-email", () => ({
VerifyEmail: (props: any) => (
<div data-testid="verify-email">VerifyEmail {props.isErrorComponent ? "Error" : ""}</div>
),
}));
// Mock SurveyInline to display key props so we can inspect them.
vi.mock("@/modules/ui/components/survey", () => ({
SurveyInline: (props: any) => (
<div data-testid="survey-inline">
SurveyInline {props.startAtQuestionId ? `StartAt:${props.startAtQuestionId}` : ""}
{props.autoFocus ? " AutoFocus" : ""}
{props.hiddenFieldsRecord ? ` HiddenFields:${JSON.stringify(props.hiddenFieldsRecord)}` : ""}
{props.prefillResponseData ? ` Prefill:${JSON.stringify(props.prefillResponseData)}` : ""}
</div>
),
}));
// --- Dummy Data ---
const dummySurvey = {
id: "survey1",
type: "link",
environmentId: "env1",
welcomeCard: { enabled: true },
questions: [{ id: "q1" }, { id: "q2" }],
isVerifyEmailEnabled: false,
hiddenFields: { fieldIds: ["hidden1"] },
singleUse: "Single Use Message",
styling: { overwriteThemeStyling: false },
} as unknown as TSurvey;
const dummyProject = {
styling: { allowStyleOverwrite: false },
logo: "logo.png",
linkSurveyBranding: true,
};
const dummySingleUseResponse = {
id: "r1",
finished: true,
};
// --- Helper to render the component with default props ---
const renderComponent = (props: Partial<React.ComponentProps<typeof LinkSurvey>> = {}) => {
// Reset search params to an empty state for each test.
searchParamsValue = new URLSearchParams("");
const defaultProps = {
survey: dummySurvey,
project: dummyProject,
emailVerificationStatus: "verified",
singleUseId: "single-use-123",
webAppUrl: "https://example.com",
responseCount: 0,
languageCode: "en",
isEmbed: false,
IMPRINT_URL: "https://example.com/imprint",
PRIVACY_URL: "https://example.com/privacy",
IS_FORMBRICKS_CLOUD: false,
locale: "en",
isPreview: false,
};
return render(<LinkSurvey {...defaultProps} {...props} />);
};
// --- Test Suite ---
describe("LinkSurvey Component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("renders SurveyLinkUsed when singleUseResponse is finished", () => {
renderComponent({ singleUseResponse: dummySingleUseResponse });
expect(screen.getByTestId("survey-link-used")).toBeInTheDocument();
expect(screen.getByText(/SurveyLinkUsed:/)).toHaveTextContent("Single Use Message");
});
test("renders VerifyEmail error component when emailVerificationStatus is fishy", () => {
// Set up survey with email verification enabled.
const survey = { ...dummySurvey, isVerifyEmailEnabled: true };
renderComponent({ survey, emailVerificationStatus: "fishy" });
const verifyEmail = screen.getByTestId("verify-email");
expect(verifyEmail).toBeInTheDocument();
expect(verifyEmail).toHaveTextContent("Error");
});
test("renders VerifyEmail component when emailVerificationStatus is not-verified", () => {
const survey = { ...dummySurvey, isVerifyEmailEnabled: true };
renderComponent({ survey, emailVerificationStatus: "not-verified" });
// Get all rendered VerifyEmail components.
const verifyEmailElements = screen.getAllByTestId("verify-email");
// Filter out the ones that have "Error" in their text.
const nonErrorVerifyEmail = verifyEmailElements.filter((el) => !el.textContent?.includes("Error"));
expect(nonErrorVerifyEmail.length).toBeGreaterThan(0);
});
test("renders LinkSurveyWrapper and SurveyInline when conditions are met", async () => {
// Use a survey that does not require email verification and is not single-use finished.
// Also provide a startAt query param and a hidden field.
const mockUseSearchParams = vi.spyOn(navigation, "useSearchParams");
const mockGetPrefillValue = vi.spyOn(utils, "getPrefillValue");
mockUseSearchParams.mockReturnValue(
new URLSearchParams("startAt=q1&hidden1=value1") as unknown as navigation.ReadonlyURLSearchParams
);
mockGetPrefillValue.mockReturnValue({ prefilled: "dummy" });
renderComponent();
// Check that the LinkSurveyWrapper is rendered.
expect(screen.getByTestId("link-survey-wrapper")).toBeInTheDocument();
// Check that SurveyInline is rendered.
const surveyInline = screen.getByTestId("survey-inline");
expect(surveyInline).toBeInTheDocument();
// Verify that startAtQuestionId is passed when valid.
expect(surveyInline).toHaveTextContent("StartAt:q1");
// Verify that prefillResponseData is passed (from getPrefillValue mock).
expect(surveyInline).toHaveTextContent('Prefill:{"prefilled":"dummy"}');
// Verify that hiddenFieldsRecord includes the hidden field value.
expect(surveyInline).toHaveTextContent('HiddenFields:{"hidden1":"value1"}');
});
test("sets autoFocus to true when not in an iframe", async () => {
// In the test environment, window.self === window.top.
renderComponent();
const surveyInlineElements = screen.getAllByTestId("survey-inline");
await waitFor(() => {
surveyInlineElements.forEach((el) => {
expect(el).toHaveTextContent("AutoFocus");
});
});
});
test("includes verifiedEmail in hiddenFieldsRecord when survey verifies email", () => {
const survey = { ...dummySurvey, isVerifyEmailEnabled: true };
renderComponent({ survey, emailVerificationStatus: "verified", verifiedEmail: "test@example.com" });
const surveyInlineElements = screen.getAllByTestId("survey-inline");
// Find the instance that includes the verifiedEmail in its hiddenFieldsRecord
const withVerifiedEmail = surveyInlineElements.find((el) =>
el.textContent?.includes('"verifiedEmail":"test@example.com"')
);
expect(withVerifiedEmail).toBeDefined();
});
test("handleResetSurvey sets questionId and resets response data", () => {
// We will capture the functions that LinkSurvey passes via getSetQuestionId and getSetResponseData.
let capturedSetQuestionId: (value: string) => void = () => {};
let capturedSetResponseData: (value: TResponseData) => void = () => {};
// Override our SurveyInline mock to capture the props.
vi.doMock("@/modules/ui/components/survey", () => ({
SurveyInline: (props: any) => {
capturedSetQuestionId = props.getSetQuestionId;
capturedSetResponseData = props.getSetResponseData;
return (
<div data-testid="survey-inline">
SurveyInline {props.startAtQuestionId ? `StartAt:${props.startAtQuestionId}` : ""}
</div>
);
},
}));
// Re-import LinkSurvey to pick up the new mock (if necessary).
// For this example, assume our mock is used.
renderComponent();
// Simulate calling the captured functions by invoking the handleResetSurvey function indirectly.
// In the component, handleResetSurvey is passed to LinkSurveyWrapper.
// We can obtain it by accessing the LinkSurveyWrapper's props.
// For simplicity, call the captured functions directly:
capturedSetQuestionId("start");
capturedSetResponseData({});
// Now, verify that the captured functions work as expected.
// (In a real app, these functions would update state in LinkSurvey; here, we can only ensure they are callable.)
expect(typeof capturedSetQuestionId).toBe("function");
expect(typeof capturedSetResponseData).toBe("function");
});
});
+1 -1
View File
@@ -155,7 +155,7 @@
"@types/qrcode": "1.5.5",
"@types/testing-library__react": "10.2.0",
"@vitest/coverage-v8": "2.1.8",
"vite": "6.2.0",
"vite": "6.2.3",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.0.7",
"vitest-mock-extended": "2.0.2"
+1
View File
@@ -1,2 +1,3 @@
export const RESPONSES_API_URL = `/api/v2/management/responses`;
export const SURVEYS_API_URL = `/api/v1/management/surveys`;
export const WEBHOOKS_API_URL = `/api/v2/management/webhooks`;
@@ -0,0 +1,137 @@
import { SURVEYS_API_URL, WEBHOOKS_API_URL } from "@/playwright/api/constants";
import { expect } from "@playwright/test";
import { logger } from "@formbricks/logger";
import { test } from "../../lib/fixtures";
import { loginAndGetApiKey } from "../../lib/utils";
test.describe("API Tests for Webhooks", () => {
test("Create, Retrieve, Update, and Delete Webhooks via API", async ({ page, users, request }) => {
let environmentId, apiKey;
try {
({ environmentId, apiKey } = await loginAndGetApiKey(page, users));
} catch (error) {
logger.error(error, "Error during login and getting API key");
throw error;
}
let surveyId: string;
await test.step("Create Survey via API", async () => {
const surveyBody = {
environmentId: environmentId,
type: "link",
name: "My new Survey from API",
questions: [
{
id: "jpvm9b73u06xdrhzi11k2h76",
type: "openText",
headline: {
default: "What would you like to know?",
},
required: true,
inputType: "text",
subheader: {
default: "This is an example survey.",
},
placeholder: {
default: "Type your answer here...",
},
charLimit: {
enabled: false,
},
},
],
};
const response = await request.post(SURVEYS_API_URL, {
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
},
data: surveyBody,
});
expect(response.ok()).toBe(true);
const responseBody = await response.json();
expect(responseBody.data.name).toEqual("My new Survey from API");
surveyId = responseBody.data.id;
});
let createdWebhookId: string;
await test.step("Create Webhook via API", async () => {
const webhookBody = {
environmentId,
name: "New Webhook",
url: "https://examplewebhook.com",
source: "user",
triggers: ["responseFinished"],
surveyIds: [surveyId],
};
const response = await request.post(WEBHOOKS_API_URL, {
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
},
data: webhookBody,
});
expect(response.ok()).toBe(true);
const responseBody = await response.json();
expect(responseBody.data.name).toEqual("New Webhook");
createdWebhookId = responseBody.data.id;
});
await test.step("Retrieve Webhooks via API", async () => {
const params = { limit: 10, skip: 0, sortBy: "createdAt", order: "desc" };
const response = await request.get(WEBHOOKS_API_URL, {
headers: {
"x-api-key": apiKey,
},
params,
});
expect(response.ok()).toBe(true);
const responseBody = await response.json();
const newlyCreated = responseBody.data.find((hook: any) => hook.id === createdWebhookId);
expect(newlyCreated).toBeTruthy();
expect(newlyCreated.name).toBe("New Webhook");
});
await test.step("Update Webhook by ID via API", async () => {
const updatedBody = {
environmentId,
name: "Updated Webhook",
url: "https://updated-webhook-url.com",
source: "zapier",
triggers: ["responseCreated"],
surveyIds: [surveyId],
};
const response = await request.put(`${WEBHOOKS_API_URL}/${createdWebhookId}`, {
headers: {
"x-api-key": apiKey,
"Content-Type": "application/json",
},
data: updatedBody,
});
expect(response.ok()).toBe(true);
const responseJson = await response.json();
expect(responseJson.data.name).toBe("Updated Webhook");
expect(responseJson.data.source).toBe("zapier");
});
await test.step("Delete Webhook via API", async () => {
const deleteResponse = await request.delete(`${WEBHOOKS_API_URL}/${createdWebhookId}`, {
headers: {
"x-api-key": apiKey,
},
});
expect(deleteResponse.ok()).toBe(true);
});
});
});
+335
View File
@@ -294,6 +294,341 @@ const v1ClientEndpoints = {
],
},
},
"/{environmentId}/storage": {
post: {
summary: "Upload Private File",
description:
"API endpoint for uploading private files. Uploaded files are kept private so that only users with access to the specified environment can retrieve them. The endpoint validates the survey ID, file name, and file type from the request body, and returns a signed URL for S3 uploads along with a local upload URL.",
tags: ["Client API > File Upload"],
parameters: [
{
in: "path",
name: "environmentId",
required: true,
schema: {
type: "string",
},
description: "The ID of the environment.",
},
],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
surveyId: {
type: "string",
description: "The ID of the survey associated with the file.",
},
fileName: {
type: "string",
description: "The name of the file to be uploaded.",
},
fileType: {
type: "string",
description: "The MIME type of the file.",
},
},
required: ["surveyId", "fileName", "fileType"],
example: {
surveyId: "cm7pr0x2y004o192zmit8cjvb",
fileName: "example.jpg",
fileType: "image/jpeg",
},
},
},
},
},
responses: {
"200": {
description: "OK - Returns the signed URL, signing data, updated file name, and file URL.",
content: {
"application/json": {
schema: {
type: "object",
properties: {
data: {
type: "object",
properties: {
signedUrl: {
type: "string",
description: "Signed URL for uploading the file to local storage.",
},
signingData: {
type: "object",
properties: {
signature: {
type: "string",
description: "Signature for verifying the upload.",
},
timestamp: {
type: "number",
description: "Timestamp used in the signature.",
},
uuid: {
type: "string",
description: "Unique identifier for the signed upload.",
},
},
},
updatedFileName: {
type: "string",
description: "The updated file name after processing.",
},
fileUrl: {
type: "string",
description: "URL where the uploaded file can be accessed.",
},
},
},
},
example: {
data: {
signedUrl: "http://localhost:3000/api/v1/client/cm1ubebtj000614kqe4hs3c67/storage/local",
signingData: {
signature: "3e51c6f441e646a0c9a47fdcdd25eee9bfac26d5506461d811b9c55cbdd90914",
timestamp: 1741693207760,
uuid: "f48bcb1aad904f574069a253388024af",
},
updatedFileName: "halle--fid--b153ba3e-6602-4bb3-bed9-211b5b1ae463.jpg",
fileUrl:
"http://localhost:3000/storage/cm1ubebtj000614kqe4hs3c67/private/halle--fid--b153ba3e-6602-4bb3-bed9-211b5b1ae463.jpg",
},
},
},
},
},
},
"400": {
description: "Bad Request - One or more required fields are missing.",
content: {
"application/json": {
schema: {
type: "object",
properties: {
error: {
type: "string",
description: "Detailed error message.",
},
},
example: {
error: "fileName is required",
},
},
},
},
},
"404": {
description: "Not Found - The specified survey or organization does not exist.",
content: {
"application/json": {
schema: {
type: "object",
properties: {
error: {
type: "string",
description: "Detailed error message.",
},
},
example: {
error: "Survey survey123 not found",
},
},
},
},
},
},
servers: [
{
url: "https://app.formbricks.com/api/v2/client",
description: "Formbricks API Server",
},
],
},
},
"/{environmentId}/storage/local": {
post: {
summary: "Upload Private File to Local Storage",
description:
'API endpoint for uploading private files to local storage. The request must include a valid signature, UUID, and timestamp to verify the upload. The file is provided as a Base64 encoded string in the request body. The "Content-Type" header must be set to a valid MIME type, and the file data must be a valid file object (buffer).',
tags: ["Client API > File Upload"],
parameters: [
{
in: "path",
name: "environmentId",
required: true,
schema: {
type: "string",
},
description: "The ID of the environment.",
},
],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
surveyId: {
type: "string",
description: "The ID of the survey associated with the file.",
},
fileName: {
type: "string",
description: "The URI encoded file name.",
},
fileType: {
type: "string",
description: "The MIME type of the file.",
},
signature: {
type: "string",
description: "Signed signature for verifying the file upload.",
},
uuid: {
type: "string",
description: "Unique identifier used in the signature validation.",
},
timestamp: {
type: "string",
description: "Timestamp used in the signature validation.",
},
fileBase64String: {
type: "string",
description:
'Base64 encoded string of the file. It should include data type information, e.g. "data:<mime-type>;base64,<base64-encoded-data>".',
},
},
required: [
"surveyId",
"fileName",
"fileType",
"signature",
"uuid",
"timestamp",
"fileBase64String",
],
example: {
surveyId: "survey123",
fileName: "example.jpg",
fileType: "image/jpeg",
signature: "signedSignatureValue",
uuid: "uniqueUuidValue",
timestamp: "1627891234567",
fileBase64String: "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/...",
},
},
},
},
},
responses: {
"200": {
description: "OK - File uploaded successfully.",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: {
type: "string",
description: "Success message.",
},
},
example: {
message: "File uploaded successfully",
},
},
},
},
},
"400": {
description: "Bad Request - One or more required fields are missing or the file is too large.",
content: {
"application/json": {
schema: {
type: "object",
properties: {
error: {
type: "string",
description: "Detailed error message.",
},
},
example: {
error: "fileName is required",
},
},
},
},
},
"401": {
description: "Unauthorized - Signature validation failed or required signature fields are missing.",
content: {
"application/json": {
schema: {
type: "object",
properties: {
error: {
type: "string",
description: "Detailed error message.",
},
},
example: {
error: "Unauthorized",
},
},
},
},
},
"404": {
description: "Not Found - The specified survey or organization does not exist.",
content: {
"application/json": {
schema: {
type: "object",
properties: {
error: {
type: "string",
description: "Detailed error message.",
},
},
example: {
error: "Survey survey123 not found",
},
},
},
},
},
"500": {
description: "Internal Server Error - File upload failed.",
content: {
"application/json": {
schema: {
type: "object",
properties: {
error: {
type: "string",
description: "Detailed error message.",
},
},
example: {
error: "File upload failed",
},
},
},
},
},
},
servers: [
{
url: "https://app.formbricks.com/api/v2",
description: "Formbricks API Server",
},
],
},
},
};
// Read the generated openapi.yml file
+4
View File
@@ -0,0 +1,4 @@
{
"failedTests": [],
"status": "failed"
}
+2
View File
@@ -39,6 +39,8 @@ export default defineConfig({
"app/layout.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/SurveyAnalysisCTA.tsx",
"app/intercom/*.tsx",
"app/lib/**/*.ts",
"app/api/(internal)/insights/lib/**/*.ts",
"modules/ee/role-management/*.ts",
"modules/organization/settings/teams/actions.ts",
"modules/survey/hooks/*.tsx",
@@ -0,0 +1,3 @@
---
openapi: post /api/v1/management/storage/local
---
@@ -0,0 +1,3 @@
---
openapi: post /api/v1/management/storage
---
+337
View File
@@ -5672,6 +5672,343 @@
"tags": ["Management API > Response"]
}
},
"/api/v1/management/storage": {
"post": {
"description": "API endpoint for uploading public files. Uploaded files are public and accessible by anyone. This endpoint requires authentication. It accepts a JSON body with fileName, fileType, environmentId, and optionally allowedFileExtensions to restrict file types. On success, it returns a signed URL for uploading the file to S3 along with a local upload URL.",
"parameters": [
{
"example": "{{apiKey}}",
"in": "header",
"name": "x-api-key",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"example": {
"allowedFileExtensions": ["png", "jpg", "jpeg"],
"environmentId": "env123",
"fileName": "profile.png",
"fileType": "image/png"
},
"schema": {
"properties": {
"allowedFileExtensions": {
"description": "Optional. List of allowed file extensions.",
"items": {
"type": "string"
},
"type": "array"
},
"environmentId": {
"description": "The ID of the environment.",
"type": "string"
},
"fileName": {
"description": "The name of the file to be uploaded.",
"type": "string"
},
"fileType": {
"description": "The MIME type of the file.",
"type": "string"
}
},
"required": ["fileName", "fileType", "environmentId"],
"type": "object"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"data": {
"fileUrl": "http://localhost:3000/storage/cm1ubebtj000614kqe4hs3c67/public/profile--fid--abc123.png",
"localUrl": "http://localhost:3000/storage/cm1ubebtj000614kqe4hs3c67/public/profile.png",
"signedUrl": "http://localhost:3000/api/v1/client/cm1ubebtj000614kqe4hs3c67/storage/public",
"updatedFileName": "profile--fid--abc123.png"
}
},
"schema": {
"properties": {
"data": {
"properties": {
"fileUrl": {
"description": "URL where the uploaded file can be accessed.",
"type": "string"
},
"localUrl": {
"description": "URL for uploading the file to local storage.",
"type": "string"
},
"signedUrl": {
"description": "Signed URL for uploading the file to S3.",
"type": "string"
},
"updatedFileName": {
"description": "The updated file name after processing.",
"type": "string"
}
},
"type": "object"
}
},
"type": "object"
}
}
},
"description": "OK - Returns the signed URL, updated file name, and file URL."
},
"400": {
"content": {
"application/json": {
"example": {
"error": "fileName is required"
},
"schema": {
"properties": {
"error": {
"description": "Detailed error message.",
"type": "string"
}
},
"type": "object"
}
}
},
"description": "Bad Request - Missing required fields or invalid file extension."
},
"401": {
"content": {
"application/json": {
"example": {
"error": "Not authenticated"
},
"schema": {
"properties": {
"error": {
"description": "Detailed error message.",
"type": "string"
}
},
"type": "object"
}
}
},
"description": "Unauthorized - Authentication failed or user not logged in."
},
"403": {
"content": {
"application/json": {
"example": {
"error": "User does not have access to environment env123"
},
"schema": {
"properties": {
"error": {
"description": "Detailed error message.",
"type": "string"
}
},
"type": "object"
}
}
},
"description": "Forbidden - User does not have access to the specified environment."
}
},
"summary": "Upload Public File",
"tags": ["Management API > Storage"]
}
},
"/api/v1/management/storage/local": {
"post": {
"description": "Management API endpoint for uploading public files to local storage. This endpoint requires authentication. File metadata is provided via headers (X-File-Type, X-File-Name, X-Environment-ID, X-Signature, X-UUID, X-Timestamp) and the file is provided as a multipart/form-data file field named \"file\". The \"Content-Type\" header must be set to a valid MIME type.",
"parameters": [
{
"example": "{{apiKey}}",
"in": "header",
"name": "x-api-key",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "MIME type of the file. Must be a valid MIME type.",
"in": "header",
"name": "X-File-Type",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "URI encoded file name.",
"in": "header",
"name": "X-File-Name",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "ID of the environment.",
"in": "header",
"name": "X-Environment-ID",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Signature for verifying the request.",
"in": "header",
"name": "X-Signature",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Unique identifier for the signed upload.",
"in": "header",
"name": "X-UUID",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Timestamp used for the signature.",
"in": "header",
"name": "X-Timestamp",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"properties": {
"file": {
"description": "The file to be uploaded as a valid file object (buffer).",
"format": "binary",
"type": "string"
}
},
"required": ["file"],
"type": "object"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"data": {
"message": "File uploaded successfully"
}
},
"schema": {
"properties": {
"data": {
"properties": {
"message": {
"description": "Success message.",
"type": "string"
}
},
"type": "object"
}
},
"type": "object"
}
}
},
"description": "OK - File uploaded successfully."
},
"400": {
"content": {
"application/json": {
"example": {
"error": "fileType is required"
},
"schema": {
"properties": {
"error": {
"description": "Detailed error message.",
"type": "string"
}
},
"type": "object"
}
}
},
"description": "Bad Request - Missing required fields, invalid header values, or file issues."
},
"401": {
"content": {
"application/json": {
"example": {
"error": "Not authenticated"
},
"schema": {
"properties": {
"error": {
"description": "Detailed error message.",
"type": "string"
}
},
"type": "object"
}
}
},
"description": "Unauthorized - Authentication failed, invalid signature, or user not authorized."
},
"500": {
"content": {
"application/json": {
"example": {
"error": "File upload failed"
},
"schema": {
"properties": {
"error": {
"description": "Detailed error message.",
"type": "string"
}
},
"type": "object"
}
}
},
"description": "Internal Server Error - File upload failed due to server error."
}
},
"servers": [
{
"description": "Formbricks API Server",
"url": "https://app.formbricks.com/api/v1"
}
],
"summary": "Upload Public File to Local Storage",
"tags": ["Management API > Storage"]
}
},
"/api/v1/management/surveys": {
"get": {
"description": "Fetches all existing surveys",
File diff suppressed because it is too large Load Diff
+50 -47
View File
@@ -1,5 +1,5 @@
---
title: 'License'
title: "License"
description: "License for Formbricks"
icon: "file-certificate"
---
@@ -7,7 +7,9 @@ icon: "file-certificate"
The Formbricks core source code is licensed under AGPLv3 and available on GitHub. Additionally, we offer features for bigger organisations & enterprises under a separate, paid Enterprise License. This assures the long-term sustainability of the open source project. All free features are listed [below](#what-features-are-free).
<Note>
Want to get your hands on the Enterprise Edition? [Request a free 60-day Enterprise Edition Trial](https://formbricks.com/enterprise-license?source=docs) License to build a fully functioning Proof of Concept.
Want to get your hands on the Enterprise Edition? [Request a free 60-day Enterprise Edition
Trial](https://formbricks.com/enterprise-license?source=docs) License to build a fully functioning Proof of
Concept.
</Note>
## Enterprise Edition
@@ -18,20 +20,19 @@ Additional to the AGPLv3 licensed Formbricks core, the Formbricks repository con
| | Community Edition | Enterprise License |
| ------------------------------------------------------------- | ----------------- | ------------------ |
| Self-host for commercial purposes | ✅ | No license needed |
| Fork codebase, make changes, release under AGPLv3 | ✅ | No license needed |
| Fork codebase, make changes, **keep private** | ❌ | ✅ |
| Unlimited responses | ✅ | No license needed |
| Unlimited surveys | ✅ | No license needed |
| Unlimited users | ✅ | No license needed |
| Self-host for commercial purposes | ✅ | No license needed |
| Fork codebase, make changes, release under AGPLv3 | ✅ | No license needed |
| Fork codebase, make changes, **keep private** | ❌ | ✅ |
| Unlimited responses | ✅ | No license needed |
| Unlimited surveys | ✅ | No license needed |
| Unlimited users | ✅ | No license needed |
| Projects | 3 | Unlimited |
| Use any of the other [free features](#what-features-are-free) | ✅ | No license needed |
| Remove branding | ❌ | ✅ |
| SSO | ❌ | ✅ |
| Contacts & Targeting | ❌ | ✅ |
| Teams & access roles | ❌ | ✅ |
| Cluster support | ❌ | ✅ |
| Use any of the [paid features](#what-features-are-free) | ❌ | ✅ |
| Use any of the other [free features](#what-features-are-free) | ✅ | No license needed |
| Remove branding | ❌ | ✅ |
| SSO | ❌ | ✅ |
| Contacts & Targeting | ❌ | ✅ |
| Teams & access roles | ❌ | ✅ |
| Use any of the [paid features](#what-features-are-free) | ❌ | ✅ |
## Open Core Licensing
@@ -44,7 +45,9 @@ The Formbricks core application is licensed under the [AGPLv3 Open Source Licens
Additional to the AGPL licensed Formbricks core, this repository contains code licensed under an Enterprise license. The [code](https://github.com/formbricks/formbricks/tree/main/apps/web/modules/ee) and [license](https://github.com/formbricks/formbricks/blob/main/apps/web/modules/ee/LICENSE) for the enterprise functionality can be found in the `/apps/web/modules/ee` folder of this repository. This additional functionality is not part of the AGPLv3 licensed Formbricks core and is designed to meet the needs of larger teams and enterprises. This advanced functionality is already included in the Docker images, but you need an [Enterprise License Key](https://formbricks.com/enterprise-license?source=docs) to unlock it.
<Note>
Want to get your hands on the Enterprise Edition? [Request a free 60-day Enterprise Edition Trial](https://formbricks.com/enterprise-license?source=docs) License to build a fully functioning Proof of Concept.
Want to get your hands on the Enterprise Edition? [Request a free 60-day Enterprise Edition
Trial](https://formbricks.com/enterprise-license?source=docs) License to build a fully functioning Proof of
Concept.
</Note>
## White-Labeling Formbricks and Other Licensing Needs
@@ -59,35 +62,35 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
| Feature | Community Edition | Enterprise Edition |
| ---------------------------------------------- | ----------------- | ------------------ |
| Unlimited surveys | ✅ | ✅ |
| Website & App surveys | ✅ | ✅ |
| Link surveys | ✅ | ✅ |
| Email embedded surveys | ✅ | ✅ |
| Advanced logic | ✅ | ✅ |
| Custom styling | ✅ | ✅ |
| Custom URL | ✅ | ✅ |
| Recall information | ✅ | ✅ |
| All question types | ✅ | ✅ |
| Multi-media backgrounds | ✅ | ✅ |
| Partial responses | ✅ | ✅ |
| File upload | ✅ | ✅ |
| Hidden fields | ✅ | ✅ |
| Single-use links | ✅ | ✅ |
| Pin-protected surveys | ✅ | ✅ |
| Full API Access | ✅ | ✅ |
| All SDKs | ✅ | ✅ |
| Webhooks | ✅ | ✅ |
| Email follow-ups | ✅ | ✅ |
| Multi-language UI | ✅ | ✅ |
| All integrations (Slack, Zapier, Notion, etc.) | ✅ | ✅ |
| Hide "Powered by Formbricks" | ❌ | ✅ |
| Whitelabel email follow-ups | ❌ | ✅ |
| Teams & access roles | ❌ | ✅ |
| Contact management & segments | ❌ | ✅ |
| Multi-language surveys | ❌ | ✅ |
| OAuth SSO (AzureAD, Google, OpenID) | ❌ | ✅ |
| SAML SSO | ❌ | ✅ |
| White-glove onboarding | ❌ | ✅ |
| Support SLAs | ❌ | ✅ |
| Unlimited surveys | ✅ | ✅ |
| Website & App surveys | ✅ | ✅ |
| Link surveys | ✅ | ✅ |
| Email embedded surveys | ✅ | ✅ |
| Advanced logic | ✅ | ✅ |
| Custom styling | ✅ | ✅ |
| Custom URL | ✅ | ✅ |
| Recall information | ✅ | ✅ |
| All question types | ✅ | ✅ |
| Multi-media backgrounds | ✅ | ✅ |
| Partial responses | ✅ | ✅ |
| File upload | ✅ | ✅ |
| Hidden fields | ✅ | ✅ |
| Single-use links | ✅ | ✅ |
| Pin-protected surveys | ✅ | ✅ |
| Full API Access | ✅ | ✅ |
| All SDKs | ✅ | ✅ |
| Webhooks | ✅ | ✅ |
| Email follow-ups | ✅ | ✅ |
| Multi-language UI | ✅ | ✅ |
| All integrations (Slack, Zapier, Notion, etc.) | ✅ | ✅ |
| Hide "Powered by Formbricks" | ❌ | ✅ |
| Whitelabel email follow-ups | ❌ | ✅ |
| Teams & access roles | ❌ | ✅ |
| Contact management & segments | ❌ | ✅ |
| Multi-language surveys | ❌ | ✅ |
| OAuth SSO (AzureAD, Google, OpenID) | ❌ | ✅ |
| SAML SSO | ❌ | ✅ |
| White-glove onboarding | ❌ | ✅ |
| Support SLAs | ❌ | ✅ |
**Any more questions?** [Send us an email](mailto:johannes@formbricks.com) or [book a call with us.](https://cal.com/johannes/license)
**Any more questions?** [Send us an email](mailto:johannes@formbricks.com) or [book a call with us.](https://cal.com/johannes/license)
+1 -1
View File
@@ -40,7 +40,7 @@
"@rollup/plugin-inject": "5.0.5",
"buffer": "6.0.3",
"terser": "5.37.0",
"vite": "6.0.9",
"vite": "6.0.12",
"vite-plugin-dts": "4.3.0",
"vite-plugin-node-polyfills": "0.22.0"
}
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Webhook" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
+1 -1
View File
@@ -48,7 +48,7 @@ model Webhook {
id String @id @default(cuid())
name String?
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at")
url String
source WebhookSource @default(user)
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
+5
View File
@@ -0,0 +1,5 @@
export enum PrismaErrorType {
UniqueConstraintViolation = "P2002",
RecordDoesNotExist = "P2015",
RelatedRecordDoesNotExist = "P2025",
}
+41
View File
@@ -0,0 +1,41 @@
import type { Organization } from "@prisma/client";
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZOrganizationWhiteLabel = z.object({
logoUrl: z.string().nullable(),
});
export const ZOrganizationBilling = z.object({
stripeCustomerId: z.string().nullable(),
plan: z.enum(["free", "startup", "scale", "enterprise"]).default("free"),
period: z.enum(["monthly", "yearly"]).default("monthly"),
limits: z
.object({
projects: z.number().nullable(),
monthly: z.object({
responses: z.number().nullable(),
miu: z.number().nullable(),
}),
})
.default({
projects: 3,
monthly: {
responses: 1500,
miu: 2000,
},
}),
periodStart: z.coerce.date().nullable(),
});
export const ZOrganization = z.object({
id: z.string().cuid2(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
name: z.string(),
whitelabel: ZOrganizationWhiteLabel,
billing: ZOrganizationBilling as z.ZodType<Organization["billing"]>,
isAIEnabled: z.boolean().default(false) as z.ZodType<Organization["isAIEnabled"]>,
}) satisfies z.ZodType<Organization>;
+37 -9
View File
@@ -1,14 +1,42 @@
import type { Webhook } from "@prisma/client";
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZWebhook = z.object({
id: z.string().cuid2(),
name: z.string().nullable(),
createdAt: z.date(),
updatedAt: z.date(),
url: z.string().url(),
source: z.enum(["user", "zapier", "make", "n8n"]),
environmentId: z.string().cuid2(),
triggers: z.array(z.enum(["responseFinished", "responseCreated", "responseUpdated"])),
surveyIds: z.array(z.string().cuid2()),
id: z.string().cuid2().openapi({
description: "The ID of the webhook",
}),
name: z.string().nullable().openapi({
description: "The name of the webhook",
}),
createdAt: z.date().openapi({
description: "The date and time the webhook was created",
example: "2021-01-01T00:00:00.000Z",
}),
updatedAt: z.date().openapi({
description: "The date and time the webhook was last updated",
example: "2021-01-01T00:00:00.000Z",
}),
url: z.string().url().openapi({
description: "The URL of the webhook",
}),
source: z.enum(["user", "zapier", "make", "n8n"]).openapi({
description: "The source of the webhook",
}),
environmentId: z.string().cuid2().openapi({
description: "The ID of the environment",
}),
triggers: z.array(z.enum(["responseFinished", "responseCreated", "responseUpdated"])).openapi({
description: "The triggers of the webhook",
}),
surveyIds: z.array(z.string().cuid2()).openapi({
description: "The IDs of the surveys ",
}),
}) satisfies z.ZodType<Webhook>;
ZWebhook.openapi({
ref: "webhook",
description: "A webhook",
});
+1 -1
View File
@@ -48,7 +48,7 @@
"@formbricks/eslint-config": "workspace:*",
"@vitest/coverage-v8": "3.0.7",
"terser": "5.37.0",
"vite": "6.0.9",
"vite": "6.0.12",
"vite-plugin-dts": "4.3.0",
"vitest": "3.0.6"
}
+1 -1
View File
@@ -44,7 +44,7 @@
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"terser": "5.37.0",
"vite": "6.0.9",
"vite": "6.0.12",
"vite-plugin-dts": "4.3.0"
}
}
+10 -4
View File
@@ -4,9 +4,9 @@ import "server-only";
import { ActionClass, Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/common";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { cache } from "../cache";
import { ITEMS_PER_PAGE } from "../constants";
@@ -163,7 +163,10 @@ export const createActionClass = async (
return actionClassPrisma;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.UniqueConstraintViolation
) {
throw new DatabaseError(
`Action with ${error.meta?.target?.[0]} ${actionClass[error.meta?.target?.[0]]} already exists`
);
@@ -219,7 +222,10 @@ export const updateActionClass = async (
return result;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.UniqueConstraintViolation
) {
throw new DatabaseError(
`Action with ${error.meta?.target?.[0]} ${inputActionClass[error.meta?.target?.[0]]} already exists`
);
+3 -2
View File
@@ -10,6 +10,7 @@ import {
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { testInputValidation } from "vitestSetup";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError } from "@formbricks/types/errors";
import { createDisplay } from "../../../../apps/web/app/api/v1/client/[environmentId]/displays/lib/display";
import { deleteDisplay } from "../service";
@@ -52,7 +53,7 @@ describe("Tests for createDisplay service", () => {
const mockErrorMessage = "Mock error message";
prisma.environment.findUnique.mockResolvedValue(mockEnvironment);
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: "P2002",
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
@@ -83,7 +84,7 @@ describe("Tests for delete display service", () => {
it("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: "P2002",
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
+5 -5
View File
@@ -17,10 +17,10 @@ export const env = createEnv({
AZUREAD_CLIENT_ID: z.string().optional(),
AZUREAD_CLIENT_SECRET: z.string().optional(),
AZUREAD_TENANT_ID: z.string().optional(),
CRON_SECRET: z.string().min(10),
CRON_SECRET: z.string().optional(),
BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.string().url(),
DATABASE_URL: z.string().url().optional(),
DEBUG: z.enum(["1", "0"]).optional(),
DOCKER_CRON_ENABLED: z.enum(["1", "0"]).optional(),
DEFAULT_ORGANIZATION_ID: z.string().optional(),
@@ -28,9 +28,9 @@ export const env = createEnv({
E2E_TESTING: z.enum(["1", "0"]).optional(),
EMAIL_AUTH_DISABLED: z.enum(["1", "0"]).optional(),
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
ENCRYPTION_KEY: z.string().length(64).or(z.string().length(32)),
ENCRYPTION_KEY: z.string().optional(),
ENTERPRISE_LICENSE_KEY: z.string().optional(),
FORMBRICKS_ENCRYPTION_KEY: z.string().length(24).or(z.string().length(0)).optional(),
FORMBRICKS_ENCRYPTION_KEY: z.string().optional(),
GITHUB_ID: z.string().optional(),
GITHUB_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
@@ -52,8 +52,8 @@ export const env = createEnv({
IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error", "fatal"]).optional(),
MAIL_FROM: z.string().email().optional(),
NEXTAUTH_SECRET: z.string().optional(),
MAIL_FROM_NAME: z.string().optional(),
NEXTAUTH_SECRET: z.string().min(1),
NOTION_OAUTH_CLIENT_ID: z.string().optional(),
NOTION_OAUTH_CLIENT_SECRET: z.string().optional(),
OIDC_CLIENT_ID: z.string().optional(),
+41
View File
@@ -5,20 +5,44 @@ import { symmetricDecrypt, symmetricEncrypt } from "./crypto";
import { env } from "./env";
export const createToken = (userId: string, userEmail: string, options = {}): string => {
if (!env.ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
return jwt.sign({ id: encryptedUserId }, env.NEXTAUTH_SECRET + userEmail, options);
};
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
if (!env.ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedEmail = symmetricEncrypt(userEmail, env.ENCRYPTION_KEY);
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET + surveyId);
};
export const createEmailToken = (email: string): string => {
if (!env.ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
if (!env.NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET);
};
export const getEmailFromEmailToken = (token: string): string => {
if (!env.ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
if (!env.NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as JwtPayload;
try {
// Try to decrypt first (for newer tokens)
@@ -31,6 +55,13 @@ export const getEmailFromEmailToken = (token: string): string => {
};
export const createInviteToken = (inviteId: string, email: string, options = {}): string => {
if (!env.ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
if (!env.NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
const encryptedInviteId = symmetricEncrypt(inviteId, env.ENCRYPTION_KEY);
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, env.NEXTAUTH_SECRET, options);
@@ -41,6 +72,9 @@ export const verifyTokenForLinkSurvey = (token: string, surveyId: string): strin
const { email } = jwt.verify(token, env.NEXTAUTH_SECRET + surveyId) as JwtPayload;
try {
// Try to decrypt first (for newer tokens)
if (!env.ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
return decryptedEmail;
} catch {
@@ -53,6 +87,9 @@ export const verifyTokenForLinkSurvey = (token: string, surveyId: string): strin
};
export const verifyToken = async (token: string): Promise<JwtPayload> => {
if (!env.ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
// First decode to get the ID
const decoded = jwt.decode(token);
const payload: JwtPayload = decoded as JwtPayload;
@@ -90,6 +127,10 @@ export const verifyToken = async (token: string): Promise<JwtPayload> => {
export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
try {
if (!env.ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const decoded = jwt.decode(token);
const payload: JwtPayload = decoded as JwtPayload;
+4 -3
View File
@@ -9,6 +9,7 @@ import {
} from "./__mocks__/data.mock";
import { Prisma } from "@prisma/client";
import { prismaMock } from "@formbricks/database/src/jestClient";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { createLanguage, deleteLanguage, updateLanguage } from "../service";
@@ -34,7 +35,7 @@ describe("Tests for createLanguage service", () => {
it("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: "P2002",
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
@@ -72,7 +73,7 @@ describe("Tests for updateLanguage Service", () => {
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: "P2002",
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
@@ -109,7 +110,7 @@ describe("Tests for deleteLanguage", () => {
it("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: "P2002",
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
+3
View File
@@ -270,6 +270,7 @@
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.",
"or": "oder",
"organization": "Organisation",
"organization_id": "Organisations-ID",
"organization_not_found": "Organisation nicht gefunden",
"organization_teams_not_found": "Organisations-Teams nicht gefunden",
"other": "Andere",
@@ -354,6 +355,7 @@
"summary": "Zusammenfassung",
"survey": "Umfrage",
"survey_completed": "Umfrage abgeschlossen.",
"survey_id": "Umfrage-ID",
"survey_languages": "Umfragesprachen",
"survey_live": "Umfrage live",
"survey_not_found": "Umfrage nicht gefunden",
@@ -778,6 +780,7 @@
"add_env_api_key": "{environmentType} API-Schlüssel hinzufügen",
"api_key": "API-Schlüssel",
"api_key_copied_to_clipboard": "API-Schlüssel in die Zwischenablage kopiert",
"api_key_created": "API-Schlüssel erstellt",
"api_key_deleted": "API-Schlüssel gelöscht",
"api_key_label": "API-Schlüssel Label",
"api_key_security_warning": "Aus Sicherheitsgründen wird der API-Schlüssel nur einmal nach der Erstellung angezeigt. Bitte kopiere ihn sofort an einen sicheren Ort.",
+3
View File
@@ -270,6 +270,7 @@
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
"or": "or",
"organization": "Organization",
"organization_id": "Organization ID",
"organization_not_found": "Organization not found",
"organization_teams_not_found": "Organization teams not found",
"other": "Other",
@@ -354,6 +355,7 @@
"summary": "Summary",
"survey": "Survey",
"survey_completed": "Survey completed.",
"survey_id": "Survey ID",
"survey_languages": "Survey Languages",
"survey_live": "Survey live",
"survey_not_found": "Survey not found",
@@ -778,6 +780,7 @@
"add_env_api_key": "Add {environmentType} API Key",
"api_key": "API Key",
"api_key_copied_to_clipboard": "API key copied to clipboard",
"api_key_created": "API key created",
"api_key_deleted": "API Key deleted",
"api_key_label": "API Key Label",
"api_key_security_warning": "For security reasons, the API key will only be shown once after creation. Please copy it to your destination right away.",

Some files were not shown because too many files have changed in this diff Show More