mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
chore: merge with main
This commit is contained in:
3
.github/actions/cache-build-web/action.yml
vendored
3
.github/actions/cache-build-web/action.yml
vendored
@@ -57,9 +57,6 @@ runs:
|
||||
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
|
||||
|
||||
|
||||
84
.github/dependabot.yml
vendored
Normal file
84
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm" # For pnpm monorepos, use npm ecosystem
|
||||
directory: "/" # Root package.json
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
versioning-strategy: increase
|
||||
|
||||
# Apps directory packages
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/demo"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/demo-react-native"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/storybook"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/web"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
# Packages directory
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/database"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/lib"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/types"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/config-eslint"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/config-prettier"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/config-typescript"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/js-core"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/surveys"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/logger"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -15,7 +15,6 @@ env:
|
||||
IMAGE_NAME: ${{ github.repository }}-experimental
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -80,6 +79,9 @@ jobs:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
|
||||
4
.github/workflows/release-docker-github.yml
vendored
4
.github/workflows/release-docker-github.yml
vendored
@@ -19,7 +19,6 @@ env:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -100,6 +99,9 @@ jobs:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
|
||||
34
.github/workflows/terrafrom-plan-and-apply.yml
vendored
34
.github/workflows/terrafrom-plan-and-apply.yml
vendored
@@ -3,16 +3,21 @@ name: 'Terraform'
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# TODO: enable it back when migration is completed.
|
||||
# push:
|
||||
# branches:
|
||||
# - main
|
||||
# pull_request:
|
||||
# branches:
|
||||
# - main
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "infra/terraform/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "infra/terraform/**"
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
@@ -58,18 +63,17 @@ jobs:
|
||||
run: terraform plan -out .planfile
|
||||
working-directory: infra/terraform
|
||||
|
||||
# - name: Post PR comment
|
||||
# uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0
|
||||
# if: always() && github.ref != 'refs/heads/main' && (steps.validate.outcome == 'success' || steps.validate.outcome == 'failure')
|
||||
# with:
|
||||
# token: ${{ github.token }}
|
||||
# planfile: .planfile
|
||||
# working-directory: "infra/terraform"
|
||||
# skip-comment: true
|
||||
- name: Post PR comment
|
||||
uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0
|
||||
if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
planfile: .planfile
|
||||
working-directory: "infra/terraform"
|
||||
|
||||
- name: Terraform Apply
|
||||
id: apply
|
||||
# if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
run: terraform apply .planfile
|
||||
working-directory: "infra/terraform"
|
||||
|
||||
|
||||
@@ -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,17 +24,27 @@ 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"
|
||||
# BuildKit secret handling without hardcoded fallback values
|
||||
# This approach relies entirely on secrets passed from GitHub Actions
|
||||
RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
|
||||
echo 'if [ -f "/run/secrets/database_url" ]; then' >> /tmp/read-secrets.sh && \
|
||||
echo ' export DATABASE_URL=$(cat /run/secrets/database_url)' >> /tmp/read-secrets.sh && \
|
||||
echo 'else' >> /tmp/read-secrets.sh && \
|
||||
echo ' echo "DATABASE_URL secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
|
||||
echo 'fi' >> /tmp/read-secrets.sh && \
|
||||
echo 'if [ -f "/run/secrets/encryption_key" ]; then' >> /tmp/read-secrets.sh && \
|
||||
echo ' export ENCRYPTION_KEY=$(cat /run/secrets/encryption_key)' >> /tmp/read-secrets.sh && \
|
||||
echo 'else' >> /tmp/read-secrets.sh && \
|
||||
echo ' echo "ENCRYPTION_KEY secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
|
||||
echo 'fi' >> /tmp/read-secrets.sh && \
|
||||
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
|
||||
chmod +x /tmp/read-secrets.sh
|
||||
|
||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||
ARG SENTRY_AUTH_TOKEN
|
||||
|
||||
# Increase Node.js memory limit
|
||||
# ENV NODE_OPTIONS="--max_old_space_size=4096"
|
||||
# Increase Node.js memory limit as a regular build argument
|
||||
ARG NODE_OPTIONS="--max_old_space_size=4096"
|
||||
ENV NODE_OPTIONS=${NODE_OPTIONS}
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
@@ -53,8 +63,11 @@ RUN touch apps/web/.env
|
||||
# Install the dependencies
|
||||
RUN pnpm install
|
||||
|
||||
# Build the project
|
||||
RUN NODE_OPTIONS="--max_old_space_size=4096" pnpm build --filter=@formbricks/web...
|
||||
# Build the project using our secret reader script
|
||||
# This mounts the secrets only during this build step without storing them in layers
|
||||
RUN --mount=type=secret,id=database_url \
|
||||
--mount=type=secret,id=encryption_key \
|
||||
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
||||
|
||||
# Extract Prisma version
|
||||
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
77
apps/web/app/(app)/components/FormbricksClient.test.tsx
Normal file
77
apps/web/app/(app)/components/FormbricksClient.test.tsx
Normal file
@@ -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" target="_blank">
|
||||
<BugIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
|
||||
<TooltipRenderer tooltipContent={t("common.account")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -88,7 +88,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
<SettingsId title={t("common.organization")} id={organization.id}></SettingsId>
|
||||
<SettingsId title={t("common.organization_id")} id={organization.id}></SettingsId>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SettingsId } from "@/modules/ui/components/settings-id";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
@@ -93,6 +94,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
isReadOnly={isReadOnly}
|
||||
locale={user.locale ?? DEFAULT_LOCALE}
|
||||
/>
|
||||
|
||||
<SettingsId title={t("common.survey_id")} id={surveyId}></SettingsId>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
390
apps/web/app/api/(internal)/insights/lib/utils.test.ts
Normal file
390
apps/web/app/api/(internal)/insights/lib/utils.test.ts
Normal file
@@ -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();
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ export const GET = async (req: NextRequest) => {
|
||||
<h2 tw="flex flex-col text-[8] sm:text-4xl font-bold tracking-tight text-slate-900 text-left mt-15">
|
||||
{name}
|
||||
</h2>
|
||||
<span tw="text-slate-600 text-xl">Complete in ~ 4 minutes</span>
|
||||
</div>
|
||||
</div>
|
||||
<div tw="flex justify-end mr-10 ">
|
||||
|
||||
@@ -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 };
|
||||
3
apps/web/app/api/v2/management/webhooks/route.ts
Normal file
3
apps/web/app/api/v2/management/webhooks/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GET, POST } from "@/modules/api/v2/management/webhooks/route";
|
||||
|
||||
export { GET, POST };
|
||||
@@ -29,6 +29,7 @@ vi.mock("@formbricks/lib/constants", () => ({
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/language", () => ({
|
||||
@@ -69,6 +70,15 @@ vi.mock("@/tolgee/client", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/sentry/SentryProvider", () => ({
|
||||
SentryProvider: ({ children, sentryDsn }: { children: React.ReactNode; sentryDsn?: string }) => (
|
||||
<div data-testid="sentry-provider">
|
||||
SentryProvider: {sentryDsn}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("RootLayout", () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
@@ -97,6 +107,7 @@ describe("RootLayout", () => {
|
||||
expect(screen.getByTestId("speed-insights")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("ph-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("sentry-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Child Content");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SentryProvider } from "@/app/sentry/SentryProvider";
|
||||
import { PHProvider } from "@/modules/ui/components/post-hog-client";
|
||||
import { TolgeeNextProvider } from "@/tolgee/client";
|
||||
import { getLocale } from "@/tolgee/language";
|
||||
@@ -6,7 +7,7 @@ import { TolgeeStaticData } from "@tolgee/react";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import { Metadata } from "next";
|
||||
import React from "react";
|
||||
import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants";
|
||||
import { IS_POSTHOG_CONFIGURED, SENTRY_DSN } from "@formbricks/lib/constants";
|
||||
import "../modules/ui/globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -27,11 +28,13 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
<html lang={locale} translate="no">
|
||||
<body className="flex h-dvh flex-col transition-all ease-in-out">
|
||||
{process.env.VERCEL === "1" && <SpeedInsights sampleRate={0.1} />}
|
||||
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
|
||||
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
|
||||
{children}
|
||||
</TolgeeNextProvider>
|
||||
</PHProvider>
|
||||
<SentryProvider sentryDsn={SENTRY_DSN}>
|
||||
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
|
||||
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
|
||||
{children}
|
||||
</TolgeeNextProvider>
|
||||
</PHProvider>
|
||||
</SentryProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
113
apps/web/app/lib/pipelines.test.ts
Normal file
113
apps/web/app/lib/pipelines.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { TPipelineInput } from "@/app/lib/types/pipelines";
|
||||
import { PipelineTriggers } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { sendToPipeline } from "./pipelines";
|
||||
|
||||
// Mock the constants module
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
CRON_SECRET: "mocked-cron-secret",
|
||||
WEBAPP_URL: "https://test.formbricks.com",
|
||||
}));
|
||||
|
||||
// Mock the logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock global fetch
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
describe("pipelines", () => {
|
||||
// Reset mocks before each test
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("sendToPipeline should call fetch with correct parameters", async () => {
|
||||
// Mock the fetch implementation to return a successful response
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
// Create sample data for testing
|
||||
const testData: TPipelineInput = {
|
||||
event: PipelineTriggers.responseCreated,
|
||||
surveyId: "cm8ckvchx000008lb710n0gdn",
|
||||
environmentId: "cm8cmp9hp000008jf7l570ml2",
|
||||
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
|
||||
};
|
||||
|
||||
// Call the function with test data
|
||||
await sendToPipeline(testData);
|
||||
|
||||
// Check that fetch was called with the correct arguments
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch).toHaveBeenCalledWith("https://test.formbricks.com/api/pipeline", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": "mocked-cron-secret",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
environmentId: testData.environmentId,
|
||||
surveyId: testData.surveyId,
|
||||
event: testData.event,
|
||||
response: testData.response,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("sendToPipeline should handle fetch errors", async () => {
|
||||
// Mock fetch to throw an error
|
||||
const testError = new Error("Network error");
|
||||
mockFetch.mockRejectedValueOnce(testError);
|
||||
|
||||
// Create sample data for testing
|
||||
const testData: TPipelineInput = {
|
||||
event: PipelineTriggers.responseCreated,
|
||||
surveyId: "cm8ckvchx000008lb710n0gdn",
|
||||
environmentId: "cm8cmp9hp000008jf7l570ml2",
|
||||
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
|
||||
};
|
||||
|
||||
// Call the function
|
||||
await sendToPipeline(testData);
|
||||
|
||||
// Check that the error was logged using logger
|
||||
expect(logger.error).toHaveBeenCalledWith(testError, "Error sending event to pipeline");
|
||||
});
|
||||
|
||||
test("sendToPipeline should throw error if CRON_SECRET is not set", async () => {
|
||||
// For this test, we need to mock CRON_SECRET as undefined
|
||||
// Let's use a more compatible approach to reset the mocks
|
||||
const originalModule = await import("@formbricks/lib/constants");
|
||||
const mockConstants = { ...originalModule, CRON_SECRET: undefined };
|
||||
|
||||
vi.doMock("@formbricks/lib/constants", () => mockConstants);
|
||||
|
||||
// Re-import the module to get the new mocked values
|
||||
const { sendToPipeline: sendToPipelineNoSecret } = await import("./pipelines");
|
||||
|
||||
// Create sample data for testing
|
||||
const testData: TPipelineInput = {
|
||||
event: PipelineTriggers.responseCreated,
|
||||
surveyId: "cm8ckvchx000008lb710n0gdn",
|
||||
environmentId: "cm8cmp9hp000008jf7l570ml2",
|
||||
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
|
||||
};
|
||||
|
||||
// Expect the function to throw an error
|
||||
await expect(sendToPipelineNoSecret(testData)).rejects.toThrow("CRON_SECRET is not set");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,10 @@ import { CRON_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
export const sendToPipeline = async ({ event, surveyId, environmentId, response }: TPipelineInput) => {
|
||||
if (!CRON_SECRET) {
|
||||
throw new Error("CRON_SECRET is not set");
|
||||
}
|
||||
|
||||
return fetch(`${WEBAPP_URL}/api/pipeline`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
120
apps/web/app/lib/singleUseSurveys.test.ts
Normal file
120
apps/web/app/lib/singleUseSurveys.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import cuid2 from "@paralleldrive/cuid2";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as crypto from "@formbricks/lib/crypto";
|
||||
import { generateSurveySingleUseId, validateSurveySingleUseId } from "./singleUseSurveys";
|
||||
|
||||
// Mock the crypto module
|
||||
vi.mock("@formbricks/lib/crypto", () => ({
|
||||
symmetricEncrypt: vi.fn(),
|
||||
symmetricDecrypt: vi.fn(),
|
||||
decryptAES128: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock constants
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
ENCRYPTION_KEY: "test-encryption-key",
|
||||
FORMBRICKS_ENCRYPTION_KEY: "test-formbricks-encryption-key",
|
||||
}));
|
||||
|
||||
// Mock cuid2
|
||||
vi.mock("@paralleldrive/cuid2", () => {
|
||||
const createIdMock = vi.fn();
|
||||
const isCuidMock = vi.fn();
|
||||
|
||||
return {
|
||||
default: {
|
||||
createId: createIdMock,
|
||||
isCuid: isCuidMock,
|
||||
},
|
||||
createId: createIdMock,
|
||||
isCuid: isCuidMock,
|
||||
};
|
||||
});
|
||||
|
||||
describe("generateSurveySingleUseId", () => {
|
||||
const mockCuid = "test-cuid-123";
|
||||
const mockEncryptedCuid = "encrypted-cuid-123";
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup mocks
|
||||
vi.mocked(cuid2.createId).mockReturnValue(mockCuid);
|
||||
vi.mocked(crypto.symmetricEncrypt).mockReturnValue(mockEncryptedCuid);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("returns unencrypted cuid when isEncrypted is false", () => {
|
||||
const result = generateSurveySingleUseId(false);
|
||||
|
||||
expect(result).toBe(mockCuid);
|
||||
expect(crypto.symmetricEncrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns encrypted cuid when isEncrypted is true", () => {
|
||||
const result = generateSurveySingleUseId(true);
|
||||
|
||||
expect(result).toBe(mockEncryptedCuid);
|
||||
expect(crypto.symmetricEncrypt).toHaveBeenCalledWith(mockCuid, "test-encryption-key");
|
||||
});
|
||||
|
||||
it("returns undefined when cuid is not valid", () => {
|
||||
vi.mocked(cuid2.isCuid).mockReturnValue(false);
|
||||
|
||||
const result = validateSurveySingleUseId(mockEncryptedCuid);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when decryption fails", () => {
|
||||
vi.mocked(crypto.symmetricDecrypt).mockImplementation(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
const result = validateSurveySingleUseId(mockEncryptedCuid);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws error when ENCRYPTION_KEY is not set in generateSurveySingleUseId", async () => {
|
||||
// Temporarily mock ENCRYPTION_KEY as undefined
|
||||
vi.doMock("@formbricks/lib/constants", () => ({
|
||||
ENCRYPTION_KEY: undefined,
|
||||
FORMBRICKS_ENCRYPTION_KEY: "test-formbricks-encryption-key",
|
||||
}));
|
||||
|
||||
// Re-import to get the new mock values
|
||||
const { generateSurveySingleUseId: generateSurveySingleUseIdNoKey } = await import("./singleUseSurveys");
|
||||
|
||||
expect(() => generateSurveySingleUseIdNoKey(true)).toThrow("ENCRYPTION_KEY is not set");
|
||||
});
|
||||
|
||||
it("throws error when ENCRYPTION_KEY is not set in validateSurveySingleUseId for symmetric encryption", async () => {
|
||||
// Temporarily mock ENCRYPTION_KEY as undefined
|
||||
vi.doMock("@formbricks/lib/constants", () => ({
|
||||
ENCRYPTION_KEY: undefined,
|
||||
FORMBRICKS_ENCRYPTION_KEY: "test-formbricks-encryption-key",
|
||||
}));
|
||||
|
||||
// Re-import to get the new mock values
|
||||
const { validateSurveySingleUseId: validateSurveySingleUseIdNoKey } = await import("./singleUseSurveys");
|
||||
|
||||
expect(() => validateSurveySingleUseIdNoKey(mockEncryptedCuid)).toThrow("ENCRYPTION_KEY is not set");
|
||||
});
|
||||
|
||||
it("throws error when FORMBRICKS_ENCRYPTION_KEY is not set in validateSurveySingleUseId for AES128", async () => {
|
||||
// Temporarily mock FORMBRICKS_ENCRYPTION_KEY as undefined
|
||||
vi.doMock("@formbricks/lib/constants", () => ({
|
||||
ENCRYPTION_KEY: "test-encryption-key",
|
||||
FORMBRICKS_ENCRYPTION_KEY: undefined,
|
||||
}));
|
||||
|
||||
// Re-import to get the new mock values
|
||||
const { validateSurveySingleUseId: validateSurveySingleUseIdNoKey } = await import("./singleUseSurveys");
|
||||
|
||||
expect(() =>
|
||||
validateSurveySingleUseIdNoKey("M(.Bob=dS1!wUSH2lb,E7hxO=He1cnnitmXrG|Su/DKYZrPy~zgS)u?dgI53sfs/")
|
||||
).toThrow("FORMBRICKS_ENCRYPTION_KEY is not defined");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
101
apps/web/app/sentry/SentryProvider.test.tsx
Normal file
101
apps/web/app/sentry/SentryProvider.test.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SentryProvider } from "./SentryProvider";
|
||||
|
||||
vi.mock("@sentry/nextjs", async () => {
|
||||
const actual = await vi.importActual<typeof import("@sentry/nextjs")>("@sentry/nextjs");
|
||||
return {
|
||||
...actual,
|
||||
replayIntegration: (options: any) => {
|
||||
return {
|
||||
name: "Replay",
|
||||
id: "Replay",
|
||||
options,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("SentryProvider", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("calls Sentry.init when sentryDsn is provided", () => {
|
||||
const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
|
||||
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||
|
||||
render(
|
||||
<SentryProvider sentryDsn={sentryDsn}>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</SentryProvider>
|
||||
);
|
||||
|
||||
// The useEffect runs after mount, so Sentry.init should have been called.
|
||||
expect(initSpy).toHaveBeenCalled();
|
||||
expect(initSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dsn: sentryDsn,
|
||||
tracesSampleRate: 1,
|
||||
debug: false,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
integrations: expect.any(Array),
|
||||
beforeSend: expect.any(Function),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call Sentry.init when sentryDsn is not provided", () => {
|
||||
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||
|
||||
render(
|
||||
<SentryProvider>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</SentryProvider>
|
||||
);
|
||||
|
||||
expect(initSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders children", () => {
|
||||
const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
|
||||
render(
|
||||
<SentryProvider sentryDsn={sentryDsn}>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</SentryProvider>
|
||||
);
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
|
||||
});
|
||||
|
||||
it("processes beforeSend correctly", () => {
|
||||
const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
|
||||
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||
|
||||
render(
|
||||
<SentryProvider sentryDsn={sentryDsn}>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</SentryProvider>
|
||||
);
|
||||
|
||||
const config = initSpy.mock.calls[0][0];
|
||||
expect(config).toHaveProperty("beforeSend");
|
||||
const beforeSend = config.beforeSend;
|
||||
|
||||
if (!beforeSend) {
|
||||
throw new Error("beforeSend is not defined");
|
||||
}
|
||||
|
||||
const dummyEvent = { some: "event" } as unknown as Sentry.ErrorEvent;
|
||||
|
||||
const hintWithNextNotFound = { originalException: { digest: "NEXT_NOT_FOUND" } };
|
||||
expect(beforeSend(dummyEvent, hintWithNextNotFound)).toBeNull();
|
||||
|
||||
const hintWithOtherError = { originalException: { digest: "OTHER_ERROR" } };
|
||||
expect(beforeSend(dummyEvent, hintWithOtherError)).toEqual(dummyEvent);
|
||||
|
||||
const hintWithoutError = { originalException: undefined };
|
||||
expect(beforeSend(dummyEvent, hintWithoutError)).toEqual(dummyEvent);
|
||||
});
|
||||
});
|
||||
53
apps/web/app/sentry/SentryProvider.tsx
Normal file
53
apps/web/app/sentry/SentryProvider.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface SentryProviderProps {
|
||||
children: React.ReactNode;
|
||||
sentryDsn?: string;
|
||||
}
|
||||
|
||||
export const SentryProvider = ({ children, sentryDsn }: SentryProviderProps) => {
|
||||
useEffect(() => {
|
||||
if (sentryDsn) {
|
||||
Sentry.init({
|
||||
dsn: sentryDsn,
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
|
||||
// This sets the sample rate to be 10%. You may want this to be 100% while
|
||||
// in development and sample at a lower rate in production
|
||||
replaysSessionSampleRate: 0.1,
|
||||
|
||||
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
|
||||
integrations: [
|
||||
Sentry.replayIntegration({
|
||||
// Additional Replay configuration goes in here, for example:
|
||||
maskAllText: true,
|
||||
blockAllMedia: true,
|
||||
}),
|
||||
],
|
||||
|
||||
beforeSend(event, hint) {
|
||||
const error = hint.originalException as Error;
|
||||
|
||||
// @ts-expect-error
|
||||
if (error && error.digest === "NEXT_NOT_FOUND") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return event;
|
||||
},
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { env } from "@formbricks/lib/env";
|
||||
import { PROMETHEUS_ENABLED, SENTRY_DSN } from "@formbricks/lib/constants";
|
||||
|
||||
// instrumentation.ts
|
||||
export const register = async () => {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs" && env.PROMETHEUS_ENABLED) {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs" && PROMETHEUS_ENABLED) {
|
||||
await import("./instrumentation-node");
|
||||
}
|
||||
if (process.env.NEXT_RUNTIME === "nodejs" && SENTRY_DSN) {
|
||||
await import("./sentry.server.config");
|
||||
}
|
||||
if (process.env.NEXT_RUNTIME === "edge" && SENTRY_DSN) {
|
||||
await import("./sentry.edge.config");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ export const authenticateRequest = async (
|
||||
request: Request
|
||||
): Promise<Result<TAuthenticationApiKey, ApiErrorResponseV2>> => {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
|
||||
if (apiKey) {
|
||||
const environmentIdResult = await getEnvironmentIdFromApiKey(apiKey);
|
||||
if (!environmentIdResult.ok) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { fetchEnvironmentId } from "@/modules/api/v2/management/lib/services";
|
||||
import {
|
||||
fetchEnvironmentId,
|
||||
fetchEnvironmentIdFromSurveyIds,
|
||||
} from "@/modules/api/v2/management/lib/services";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Result, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
@@ -14,3 +17,31 @@ export const getEnvironmentId = async (
|
||||
|
||||
return ok(result.data.environmentId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that all surveys are in the same environment and return the environment id
|
||||
* @param surveyIds array of survey ids from the same environment
|
||||
* @returns the common environment id
|
||||
*/
|
||||
export const getEnvironmentIdFromSurveyIds = async (
|
||||
surveyIds: string[]
|
||||
): Promise<Result<string, ApiErrorResponseV2>> => {
|
||||
const result = await fetchEnvironmentIdFromSurveyIds(surveyIds);
|
||||
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check if all items in the array are the same
|
||||
if (new Set(result.data).size !== 1) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
type: "bad_request",
|
||||
details: [{ field: "surveyIds", issue: "not all surveys are in the same environment" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return ok(result.data[0]);
|
||||
};
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import {
|
||||
deleteResponseEndpoint,
|
||||
getResponseEndpoint,
|
||||
updateResponseEndpoint,
|
||||
} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
|
||||
import {
|
||||
createResponseEndpoint,
|
||||
getResponsesEndpoint,
|
||||
} from "@/modules/api/v2/management/responses/lib/openapi";
|
||||
import { ZodOpenApiPathsObject } from "zod-openapi";
|
||||
|
||||
export const responsePaths: ZodOpenApiPathsObject = {
|
||||
"/responses": {
|
||||
get: getResponsesEndpoint,
|
||||
post: createResponseEndpoint,
|
||||
},
|
||||
"/responses/{id}": {
|
||||
get: getResponseEndpoint,
|
||||
put: updateResponseEndpoint,
|
||||
delete: deleteResponseEndpoint,
|
||||
},
|
||||
};
|
||||
@@ -41,3 +41,36 @@ export const fetchEnvironmentId = reactCache(async (id: string, isResponseId: bo
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const fetchEnvironmentIdFromSurveyIds = reactCache(async (surveyIds: string[]) =>
|
||||
cache(
|
||||
async (): Promise<Result<string[], ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const results = await prisma.survey.findMany({
|
||||
where: { id: { in: surveyIds } },
|
||||
select: {
|
||||
environmentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (results.length !== surveyIds.length) {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: "survey", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
|
||||
return ok(results.map((result) => result.environmentId));
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "survey", issue: error.message }],
|
||||
});
|
||||
}
|
||||
},
|
||||
[`services-fetchEnvironmentIdFromSurveyIds-${surveyIds.join("-")}`],
|
||||
{
|
||||
tags: surveyIds.map((surveyId) => surveyCache.tag.byId(surveyId)),
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { fetchEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/services";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import { getEnvironmentId } from "../helper";
|
||||
import { getEnvironmentId, getEnvironmentIdFromSurveyIds } from "../helper";
|
||||
import { fetchEnvironmentId } from "../services";
|
||||
|
||||
vi.mock("../services", () => ({
|
||||
fetchEnvironmentId: vi.fn(),
|
||||
fetchEnvironmentIdFromSurveyIds: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Helper Functions", () => {
|
||||
describe("Tests for getEnvironmentId", () => {
|
||||
it("should return environmentId for surveyId", async () => {
|
||||
vi.mocked(fetchEnvironmentId).mockResolvedValue(ok({ environmentId: "env-id" }));
|
||||
|
||||
@@ -41,3 +44,42 @@ describe("Helper Functions", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnvironmentIdFromSurveyIds", () => {
|
||||
const envId1 = createId();
|
||||
const envId2 = createId();
|
||||
|
||||
it("returns the common environment id when all survey ids are in the same environment", async () => {
|
||||
vi.mocked(fetchEnvironmentIdFromSurveyIds).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: [envId1, envId1],
|
||||
});
|
||||
const result = await getEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
|
||||
expect(result).toEqual(ok(envId1));
|
||||
});
|
||||
|
||||
it("returns error when surveys are not in the same environment", async () => {
|
||||
vi.mocked(fetchEnvironmentIdFromSurveyIds).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: [envId1, envId2],
|
||||
});
|
||||
const result = await getEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "bad_request",
|
||||
details: [{ field: "surveyIds", issue: "not all surveys are in the same environment" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("returns error when API call fails", async () => {
|
||||
const apiError = {
|
||||
type: "server_error",
|
||||
details: [{ field: "api", issue: "failed" }],
|
||||
} as unknown as ApiErrorResponseV2;
|
||||
vi.mocked(fetchEnvironmentIdFromSurveyIds).mockResolvedValueOnce({ ok: false, error: apiError });
|
||||
const result = await getEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
|
||||
expect(result).toEqual({ ok: false, error: apiError });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { fetchEnvironmentId } from "../services";
|
||||
import { fetchEnvironmentId, fetchEnvironmentIdFromSurveyIds } from "../services";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
survey: { findFirst: vi.fn() },
|
||||
survey: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Services", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getSurveyAndEnvironmentId", () => {
|
||||
test("should return surveyId and environmentId for responseId", async () => {
|
||||
vi.mocked(prisma.survey.findFirst).mockResolvedValue({
|
||||
@@ -80,4 +79,36 @@ describe("Services", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchEnvironmentIdFromSurveyIds", () => {
|
||||
test("should return an array of environmentIds if all surveys exist", async () => {
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue([
|
||||
{ environmentId: "env-1" },
|
||||
{ environmentId: "env-2" },
|
||||
]);
|
||||
const result = await fetchEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(["env-1", "env-2"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("should return not_found error if any survey is missing", async () => {
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue([{ environmentId: "env-1" }]);
|
||||
const result = await fetchEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("not_found");
|
||||
}
|
||||
});
|
||||
|
||||
test("should return internal_server_error if prisma query fails", async () => {
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValue(new Error("Query failed"));
|
||||
const result = await fetchEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { hashApiKey } from "../utils";
|
||||
import { buildCommonFilterQuery, hashApiKey, pickCommonFilter } from "../utils";
|
||||
|
||||
describe("hashApiKey", () => {
|
||||
test("generate the correct sha256 hash for a given input", () => {
|
||||
@@ -15,3 +17,72 @@ describe("hashApiKey", () => {
|
||||
expect(result).toHaveLength(9); // mocked on the vitestSetup.ts file;;
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickCommonFilter", () => {
|
||||
test("picks the common filter fields correctly", () => {
|
||||
const params = {
|
||||
limit: 10,
|
||||
skip: 5,
|
||||
sortBy: "createdAt",
|
||||
order: "asc",
|
||||
startDate: new Date("2023-01-01"),
|
||||
endDate: new Date("2023-12-31"),
|
||||
} as TGetFilter;
|
||||
const result = pickCommonFilter(params);
|
||||
expect(result).toEqual(params);
|
||||
});
|
||||
|
||||
test("handles missing fields gracefully", () => {
|
||||
const params = { limit: 10 } as TGetFilter;
|
||||
const result = pickCommonFilter(params);
|
||||
expect(result).toEqual({
|
||||
limit: 10,
|
||||
skip: undefined,
|
||||
sortBy: undefined,
|
||||
order: undefined,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCommonFilterQuery", () => {
|
||||
test("applies startDate and endDate when provided", () => {
|
||||
const query: Prisma.WebhookFindManyArgs = { where: {} };
|
||||
const params = {
|
||||
startDate: new Date("2023-01-01"),
|
||||
endDate: new Date("2023-12-31"),
|
||||
} as TGetFilter;
|
||||
const result = buildCommonFilterQuery(query, params);
|
||||
expect(result.where?.createdAt?.gte).toEqual(params.startDate);
|
||||
expect(result.where?.createdAt?.lte).toEqual(params.endDate);
|
||||
});
|
||||
|
||||
test("applies sortBy and order when provided", () => {
|
||||
const query: Prisma.WebhookFindManyArgs = { where: {} };
|
||||
const params = { sortBy: "createdAt", order: "desc" } as TGetFilter;
|
||||
const result = buildCommonFilterQuery(query, params);
|
||||
expect(result.orderBy).toEqual({ createdAt: "desc" });
|
||||
});
|
||||
|
||||
test("applies limit (take) when provided", () => {
|
||||
const query: Prisma.WebhookFindManyArgs = { where: {} };
|
||||
const params = { limit: 5 } as TGetFilter;
|
||||
const result = buildCommonFilterQuery(query, params);
|
||||
expect(result.take).toBe(5);
|
||||
});
|
||||
|
||||
test("applies skip when provided", () => {
|
||||
const query: Prisma.WebhookFindManyArgs = { where: {} };
|
||||
const params = { skip: 10 } as TGetFilter;
|
||||
const result = buildCommonFilterQuery(query, params);
|
||||
expect(result.skip).toBe(10);
|
||||
});
|
||||
|
||||
test("handles missing fields gracefully", () => {
|
||||
const query = {};
|
||||
const params = {} as TGetFilter;
|
||||
const result = buildCommonFilterQuery(query, params);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,65 @@
|
||||
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
|
||||
|
||||
export function pickCommonFilter<T extends TGetFilter>(params: T) {
|
||||
const { limit, skip, sortBy, order, startDate, endDate } = params;
|
||||
return { limit, skip, sortBy, order, startDate, endDate };
|
||||
}
|
||||
|
||||
type HasFindMany = Prisma.WebhookFindManyArgs | Prisma.ResponseFindManyArgs;
|
||||
|
||||
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
|
||||
const { limit, skip, sortBy, order, startDate, endDate } = params || {};
|
||||
|
||||
let filteredQuery = {
|
||||
...query,
|
||||
};
|
||||
|
||||
if (startDate) {
|
||||
filteredQuery = {
|
||||
...filteredQuery,
|
||||
where: {
|
||||
...filteredQuery.where,
|
||||
createdAt: {
|
||||
...((filteredQuery.where?.createdAt as Prisma.DateTimeFilter) ?? {}),
|
||||
gte: startDate,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
filteredQuery = {
|
||||
...filteredQuery,
|
||||
where: {
|
||||
...filteredQuery.where,
|
||||
createdAt: {
|
||||
...((filteredQuery.where?.createdAt as Prisma.DateTimeFilter) ?? {}),
|
||||
lte: endDate,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (sortBy) {
|
||||
filteredQuery = {
|
||||
...filteredQuery,
|
||||
orderBy: {
|
||||
[sortBy]: order,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (limit) {
|
||||
filteredQuery = { ...filteredQuery, take: limit };
|
||||
}
|
||||
|
||||
if (skip) {
|
||||
filteredQuery = { ...filteredQuery, skip };
|
||||
}
|
||||
|
||||
return filteredQuery;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { displayCache } from "@formbricks/lib/display/cache";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
@@ -26,7 +27,10 @@ export const deleteDisplay = async (displayId: string): Promise<Result<boolean,
|
||||
return ok(true);
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2016" || error.code === "P2025") {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
) {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: "display", issue: "not found" }],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { responseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
|
||||
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||
import { ZResponse } from "@formbricks/database/zod/responses";
|
||||
@@ -19,7 +20,7 @@ export const getResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
description: "Response retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZResponse,
|
||||
schema: makePartialSchema(ZResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -41,7 +42,7 @@ export const deleteResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
description: "Response deleted successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZResponse,
|
||||
schema: makePartialSchema(ZResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -72,7 +73,7 @@ export const updateResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
description: "Response updated successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZResponse,
|
||||
schema: makePartialSchema(ZResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { responseCache } from "@formbricks/lib/response/cache";
|
||||
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
|
||||
@@ -77,7 +78,10 @@ export const deleteResponse = async (responseId: string): Promise<Result<Respons
|
||||
return ok(deletedResponse);
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2016" || error.code === "P2025") {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
) {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: "response", issue: "not found" }],
|
||||
@@ -116,7 +120,10 @@ export const updateResponse = async (
|
||||
return ok(updatedResponse);
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2016" || error.code === "P2025") {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
) {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: "response", issue: "not found" }],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { displayId, mockDisplay } from "./__mocks__/display.mock";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { deleteDisplay } from "../display";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -39,7 +40,7 @@ describe("Display Lib", () => {
|
||||
test("return a not_found error when the display is not found", async () => {
|
||||
vi.mocked(prisma.display.delete).mockRejectedValue(
|
||||
new PrismaClientKnownRequestError("Display not found", {
|
||||
code: "P2025",
|
||||
code: PrismaErrorType.RelatedRecordDoesNotExist,
|
||||
clientVersion: "1.0.0",
|
||||
meta: {
|
||||
cause: "Display not found",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { response, responseId, responseInput, survey } from "./__mocks__/respons
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ok, okVoid } from "@formbricks/types/error-handlers";
|
||||
import { deleteDisplay } from "../display";
|
||||
import { deleteResponse, getResponse, updateResponse } from "../response";
|
||||
@@ -154,7 +155,7 @@ describe("Response Lib", () => {
|
||||
test("handle prisma client error code P2025", async () => {
|
||||
vi.mocked(prisma.response.delete).mockRejectedValue(
|
||||
new PrismaClientKnownRequestError("Response not found", {
|
||||
code: "P2025",
|
||||
code: PrismaErrorType.RelatedRecordDoesNotExist,
|
||||
clientVersion: "1.0.0",
|
||||
meta: {
|
||||
cause: "Response not found",
|
||||
@@ -192,7 +193,7 @@ describe("Response Lib", () => {
|
||||
test("return a not_found error when the response is not found", async () => {
|
||||
vi.mocked(prisma.response.update).mockRejectedValue(
|
||||
new PrismaClientKnownRequestError("Response not found", {
|
||||
code: "P2025",
|
||||
code: PrismaErrorType.RelatedRecordDoesNotExist,
|
||||
clientVersion: "1.0.0",
|
||||
meta: {
|
||||
cause: "Response not found",
|
||||
|
||||
@@ -47,7 +47,7 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI
|
||||
return handleApiError(request, response.error);
|
||||
}
|
||||
|
||||
return responses.successResponse({ data: response.data });
|
||||
return responses.successResponse(response);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -88,7 +88,7 @@ export const DELETE = async (request: Request, props: { params: Promise<{ respon
|
||||
return handleApiError(request, response.error);
|
||||
}
|
||||
|
||||
return responses.successResponse({ data: response.data });
|
||||
return responses.successResponse(response);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -130,6 +130,6 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
||||
return handleApiError(request, response.error);
|
||||
}
|
||||
|
||||
return responses.successResponse({ data: response.data });
|
||||
return responses.successResponse(response);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,10 +3,11 @@ import {
|
||||
getResponseEndpoint,
|
||||
updateResponseEndpoint,
|
||||
} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
|
||||
import { ZGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
|
||||
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
|
||||
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||
import { ZResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { ZResponse } from "@formbricks/database/zod/responses";
|
||||
|
||||
export const getResponsesEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getResponses",
|
||||
@@ -21,7 +22,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
|
||||
description: "Responses retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(ZResponse),
|
||||
schema: z.array(responseWithMetaSchema(makePartialSchema(ZResponse))),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -47,7 +48,7 @@ export const createResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
description: "Response created successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZResponse,
|
||||
schema: makePartialSchema(ZResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -48,7 +48,7 @@ export const getOrganizationIdFromEnvironmentId = reactCache(async (environmentI
|
||||
|
||||
export const getOrganizationBilling = reactCache(async (organizationId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<Pick<Organization, "billing">, ApiErrorResponseV2>> => {
|
||||
async (): Promise<Result<Organization["billing"], ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
@@ -62,7 +62,8 @@ export const getOrganizationBilling = reactCache(async (organizationId: string)
|
||||
if (!organization) {
|
||||
return err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] });
|
||||
}
|
||||
return ok(organization);
|
||||
|
||||
return ok(organization.billing);
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
@@ -126,26 +127,27 @@ export const getMonthlyOrganizationResponseCount = reactCache(async (organizatio
|
||||
cache(
|
||||
async (): Promise<Result<number, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const organization = await getOrganizationBilling(organizationId);
|
||||
if (!organization.ok) {
|
||||
return err(organization.error);
|
||||
const billing = await getOrganizationBilling(organizationId);
|
||||
if (!billing.ok) {
|
||||
return err(billing.error);
|
||||
}
|
||||
|
||||
// Determine the start date based on the plan type
|
||||
let startDate: Date;
|
||||
if (organization.data.billing.plan === "free") {
|
||||
|
||||
if (billing.data.plan === "free") {
|
||||
// For free plans, use the first day of the current calendar month
|
||||
const now = new Date();
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
} else {
|
||||
// For other plans, use the periodStart from billing
|
||||
if (!organization.data.billing.periodStart) {
|
||||
if (!billing.data.periodStart) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "organization", issue: "billing period start is not set" }],
|
||||
});
|
||||
}
|
||||
startDate = organization.data.billing.periodStart;
|
||||
startDate = billing.data.periodStart;
|
||||
}
|
||||
|
||||
// Get all environment IDs for the organization
|
||||
|
||||
@@ -41,7 +41,14 @@ export const createResponse = async (
|
||||
} = responseInput;
|
||||
|
||||
try {
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
let ttc = {};
|
||||
if (initialTtc) {
|
||||
if (finished) {
|
||||
ttc = calculateTtcTotal(initialTtc);
|
||||
} else {
|
||||
ttc = initialTtc;
|
||||
}
|
||||
}
|
||||
|
||||
const prismaData: Prisma.ResponseCreateInput = {
|
||||
survey: {
|
||||
@@ -67,11 +74,11 @@ export const createResponse = async (
|
||||
return err(organizationIdResult.error);
|
||||
}
|
||||
|
||||
const organizationResult = await getOrganizationBilling(organizationIdResult.data);
|
||||
if (!organizationResult.ok) {
|
||||
return err(organizationResult.error);
|
||||
const billing = await getOrganizationBilling(organizationIdResult.data);
|
||||
if (!billing.ok) {
|
||||
return err(billing.error);
|
||||
}
|
||||
const organization = organizationResult.data;
|
||||
const billingData = billing.data;
|
||||
|
||||
const response = await prisma.response.create({
|
||||
data: prismaData,
|
||||
@@ -95,12 +102,12 @@ export const createResponse = async (
|
||||
}
|
||||
|
||||
const responsesCount = responsesCountResult.data;
|
||||
const responsesLimit = organization.billing.limits.monthly.responses;
|
||||
const responsesLimit = billingData.limits?.monthly.responses;
|
||||
|
||||
if (responsesLimit && responsesCount >= responsesLimit) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
plan: billingData.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("Organization Lib", () => {
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.billing).toEqual(organizationBilling);
|
||||
expect(result.data).toEqual(organizationBilling);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ describe("Response Lib", () => {
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
|
||||
|
||||
const result = await createResponse(environmentId, responseInput);
|
||||
@@ -70,7 +70,7 @@ describe("Response Lib", () => {
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
|
||||
|
||||
const result = await createResponse(environmentId, responseInputNotFinished);
|
||||
@@ -85,7 +85,7 @@ describe("Response Lib", () => {
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
|
||||
|
||||
const result = await createResponse(environmentId, responseInputWithoutTtc);
|
||||
@@ -100,7 +100,7 @@ describe("Response Lib", () => {
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
|
||||
|
||||
const result = await createResponse(environmentId, responseInputWithoutDisplay);
|
||||
@@ -145,7 +145,7 @@ describe("Response Lib", () => {
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
|
||||
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
|
||||
|
||||
@@ -165,7 +165,7 @@ describe("Response Lib", () => {
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
|
||||
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(
|
||||
err({ type: "internal_server_error", details: [{ field: "organization", issue: "Aggregate error" }] })
|
||||
@@ -186,7 +186,7 @@ describe("Response Lib", () => {
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
|
||||
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
|
||||
|
||||
|
||||
@@ -1,97 +1,40 @@
|
||||
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
|
||||
import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getResponsesQuery } from "../utils";
|
||||
|
||||
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||
pickCommonFilter: vi.fn(),
|
||||
buildCommonFilterQuery: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getResponsesQuery", () => {
|
||||
const environmentId = "env_1";
|
||||
const filters: TGetResponsesFilter = {
|
||||
limit: 10,
|
||||
skip: 0,
|
||||
sortBy: "createdAt",
|
||||
order: "asc",
|
||||
};
|
||||
|
||||
test("return the base query when no params are provided", () => {
|
||||
const query = getResponsesQuery(environmentId);
|
||||
expect(query).toEqual({
|
||||
where: {
|
||||
survey: { environmentId },
|
||||
},
|
||||
});
|
||||
it("adds surveyId to where clause if provided", () => {
|
||||
const result = getResponsesQuery("env-id", { surveyId: "survey123" } as TGetResponsesFilter);
|
||||
expect(result?.where?.surveyId).toBe("survey123");
|
||||
});
|
||||
|
||||
test("add surveyId to the query when provided", () => {
|
||||
const query = getResponsesQuery(environmentId, { ...filters, surveyId: "survey_1" });
|
||||
expect(query.where).toEqual({
|
||||
survey: { environmentId },
|
||||
surveyId: "survey_1",
|
||||
});
|
||||
it("adds contactId to where clause if provided", () => {
|
||||
const result = getResponsesQuery("env-id", { contactId: "contact123" } as TGetResponsesFilter);
|
||||
expect(result?.where?.contactId).toBe("contact123");
|
||||
});
|
||||
|
||||
test("add startDate filter to the query", () => {
|
||||
const startDate = new Date("2023-01-01");
|
||||
const query = getResponsesQuery(environmentId, { ...filters, startDate });
|
||||
expect(query.where).toEqual({
|
||||
survey: { environmentId },
|
||||
createdAt: { gte: startDate },
|
||||
});
|
||||
});
|
||||
it("calls pickCommonFilter & buildCommonFilterQuery with correct arguments", () => {
|
||||
vi.mocked(pickCommonFilter).mockReturnValueOnce({ someFilter: true } as any);
|
||||
vi.mocked(buildCommonFilterQuery).mockReturnValueOnce({ where: { combined: true } as any });
|
||||
|
||||
test("add endDate filter to the query", () => {
|
||||
const endDate = new Date("2023-01-31");
|
||||
const query = getResponsesQuery(environmentId, { ...filters, endDate });
|
||||
expect(query.where).toEqual({
|
||||
survey: { environmentId },
|
||||
createdAt: { lte: endDate },
|
||||
});
|
||||
});
|
||||
|
||||
test("add sortBy and order to the query", () => {
|
||||
const query = getResponsesQuery(environmentId, { ...filters, sortBy: "createdAt", order: "desc" });
|
||||
expect(query.orderBy).toEqual({
|
||||
createdAt: "desc",
|
||||
});
|
||||
});
|
||||
|
||||
test("add limit (take) to the query", () => {
|
||||
const query = getResponsesQuery(environmentId, { ...filters, limit: 10 });
|
||||
expect(query.take).toBe(10);
|
||||
});
|
||||
|
||||
test("add skip to the query", () => {
|
||||
const query = getResponsesQuery(environmentId, { ...filters, skip: 5 });
|
||||
expect(query.skip).toBe(5);
|
||||
});
|
||||
|
||||
test("add contactId to the query", () => {
|
||||
const query = getResponsesQuery(environmentId, { ...filters, contactId: "contact_1" });
|
||||
expect(query.where).toEqual({
|
||||
survey: { environmentId },
|
||||
contactId: "contact_1",
|
||||
});
|
||||
});
|
||||
|
||||
test("combine multiple filters correctly", () => {
|
||||
const params = {
|
||||
...filters,
|
||||
surveyId: "survey_1",
|
||||
startDate: new Date("2023-01-01"),
|
||||
endDate: new Date("2023-01-31"),
|
||||
limit: 20,
|
||||
skip: 10,
|
||||
contactId: "contact_1",
|
||||
};
|
||||
const query = getResponsesQuery(environmentId, params);
|
||||
expect(query.where).toEqual({
|
||||
survey: { environmentId },
|
||||
surveyId: "survey_1",
|
||||
createdAt: { lte: params.endDate, gte: params.startDate },
|
||||
contactId: "contact_1",
|
||||
});
|
||||
expect(query.orderBy).toEqual({
|
||||
createdAt: "asc",
|
||||
});
|
||||
expect(query.take).toBe(20);
|
||||
expect(query.skip).toBe(10);
|
||||
const result = getResponsesQuery("env-id", { surveyId: "test" } as TGetResponsesFilter);
|
||||
expect(pickCommonFilter).toHaveBeenCalledWith({ surveyId: "test" });
|
||||
expect(buildCommonFilterQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining<Prisma.ResponseFindManyArgs>({
|
||||
where: {
|
||||
survey: { environmentId: "env-id" },
|
||||
surveyId: "test",
|
||||
},
|
||||
}),
|
||||
{ someFilter: true }
|
||||
);
|
||||
expect(result).toEqual({ where: { combined: true } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
|
||||
import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export const getResponsesQuery = (environmentId: string, params?: TGetResponsesFilter) => {
|
||||
const { surveyId, limit, skip, sortBy, order, startDate, endDate, contactId } = params || {};
|
||||
|
||||
let query: Prisma.ResponseFindManyArgs = {
|
||||
where: {
|
||||
survey: {
|
||||
@@ -12,6 +11,10 @@ export const getResponsesQuery = (environmentId: string, params?: TGetResponsesF
|
||||
},
|
||||
};
|
||||
|
||||
if (!params) return query;
|
||||
|
||||
const { surveyId, contactId } = params || {};
|
||||
|
||||
if (surveyId) {
|
||||
query = {
|
||||
...query,
|
||||
@@ -22,55 +25,6 @@ export const getResponsesQuery = (environmentId: string, params?: TGetResponsesF
|
||||
};
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
query = {
|
||||
...query,
|
||||
where: {
|
||||
...query.where,
|
||||
createdAt: {
|
||||
...(query.where?.createdAt as Prisma.DateTimeFilter<"Response">),
|
||||
gte: startDate,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
query = {
|
||||
...query,
|
||||
where: {
|
||||
...query.where,
|
||||
createdAt: {
|
||||
...(query.where?.createdAt as Prisma.DateTimeFilter<"Response">),
|
||||
lte: endDate,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (sortBy) {
|
||||
query = {
|
||||
...query,
|
||||
orderBy: {
|
||||
[sortBy]: order,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (limit) {
|
||||
query = {
|
||||
...query,
|
||||
take: limit,
|
||||
};
|
||||
}
|
||||
|
||||
if (skip) {
|
||||
query = {
|
||||
...query,
|
||||
skip: skip,
|
||||
};
|
||||
}
|
||||
|
||||
if (contactId) {
|
||||
query = {
|
||||
...query,
|
||||
@@ -81,5 +35,11 @@ export const getResponsesQuery = (environmentId: string, params?: TGetResponsesF
|
||||
};
|
||||
}
|
||||
|
||||
const baseFilter = pickCommonFilter(params);
|
||||
|
||||
if (baseFilter) {
|
||||
query = buildCommonFilterQuery<Prisma.ResponseFindManyArgs>(query, baseFilter);
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import { z } from "zod";
|
||||
import { ZResponse } from "@formbricks/database/zod/responses";
|
||||
|
||||
export const ZGetResponsesFilter = z
|
||||
.object({
|
||||
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
|
||||
skip: z.coerce.number().nonnegative().optional().default(0),
|
||||
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
|
||||
order: z.enum(["asc", "desc"]).optional().default("desc"),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
surveyId: z.string().cuid2().optional(),
|
||||
contactId: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.startDate && data.endDate && data.startDate > data.endDate) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "startDate must be before endDate",
|
||||
export const ZGetResponsesFilter = ZGetFilter.extend({
|
||||
surveyId: z.string().cuid2().optional(),
|
||||
contactId: z.string().optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
if (data.startDate && data.endDate && data.startDate > data.endDate) {
|
||||
return false;
|
||||
}
|
||||
);
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "startDate must be before endDate",
|
||||
}
|
||||
);
|
||||
|
||||
export type TGetResponsesFilter = z.infer<typeof ZGetResponsesFilter>;
|
||||
|
||||
@@ -39,21 +32,16 @@ export const ZResponseInput = ZResponse.pick({
|
||||
variables: true,
|
||||
ttc: true,
|
||||
meta: true,
|
||||
})
|
||||
.partial({
|
||||
displayId: true,
|
||||
singleUseId: true,
|
||||
endingId: true,
|
||||
language: true,
|
||||
variables: true,
|
||||
ttc: true,
|
||||
meta: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
})
|
||||
.openapi({
|
||||
ref: "responseCreate",
|
||||
description: "A response to create",
|
||||
});
|
||||
}).partial({
|
||||
displayId: true,
|
||||
singleUseId: true,
|
||||
endingId: true,
|
||||
language: true,
|
||||
variables: true,
|
||||
ttc: true,
|
||||
meta: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
|
||||
export type TResponseInput = z.infer<typeof ZResponseInput>;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { webhookIdSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||
import { ZWebhook } from "@formbricks/database/zod/webhooks";
|
||||
|
||||
export const getWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getWebhook",
|
||||
summary: "Get a webhook",
|
||||
description: "Gets a webhook from the database.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
webhookId: webhookIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Management API > Webhooks"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Webhook retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: makePartialSchema(ZWebhook),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const deleteWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "deleteWebhook",
|
||||
summary: "Delete a webhook",
|
||||
description: "Deletes a webhook from the database.",
|
||||
tags: ["Management API > Webhooks"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
webhookId: webhookIdSchema,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Webhook deleted successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: makePartialSchema(ZWebhook),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const updateWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "updateWebhook",
|
||||
summary: "Update a webhook",
|
||||
description: "Updates a webhook in the database.",
|
||||
tags: ["Management API > Webhooks"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
webhookId: webhookIdSchema,
|
||||
}),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The webhook to update",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZWebhookInput,
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Webhook updated successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: makePartialSchema(ZWebhook),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { WebhookSource } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
|
||||
export const mockedPrismaWebhookUpdateReturn = {
|
||||
id: "123",
|
||||
url: "",
|
||||
name: null,
|
||||
createdAt: new Date("2025-03-24T07:27:36.850Z"),
|
||||
updatedAt: new Date("2025-03-24T07:27:36.850Z"),
|
||||
source: "user" as WebhookSource,
|
||||
environmentId: "",
|
||||
triggers: [],
|
||||
surveyIds: [],
|
||||
};
|
||||
|
||||
export const prismaNotFoundError = new PrismaClientKnownRequestError("Record does not exist", {
|
||||
code: PrismaErrorType.RecordDoesNotExist,
|
||||
clientVersion: "PrismaClient 4.0.0",
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { webhookCache } from "@/lib/cache/webhook";
|
||||
import {
|
||||
mockedPrismaWebhookUpdateReturn,
|
||||
prismaNotFoundError,
|
||||
} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock";
|
||||
import { webhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { deleteWebhook, getWebhook, updateWebhook } from "../webhook";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
webhook: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/webhook", () => ({
|
||||
webhookCache: {
|
||||
tag: {
|
||||
byId: () => "mockTag",
|
||||
},
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getWebhook", () => {
|
||||
test("returns ok if webhook is found", async () => {
|
||||
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce({ id: "123" });
|
||||
const result = await getWebhook("123");
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({ id: "123" });
|
||||
}
|
||||
});
|
||||
|
||||
test("returns err if webhook not found", async () => {
|
||||
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce(null);
|
||||
const result = await getWebhook("999");
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error?.type).toBe("not_found");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns err on Prisma error", async () => {
|
||||
vi.mocked(prisma.webhook.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const result = await getWebhook("error");
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateWebhook", () => {
|
||||
const mockedWebhookUpdateReturn = { url: "https://example.com" } as z.infer<typeof webhookUpdateSchema>;
|
||||
|
||||
test("returns ok on successful update", async () => {
|
||||
vi.mocked(prisma.webhook.update).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn);
|
||||
const result = await updateWebhook("123", mockedWebhookUpdateReturn);
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(mockedPrismaWebhookUpdateReturn);
|
||||
}
|
||||
|
||||
expect(webhookCache.revalidate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns not_found if record does not exist", async () => {
|
||||
vi.mocked(prisma.webhook.update).mockRejectedValueOnce(prismaNotFoundError);
|
||||
const result = await updateWebhook("999", mockedWebhookUpdateReturn);
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error?.type).toBe("not_found");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns internal_server_error if other error occurs", async () => {
|
||||
vi.mocked(prisma.webhook.update).mockRejectedValueOnce(new Error("Unknown error"));
|
||||
const result = await updateWebhook("abc", mockedWebhookUpdateReturn);
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error?.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteWebhook", () => {
|
||||
test("returns ok on successful delete", async () => {
|
||||
vi.mocked(prisma.webhook.delete).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn);
|
||||
const result = await deleteWebhook("123");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(webhookCache.revalidate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns not_found if record does not exist", async () => {
|
||||
vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(prismaNotFoundError);
|
||||
const result = await deleteWebhook("999");
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error?.type).toBe("not_found");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns internal_server_error on other errors", async () => {
|
||||
vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(new Error("Delete error"));
|
||||
const result = await deleteWebhook("abc");
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error?.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { webhookCache } from "@/lib/cache/webhook";
|
||||
import { webhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Webhook } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getWebhook = async (webhookId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<Webhook, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const webhook = await prisma.webhook.findUnique({
|
||||
where: {
|
||||
id: webhookId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: "webhook", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
|
||||
return ok(webhook);
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "webhook", issue: error.message }],
|
||||
});
|
||||
}
|
||||
},
|
||||
[`management-getWebhook-${webhookId}`],
|
||||
{
|
||||
tags: [webhookCache.tag.byId(webhookId)],
|
||||
}
|
||||
)();
|
||||
|
||||
export const updateWebhook = async (
|
||||
webhookId: string,
|
||||
webhookInput: z.infer<typeof webhookUpdateSchema>
|
||||
): Promise<Result<Webhook, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const updatedWebhook = await prisma.webhook.update({
|
||||
where: {
|
||||
id: webhookId,
|
||||
},
|
||||
data: webhookInput,
|
||||
});
|
||||
|
||||
webhookCache.revalidate({
|
||||
id: webhookId,
|
||||
});
|
||||
|
||||
return ok(updatedWebhook);
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
) {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: "webhook", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
}
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "webhook", issue: error.message }],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteWebhook = async (webhookId: string): Promise<Result<Webhook, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const deletedWebhook = await prisma.webhook.delete({
|
||||
where: {
|
||||
id: webhookId,
|
||||
},
|
||||
});
|
||||
|
||||
webhookCache.revalidate({
|
||||
id: deletedWebhook.id,
|
||||
environmentId: deletedWebhook.environmentId,
|
||||
source: deletedWebhook.source,
|
||||
});
|
||||
|
||||
return ok(deletedWebhook);
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
) {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: "webhook", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
}
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "webhook", issue: error.message }],
|
||||
});
|
||||
}
|
||||
};
|
||||
156
apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts
Normal file
156
apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts
Normal file
@@ -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.",
|
||||
});
|
||||
68
apps/web/modules/api/v2/management/webhooks/lib/openapi.ts
Normal file
68
apps/web/modules/api/v2/management/webhooks/lib/openapi.ts
Normal file
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
35
apps/web/modules/api/v2/management/webhooks/lib/utils.ts
Normal file
35
apps/web/modules/api/v2/management/webhooks/lib/utils.ts
Normal file
@@ -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;
|
||||
};
|
||||
83
apps/web/modules/api/v2/management/webhooks/lib/webhook.ts
Normal file
83
apps/web/modules/api/v2/management/webhooks/lib/webhook.ts
Normal file
@@ -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 }],
|
||||
});
|
||||
}
|
||||
};
|
||||
86
apps/web/modules/api/v2/management/webhooks/route.ts
Normal file
86
apps/web/modules/api/v2/management/webhooks/route.ts
Normal file
@@ -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 { bulkContactPaths } from "@/modules/ee/contacts/api/v2/management/contacts/bulk/lib/openapi";
|
||||
import * as yaml from "yaml";
|
||||
import { z } from "zod";
|
||||
@@ -12,6 +13,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);
|
||||
|
||||
@@ -29,6 +31,7 @@ const document = createDocument({
|
||||
...contactAttributePaths,
|
||||
...contactAttributeKeyPaths,
|
||||
...surveyPaths,
|
||||
...webhookPaths,
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
@@ -57,6 +60,10 @@ const document = createDocument({
|
||||
name: "Management API > Surveys",
|
||||
description: "Operations for managing surveys.",
|
||||
},
|
||||
{
|
||||
name: "Management API > Webhooks",
|
||||
description: "Operations for managing webhooks.",
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
@@ -73,6 +80,7 @@ const document = createDocument({
|
||||
contactAttribute: ZContactAttribute,
|
||||
contactAttributeKey: ZContactAttributeKey,
|
||||
survey: ZSurveyWithoutQuestionType,
|
||||
webhook: ZWebhook,
|
||||
},
|
||||
},
|
||||
security: [
|
||||
|
||||
12
apps/web/modules/api/v2/types/api-filter.ts
Normal file
12
apps/web/modules/api/v2/types/api-filter.ts
Normal file
@@ -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>;
|
||||
19
apps/web/modules/api/v2/types/openapi-response.ts
Normal file
19
apps/web/modules/api/v2/types/openapi-response.ts
Normal file
@@ -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();
|
||||
}
|
||||
@@ -173,6 +173,9 @@ export const authOptions: NextAuthOptions = {
|
||||
// Conditionally add enterprise SSO providers
|
||||
...(ENTERPRISE_LICENSE_KEY ? getSSOProviders() : []),
|
||||
],
|
||||
session: {
|
||||
maxAge: 3600,
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token }) {
|
||||
const existingUser = await getUserByEmail(token?.email!);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,10 @@ export const PricingCard = ({
|
||||
text={t("environments.settings.billing.switch_plan_confirmation_text", {
|
||||
plan: t(plan.name),
|
||||
price: planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly,
|
||||
period:
|
||||
planPeriod === "monthly"
|
||||
? t("environments.settings.billing.per_month")
|
||||
: t("environments.settings.billing.per_year"),
|
||||
})}
|
||||
buttonVariant="default"
|
||||
buttonLoading={loading}
|
||||
|
||||
164
apps/web/modules/ee/insights/components/insights-view.test.tsx
Normal file
164
apps/web/modules/ee/insights/components/insights-view.test.tsx
Normal file
@@ -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`
|
||||
);
|
||||
|
||||
217
apps/web/modules/survey/link/components/link-survey.test.tsx
Normal file
217
apps/web/modules/survey/link/components/link-survey.test.tsx
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,7 @@ export const getSurveyMetadata = reactCache(async (surveyId: string) =>
|
||||
return survey;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting survey metadata");
|
||||
logger.error(error);
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
|
||||
74
apps/web/modules/ui/components/alert/index.test.tsx
Normal file
74
apps/web/modules/ui/components/alert/index.test.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Alert, AlertButton, AlertDescription, AlertTitle } from "./index";
|
||||
|
||||
describe("Alert", () => {
|
||||
it("renders with default variant", () => {
|
||||
render(
|
||||
<Alert>
|
||||
<AlertTitle>Test Title</AlertTitle>
|
||||
<AlertDescription>Test Description</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with different variants", () => {
|
||||
const variants = ["default", "error", "warning", "info", "success"] as const;
|
||||
|
||||
variants.forEach((variant) => {
|
||||
const { container } = render(
|
||||
<Alert variant={variant}>
|
||||
<AlertTitle>Test Title</AlertTitle>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass(
|
||||
variant === "default" ? "text-foreground" : `text-${variant}-foreground`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders with different sizes", () => {
|
||||
const sizes = ["default", "small"] as const;
|
||||
|
||||
sizes.forEach((size) => {
|
||||
const { container } = render(
|
||||
<Alert size={size}>
|
||||
<AlertTitle>Test Title</AlertTitle>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass(size === "default" ? "py-3" : "py-2");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders with button and handles click", () => {
|
||||
const handleClick = vi.fn();
|
||||
|
||||
render(
|
||||
<Alert>
|
||||
<AlertTitle>Test Title</AlertTitle>
|
||||
<AlertButton onClick={handleClick}>Click me</AlertButton>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
const button = screen.getByText("Click me");
|
||||
expect(button).toBeInTheDocument();
|
||||
fireEvent.click(button);
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies custom className", () => {
|
||||
const { container } = render(
|
||||
<Alert className="custom-class">
|
||||
<AlertTitle>Test Title</AlertTitle>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass("custom-class");
|
||||
});
|
||||
});
|
||||
@@ -1,49 +1,141 @@
|
||||
import { VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
"use client";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-xl border p-3 [&>svg]:absolute [&>svg]:text-foreground [&>svg]:left-3 [&>svg]:top-3 [&>svg+div]:translate-y-[-3px] [&:has(svg)]:pl-9",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"text-destructive border-destructive/50 dark:border-destructive [&>svg]:text-destructive text-destructive",
|
||||
info: "text-slate-800 bg-brand/5",
|
||||
warning: "text-yellow-700 bg-yellow-50",
|
||||
error: "border-error/50 dark:border-error [&>svg]:text-error text-error",
|
||||
},
|
||||
import { VariantProps, cva } from "class-variance-authority";
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2Icon, Info } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { Button, ButtonProps } from "../button";
|
||||
|
||||
// Create a context to share variant and size with child components
|
||||
interface AlertContextValue {
|
||||
variant?: "default" | "error" | "warning" | "info" | "success" | null;
|
||||
size?: "default" | "small" | null;
|
||||
}
|
||||
|
||||
const AlertContext = createContext<AlertContextValue>({
|
||||
variant: "default",
|
||||
size: "default",
|
||||
});
|
||||
|
||||
const useAlertContext = () => useContext(AlertContext);
|
||||
|
||||
// Define alert styles with variants
|
||||
const alertVariants = cva("relative w-full rounded-lg border [&>svg]:size-4 [&>svg]:text-foreground", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "text-foreground border-border",
|
||||
error:
|
||||
"text-error-foreground border-error/50 [&_button]:bg-error-background [&_button]:text-error-foreground [&_button:hover]:bg-error-background-muted",
|
||||
warning:
|
||||
"text-warning-foreground border-warning/50 [&_button]:bg-warning-background [&_button]:text-warning-foreground [&_button:hover]:bg-warning-background-muted",
|
||||
info: "text-info-foreground border-info/50 [&_button]:bg-info-background [&_button]:text-info-foreground [&_button:hover]:bg-info-background-muted",
|
||||
success:
|
||||
"text-success-foreground border-success/50 [&_button]:bg-success-background [&_button]:text-success-foreground [&_button:hover]:bg-success-background-muted",
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: {
|
||||
default:
|
||||
"py-3 px-4 text-sm grid grid-cols-[1fr_auto] grid-rows-[auto_auto] gap-x-3 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7",
|
||||
small:
|
||||
"px-3 py-2 text-xs flex items-center justify-between gap-2 [&>svg]:flex-shrink-0 [&_button]:text-xs [&_button]:bg-transparent [&_button:hover]:bg-transparent [&>svg~*]:pl-0",
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
const alertVariantIcons: Record<"default" | "error" | "warning" | "info" | "success", React.ReactNode> = {
|
||||
default: null,
|
||||
error: <AlertCircle className="size-4" />,
|
||||
warning: <AlertTriangle className="size-4" />,
|
||||
info: <Info className="size-4" />,
|
||||
success: <CheckCircle2Icon className="size-4" />,
|
||||
};
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> &
|
||||
VariantProps<typeof alertVariants> & { dangerouslySetInnerHTML?: { __html: string } }
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
));
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => {
|
||||
const variantIcon = variant ? (variant !== "default" ? alertVariantIcons[variant] : null) : null;
|
||||
|
||||
return (
|
||||
<AlertContext.Provider value={{ variant, size }}>
|
||||
<div ref={ref} role="alert" className={cn(alertVariants({ variant, size }), className)} {...props}>
|
||||
{variantIcon}
|
||||
{props.children}
|
||||
</div>
|
||||
</AlertContext.Provider>
|
||||
);
|
||||
});
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement> & { dangerouslySetInnerHTML?: { __html: string } }
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5 ref={ref} className={cn("mb-1 cursor-default font-medium leading-none", className)} {...props} />
|
||||
));
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { size } = useAlertContext();
|
||||
return (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"col-start-1 row-start-1 font-medium leading-none tracking-tight",
|
||||
size === "small" ? "min-w-0 flex-shrink truncate" : "col-start-1 row-start-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement> & { dangerouslySetInnerHTML?: { __html: string } }
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("cursor-default text-sm [&_p]:leading-relaxed", className)} {...props} />
|
||||
));
|
||||
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { size } = useAlertContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"[&_p]:leading-relaxed",
|
||||
size === "small"
|
||||
? "hidden min-w-0 flex-shrink flex-grow truncate opacity-80 sm:block" // Hidden on very small screens, limited width
|
||||
: "col-start-1 row-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertDescription, AlertTitle };
|
||||
const AlertButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, children, ...props }, ref) => {
|
||||
const { size: alertSize } = useAlertContext();
|
||||
|
||||
// Determine button styling based on alert context
|
||||
const buttonVariant = variant || (alertSize === "small" ? "link" : "secondary");
|
||||
const buttonSize = size || (alertSize === "small" ? "sm" : "default");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"self-end",
|
||||
alertSize === "small"
|
||||
? "-my-2 -mr-3 ml-auto flex-shrink-0"
|
||||
: "col-start-2 row-span-2 row-start-1 flex items-center justify-center"
|
||||
)}>
|
||||
<Button ref={ref} variant={buttonVariant} size={buttonSize} className={className} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
AlertButton.displayName = "AlertButton";
|
||||
|
||||
// Export the new component
|
||||
export { Alert, AlertTitle, AlertDescription, AlertButton };
|
||||
|
||||
62
apps/web/modules/ui/components/alert/stories.test.tsx
Normal file
62
apps/web/modules/ui/components/alert/stories.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Default, Error, Info, Small, Success, Warning, withButtonAndIcon } from "./stories";
|
||||
|
||||
describe("Alert Stories", () => {
|
||||
const renderStory = (Story: any) => {
|
||||
return render(Story.render(Story.args));
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders Default story", () => {
|
||||
renderStory(Default);
|
||||
expect(screen.getByText("Alert Title")).toBeInTheDocument();
|
||||
expect(screen.getByText("This is an important notification.")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Small story", () => {
|
||||
renderStory(Small);
|
||||
expect(screen.getByText("Information Alert")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
expect(screen.getByText("Learn more")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders withButtonAndIcon story", () => {
|
||||
renderStory(withButtonAndIcon);
|
||||
expect(screen.getByText("Alert Title")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
expect(screen.getByText("Learn more")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Error story", () => {
|
||||
renderStory(Error);
|
||||
expect(screen.getByText("Error Alert")).toBeInTheDocument();
|
||||
expect(screen.getByText("Your session has expired. Please log in again.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Log in")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Warning story", () => {
|
||||
renderStory(Warning);
|
||||
expect(screen.getByText("Warning Alert")).toBeInTheDocument();
|
||||
expect(screen.getByText("You are editing sensitive data. Be cautious")).toBeInTheDocument();
|
||||
expect(screen.getByText("Proceed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Info story", () => {
|
||||
renderStory(Info);
|
||||
expect(screen.getByText("Info Alert")).toBeInTheDocument();
|
||||
expect(screen.getByText("There was an update to your application.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Refresh")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Success story", () => {
|
||||
renderStory(Success);
|
||||
expect(screen.getByText("Success Alert")).toBeInTheDocument();
|
||||
expect(screen.getByText("This worked! Please proceed.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Close")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,49 +1,252 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./index";
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import { LightbulbIcon } from "lucide-react";
|
||||
import { Alert, AlertButton, AlertDescription, AlertTitle } from "./index";
|
||||
|
||||
const meta = {
|
||||
title: "ui/Alert",
|
||||
// We'll define the story options separately from the component props
|
||||
interface StoryOptions {
|
||||
title: string;
|
||||
description: string;
|
||||
showIcon: boolean;
|
||||
showButton: boolean;
|
||||
actionButtonText: string;
|
||||
}
|
||||
|
||||
type StoryProps = React.ComponentProps<typeof Alert> & StoryOptions;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI/Alert",
|
||||
component: Alert,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "radio",
|
||||
options: ["default", "error"],
|
||||
parameters: {
|
||||
controls: {
|
||||
sort: "requiredFirst",
|
||||
exclude: [],
|
||||
},
|
||||
},
|
||||
args: {
|
||||
variant: "default",
|
||||
// These argTypes are for story controls, not component props
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["default", "error", "warning", "info", "success"],
|
||||
description: "Style variant of the alert",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "default" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
size: {
|
||||
control: "select",
|
||||
options: ["default", "small"],
|
||||
description: "Size of the alert component",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "default" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
showIcon: {
|
||||
control: "boolean",
|
||||
description: "Whether to show an icon",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "boolean" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
showButton: {
|
||||
control: "boolean",
|
||||
description: "Whether to show action buttons",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "boolean" },
|
||||
},
|
||||
order: 4,
|
||||
},
|
||||
title: {
|
||||
control: "text",
|
||||
description: "Alert title text",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
description: {
|
||||
control: "text",
|
||||
description: "Alert description text",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
actionButtonText: {
|
||||
control: "text",
|
||||
description: "Text for the action button",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
},
|
||||
render: (args) => (
|
||||
<Alert {...args}>
|
||||
<AlertTitle>This is an alert</AlertTitle>
|
||||
<AlertDescription>This is a description</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
} satisfies Meta<typeof Alert>;
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
// Our story type just specifies Alert props plus our story options
|
||||
type Story = StoryObj<typeof Alert> & { args: StoryOptions };
|
||||
|
||||
export const Default: Story = {};
|
||||
// Create a common render function to reduce duplication
|
||||
const renderAlert = (args: StoryProps) => {
|
||||
// Extract component props
|
||||
const { variant = "default", size = "default", className = "" } = args;
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
variant: "error",
|
||||
},
|
||||
};
|
||||
// Extract story content options
|
||||
const {
|
||||
title = "",
|
||||
description = "",
|
||||
showIcon = false,
|
||||
showButton = false,
|
||||
actionButtonText = "",
|
||||
} = args as StoryOptions;
|
||||
|
||||
export const WithIcon: Story = {
|
||||
args: {
|
||||
variant: "error",
|
||||
},
|
||||
render: (args) => (
|
||||
<Alert {...args}>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>This is an alert</AlertTitle>
|
||||
<AlertDescription>This is a description</AlertDescription>
|
||||
return (
|
||||
<Alert variant={variant} size={size} className={className}>
|
||||
{showIcon && <LightbulbIcon />}
|
||||
<AlertTitle className={showIcon ? "pl-7" : ""}>{title}</AlertTitle>
|
||||
{description && <AlertDescription className={showIcon ? "pl-7" : ""}>{description}</AlertDescription>}
|
||||
{showButton && <AlertButton onClick={() => alert("Button clicked")}>{actionButtonText}</AlertButton>}
|
||||
</Alert>
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
// Basic example with direct props
|
||||
export const Default: Story = {
|
||||
render: renderAlert,
|
||||
args: {
|
||||
variant: "default",
|
||||
showIcon: false,
|
||||
showButton: false,
|
||||
title: "Alert Title",
|
||||
description: "This is an important notification.",
|
||||
actionButtonText: "Learn more",
|
||||
},
|
||||
};
|
||||
|
||||
// Small size example
|
||||
export const Small: Story = {
|
||||
render: renderAlert,
|
||||
args: {
|
||||
variant: "default",
|
||||
size: "small",
|
||||
title: "Information Alert",
|
||||
description: "This is an important notification.",
|
||||
showIcon: false,
|
||||
showButton: true,
|
||||
actionButtonText: "Learn more",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use if space is limited or the alert is not the main focus.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// With custom icon
|
||||
export const withButtonAndIcon: Story = {
|
||||
render: renderAlert,
|
||||
args: {
|
||||
variant: "default",
|
||||
title: "Alert Title",
|
||||
description: "This is an important notification.",
|
||||
showIcon: true,
|
||||
showButton: true,
|
||||
actionButtonText: "Learn more",
|
||||
},
|
||||
};
|
||||
|
||||
// Error variant
|
||||
export const Error: Story = {
|
||||
render: renderAlert,
|
||||
args: {
|
||||
variant: "error",
|
||||
title: "Error Alert",
|
||||
description: "Your session has expired. Please log in again.",
|
||||
showIcon: false,
|
||||
showButton: true,
|
||||
actionButtonText: "Log in",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Only use if the user needs to take immediate action or there is a critical error.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Warning variant
|
||||
export const Warning: Story = {
|
||||
render: renderAlert,
|
||||
args: {
|
||||
variant: "warning",
|
||||
title: "Warning Alert",
|
||||
description: "You are editing sensitive data. Be cautious",
|
||||
showIcon: false,
|
||||
showButton: true,
|
||||
actionButtonText: "Proceed",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this to make the user aware of potential issues.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Info variant
|
||||
export const Info: Story = {
|
||||
render: renderAlert,
|
||||
args: {
|
||||
variant: "info",
|
||||
title: "Info Alert",
|
||||
description: "There was an update to your application.",
|
||||
showIcon: false,
|
||||
showButton: true,
|
||||
actionButtonText: "Refresh",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this to give contextual information and support the user.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Success variant
|
||||
export const Success: Story = {
|
||||
render: renderAlert,
|
||||
args: {
|
||||
variant: "success",
|
||||
title: "Success Alert",
|
||||
description: "This worked! Please proceed.",
|
||||
showIcon: false,
|
||||
showButton: true,
|
||||
actionButtonText: "Close",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this to give positive feedback.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -321,7 +321,7 @@ const sentryConfig = {
|
||||
disableLogger: true,
|
||||
};
|
||||
|
||||
const exportConfig = process.env.NEXT_PUBLIC_SENTRY_DSN
|
||||
const exportConfig = process.env.SENTRY_DSN
|
||||
? withSentryConfig(nextConfig, sentryOptions)
|
||||
: nextConfig;
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
137
apps/web/playwright/api/management/webhook.spec.ts
Normal file
137
apps/web/playwright/api/management/webhook.spec.ts
Normal file
@@ -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
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
// This file configures the initialization of Sentry on the client.
|
||||
// The config you add here will be used whenever a users loads a page in their browser.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
|
||||
// This sets the sample rate to be 10%. You may want this to be 100% while
|
||||
// in development and sample at a lower rate in production
|
||||
replaysSessionSampleRate: 0.1,
|
||||
|
||||
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
|
||||
integrations: [
|
||||
Sentry.replayIntegration({
|
||||
// Additional Replay configuration goes in here, for example:
|
||||
maskAllText: true,
|
||||
blockAllMedia: true,
|
||||
}),
|
||||
],
|
||||
|
||||
beforeSend(event, hint) {
|
||||
const error = hint.originalException as Error;
|
||||
|
||||
// @ts-expect-error
|
||||
if (error && error.digest === "NEXT_NOT_FOUND") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return event;
|
||||
},
|
||||
});
|
||||
@@ -3,13 +3,20 @@
|
||||
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { SENTRY_DSN } from "@formbricks/lib/constants";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
if (SENTRY_DSN) {
|
||||
console.log("Sentry DSN found, enabling Sentry on the edge");
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1,
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
});
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
});
|
||||
} else {
|
||||
console.warn("Sentry DSN not found, Sentry will be disabled on the edge");
|
||||
}
|
||||
|
||||
@@ -2,27 +2,34 @@
|
||||
// The config you add here will be used whenever the server handles a request.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { SENTRY_DSN } from "@formbricks/lib/constants";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
if (SENTRY_DSN) {
|
||||
console.log("Sentry DSN found, enabling Sentry on the server");
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1,
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1,
|
||||
|
||||
// uncomment the line below to enable Spotlight (https://spotlightjs.com)
|
||||
// spotlight: process.env.NODE_ENV === 'development',
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
|
||||
beforeSend(event, hint) {
|
||||
const error = hint.originalException as Error;
|
||||
// uncomment the line below to enable Spotlight (https://spotlightjs.com)
|
||||
// spotlight: process.env.NODE_ENV === 'development',
|
||||
|
||||
// @ts-expect-error
|
||||
if (error && error.digest === "NEXT_NOT_FOUND") {
|
||||
return null;
|
||||
}
|
||||
beforeSend(event, hint) {
|
||||
const error = hint.originalException as Error;
|
||||
|
||||
return event;
|
||||
},
|
||||
});
|
||||
// @ts-expect-error
|
||||
if (error && error.digest === "NEXT_NOT_FOUND") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return event;
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.warn("Sentry DSN not found, Sentry will be disabled on the server");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const colors = require("tailwindcss/colors");
|
||||
|
||||
module.exports = {
|
||||
content: [
|
||||
// app content
|
||||
@@ -39,7 +41,7 @@ module.exports = {
|
||||
dark: "#00C4B8",
|
||||
},
|
||||
focus: "var(--formbricks-focus, #1982fc)",
|
||||
error: "rgb(from var(--formbricks-error) r g b / <alpha-value>)",
|
||||
// error: "rgb(from var(--formbricks-error) r g b / <alpha-value>)",
|
||||
brandnew: "var(--formbricks-brand, #038178)",
|
||||
primary: {
|
||||
DEFAULT: "#0f172a",
|
||||
@@ -57,6 +59,34 @@ module.exports = {
|
||||
DEFAULT: "#f4f6f8", // light gray background
|
||||
foreground: "#0f172a", // same as primary default for consistency
|
||||
},
|
||||
info: {
|
||||
DEFAULT: colors.blue[600],
|
||||
foreground: colors.blue[900],
|
||||
muted: colors.blue[700],
|
||||
background: colors.blue[50],
|
||||
"background-muted": colors.blue[100],
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: colors.amber[500],
|
||||
foreground: colors.amber[900],
|
||||
muted: colors.amber[700],
|
||||
background: colors.amber[50],
|
||||
"background-muted": colors.amber[100],
|
||||
},
|
||||
success: {
|
||||
DEFAULT: colors.green[600],
|
||||
foreground: colors.green[900],
|
||||
muted: colors.green[700],
|
||||
background: colors.green[50],
|
||||
"background-muted": colors.green[100],
|
||||
},
|
||||
error: {
|
||||
DEFAULT: colors.red[600],
|
||||
foreground: colors.red[900],
|
||||
muted: colors.red[700],
|
||||
background: colors.red[50],
|
||||
"background-muted": colors.red[100],
|
||||
},
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
|
||||
4
apps/web/test-results/.last-run.json
Normal file
4
apps/web/test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"failedTests": [],
|
||||
"status": "failed"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user