diff --git a/.github/actions/cache-build-web/action.yml b/.github/actions/cache-build-web/action.yml index 6ae00d0203..25d18f4245 100644 --- a/.github/actions/cache-build-web/action.yml +++ b/.github/actions/cache-build-web/action.yml @@ -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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..d27ee547a7 --- /dev/null +++ b/.github/dependabot.yml @@ -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" diff --git a/.github/workflows/release-docker-github-experimental.yml b/.github/workflows/release-docker-github-experimental.yml index c009debdcd..25b8e5e61e 100644 --- a/.github/workflows/release-docker-github-experimental.yml +++ b/.github/workflows/release-docker-github-experimental.yml @@ -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 diff --git a/.github/workflows/release-docker-github.yml b/.github/workflows/release-docker-github.yml index c09d66d553..457940fb7e 100644 --- a/.github/workflows/release-docker-github.yml +++ b/.github/workflows/release-docker-github.yml @@ -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 diff --git a/.github/workflows/terrafrom-plan-and-apply.yml b/.github/workflows/terrafrom-plan-and-apply.yml index 49f2b99081..78d0c72e6c 100644 --- a/.github/workflows/terrafrom-plan-and-apply.yml +++ b/.github/workflows/terrafrom-plan-and-apply.yml @@ -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" diff --git a/apps/storybook/package.json b/apps/storybook/package.json index 5ffdc138d3..2399cd16d9 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -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" } } diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index cf30c09ef2..3e8039e5f2 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -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 diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.test.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.test.tsx new file mode 100644 index 0000000000..d26c801406 --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.test.tsx @@ -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(); + + // 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(); + 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(); + 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(); + 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" + ); + }); +}); diff --git a/apps/web/app/(app)/components/FormbricksClient.test.tsx b/apps/web/app/(app)/components/FormbricksClient.test.tsx new file mode 100644 index 0000000000..a0e0b986ca --- /dev/null +++ b/apps/web/app/(app)/components/FormbricksClient.test.tsx @@ -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(); + + // 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(); + + // 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(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx b/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx index eea9218900..205de99e2d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx @@ -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 = ({ diff --git a/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.tsx b/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.tsx index 2646546db3..22ef9d8218 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.tsx @@ -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 (
{!isBilling && } - {isFormbricksCloud && ( - - - - )} + + + + +
diff --git a/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts b/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts index 9f299ee840..4e7ffb9a47 100644 --- a/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts +++ b/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts @@ -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 => { 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}`); diff --git a/apps/web/app/api/v2/management/webhooks/[webhookId]/route.ts b/apps/web/app/api/v2/management/webhooks/[webhookId]/route.ts new file mode 100644 index 0000000000..6655f124cc --- /dev/null +++ b/apps/web/app/api/v2/management/webhooks/[webhookId]/route.ts @@ -0,0 +1,3 @@ +import { DELETE, GET, PUT } from "@/modules/api/v2/management/webhooks/[webhookId]/route"; + +export { GET, PUT, DELETE }; diff --git a/apps/web/app/api/v2/management/webhooks/route.ts b/apps/web/app/api/v2/management/webhooks/route.ts new file mode 100644 index 0000000000..d6497c990c --- /dev/null +++ b/apps/web/app/api/v2/management/webhooks/route.ts @@ -0,0 +1,3 @@ +import { GET, POST } from "@/modules/api/v2/management/webhooks/route"; + +export { GET, POST }; diff --git a/apps/web/app/layout.test.tsx b/apps/web/app/layout.test.tsx index 51abc5195b..495ec8f9ce 100644 --- a/apps/web/app/layout.test.tsx +++ b/apps/web/app/layout.test.tsx @@ -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 }) => ( +
+ SentryProvider: {sentryDsn} + {children} +
+ ), +})); + 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"); }); }); diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index ede4738cd7..bc235ff730 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -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 }) => { {process.env.VERCEL === "1" && } - - - {children} - - + + + + {children} + + + ); diff --git a/apps/web/app/lib/pipelines.test.ts b/apps/web/app/lib/pipelines.test.ts new file mode 100644 index 0000000000..306a4260d5 --- /dev/null +++ b/apps/web/app/lib/pipelines.test.ts @@ -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"); + }); +}); diff --git a/apps/web/app/lib/pipelines.ts b/apps/web/app/lib/pipelines.ts index 686ece3f51..d1f040efa2 100644 --- a/apps/web/app/lib/pipelines.ts +++ b/apps/web/app/lib/pipelines.ts @@ -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: { diff --git a/apps/web/app/lib/singleUseSurveys.test.ts b/apps/web/app/lib/singleUseSurveys.test.ts new file mode 100644 index 0000000000..c941c135d4 --- /dev/null +++ b/apps/web/app/lib/singleUseSurveys.test.ts @@ -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"); + }); +}); diff --git a/apps/web/app/lib/singleUseSurveys.ts b/apps/web/app/lib/singleUseSurveys.ts index 33318b6567..aaceacd6d9 100644 --- a/apps/web/app/lib/singleUseSurveys.ts +++ b/apps/web/app/lib/singleUseSurveys.ts @@ -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; } }; diff --git a/apps/web/app/sentry/SentryProvider.test.tsx b/apps/web/app/sentry/SentryProvider.test.tsx new file mode 100644 index 0000000000..40b58e7165 --- /dev/null +++ b/apps/web/app/sentry/SentryProvider.test.tsx @@ -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("@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( + +
Test Content
+
+ ); + + // 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( + +
Test Content
+
+ ); + + expect(initSpy).not.toHaveBeenCalled(); + }); + + it("renders children", () => { + const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0"; + render( + +
Test Content
+
+ ); + 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( + +
Test Content
+
+ ); + + 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); + }); +}); diff --git a/apps/web/app/sentry/SentryProvider.tsx b/apps/web/app/sentry/SentryProvider.tsx new file mode 100644 index 0000000000..b01e71dfc4 --- /dev/null +++ b/apps/web/app/sentry/SentryProvider.tsx @@ -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}; +}; diff --git a/apps/web/cache-handler.mjs b/apps/web/cache-handler.mjs index 5292d88e97..1065fa3b83 100644 --- a/apps/web/cache-handler.mjs +++ b/apps/web/cache-handler.mjs @@ -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, + }, }; }); diff --git a/apps/web/instrumentation.ts b/apps/web/instrumentation.ts index 0b527429c2..e86284efd3 100644 --- a/apps/web/instrumentation.ts +++ b/apps/web/instrumentation.ts @@ -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"); + } }; diff --git a/apps/web/modules/api/v2/management/auth/authenticate-request.ts b/apps/web/modules/api/v2/management/auth/authenticate-request.ts index 7e6a1cacde..f0ec9e3165 100644 --- a/apps/web/modules/api/v2/management/auth/authenticate-request.ts +++ b/apps/web/modules/api/v2/management/auth/authenticate-request.ts @@ -8,6 +8,7 @@ export const authenticateRequest = async ( request: Request ): Promise> => { const apiKey = request.headers.get("x-api-key"); + if (apiKey) { const environmentIdResult = await getEnvironmentIdFromApiKey(apiKey); if (!environmentIdResult.ok) { diff --git a/apps/web/modules/api/v2/management/lib/helper.ts b/apps/web/modules/api/v2/management/lib/helper.ts index 0b5d07e406..0e86d002e1 100644 --- a/apps/web/modules/api/v2/management/lib/helper.ts +++ b/apps/web/modules/api/v2/management/lib/helper.ts @@ -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> => { + 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]); +}; diff --git a/apps/web/modules/api/v2/management/lib/openapi.ts b/apps/web/modules/api/v2/management/lib/openapi.ts deleted file mode 100644 index f268bb2516..0000000000 --- a/apps/web/modules/api/v2/management/lib/openapi.ts +++ /dev/null @@ -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, - }, -}; diff --git a/apps/web/modules/api/v2/management/lib/services.ts b/apps/web/modules/api/v2/management/lib/services.ts index 1d1a769104..9420165725 100644 --- a/apps/web/modules/api/v2/management/lib/services.ts +++ b/apps/web/modules/api/v2/management/lib/services.ts @@ -41,3 +41,36 @@ export const fetchEnvironmentId = reactCache(async (id: string, isResponseId: bo } )() ); + +export const fetchEnvironmentIdFromSurveyIds = reactCache(async (surveyIds: string[]) => + cache( + async (): Promise> => { + 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)), + } + )() +); diff --git a/apps/web/modules/api/v2/management/lib/tests/helper.test.ts b/apps/web/modules/api/v2/management/lib/tests/helper.test.ts index 5b76f2360b..845c61cd15 100644 --- a/apps/web/modules/api/v2/management/lib/tests/helper.test.ts +++ b/apps/web/modules/api/v2/management/lib/tests/helper.test.ts @@ -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 }); + }); +}); diff --git a/apps/web/modules/api/v2/management/lib/tests/services.test.ts b/apps/web/modules/api/v2/management/lib/tests/services.test.ts index 9e22295f7a..02af5f1406 100644 --- a/apps/web/modules/api/v2/management/lib/tests/services.test.ts +++ b/apps/web/modules/api/v2/management/lib/tests/services.test.ts @@ -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"); + } + }); + }); }); diff --git a/apps/web/modules/api/v2/management/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/lib/tests/utils.test.ts index a748f15451..f189e4f76a 100644 --- a/apps/web/modules/api/v2/management/lib/tests/utils.test.ts +++ b/apps/web/modules/api/v2/management/lib/tests/utils.test.ts @@ -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({}); + }); + }); +}); diff --git a/apps/web/modules/api/v2/management/lib/utils.ts b/apps/web/modules/api/v2/management/lib/utils.ts index 0d8195da8a..3e601de2cc 100644 --- a/apps/web/modules/api/v2/management/lib/utils.ts +++ b/apps/web/modules/api/v2/management/lib/utils.ts @@ -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(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(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; +} diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts index a957d09e3b..b13245d343 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts @@ -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 ({ @@ -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", diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts index b4a5717337..edd9fb78d6 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts @@ -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", diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts index 08a01513aa..90443a5202 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts @@ -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); }, }); diff --git a/apps/web/modules/api/v2/management/responses/lib/openapi.ts b/apps/web/modules/api/v2/management/responses/lib/openapi.ts index f562b1c3c6..e46da37627 100644 --- a/apps/web/modules/api/v2/management/responses/lib/openapi.ts +++ b/apps/web/modules/api/v2/management/responses/lib/openapi.ts @@ -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), }, }, }, diff --git a/apps/web/modules/api/v2/management/responses/lib/organization.ts b/apps/web/modules/api/v2/management/responses/lib/organization.ts index 9ca2a06cef..334f892e02 100644 --- a/apps/web/modules/api/v2/management/responses/lib/organization.ts +++ b/apps/web/modules/api/v2/management/responses/lib/organization.ts @@ -48,7 +48,7 @@ export const getOrganizationIdFromEnvironmentId = reactCache(async (environmentI export const getOrganizationBilling = reactCache(async (organizationId: string) => cache( - async (): Promise, ApiErrorResponseV2>> => { + async (): Promise> => { 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> => { 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 diff --git a/apps/web/modules/api/v2/management/responses/lib/response.ts b/apps/web/modules/api/v2/management/responses/lib/response.ts index f48eb413d8..6e0ce2516d 100644 --- a/apps/web/modules/api/v2/management/responses/lib/response.ts +++ b/apps/web/modules/api/v2/management/responses/lib/response.ts @@ -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: { diff --git a/apps/web/modules/api/v2/management/responses/lib/tests/organization.test.ts b/apps/web/modules/api/v2/management/responses/lib/tests/organization.test.ts index 3dc84295d0..d908a5d1b4 100644 --- a/apps/web/modules/api/v2/management/responses/lib/tests/organization.test.ts +++ b/apps/web/modules/api/v2/management/responses/lib/tests/organization.test.ts @@ -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); } }); diff --git a/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts b/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts index d225af34a1..524749896c 100644 --- a/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts +++ b/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts @@ -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)); diff --git a/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts index 088c955350..6ee8be7731 100644 --- a/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts +++ b/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts @@ -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({ + where: { + survey: { environmentId: "env-id" }, + surveyId: "test", + }, + }), + { someFilter: true } + ); + expect(result).toEqual({ where: { combined: true } }); }); }); diff --git a/apps/web/modules/api/v2/management/responses/lib/utils.ts b/apps/web/modules/api/v2/management/responses/lib/utils.ts index 536022d508..5fa258311c 100644 --- a/apps/web/modules/api/v2/management/responses/lib/utils.ts +++ b/apps/web/modules/api/v2/management/responses/lib/utils.ts @@ -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(query, baseFilter); + } + return query; }; diff --git a/apps/web/modules/api/v2/management/responses/types/responses.ts b/apps/web/modules/api/v2/management/responses/types/responses.ts index b2161aa953..96a1655929 100644 --- a/apps/web/modules/api/v2/management/responses/types/responses.ts +++ b/apps/web/modules/api/v2/management/responses/types/responses.ts @@ -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; @@ -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; diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/openapi.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/openapi.ts new file mode 100644 index 0000000000..6d0c6e2615 --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/openapi.ts @@ -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), + }, + }, + }, + }, +}; diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock.ts new file mode 100644 index 0000000000..a6b335ba5e --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock.ts @@ -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", +}); diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts new file mode 100644 index 0000000000..858f7fc74c --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts @@ -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; + + 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"); + } + }); +}); diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts new file mode 100644 index 0000000000..519cc3a9a7 --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts @@ -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> => { + 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 +): Promise> => { + 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> => { + 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 }], + }); + } +}; diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts new file mode 100644 index 0000000000..2c1fa0cb53 --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts @@ -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); + }, + }); diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/types/webhooks.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/types/webhooks.ts new file mode 100644 index 0000000000..9bcc7a708a --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/types/webhooks.ts @@ -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.", +}); diff --git a/apps/web/modules/api/v2/management/webhooks/lib/openapi.ts b/apps/web/modules/api/v2/management/webhooks/lib/openapi.ts new file mode 100644 index 0000000000..92bac070d2 --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/lib/openapi.ts @@ -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, + }, +}; diff --git a/apps/web/modules/api/v2/management/webhooks/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/webhooks/lib/tests/utils.test.ts new file mode 100644 index 0000000000..1314708eaf --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/lib/tests/utils.test.ts @@ -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(); + }); +}); diff --git a/apps/web/modules/api/v2/management/webhooks/lib/tests/webhook.test.ts b/apps/web/modules/api/v2/management/webhooks/lib/tests/webhook.test.ts new file mode 100644 index 0000000000..b0e2104d9c --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/lib/tests/webhook.test.ts @@ -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"); + } + }); +}); diff --git a/apps/web/modules/api/v2/management/webhooks/lib/utils.ts b/apps/web/modules/api/v2/management/webhooks/lib/utils.ts new file mode 100644 index 0000000000..59716e4cd8 --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/lib/utils.ts @@ -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(query, baseFilter); + } + + return query; +}; diff --git a/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts b/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts new file mode 100644 index 0000000000..7d9d15fbf3 --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts @@ -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, 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> => { + 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 }], + }); + } +}; diff --git a/apps/web/modules/api/v2/management/webhooks/route.ts b/apps/web/modules/api/v2/management/webhooks/route.ts new file mode 100644 index 0000000000..994635e13e --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/route.ts @@ -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); + }, + }); diff --git a/apps/web/modules/api/v2/management/webhooks/types/webhooks.ts b/apps/web/modules/api/v2/management/webhooks/types/webhooks.ts new file mode 100644 index 0000000000..e049c92413 --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/types/webhooks.ts @@ -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; + +export const ZWebhookInput = ZWebhook.pick({ + name: true, + url: true, + source: true, + environmentId: true, + triggers: true, + surveyIds: true, +}); + +export type TWebhookInput = z.infer; diff --git a/apps/web/modules/api/v2/openapi-document.ts b/apps/web/modules/api/v2/openapi-document.ts index 319392e532..250e8f3dc6 100644 --- a/apps/web/modules/api/v2/openapi-document.ts +++ b/apps/web/modules/api/v2/openapi-document.ts @@ -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: [ diff --git a/apps/web/modules/api/v2/types/api-filter.ts b/apps/web/modules/api/v2/types/api-filter.ts new file mode 100644 index 0000000000..29fe9ab051 --- /dev/null +++ b/apps/web/modules/api/v2/types/api-filter.ts @@ -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; diff --git a/apps/web/modules/api/v2/types/openapi-response.ts b/apps/web/modules/api/v2/types/openapi-response.ts new file mode 100644 index 0000000000..50c2e8445a --- /dev/null +++ b/apps/web/modules/api/v2/types/openapi-response.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export function responseWithMetaSchema(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>(schema: T) { + return schema.partial(); +} diff --git a/apps/web/modules/auth/lib/authOptions.ts b/apps/web/modules/auth/lib/authOptions.ts index b9f89c2122..eb9fdd9cfe 100644 --- a/apps/web/modules/auth/lib/authOptions.ts +++ b/apps/web/modules/auth/lib/authOptions.ts @@ -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!); diff --git a/apps/web/modules/auth/lib/user.test.ts b/apps/web/modules/auth/lib/user.test.ts index 1cbbd63dc8..10a7f6b984 100644 --- a/apps/web/modules/auth/lib/user.test.ts +++ b/apps/web/modules/auth/lib/user.test.ts @@ -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); diff --git a/apps/web/modules/auth/lib/user.ts b/apps/web/modules/auth/lib/user.ts index ab47f40e6e..b5d647dc9e 100644 --- a/apps/web/modules/auth/lib/user.ts +++ b/apps/web/modules/auth/lib/user.ts @@ -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"); } diff --git a/apps/web/modules/ee/billing/components/pricing-card.tsx b/apps/web/modules/ee/billing/components/pricing-card.tsx index 09fab34367..5edcc3d297 100644 --- a/apps/web/modules/ee/billing/components/pricing-card.tsx +++ b/apps/web/modules/ee/billing/components/pricing-card.tsx @@ -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} diff --git a/apps/web/modules/ee/insights/components/insights-view.test.tsx b/apps/web/modules/ee/insights/components/insights-view.test.tsx new file mode 100644 index 0000000000..9f41fb3c2e --- /dev/null +++ b/apps/web/modules/ee/insights/components/insights-view.test.tsx @@ -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 }) => ( + + ), +})); + +// Mock InsightSheet to display its open/closed state and the insight title. +vi.mock("@/modules/ee/insights/components/insight-sheet", () => ({ + InsightSheet: ({ isOpen, insight }) => ( +
+ {isOpen ? "InsightSheet Open" : "InsightSheet Closed"} + {insight && ` - ${insight.title}`} +
+ ), +})); + +// 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(); +}; + +// --- 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"); + }); +}); diff --git a/apps/web/modules/ee/insights/experience/components/insight-view.test.tsx b/apps/web/modules/ee/insights/experience/components/insight-view.test.tsx new file mode 100644 index 0000000000..0232660c80 --- /dev/null +++ b/apps/web/modules/ee/insights/experience/components/insight-view.test.tsx @@ -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; + }) => ( +
+ {isOpen ? `InsightSheet Open${insight ? ` - ${insight.title}` : ""}` : "InsightSheet Closed"} +
+ ), +})); + +// Mock InsightLoading. +vi.mock("./insight-loading", () => ({ + InsightLoading: () =>
Loading...
, +})); + +// 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(); +}; + +// --- 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); + }); + }); +}); diff --git a/apps/web/modules/ee/role-management/lib/invite.ts b/apps/web/modules/ee/role-management/lib/invite.ts index 4b5f0124ef..d00e63f3b1 100644 --- a/apps/web/modules/ee/role-management/lib/invite.ts +++ b/apps/web/modules/ee/role-management/lib/invite.ts @@ -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 => { @@ -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 diff --git a/apps/web/modules/ee/role-management/lib/membership.ts b/apps/web/modules/ee/role-management/lib/membership.ts index ee0803f21c..d631455cd0 100644 --- a/apps/web/modules/ee/role-management/lib/membership.ts +++ b/apps/web/modules/ee/role-management/lib/membership.ts @@ -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}`); } diff --git a/apps/web/modules/ee/whitelabel/email-customization/lib/organization.ts b/apps/web/modules/ee/whitelabel/email-customization/lib/organization.ts index 105be391f9..2fb163ec60 100644 --- a/apps/web/modules/ee/whitelabel/email-customization/lib/organization.ts +++ b/apps/web/modules/ee/whitelabel/email-customization/lib/organization.ts @@ -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); } diff --git a/apps/web/modules/integrations/webhooks/lib/webhook.ts b/apps/web/modules/integrations/webhooks/lib/webhook.ts index dcab09ce28..1eced5881b 100644 --- a/apps/web/modules/integrations/webhooks/lib/webhook.ts +++ b/apps/web/modules/integrations/webhooks/lib/webhook.ts @@ -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 => { 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}`); diff --git a/apps/web/modules/projects/settings/(setup)/components/setup-instructions.test.tsx b/apps/web/modules/projects/settings/(setup)/components/setup-instructions.test.tsx new file mode 100644 index 0000000000..58aead244e --- /dev/null +++ b/apps/web/modules/projects/settings/(setup)/components/setup-instructions.test.tsx @@ -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) => ( +
+ {tabs.map((tab: any) => ( + + ))} +
+ ), +})); + +// Mock the CodeBlock component. +vi.mock("@/modules/ui/components/code-block", () => ({ + CodeBlock: ({ children }: { children: React.ReactNode; language?: string }) => ( +
{children}
+ ), +})); + +// Mock Next.js Link to simply render an anchor. +vi.mock("next/link", () => { + return { + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), + }; +}); + +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(); + + // 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(); + + // 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(//i)).toBeInTheDocument(); + }); + }); + + test("npm instructions code block contains environmentId and webAppUrl", async () => { + render(); + + // 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); + }); +}); diff --git a/apps/web/modules/projects/settings/lib/project.ts b/apps/web/modules/projects/settings/lib/project.ts index d0733b3d21..933a7c50d7 100644 --- a/apps/web/modules/projects/settings/lib/project.ts +++ b/apps/web/modules/projects/settings/lib/project.ts @@ -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); diff --git a/apps/web/modules/survey/components/template-list/lib/user.ts b/apps/web/modules/survey/components/template-list/lib/user.ts index 6cf63a4138..8719847c1a 100644 --- a/apps/web/modules/survey/components/template-list/lib/user.ts +++ b/apps/web/modules/survey/components/template-list/lib/user.ts @@ -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 => { @@ -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 diff --git a/apps/web/modules/survey/editor/lib/action-class.ts b/apps/web/modules/survey/editor/lib/action-class.ts index e84e0e0535..0962aba29a 100644 --- a/apps/web/modules/survey/editor/lib/action-class.ts +++ b/apps/web/modules/survey/editor/lib/action-class.ts @@ -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` ); diff --git a/apps/web/modules/survey/link/components/link-survey.test.tsx b/apps/web/modules/survey/link/components/link-survey.test.tsx new file mode 100644 index 0000000000..5651be4964 --- /dev/null +++ b/apps/web/modules/survey/link/components/link-survey.test.tsx @@ -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 }) => ( +
{children}
+ ), +})); + +// Mock SurveyLinkUsed to render a div with a test id. +vi.mock("@/modules/survey/link/components/survey-link-used", () => ({ + SurveyLinkUsed: ({ singleUseMessage }: { singleUseMessage: string }) => ( +
SurveyLinkUsed: {singleUseMessage}
+ ), +})); + +// 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) => ( +
VerifyEmail {props.isErrorComponent ? "Error" : ""}
+ ), +})); + +// Mock SurveyInline to display key props so we can inspect them. +vi.mock("@/modules/ui/components/survey", () => ({ + SurveyInline: (props: any) => ( +
+ SurveyInline {props.startAtQuestionId ? `StartAt:${props.startAtQuestionId}` : ""} + {props.autoFocus ? " AutoFocus" : ""} + {props.hiddenFieldsRecord ? ` HiddenFields:${JSON.stringify(props.hiddenFieldsRecord)}` : ""} + {props.prefillResponseData ? ` Prefill:${JSON.stringify(props.prefillResponseData)}` : ""} +
+ ), +})); + +// --- 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> = {}) => { + // 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(); +}; + +// --- 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 ( +
+ SurveyInline {props.startAtQuestionId ? `StartAt:${props.startAtQuestionId}` : ""} +
+ ); + }, + })); + // 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"); + }); +}); diff --git a/apps/web/modules/survey/link/lib/survey.ts b/apps/web/modules/survey/link/lib/survey.ts index 7a54acd089..1185de0151 100644 --- a/apps/web/modules/survey/link/lib/survey.ts +++ b/apps/web/modules/survey/link/lib/survey.ts @@ -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; diff --git a/apps/web/modules/ui/components/alert/index.test.tsx b/apps/web/modules/ui/components/alert/index.test.tsx new file mode 100644 index 0000000000..08c218f24b --- /dev/null +++ b/apps/web/modules/ui/components/alert/index.test.tsx @@ -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( + + Test Title + Test Description + + ); + + 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( + + Test Title + + ); + + 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( + + Test Title + + ); + + expect(container.firstChild).toHaveClass(size === "default" ? "py-3" : "py-2"); + }); + }); + + it("renders with button and handles click", () => { + const handleClick = vi.fn(); + + render( + + Test Title + Click me + + ); + + const button = screen.getByText("Click me"); + expect(button).toBeInTheDocument(); + fireEvent.click(button); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("applies custom className", () => { + const { container } = render( + + Test Title + + ); + + expect(container.firstChild).toHaveClass("custom-class"); + }); +}); diff --git a/apps/web/modules/ui/components/alert/index.tsx b/apps/web/modules/ui/components/alert/index.tsx index d9e3c89c6e..c17f7f4b4e 100644 --- a/apps/web/modules/ui/components/alert/index.tsx +++ b/apps/web/modules/ui/components/alert/index.tsx @@ -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({ + 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: , + warning: , + info: , + success: , +}; const Alert = React.forwardRef< HTMLDivElement, - React.HTMLAttributes & - VariantProps & { dangerouslySetInnerHTML?: { __html: string } } ->(({ className, variant, ...props }, ref) => ( -
-)); + React.HTMLAttributes & VariantProps +>(({ className, variant, size, ...props }, ref) => { + const variantIcon = variant ? (variant !== "default" ? alertVariantIcons[variant] : null) : null; + + return ( + +
+ {variantIcon} + {props.children} +
+
+ ); +}); Alert.displayName = "Alert"; -const AlertTitle = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes & { dangerouslySetInnerHTML?: { __html: string } } ->(({ className, ...props }, ref) => ( -
-)); +const AlertTitle = React.forwardRef>( + ({ className, ...props }, ref) => { + const { size } = useAlertContext(); + return ( +
+ ); + } +); + AlertTitle.displayName = "AlertTitle"; -const AlertDescription = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes & { dangerouslySetInnerHTML?: { __html: string } } ->(({ className, ...props }, ref) => ( -
-)); +const AlertDescription = React.forwardRef>( + ({ className, ...props }, ref) => { + const { size } = useAlertContext(); + + return ( +