mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-15 04:09:03 -05:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9dbaa3c28 | |||
| d352d03071 | |||
| ebefe775bb | |||
| 0852a961cc | |||
| 46f06f4c0e | |||
| afb39e4aba | |||
| 2c6a90f82b | |||
| e35f732e48 | |||
| ec8b17dee2 |
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+103
@@ -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"
|
||||
|
||||
+1
-1
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+3
@@ -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 };
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
+3
-2
@@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
+20
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 won’t 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");
|
||||
});
|
||||
});
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"failedTests": [],
|
||||
"status": "failed"
|
||||
}
|
||||
@@ -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
|
||||
---
|
||||
@@ -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",
|
||||
|
||||
+1219
-344
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Webhook" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum PrismaErrorType {
|
||||
UniqueConstraintViolation = "P2002",
|
||||
RecordDoesNotExist = "P2015",
|
||||
RelatedRecordDoesNotExist = "P2025",
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user