mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-03 03:14:34 -05:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 62b4c85a10 | |||
| 49fbf097f8 | |||
| 3a40568366 | |||
| f8c8b8c45d | |||
| eaeaa74ba8 | |||
| 5f90968e61 | |||
| 709cdf260d | |||
| 5c583028e0 | |||
| c70008d1be | |||
| 13fa716fe8 | |||
| d05a7c6d98 | |||
| c3af5b428f | |||
| 0c6c554cef | |||
| 40e2f28e94 | |||
| 2964f2e079 | |||
| e1a5291123 | |||
| ef41f35209 | |||
| 2f64b202c1 | |||
| 2500c739ae | |||
| bb3ff6829d | |||
| 425edf4cac | |||
| 63a9a6135b | |||
| 417005c6e9 | |||
| cd1739c901 | |||
| 709917eb8f | |||
| 3ba70122d5 | |||
| 8052ee0aaf | |||
| 5ff025543e | |||
| 896d5bad12 | |||
| e9dbaa3c28 | |||
| d352d03071 | |||
| ebefe775bb | |||
| 0852a961cc | |||
| 46f06f4c0e | |||
| afb39e4aba | |||
| 2c6a90f82b | |||
| 2f15312d5c | |||
| 5196c77277 | |||
| bd9efff3ff | |||
| 93907263a6 | |||
| 3ed35523be | |||
| e35f732e48 | |||
| 8da23c2e41 | |||
| ec8b17dee2 | |||
| cea7139b40 | |||
| d873e5b759 | |||
| cda1109ffc | |||
| b120de550f | |||
| 3f9c1c57f9 | |||
| 9abb07deba | |||
| f665e05723 | |||
| ed870ea0ce | |||
| b5212e0e0e | |||
| a16dcee01d | |||
| af9dfe63ca | |||
| e12d6a5d2d | |||
| f8bd0902d2 |
@@ -80,6 +80,9 @@ S3_ENDPOINT_URL=
|
|||||||
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
|
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
|
||||||
S3_FORCE_PATH_STYLE=0
|
S3_FORCE_PATH_STYLE=0
|
||||||
|
|
||||||
|
# Set this URL to add a custom domain to your survey links(default is WEBAPP_URL)
|
||||||
|
# SURVEY_URL=https://survey.example.com
|
||||||
|
|
||||||
#####################
|
#####################
|
||||||
# Disable Features #
|
# Disable Features #
|
||||||
#####################
|
#####################
|
||||||
|
|||||||
@@ -57,9 +57,6 @@ runs:
|
|||||||
run: |
|
run: |
|
||||||
RANDOM_KEY=$(openssl rand -hex 32)
|
RANDOM_KEY=$(openssl rand -hex 32)
|
||||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
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
|
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
name: Cron - Survey status update
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
|
||||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
|
||||||
schedule:
|
|
||||||
# Runs "At 00:00." (see https://crontab.guru)
|
|
||||||
- cron: "0 0 * * *"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
cron-weeklySummary:
|
|
||||||
env:
|
|
||||||
APP_URL: ${{ secrets.APP_URL }}
|
|
||||||
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
|
||||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: cURL request
|
|
||||||
if: ${{ env.APP_URL && env.CRON_SECRET }}
|
|
||||||
run: |
|
|
||||||
curl ${{ env.APP_URL }}/api/cron/survey-status \
|
|
||||||
-X POST \
|
|
||||||
-H 'content-type: application/json' \
|
|
||||||
-H 'x-api-key: ${{ env.CRON_SECRET }}' \
|
|
||||||
--fail
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
name: Cron - Weekly summary
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
|
||||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
|
||||||
schedule:
|
|
||||||
# Runs “At 08:00 on Monday.” (see https://crontab.guru)
|
|
||||||
- cron: "0 8 * * 1"
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
cron-weeklySummary:
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
env:
|
|
||||||
APP_URL: ${{ secrets.APP_URL }}
|
|
||||||
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
|
||||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
- name: cURL request
|
|
||||||
if: ${{ env.APP_URL && env.CRON_SECRET }}
|
|
||||||
run: |
|
|
||||||
curl ${{ env.APP_URL }}/api/cron/weekly-summary \
|
|
||||||
-X POST \
|
|
||||||
-H 'content-type: application/json' \
|
|
||||||
-H 'x-api-key: ${{ env.CRON_SECRET }}' \
|
|
||||||
--fail
|
|
||||||
@@ -15,7 +15,6 @@ env:
|
|||||||
IMAGE_NAME: ${{ github.repository }}-experimental
|
IMAGE_NAME: ${{ github.repository }}-experimental
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||||
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -80,6 +79,9 @@ jobs:
|
|||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
secrets: |
|
||||||
|
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
|
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ env:
|
|||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||||
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -100,6 +99,9 @@ jobs:
|
|||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
secrets: |
|
||||||
|
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
|
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,21 @@ name: 'Terraform'
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
# TODO: enable it back when migration is completed.
|
# TODO: enable it back when migration is completed.
|
||||||
# push:
|
push:
|
||||||
# branches:
|
branches:
|
||||||
# - main
|
- main
|
||||||
# pull_request:
|
paths:
|
||||||
# branches:
|
- "infra/terraform/**"
|
||||||
# - main
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "infra/terraform/**"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
contents: write
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
terraform:
|
terraform:
|
||||||
@@ -58,18 +63,17 @@ jobs:
|
|||||||
run: terraform plan -out .planfile
|
run: terraform plan -out .planfile
|
||||||
working-directory: infra/terraform
|
working-directory: infra/terraform
|
||||||
|
|
||||||
# - name: Post PR comment
|
- name: Post PR comment
|
||||||
# uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0
|
uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0
|
||||||
# if: always() && github.ref != 'refs/heads/main' && (steps.validate.outcome == 'success' || steps.validate.outcome == 'failure')
|
if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
|
||||||
# with:
|
with:
|
||||||
# token: ${{ github.token }}
|
token: ${{ github.token }}
|
||||||
# planfile: .planfile
|
planfile: .planfile
|
||||||
# working-directory: "infra/terraform"
|
working-directory: "infra/terraform"
|
||||||
# skip-comment: true
|
|
||||||
|
|
||||||
- name: Terraform Apply
|
- name: Terraform Apply
|
||||||
id: 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
|
run: terraform apply .planfile
|
||||||
working-directory: "infra/terraform"
|
working-directory: "infra/terraform"
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,6 @@
|
|||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
"storybook": "8.4.7",
|
"storybook": "8.4.7",
|
||||||
"tsup": "8.3.5",
|
"tsup": "8.3.5",
|
||||||
"vite": "6.0.9"
|
"vite": "6.0.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-10
@@ -24,17 +24,27 @@ RUN corepack enable
|
|||||||
# Install necessary build tools and compilers
|
# Install necessary build tools and compilers
|
||||||
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
|
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
|
||||||
|
|
||||||
# Set hardcoded environment variables
|
# BuildKit secret handling without hardcoded fallback values
|
||||||
ENV DATABASE_URL="postgresql://placeholder:for@build:5432/gets_overwritten_at_runtime?schema=public"
|
# This approach relies entirely on secrets passed from GitHub Actions
|
||||||
ENV NEXTAUTH_SECRET="placeholder_for_next_auth_of_64_chars_get_overwritten_at_runtime"
|
RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
|
||||||
ENV ENCRYPTION_KEY="placeholder_for_build_key_of_64_chars_get_overwritten_at_runtime"
|
echo 'if [ -f "/run/secrets/database_url" ]; then' >> /tmp/read-secrets.sh && \
|
||||||
ENV CRON_SECRET="placeholder_for_cron_secret_of_64_chars_get_overwritten_at_runtime"
|
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
|
ARG SENTRY_AUTH_TOKEN
|
||||||
|
|
||||||
# Increase Node.js memory limit
|
# Increase Node.js memory limit as a regular build argument
|
||||||
# ENV NODE_OPTIONS="--max_old_space_size=4096"
|
ARG NODE_OPTIONS="--max_old_space_size=4096"
|
||||||
|
ENV NODE_OPTIONS=${NODE_OPTIONS}
|
||||||
|
|
||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -53,8 +63,11 @@ RUN touch apps/web/.env
|
|||||||
# Install the dependencies
|
# Install the dependencies
|
||||||
RUN pnpm install
|
RUN pnpm install
|
||||||
|
|
||||||
# Build the project
|
# Build the project using our secret reader script
|
||||||
RUN NODE_OPTIONS="--max_old_space_size=4096" pnpm build --filter=@formbricks/web...
|
# 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
|
# Extract Prisma version
|
||||||
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
|
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
|
||||||
|
|||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
|
||||||
|
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
||||||
|
|
||||||
|
// Mock react-hot-toast so we can assert that a success message is shown
|
||||||
|
vi.mock("react-hot-toast", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Set up a spy for navigator.clipboard.writeText so it becomes a ViTest spy.
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: {
|
||||||
|
// Using a mockResolvedValue resolves the promise as writeText is async.
|
||||||
|
writeText: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OnboardingSetupInstructions", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provide some default props for testing
|
||||||
|
const defaultProps = {
|
||||||
|
environmentId: "env-123",
|
||||||
|
webAppUrl: "https://example.com",
|
||||||
|
channel: "app" as const, // Assuming channel is either "app" or "website"
|
||||||
|
widgetSetupCompleted: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
test("renders HTML tab content by default", () => {
|
||||||
|
render(<OnboardingSetupInstructions {...defaultProps} />);
|
||||||
|
|
||||||
|
// Since the default active tab is "html", we check for a unique text
|
||||||
|
expect(
|
||||||
|
screen.getByText(/environments.connect.insert_this_code_into_the_head_tag_of_your_website/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// The HTML snippet contains a marker comment
|
||||||
|
expect(screen.getByText("START")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Verify the "Copy Code" button is present
|
||||||
|
expect(screen.getByRole("button", { name: /common.copy_code/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders NPM tab content when selected", async () => {
|
||||||
|
render(<OnboardingSetupInstructions {...defaultProps} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// Click on the "NPM" tab to switch views.
|
||||||
|
const npmTab = screen.getByText("NPM");
|
||||||
|
await user.click(npmTab);
|
||||||
|
|
||||||
|
// Check that the install commands are present
|
||||||
|
expect(screen.getByText(/npm install @formbricks\/js/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/yarn add @formbricks\/js/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Verify the "Read Docs" link has the correct URL (based on channel prop)
|
||||||
|
const readDocsLink = screen.getByRole("link", { name: /common.read_docs/i });
|
||||||
|
expect(readDocsLink).toHaveAttribute("href", "https://formbricks.com/docs/app-surveys/framework-guides");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("copies HTML snippet to clipboard and shows success toast when Copy Code button is clicked", async () => {
|
||||||
|
render(<OnboardingSetupInstructions {...defaultProps} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const writeTextSpy = vi.spyOn(navigator.clipboard, "writeText");
|
||||||
|
|
||||||
|
// Click the "Copy Code" button
|
||||||
|
const copyButton = screen.getByRole("button", { name: /common.copy_code/i });
|
||||||
|
await user.click(copyButton);
|
||||||
|
|
||||||
|
// Ensure navigator.clipboard.writeText was called.
|
||||||
|
expect(writeTextSpy).toHaveBeenCalled();
|
||||||
|
const writtenText = (navigator.clipboard.writeText as any).mock.calls[0][0] as string;
|
||||||
|
|
||||||
|
// Check that the pasted snippet contains the expected environment values
|
||||||
|
expect(writtenText).toContain('var appUrl = "https://example.com"');
|
||||||
|
expect(writtenText).toContain('var environmentId = "env-123"');
|
||||||
|
|
||||||
|
// Verify that a success toast was shown
|
||||||
|
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders step-by-step manual link with correct URL in HTML tab", () => {
|
||||||
|
render(<OnboardingSetupInstructions {...defaultProps} />);
|
||||||
|
const manualLink = screen.getByRole("link", { name: /common.step_by_step_manual/i });
|
||||||
|
expect(manualLink).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"https://formbricks.com/docs/app-surveys/framework-guides#html"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import formbricks from "@formbricks/js";
|
||||||
|
import { FormbricksClient } from "./FormbricksClient";
|
||||||
|
|
||||||
|
// Mock next/navigation hooks.
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
usePathname: () => "/test-path",
|
||||||
|
useSearchParams: () => new URLSearchParams("foo=bar"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the environment variables.
|
||||||
|
vi.mock("@formbricks/lib/env", () => ({
|
||||||
|
env: {
|
||||||
|
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: "env-test",
|
||||||
|
NEXT_PUBLIC_FORMBRICKS_API_HOST: "https://api.test.com",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the flag that enables Formbricks.
|
||||||
|
vi.mock("@/app/lib/formbricks", () => ({
|
||||||
|
formbricksEnabled: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the Formbricks SDK module.
|
||||||
|
vi.mock("@formbricks/js", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
setup: vi.fn(),
|
||||||
|
setUserId: vi.fn(),
|
||||||
|
setEmail: vi.fn(),
|
||||||
|
registerRouteChange: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("FormbricksClient", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => {
|
||||||
|
const mockSetup = vi.spyOn(formbricks, "setup");
|
||||||
|
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
|
||||||
|
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
|
||||||
|
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
|
||||||
|
|
||||||
|
render(<FormbricksClient userId="user-123" email="test@example.com" />);
|
||||||
|
|
||||||
|
// Expect the first effect to call setup and assign the provided user details.
|
||||||
|
expect(mockSetup).toHaveBeenCalledWith({
|
||||||
|
environmentId: "env-test",
|
||||||
|
appUrl: "https://api.test.com",
|
||||||
|
});
|
||||||
|
expect(mockSetUserId).toHaveBeenCalledWith("user-123");
|
||||||
|
expect(mockSetEmail).toHaveBeenCalledWith("test@example.com");
|
||||||
|
|
||||||
|
// And the second effect should always register the route change when Formbricks is enabled.
|
||||||
|
expect(mockRegisterRouteChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not call setup, setUserId, or setEmail if userId is not provided yet still calls registerRouteChange", () => {
|
||||||
|
const mockSetup = vi.spyOn(formbricks, "setup");
|
||||||
|
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
|
||||||
|
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
|
||||||
|
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
|
||||||
|
|
||||||
|
render(<FormbricksClient userId="" email="test@example.com" />);
|
||||||
|
|
||||||
|
// Since userId is falsy, the first effect should not call setup or assign user details.
|
||||||
|
expect(mockSetup).not.toHaveBeenCalled();
|
||||||
|
expect(mockSetUserId).not.toHaveBeenCalled();
|
||||||
|
expect(mockSetEmail).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// The second effect only checks formbricksEnabled, so registerRouteChange should be called.
|
||||||
|
expect(mockRegisterRouteChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
|
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
|
||||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
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 { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
|
|
||||||
@@ -24,7 +23,6 @@ export const TopControlBar = ({
|
|||||||
<TopControlButtons
|
<TopControlButtons
|
||||||
environment={environment}
|
environment={environment}
|
||||||
environments={environments}
|
environments={environments}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
|
||||||
membershipRole={membershipRole}
|
membershipRole={membershipRole}
|
||||||
projectPermission={projectPermission}
|
projectPermission={projectPermission}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
|||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||||
import { useTranslate } from "@tolgee/react";
|
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 { useRouter } from "next/navigation";
|
||||||
import formbricks from "@formbricks/js";
|
|
||||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
@@ -16,7 +16,6 @@ import { TOrganizationRole } from "@formbricks/types/memberships";
|
|||||||
interface TopControlButtonsProps {
|
interface TopControlButtonsProps {
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
environments: TEnvironment[];
|
environments: TEnvironment[];
|
||||||
isFormbricksCloud: boolean;
|
|
||||||
membershipRole?: TOrganizationRole;
|
membershipRole?: TOrganizationRole;
|
||||||
projectPermission: TTeamPermission | null;
|
projectPermission: TTeamPermission | null;
|
||||||
}
|
}
|
||||||
@@ -24,7 +23,6 @@ interface TopControlButtonsProps {
|
|||||||
export const TopControlButtons = ({
|
export const TopControlButtons = ({
|
||||||
environment,
|
environment,
|
||||||
environments,
|
environments,
|
||||||
isFormbricksCloud,
|
|
||||||
membershipRole,
|
membershipRole,
|
||||||
projectPermission,
|
projectPermission,
|
||||||
}: TopControlButtonsProps) => {
|
}: TopControlButtonsProps) => {
|
||||||
@@ -38,19 +36,15 @@ export const TopControlButtons = ({
|
|||||||
return (
|
return (
|
||||||
<div className="z-50 flex items-center space-x-2">
|
<div className="z-50 flex items-center space-x-2">
|
||||||
{!isBilling && <EnvironmentSwitch environment={environment} environments={environments} />}
|
{!isBilling && <EnvironmentSwitch environment={environment} environments={environments} />}
|
||||||
{isFormbricksCloud && (
|
|
||||||
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
|
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
|
||||||
variant="ghost"
|
<Link href="https://github.com/formbricks/formbricks/issues" target="_blank">
|
||||||
size="icon"
|
<BugIcon />
|
||||||
className="h-fit w-fit bg-slate-50 p-1"
|
</Link>
|
||||||
onClick={() => {
|
</Button>
|
||||||
formbricks.track("Top Menu: Product Feedback");
|
</TooltipRenderer>
|
||||||
}}>
|
|
||||||
<MessageCircleQuestionIcon />
|
|
||||||
</Button>
|
|
||||||
</TooltipRenderer>
|
|
||||||
)}
|
|
||||||
<TooltipRenderer tooltipContent={t("common.account")}>
|
<TooltipRenderer tooltipContent={t("common.account")}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
+1
-1
@@ -88,7 +88,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SettingsId title={t("common.organization")} id={organization.id}></SettingsId>
|
<SettingsId title={t("common.organization_id")} id={organization.id}></SettingsId>
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-1
@@ -13,6 +13,7 @@ import {
|
|||||||
RESPONSES_PER_PAGE,
|
RESPONSES_PER_PAGE,
|
||||||
WEBAPP_URL,
|
WEBAPP_URL,
|
||||||
} from "@formbricks/lib/constants";
|
} from "@formbricks/lib/constants";
|
||||||
|
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
|
||||||
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
|
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
|
||||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||||
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
|
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
|
||||||
@@ -47,6 +48,7 @@ const Page = async (props) => {
|
|||||||
});
|
});
|
||||||
const shouldGenerateInsights = needsInsightsGeneration(survey);
|
const shouldGenerateInsights = needsInsightsGeneration(survey);
|
||||||
const locale = await findMatchingLocale();
|
const locale = await findMatchingLocale();
|
||||||
|
const surveyDomain = getSurveyDomain();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
@@ -57,8 +59,8 @@ const Page = async (props) => {
|
|||||||
environment={environment}
|
environment={environment}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
webAppUrl={WEBAPP_URL}
|
|
||||||
user={user}
|
user={user}
|
||||||
|
surveyDomain={surveyDomain}
|
||||||
/>
|
/>
|
||||||
}>
|
}>
|
||||||
{isAIEnabled && shouldGenerateInsights && (
|
{isAIEnabled && shouldGenerateInsights && (
|
||||||
|
|||||||
+4
-4
@@ -23,19 +23,19 @@ import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
|
|||||||
|
|
||||||
interface ShareEmbedSurveyProps {
|
interface ShareEmbedSurveyProps {
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
|
surveyDomain: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
modalView: "start" | "embed" | "panel";
|
modalView: "start" | "embed" | "panel";
|
||||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
webAppUrl: string;
|
|
||||||
user: TUser;
|
user: TUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShareEmbedSurvey = ({
|
export const ShareEmbedSurvey = ({
|
||||||
survey,
|
survey,
|
||||||
|
surveyDomain,
|
||||||
open,
|
open,
|
||||||
modalView,
|
modalView,
|
||||||
setOpen,
|
setOpen,
|
||||||
webAppUrl,
|
|
||||||
user,
|
user,
|
||||||
}: ShareEmbedSurveyProps) => {
|
}: ShareEmbedSurveyProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -104,8 +104,8 @@ export const ShareEmbedSurvey = ({
|
|||||||
<DialogDescription className="hidden" />
|
<DialogDescription className="hidden" />
|
||||||
<ShareSurveyLink
|
<ShareSurveyLink
|
||||||
survey={survey}
|
survey={survey}
|
||||||
webAppUrl={webAppUrl}
|
|
||||||
surveyUrl={surveyUrl}
|
surveyUrl={surveyUrl}
|
||||||
|
surveyDomain={surveyDomain}
|
||||||
setSurveyUrl={setSurveyUrl}
|
setSurveyUrl={setSurveyUrl}
|
||||||
locale={user.locale}
|
locale={user.locale}
|
||||||
/>
|
/>
|
||||||
@@ -159,8 +159,8 @@ export const ShareEmbedSurvey = ({
|
|||||||
survey={survey}
|
survey={survey}
|
||||||
email={email}
|
email={email}
|
||||||
surveyUrl={surveyUrl}
|
surveyUrl={surveyUrl}
|
||||||
|
surveyDomain={surveyDomain}
|
||||||
setSurveyUrl={setSurveyUrl}
|
setSurveyUrl={setSurveyUrl}
|
||||||
webAppUrl={webAppUrl}
|
|
||||||
locale={user.locale}
|
locale={user.locale}
|
||||||
/>
|
/>
|
||||||
) : showView === "panel" ? (
|
) : showView === "panel" ? (
|
||||||
|
|||||||
+4
-4
@@ -20,8 +20,8 @@ interface SurveyAnalysisCTAProps {
|
|||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
webAppUrl: string;
|
|
||||||
user: TUser;
|
user: TUser;
|
||||||
|
surveyDomain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModalState {
|
interface ModalState {
|
||||||
@@ -35,8 +35,8 @@ export const SurveyAnalysisCTA = ({
|
|||||||
survey,
|
survey,
|
||||||
environment,
|
environment,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
webAppUrl,
|
|
||||||
user,
|
user,
|
||||||
|
surveyDomain,
|
||||||
}: SurveyAnalysisCTAProps) => {
|
}: SurveyAnalysisCTAProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -50,7 +50,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
dropdown: false,
|
dropdown: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const surveyUrl = useMemo(() => `${webAppUrl}/s/${survey.id}`, [survey.id, webAppUrl]);
|
const surveyUrl = useMemo(() => `${surveyDomain}/s/${survey.id}`, [survey.id, surveyDomain]);
|
||||||
const { refreshSingleUseId } = useSingleUseId(survey);
|
const { refreshSingleUseId } = useSingleUseId(survey);
|
||||||
|
|
||||||
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||||
@@ -172,9 +172,9 @@ export const SurveyAnalysisCTA = ({
|
|||||||
<ShareEmbedSurvey
|
<ShareEmbedSurvey
|
||||||
key={key}
|
key={key}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
|
surveyDomain={surveyDomain}
|
||||||
open={modalState[key as keyof ModalState]}
|
open={modalState[key as keyof ModalState]}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
webAppUrl={webAppUrl}
|
|
||||||
user={user}
|
user={user}
|
||||||
modalView={modalView}
|
modalView={modalView}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+3
-3
@@ -20,8 +20,8 @@ interface EmbedViewProps {
|
|||||||
survey: any;
|
survey: any;
|
||||||
email: string;
|
email: string;
|
||||||
surveyUrl: string;
|
surveyUrl: string;
|
||||||
|
surveyDomain: string;
|
||||||
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
|
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
|
||||||
webAppUrl: string;
|
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,8 +35,8 @@ export const EmbedView = ({
|
|||||||
survey,
|
survey,
|
||||||
email,
|
email,
|
||||||
surveyUrl,
|
surveyUrl,
|
||||||
|
surveyDomain,
|
||||||
setSurveyUrl,
|
setSurveyUrl,
|
||||||
webAppUrl,
|
|
||||||
locale,
|
locale,
|
||||||
}: EmbedViewProps) => {
|
}: EmbedViewProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
@@ -82,8 +82,8 @@ export const EmbedView = ({
|
|||||||
) : activeId === "link" ? (
|
) : activeId === "link" ? (
|
||||||
<LinkTab
|
<LinkTab
|
||||||
survey={survey}
|
survey={survey}
|
||||||
webAppUrl={webAppUrl}
|
|
||||||
surveyUrl={surveyUrl}
|
surveyUrl={surveyUrl}
|
||||||
|
surveyDomain={surveyDomain}
|
||||||
setSurveyUrl={setSurveyUrl}
|
setSurveyUrl={setSurveyUrl}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+3
-3
@@ -8,13 +8,13 @@ import { TUserLocale } from "@formbricks/types/user";
|
|||||||
|
|
||||||
interface LinkTabProps {
|
interface LinkTabProps {
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
webAppUrl: string;
|
|
||||||
surveyUrl: string;
|
surveyUrl: string;
|
||||||
|
surveyDomain: string;
|
||||||
setSurveyUrl: (url: string) => void;
|
setSurveyUrl: (url: string) => void;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LinkTab = ({ survey, webAppUrl, surveyUrl, setSurveyUrl, locale }: LinkTabProps) => {
|
export const LinkTab = ({ survey, surveyUrl, surveyDomain, setSurveyUrl, locale }: LinkTabProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
const docsLinks = [
|
const docsLinks = [
|
||||||
@@ -43,8 +43,8 @@ export const LinkTab = ({ survey, webAppUrl, surveyUrl, setSurveyUrl, locale }:
|
|||||||
</p>
|
</p>
|
||||||
<ShareSurveyLink
|
<ShareSurveyLink
|
||||||
survey={survey}
|
survey={survey}
|
||||||
webAppUrl={webAppUrl}
|
|
||||||
surveyUrl={surveyUrl}
|
surveyUrl={surveyUrl}
|
||||||
|
surveyDomain={surveyDomain}
|
||||||
setSurveyUrl={setSurveyUrl}
|
setSurveyUrl={setSurveyUrl}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+6
-4
@@ -78,7 +78,7 @@ const dummySurvey = {
|
|||||||
} as unknown as TSurvey;
|
} as unknown as TSurvey;
|
||||||
const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment;
|
const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment;
|
||||||
const dummyUser = { id: "user123", name: "Test User" } as TUser;
|
const dummyUser = { id: "user123", name: "Test User" } as TUser;
|
||||||
const webAppUrl = "http://example.com";
|
const surveyDomain = "https://surveys.test.formbricks.com";
|
||||||
|
|
||||||
describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -91,7 +91,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
|||||||
survey={dummySurvey}
|
survey={dummySurvey}
|
||||||
environment={dummyEnvironment}
|
environment={dummyEnvironment}
|
||||||
isReadOnly={false}
|
isReadOnly={false}
|
||||||
webAppUrl={webAppUrl}
|
surveyDomain={surveyDomain}
|
||||||
user={dummyUser}
|
user={dummyUser}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -101,7 +101,9 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
|
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
|
||||||
expect(writeTextMock).toHaveBeenCalledWith("http://example.com/s/survey123?id=newSingleUseId");
|
expect(writeTextMock).toHaveBeenCalledWith(
|
||||||
|
"https://surveys.test.formbricks.com/s/survey123?id=newSingleUseId"
|
||||||
|
);
|
||||||
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -113,7 +115,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
|||||||
survey={dummySurvey}
|
survey={dummySurvey}
|
||||||
environment={dummyEnvironment}
|
environment={dummyEnvironment}
|
||||||
isReadOnly={false}
|
isReadOnly={false}
|
||||||
webAppUrl={webAppUrl}
|
surveyDomain={surveyDomain}
|
||||||
user={dummyUser}
|
user={dummyUser}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
|
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
|
||||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
||||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||||
import { getStyling } from "@formbricks/lib/utils/styling";
|
import { getStyling } from "@formbricks/lib/utils/styling";
|
||||||
@@ -17,7 +17,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styling = getStyling(project, survey);
|
const styling = getStyling(project, survey);
|
||||||
const surveyUrl = WEBAPP_URL + "/s/" + survey.id;
|
const surveyUrl = getSurveyDomain() + "/s/" + survey.id;
|
||||||
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
|
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
|
||||||
const doctype =
|
const doctype =
|
||||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
|
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
|
||||||
|
|||||||
+6
-1
@@ -7,6 +7,7 @@ import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
|
|||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
import { SettingsId } from "@/modules/ui/components/settings-id";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION,
|
MAX_RESPONSES_FOR_INSIGHT_GENERATION,
|
||||||
WEBAPP_URL,
|
WEBAPP_URL,
|
||||||
} from "@formbricks/lib/constants";
|
} from "@formbricks/lib/constants";
|
||||||
|
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
|
||||||
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
|
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
|
||||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||||
import { getUser } from "@formbricks/lib/user/service";
|
import { getUser } from "@formbricks/lib/user/service";
|
||||||
@@ -53,6 +55,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
|||||||
billing: organization.billing,
|
billing: organization.billing,
|
||||||
});
|
});
|
||||||
const shouldGenerateInsights = needsInsightsGeneration(survey);
|
const shouldGenerateInsights = needsInsightsGeneration(survey);
|
||||||
|
const surveyDomain = getSurveyDomain();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
@@ -63,8 +66,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
|||||||
environment={environment}
|
environment={environment}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
webAppUrl={WEBAPP_URL}
|
|
||||||
user={user}
|
user={user}
|
||||||
|
surveyDomain={surveyDomain}
|
||||||
/>
|
/>
|
||||||
}>
|
}>
|
||||||
{isAIEnabled && shouldGenerateInsights && (
|
{isAIEnabled && shouldGenerateInsights && (
|
||||||
@@ -93,6 +96,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
|||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
locale={user.locale ?? DEFAULT_LOCALE}
|
locale={user.locale ?? DEFAULT_LOCALE}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SettingsId title={t("common.survey_id")} id={surveyId}></SettingsId>
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { responses } from "@/app/lib/api/response";
|
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
|
||||||
import { AsyncParser } from "@json2csv/node";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { NextRequest } from "next/server";
|
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
|
|
||||||
export const POST = async (request: NextRequest) => {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return responses.unauthorizedResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await request.json();
|
|
||||||
let csv: string = "";
|
|
||||||
|
|
||||||
const { json, fields, fileName } = data;
|
|
||||||
|
|
||||||
const fallbackFileName = fileName.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
||||||
const encodedFileName = encodeURIComponent(fileName)
|
|
||||||
.replace(/['()]/g, (match) => "%" + match.charCodeAt(0).toString(16))
|
|
||||||
.replace(/\*/g, "%2A");
|
|
||||||
|
|
||||||
const parser = new AsyncParser({
|
|
||||||
fields,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
csv = await parser.parse(json).promise();
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({ error: err, url: request.url }, "Failed to convert to CSV");
|
|
||||||
throw new Error("Failed to convert to CSV");
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = new Headers();
|
|
||||||
headers.set("Content-Type", "text/csv;charset=utf-8;");
|
|
||||||
headers.set(
|
|
||||||
"Content-Disposition",
|
|
||||||
`attachment; filename="${fallbackFileName}"; filename*=UTF-8''${encodedFileName}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
fileResponse: csv,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { responses } from "@/app/lib/api/response";
|
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { NextRequest } from "next/server";
|
|
||||||
import * as xlsx from "xlsx";
|
|
||||||
|
|
||||||
export const POST = async (request: NextRequest) => {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return responses.unauthorizedResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await request.json();
|
|
||||||
|
|
||||||
const { json, fields, fileName } = data;
|
|
||||||
|
|
||||||
const fallbackFileName = fileName.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
||||||
const encodedFileName = encodeURIComponent(fileName)
|
|
||||||
.replace(/['()]/g, (match) => "%" + match.charCodeAt(0).toString(16))
|
|
||||||
.replace(/\*/g, "%2A");
|
|
||||||
|
|
||||||
const wb = xlsx.utils.book_new();
|
|
||||||
const ws = xlsx.utils.json_to_sheet(json, { header: fields });
|
|
||||||
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
|
|
||||||
|
|
||||||
const buffer = xlsx.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer;
|
|
||||||
const base64String = buffer.toString("base64");
|
|
||||||
|
|
||||||
const headers = new Headers();
|
|
||||||
|
|
||||||
headers.set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
|
||||||
headers.set(
|
|
||||||
"Content-Disposition",
|
|
||||||
`attachment; filename="${fallbackFileName}"; filename*=UTF-8''${encodedFileName}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
fileResponse: base64String,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { CRON_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||||
|
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
|
||||||
|
import { mockSurveyOutput } from "@formbricks/lib/survey/tests/__mock__/survey.mock";
|
||||||
|
import { doesSurveyHasOpenTextQuestion } from "@formbricks/lib/survey/utils";
|
||||||
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
|
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||||
|
import {
|
||||||
|
doesResponseHasAnyOpenTextAnswer,
|
||||||
|
generateInsightsEnabledForSurveyQuestions,
|
||||||
|
generateInsightsForSurvey,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
// Mock all dependencies
|
||||||
|
vi.mock("@formbricks/lib/constants", () => ({
|
||||||
|
CRON_SECRET: vi.fn(() => "mocked-cron-secret"),
|
||||||
|
WEBAPP_URL: "https://mocked-webapp-url.com",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@formbricks/lib/survey/cache", () => ({
|
||||||
|
surveyCache: {
|
||||||
|
revalidate: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@formbricks/lib/survey/service", () => ({
|
||||||
|
getSurvey: vi.fn(),
|
||||||
|
updateSurvey: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@formbricks/lib/survey/utils", () => ({
|
||||||
|
doesSurveyHasOpenTextQuestion: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@formbricks/lib/utils/validate", () => ({
|
||||||
|
validateInputs: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock global fetch
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
describe("Insights Utils", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateInsightsForSurvey", () => {
|
||||||
|
test("should call fetch with correct parameters", () => {
|
||||||
|
const surveyId = "survey-123";
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
|
generateInsightsForSurvey(surveyId);
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(`${WEBAPP_URL}/api/insights`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": CRON_SECRET,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
surveyId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle errors and return error object", () => {
|
||||||
|
const surveyId = "survey-123";
|
||||||
|
mockFetch.mockImplementationOnce(() => {
|
||||||
|
throw new Error("Network error");
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = generateInsightsForSurvey(surveyId);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: new Error("Error while generating insights for survey: Network error"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should throw error if CRON_SECRET is not set", async () => {
|
||||||
|
// Reset modules to ensure clean state
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
// Mock CRON_SECRET as undefined
|
||||||
|
vi.doMock("@formbricks/lib/constants", () => ({
|
||||||
|
CRON_SECRET: undefined,
|
||||||
|
WEBAPP_URL: "https://mocked-webapp-url.com",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Re-import the utils module to get the mocked CRON_SECRET
|
||||||
|
const { generateInsightsForSurvey } = await import("./utils");
|
||||||
|
|
||||||
|
expect(() => generateInsightsForSurvey("survey-123")).toThrow("CRON_SECRET is not set");
|
||||||
|
|
||||||
|
// Reset modules after test
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateInsightsEnabledForSurveyQuestions", () => {
|
||||||
|
test("should return success=false when survey has no open text questions", async () => {
|
||||||
|
// Mock data
|
||||||
|
const surveyId = "survey-123";
|
||||||
|
const mockSurvey: TSurvey = {
|
||||||
|
...mockSurveyOutput,
|
||||||
|
type: "link",
|
||||||
|
segment: null,
|
||||||
|
displayPercentage: null,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "cm8cjnse3000009jxf20v91ic",
|
||||||
|
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||||
|
headline: { default: "Question 1" },
|
||||||
|
required: true,
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
id: "cm8cjnse3000009jxf20v91ic",
|
||||||
|
label: { default: "Choice 1" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cm8cjo19c000109jx6znygc0u",
|
||||||
|
type: TSurveyQuestionTypeEnum.Rating,
|
||||||
|
headline: { default: "Question 2" },
|
||||||
|
required: true,
|
||||||
|
scale: "number",
|
||||||
|
range: 5,
|
||||||
|
isColorCodingEnabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup mocks
|
||||||
|
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
|
||||||
|
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(false);
|
||||||
|
|
||||||
|
// Execute function
|
||||||
|
const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
|
||||||
|
|
||||||
|
// Verify results
|
||||||
|
expect(result).toEqual({ success: false });
|
||||||
|
expect(updateSurvey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return success=true when survey is updated with insights enabled", async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Mock data
|
||||||
|
const surveyId = "cm8ckvchx000008lb710n0gdn";
|
||||||
|
|
||||||
|
// Mock survey with open text questions that have no insightsEnabled property
|
||||||
|
const mockSurveyWithOpenTextQuestions: TSurvey = {
|
||||||
|
...mockSurveyOutput,
|
||||||
|
id: surveyId,
|
||||||
|
type: "link",
|
||||||
|
segment: null,
|
||||||
|
displayPercentage: null,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "cm8cjnse3000009jxf20v91ic",
|
||||||
|
type: TSurveyQuestionTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question 1" },
|
||||||
|
required: true,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cm8cjo19c000109jx6znygc0u",
|
||||||
|
type: TSurveyQuestionTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question 2" },
|
||||||
|
required: true,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define the updated survey that should be returned after updateSurvey
|
||||||
|
const mockUpdatedSurveyWithOpenTextQuestions: TSurvey = {
|
||||||
|
...mockSurveyWithOpenTextQuestions,
|
||||||
|
questions: mockSurveyWithOpenTextQuestions.questions.map((q) => ({
|
||||||
|
...q,
|
||||||
|
insightsEnabled: true, // Updated property
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup mocks
|
||||||
|
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurveyWithOpenTextQuestions);
|
||||||
|
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
|
||||||
|
vi.mocked(updateSurvey).mockResolvedValueOnce(mockUpdatedSurveyWithOpenTextQuestions);
|
||||||
|
|
||||||
|
// Execute function
|
||||||
|
const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
survey: mockUpdatedSurveyWithOpenTextQuestions,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return success=false when all open text questions already have insightsEnabled defined", async () => {
|
||||||
|
// Mock data
|
||||||
|
const surveyId = "survey-123";
|
||||||
|
const mockSurvey: TSurvey = {
|
||||||
|
...mockSurveyOutput,
|
||||||
|
type: "link",
|
||||||
|
segment: null,
|
||||||
|
displayPercentage: null,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "cm8cjnse3000009jxf20v91ic",
|
||||||
|
type: TSurveyQuestionTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question 1" },
|
||||||
|
required: true,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: {},
|
||||||
|
insightsEnabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cm8cjo19c000109jx6znygc0u",
|
||||||
|
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||||
|
headline: { default: "Question 2" },
|
||||||
|
required: true,
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
id: "cm8cjnse3000009jxf20v91ic",
|
||||||
|
label: { default: "Choice 1" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup mocks
|
||||||
|
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
|
||||||
|
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
|
||||||
|
|
||||||
|
// Execute function
|
||||||
|
const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
|
||||||
|
|
||||||
|
// Verify results
|
||||||
|
expect(result).toEqual({ success: false });
|
||||||
|
expect(updateSurvey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should throw ResourceNotFoundError if survey is not found", async () => {
|
||||||
|
// Setup mocks
|
||||||
|
vi.mocked(getSurvey).mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
// Execute and verify function
|
||||||
|
await expect(generateInsightsEnabledForSurveyQuestions("survey-123")).rejects.toThrow(
|
||||||
|
new ResourceNotFoundError("Survey", "survey-123")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should throw ResourceNotFoundError if updateSurvey returns null", async () => {
|
||||||
|
// Mock data
|
||||||
|
const surveyId = "survey-123";
|
||||||
|
const mockSurvey: TSurvey = {
|
||||||
|
...mockSurveyOutput,
|
||||||
|
type: "link",
|
||||||
|
segment: null,
|
||||||
|
displayPercentage: null,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "cm8cjnse3000009jxf20v91ic",
|
||||||
|
type: TSurveyQuestionTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question 1" },
|
||||||
|
required: true,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup mocks
|
||||||
|
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
|
||||||
|
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
|
||||||
|
// Type assertion to handle the null case
|
||||||
|
vi.mocked(updateSurvey).mockResolvedValueOnce(null as unknown as TSurvey);
|
||||||
|
|
||||||
|
// Execute and verify function
|
||||||
|
await expect(generateInsightsEnabledForSurveyQuestions(surveyId)).rejects.toThrow(
|
||||||
|
new ResourceNotFoundError("Survey", surveyId)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return success=false when no questions have insights enabled after update", async () => {
|
||||||
|
// Mock data
|
||||||
|
const surveyId = "survey-123";
|
||||||
|
const mockSurvey: TSurvey = {
|
||||||
|
...mockSurveyOutput,
|
||||||
|
type: "link",
|
||||||
|
segment: null,
|
||||||
|
displayPercentage: null,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "cm8cjnse3000009jxf20v91ic",
|
||||||
|
type: TSurveyQuestionTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question 1" },
|
||||||
|
required: true,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: {},
|
||||||
|
insightsEnabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup mocks
|
||||||
|
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
|
||||||
|
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
|
||||||
|
vi.mocked(updateSurvey).mockResolvedValueOnce(mockSurvey);
|
||||||
|
|
||||||
|
// Execute function
|
||||||
|
const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
|
||||||
|
|
||||||
|
// Verify results
|
||||||
|
expect(result).toEqual({ success: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should propagate any errors that occur", async () => {
|
||||||
|
// Setup mocks
|
||||||
|
const testError = new Error("Test error");
|
||||||
|
vi.mocked(getSurvey).mockRejectedValueOnce(testError);
|
||||||
|
|
||||||
|
// Execute and verify function
|
||||||
|
await expect(generateInsightsEnabledForSurveyQuestions("survey-123")).rejects.toThrow(testError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("doesResponseHasAnyOpenTextAnswer", () => {
|
||||||
|
test("should return true when at least one open text question has an answer", () => {
|
||||||
|
const openTextQuestionIds = ["q1", "q2", "q3"];
|
||||||
|
const response = {
|
||||||
|
q1: "",
|
||||||
|
q2: "This is an answer",
|
||||||
|
q3: "",
|
||||||
|
q4: "This is not an open text answer",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return false when no open text questions have answers", () => {
|
||||||
|
const openTextQuestionIds = ["q1", "q2", "q3"];
|
||||||
|
const response = {
|
||||||
|
q1: "",
|
||||||
|
q2: "",
|
||||||
|
q3: "",
|
||||||
|
q4: "This is not an open text answer",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return false when response does not contain any open text question IDs", () => {
|
||||||
|
const openTextQuestionIds = ["q1", "q2", "q3"];
|
||||||
|
const response = {
|
||||||
|
q4: "This is not an open text answer",
|
||||||
|
q5: "Another answer",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return false for non-string answers", () => {
|
||||||
|
const openTextQuestionIds = ["q1", "q2", "q3"];
|
||||||
|
const response = {
|
||||||
|
q1: "",
|
||||||
|
q2: 123,
|
||||||
|
q3: true,
|
||||||
|
} as any; // Use type assertion to handle mixed types in the test
|
||||||
|
|
||||||
|
const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,10 @@ import { TResponse } from "@formbricks/types/responses";
|
|||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
|
||||||
export const generateInsightsForSurvey = (surveyId: string) => {
|
export const generateInsightsForSurvey = (surveyId: string) => {
|
||||||
|
if (!CRON_SECRET) {
|
||||||
|
throw new Error("CRON_SECRET is not set");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return fetch(`${WEBAPP_URL}/api/insights`, {
|
return fetch(`${WEBAPP_URL}/api/insights`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
OPTIONS,
|
OPTIONS,
|
||||||
PUT,
|
PUT,
|
||||||
} from "@/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/route";
|
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/route";
|
||||||
|
|
||||||
export { OPTIONS, PUT };
|
export { OPTIONS, PUT };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
GET,
|
GET,
|
||||||
OPTIONS,
|
OPTIONS,
|
||||||
} from "@/modules/ee/contacts/api/client/[environmentId]/identify/contacts/[userId]/route";
|
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route";
|
||||||
|
|
||||||
export { GET, OPTIONS };
|
export { GET, OPTIONS };
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export const OPTIONS = async (): Promise<Response> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const POST = async (req: NextRequest, context: Context): Promise<Response> => {
|
export const POST = async (req: NextRequest, context: Context): Promise<Response> => {
|
||||||
|
if (!ENCRYPTION_KEY) {
|
||||||
|
return responses.internalServerErrorResponse("Encryption key is not set");
|
||||||
|
}
|
||||||
const params = await context.params;
|
const params = await context.params;
|
||||||
const environmentId = params.environmentId;
|
const environmentId = params.environmentId;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import { OPTIONS, POST } from "@/modules/ee/contacts/api/client/[environmentId]/user/route";
|
import { OPTIONS, POST } from "@/modules/ee/contacts/api/v1/client/[environmentId]/user/route";
|
||||||
|
|
||||||
export { POST, OPTIONS };
|
export { POST, OPTIONS };
|
||||||
|
|||||||
+1
-1
@@ -2,6 +2,6 @@ import {
|
|||||||
DELETE,
|
DELETE,
|
||||||
GET,
|
GET,
|
||||||
PUT,
|
PUT,
|
||||||
} from "@/modules/ee/contacts/api/management/contact-attribute-keys/[contactAttributeKeyId]/route";
|
} from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/route";
|
||||||
|
|
||||||
export { DELETE, GET, PUT };
|
export { DELETE, GET, PUT };
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import { GET, POST } from "@/modules/ee/contacts/api/management/contact-attribute-keys/route";
|
import { GET, POST } from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/route";
|
||||||
|
|
||||||
export { GET, POST };
|
export { GET, POST };
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import { GET } from "@/modules/ee/contacts/api/management/contact-attributes/route";
|
import { GET } from "@/modules/ee/contacts/api/v1/management/contact-attributes/route";
|
||||||
|
|
||||||
export { GET };
|
export { GET };
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import { DELETE, GET } from "@/modules/ee/contacts/api/management/contacts/[contactId]/route";
|
import { DELETE, GET } from "@/modules/ee/contacts/api/v1/management/contacts/[contactId]/route";
|
||||||
|
|
||||||
export { DELETE, GET };
|
export { DELETE, GET };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { GET } from "@/modules/ee/contacts/api/management/contacts/route";
|
import { GET } from "@/modules/ee/contacts/api/v1/management/contacts/route";
|
||||||
|
|
||||||
export { GET };
|
export { GET };
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
|||||||
import { putFileToLocalStorage } from "@formbricks/lib/storage/service";
|
import { putFileToLocalStorage } from "@formbricks/lib/storage/service";
|
||||||
|
|
||||||
export const POST = async (req: NextRequest): Promise<Response> => {
|
export const POST = async (req: NextRequest): Promise<Response> => {
|
||||||
|
if (!ENCRYPTION_KEY) {
|
||||||
|
return responses.internalServerErrorResponse("Encryption key is not set");
|
||||||
|
}
|
||||||
|
|
||||||
const accessType = "public"; // public files are accessible by anyone
|
const accessType = "public"; // public files are accessible by anyone
|
||||||
const headersList = await headers();
|
const headersList = await headers();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
|
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
|
||||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||||
import { generateSurveySingleUseIds } from "@formbricks/lib/utils/singleUseSurveys";
|
import { generateSurveySingleUseIds } from "@formbricks/lib/utils/singleUseSurveys";
|
||||||
|
|
||||||
@@ -36,9 +37,10 @@ export const GET = async (
|
|||||||
|
|
||||||
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
|
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
|
||||||
|
|
||||||
|
const surveyDomain = getSurveyDomain();
|
||||||
// map single use ids to survey links
|
// map single use ids to survey links
|
||||||
const surveyLinks = singleUseIds.map(
|
const surveyLinks = singleUseIds.map(
|
||||||
(singleUseId) => `${process.env.WEBAPP_URL}/s/${survey.id}?suId=${singleUseId}`
|
(singleUseId) => `${surveyDomain}/s/${survey.id}?suId=${singleUseId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return responses.successResponse(surveyLinks);
|
return responses.successResponse(surveyLinks);
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export const GET = async (req: NextRequest) => {
|
|||||||
<h2 tw="flex flex-col text-[8] sm:text-4xl font-bold tracking-tight text-slate-900 text-left mt-15">
|
<h2 tw="flex flex-col text-[8] sm:text-4xl font-bold tracking-tight text-slate-900 text-left mt-15">
|
||||||
{name}
|
{name}
|
||||||
</h2>
|
</h2>
|
||||||
<span tw="text-slate-600 text-xl">Complete in ~ 4 minutes</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div tw="flex justify-end mr-10 ">
|
<div tw="flex justify-end mr-10 ">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { webhookCache } from "@/lib/cache/webhook";
|
import { webhookCache } from "@/lib/cache/webhook";
|
||||||
import { Prisma, Webhook } from "@prisma/client";
|
import { Prisma, Webhook } from "@prisma/client";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||||
import { cache } from "@formbricks/lib/cache";
|
import { cache } from "@formbricks/lib/cache";
|
||||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
@@ -25,7 +26,10 @@ export const deleteWebhook = async (id: string): Promise<Webhook> => {
|
|||||||
|
|
||||||
return deletedWebhook;
|
return deletedWebhook;
|
||||||
} catch (error) {
|
} 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 ResourceNotFoundError("Webhook", id);
|
||||||
}
|
}
|
||||||
throw new DatabaseError(`Database error when deleting webhook with ID ${id}`);
|
throw new DatabaseError(`Database error when deleting webhook with ID ${id}`);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
OPTIONS,
|
OPTIONS,
|
||||||
PUT,
|
PUT,
|
||||||
} from "@/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/route";
|
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/route";
|
||||||
|
|
||||||
export { OPTIONS, PUT };
|
export { OPTIONS, PUT };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
GET,
|
GET,
|
||||||
OPTIONS,
|
OPTIONS,
|
||||||
} from "@/modules/ee/contacts/api/client/[environmentId]/identify/contacts/[userId]/route";
|
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route";
|
||||||
|
|
||||||
export { GET, OPTIONS };
|
export { GET, OPTIONS };
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import { OPTIONS, POST } from "@/modules/ee/contacts/api/client/[environmentId]/user/route";
|
import { OPTIONS, POST } from "@/modules/ee/contacts/api/v1/client/[environmentId]/user/route";
|
||||||
|
|
||||||
export { POST, OPTIONS };
|
export { POST, OPTIONS };
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { PUT } from "@/modules/ee/contacts/api/v2/management/contacts/bulk/route";
|
||||||
|
|
||||||
|
export { PUT };
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { GET } from "@/modules/api/v2/management/roles/route";
|
||||||
|
|
||||||
|
export { GET };
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { DELETE, GET, PUT } from "@/modules/api/v2/management/webhooks/[webhookId]/route";
|
||||||
|
|
||||||
|
export { GET, PUT, DELETE };
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { GET, POST } from "@/modules/api/v2/management/webhooks/route";
|
||||||
|
|
||||||
|
export { GET, POST };
|
||||||
@@ -29,6 +29,7 @@ vi.mock("@formbricks/lib/constants", () => ({
|
|||||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||||
WEBAPP_URL: "test-webapp-url",
|
WEBAPP_URL: "test-webapp-url",
|
||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/tolgee/language", () => ({
|
vi.mock("@/tolgee/language", () => ({
|
||||||
@@ -69,6 +70,15 @@ vi.mock("@/tolgee/client", () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/sentry/SentryProvider", () => ({
|
||||||
|
SentryProvider: ({ children, sentryDsn }: { children: React.ReactNode; sentryDsn?: string }) => (
|
||||||
|
<div data-testid="sentry-provider">
|
||||||
|
SentryProvider: {sentryDsn}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("RootLayout", () => {
|
describe("RootLayout", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -97,6 +107,7 @@ describe("RootLayout", () => {
|
|||||||
expect(screen.getByTestId("speed-insights")).toBeInTheDocument();
|
expect(screen.getByTestId("speed-insights")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("ph-provider")).toBeInTheDocument();
|
expect(screen.getByTestId("ph-provider")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument();
|
expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("sentry-provider")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("child")).toHaveTextContent("Child Content");
|
expect(screen.getByTestId("child")).toHaveTextContent("Child Content");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SentryProvider } from "@/app/sentry/SentryProvider";
|
||||||
import { PHProvider } from "@/modules/ui/components/post-hog-client";
|
import { PHProvider } from "@/modules/ui/components/post-hog-client";
|
||||||
import { TolgeeNextProvider } from "@/tolgee/client";
|
import { TolgeeNextProvider } from "@/tolgee/client";
|
||||||
import { getLocale } from "@/tolgee/language";
|
import { getLocale } from "@/tolgee/language";
|
||||||
@@ -6,7 +7,7 @@ import { TolgeeStaticData } from "@tolgee/react";
|
|||||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import React from "react";
|
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";
|
import "../modules/ui/globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -27,11 +28,13 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
|
|||||||
<html lang={locale} translate="no">
|
<html lang={locale} translate="no">
|
||||||
<body className="flex h-dvh flex-col transition-all ease-in-out">
|
<body className="flex h-dvh flex-col transition-all ease-in-out">
|
||||||
{process.env.VERCEL === "1" && <SpeedInsights sampleRate={0.1} />}
|
{process.env.VERCEL === "1" && <SpeedInsights sampleRate={0.1} />}
|
||||||
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
|
<SentryProvider sentryDsn={SENTRY_DSN}>
|
||||||
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
|
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
|
||||||
{children}
|
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
|
||||||
</TolgeeNextProvider>
|
{children}
|
||||||
</PHProvider>
|
</TolgeeNextProvider>
|
||||||
|
</PHProvider>
|
||||||
|
</SentryProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
export const fetchFile = async (
|
|
||||||
data: { json: any; fields?: string[]; fileName?: string },
|
|
||||||
filetype: string
|
|
||||||
) => {
|
|
||||||
const endpoint = filetype === "csv" ? "csv-conversion" : "excel-conversion";
|
|
||||||
|
|
||||||
const response = await fetch(`/api/${endpoint}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error("Failed to convert to file");
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { TPipelineInput } from "@/app/lib/types/pipelines";
|
||||||
|
import { PipelineTriggers } from "@prisma/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
|
import { TResponse } from "@formbricks/types/responses";
|
||||||
|
import { sendToPipeline } from "./pipelines";
|
||||||
|
|
||||||
|
// Mock the constants module
|
||||||
|
vi.mock("@formbricks/lib/constants", () => ({
|
||||||
|
CRON_SECRET: "mocked-cron-secret",
|
||||||
|
WEBAPP_URL: "https://test.formbricks.com",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the logger
|
||||||
|
vi.mock("@formbricks/logger", () => ({
|
||||||
|
logger: {
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock global fetch
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
describe("pipelines", () => {
|
||||||
|
// Reset mocks before each test
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up after each test
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sendToPipeline should call fetch with correct parameters", async () => {
|
||||||
|
// Mock the fetch implementation to return a successful response
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create sample data for testing
|
||||||
|
const testData: TPipelineInput = {
|
||||||
|
event: PipelineTriggers.responseCreated,
|
||||||
|
surveyId: "cm8ckvchx000008lb710n0gdn",
|
||||||
|
environmentId: "cm8cmp9hp000008jf7l570ml2",
|
||||||
|
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call the function with test data
|
||||||
|
await sendToPipeline(testData);
|
||||||
|
|
||||||
|
// Check that fetch was called with the correct arguments
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith("https://test.formbricks.com/api/pipeline", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": "mocked-cron-secret",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
environmentId: testData.environmentId,
|
||||||
|
surveyId: testData.surveyId,
|
||||||
|
event: testData.event,
|
||||||
|
response: testData.response,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sendToPipeline should handle fetch errors", async () => {
|
||||||
|
// Mock fetch to throw an error
|
||||||
|
const testError = new Error("Network error");
|
||||||
|
mockFetch.mockRejectedValueOnce(testError);
|
||||||
|
|
||||||
|
// Create sample data for testing
|
||||||
|
const testData: TPipelineInput = {
|
||||||
|
event: PipelineTriggers.responseCreated,
|
||||||
|
surveyId: "cm8ckvchx000008lb710n0gdn",
|
||||||
|
environmentId: "cm8cmp9hp000008jf7l570ml2",
|
||||||
|
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call the function
|
||||||
|
await sendToPipeline(testData);
|
||||||
|
|
||||||
|
// Check that the error was logged using logger
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(testError, "Error sending event to pipeline");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sendToPipeline should throw error if CRON_SECRET is not set", async () => {
|
||||||
|
// For this test, we need to mock CRON_SECRET as undefined
|
||||||
|
// Let's use a more compatible approach to reset the mocks
|
||||||
|
const originalModule = await import("@formbricks/lib/constants");
|
||||||
|
const mockConstants = { ...originalModule, CRON_SECRET: undefined };
|
||||||
|
|
||||||
|
vi.doMock("@formbricks/lib/constants", () => mockConstants);
|
||||||
|
|
||||||
|
// Re-import the module to get the new mocked values
|
||||||
|
const { sendToPipeline: sendToPipelineNoSecret } = await import("./pipelines");
|
||||||
|
|
||||||
|
// Create sample data for testing
|
||||||
|
const testData: TPipelineInput = {
|
||||||
|
event: PipelineTriggers.responseCreated,
|
||||||
|
surveyId: "cm8ckvchx000008lb710n0gdn",
|
||||||
|
environmentId: "cm8cmp9hp000008jf7l570ml2",
|
||||||
|
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expect the function to throw an error
|
||||||
|
await expect(sendToPipelineNoSecret(testData)).rejects.toThrow("CRON_SECRET is not set");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,10 @@ import { CRON_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
|
|||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
|
|
||||||
export const sendToPipeline = async ({ event, surveyId, environmentId, response }: TPipelineInput) => {
|
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`, {
|
return fetch(`${WEBAPP_URL}/api/pipeline`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import cuid2 from "@paralleldrive/cuid2";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import * as crypto from "@formbricks/lib/crypto";
|
||||||
|
import { generateSurveySingleUseId, validateSurveySingleUseId } from "./singleUseSurveys";
|
||||||
|
|
||||||
|
// Mock the crypto module
|
||||||
|
vi.mock("@formbricks/lib/crypto", () => ({
|
||||||
|
symmetricEncrypt: vi.fn(),
|
||||||
|
symmetricDecrypt: vi.fn(),
|
||||||
|
decryptAES128: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock constants
|
||||||
|
vi.mock("@formbricks/lib/constants", () => ({
|
||||||
|
ENCRYPTION_KEY: "test-encryption-key",
|
||||||
|
FORMBRICKS_ENCRYPTION_KEY: "test-formbricks-encryption-key",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock cuid2
|
||||||
|
vi.mock("@paralleldrive/cuid2", () => {
|
||||||
|
const createIdMock = vi.fn();
|
||||||
|
const isCuidMock = vi.fn();
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
createId: createIdMock,
|
||||||
|
isCuid: isCuidMock,
|
||||||
|
},
|
||||||
|
createId: createIdMock,
|
||||||
|
isCuid: isCuidMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateSurveySingleUseId", () => {
|
||||||
|
const mockCuid = "test-cuid-123";
|
||||||
|
const mockEncryptedCuid = "encrypted-cuid-123";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Setup mocks
|
||||||
|
vi.mocked(cuid2.createId).mockReturnValue(mockCuid);
|
||||||
|
vi.mocked(crypto.symmetricEncrypt).mockReturnValue(mockEncryptedCuid);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unencrypted cuid when isEncrypted is false", () => {
|
||||||
|
const result = generateSurveySingleUseId(false);
|
||||||
|
|
||||||
|
expect(result).toBe(mockCuid);
|
||||||
|
expect(crypto.symmetricEncrypt).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns encrypted cuid when isEncrypted is true", () => {
|
||||||
|
const result = generateSurveySingleUseId(true);
|
||||||
|
|
||||||
|
expect(result).toBe(mockEncryptedCuid);
|
||||||
|
expect(crypto.symmetricEncrypt).toHaveBeenCalledWith(mockCuid, "test-encryption-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when cuid is not valid", () => {
|
||||||
|
vi.mocked(cuid2.isCuid).mockReturnValue(false);
|
||||||
|
|
||||||
|
const result = validateSurveySingleUseId(mockEncryptedCuid);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when decryption fails", () => {
|
||||||
|
vi.mocked(crypto.symmetricDecrypt).mockImplementation(() => {
|
||||||
|
throw new Error("Decryption failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = validateSurveySingleUseId(mockEncryptedCuid);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error when ENCRYPTION_KEY is not set in generateSurveySingleUseId", async () => {
|
||||||
|
// Temporarily mock ENCRYPTION_KEY as undefined
|
||||||
|
vi.doMock("@formbricks/lib/constants", () => ({
|
||||||
|
ENCRYPTION_KEY: undefined,
|
||||||
|
FORMBRICKS_ENCRYPTION_KEY: "test-formbricks-encryption-key",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Re-import to get the new mock values
|
||||||
|
const { generateSurveySingleUseId: generateSurveySingleUseIdNoKey } = await import("./singleUseSurveys");
|
||||||
|
|
||||||
|
expect(() => generateSurveySingleUseIdNoKey(true)).toThrow("ENCRYPTION_KEY is not set");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error when ENCRYPTION_KEY is not set in validateSurveySingleUseId for symmetric encryption", async () => {
|
||||||
|
// Temporarily mock ENCRYPTION_KEY as undefined
|
||||||
|
vi.doMock("@formbricks/lib/constants", () => ({
|
||||||
|
ENCRYPTION_KEY: undefined,
|
||||||
|
FORMBRICKS_ENCRYPTION_KEY: "test-formbricks-encryption-key",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Re-import to get the new mock values
|
||||||
|
const { validateSurveySingleUseId: validateSurveySingleUseIdNoKey } = await import("./singleUseSurveys");
|
||||||
|
|
||||||
|
expect(() => validateSurveySingleUseIdNoKey(mockEncryptedCuid)).toThrow("ENCRYPTION_KEY is not set");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error when FORMBRICKS_ENCRYPTION_KEY is not set in validateSurveySingleUseId for AES128", async () => {
|
||||||
|
// Temporarily mock FORMBRICKS_ENCRYPTION_KEY as undefined
|
||||||
|
vi.doMock("@formbricks/lib/constants", () => ({
|
||||||
|
ENCRYPTION_KEY: "test-encryption-key",
|
||||||
|
FORMBRICKS_ENCRYPTION_KEY: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Re-import to get the new mock values
|
||||||
|
const { validateSurveySingleUseId: validateSurveySingleUseIdNoKey } = await import("./singleUseSurveys");
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
validateSurveySingleUseIdNoKey("M(.Bob=dS1!wUSH2lb,E7hxO=He1cnnitmXrG|Su/DKYZrPy~zgS)u?dgI53sfs/")
|
||||||
|
).toThrow("FORMBRICKS_ENCRYPTION_KEY is not defined");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,31 +9,42 @@ export const generateSurveySingleUseId = (isEncrypted: boolean): string => {
|
|||||||
return cuid;
|
return cuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ENCRYPTION_KEY) {
|
||||||
|
throw new Error("ENCRYPTION_KEY is not set");
|
||||||
|
}
|
||||||
|
|
||||||
const encryptedCuid = symmetricEncrypt(cuid, ENCRYPTION_KEY);
|
const encryptedCuid = symmetricEncrypt(cuid, ENCRYPTION_KEY);
|
||||||
return encryptedCuid;
|
return encryptedCuid;
|
||||||
};
|
};
|
||||||
|
|
||||||
// validate the survey single use id
|
// validate the survey single use id
|
||||||
export const validateSurveySingleUseId = (surveySingleUseId: string): string | undefined => {
|
export const validateSurveySingleUseId = (surveySingleUseId: string): string | undefined => {
|
||||||
try {
|
let decryptedCuid: string | null = null;
|
||||||
let decryptedCuid: string | null = null;
|
|
||||||
|
|
||||||
if (surveySingleUseId.length === 64) {
|
if (surveySingleUseId.length === 64) {
|
||||||
if (!FORMBRICKS_ENCRYPTION_KEY) {
|
if (!FORMBRICKS_ENCRYPTION_KEY) {
|
||||||
throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined");
|
throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined");
|
||||||
}
|
|
||||||
|
|
||||||
decryptedCuid = decryptAES128(FORMBRICKS_ENCRYPTION_KEY!, surveySingleUseId);
|
|
||||||
} else {
|
|
||||||
decryptedCuid = symmetricDecrypt(surveySingleUseId, ENCRYPTION_KEY);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cuid2.isCuid(decryptedCuid)) {
|
try {
|
||||||
return decryptedCuid;
|
decryptedCuid = decryptAES128(FORMBRICKS_ENCRYPTION_KEY, surveySingleUseId);
|
||||||
} else {
|
} catch (error) {
|
||||||
return undefined;
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { SentryProvider } from "./SentryProvider";
|
||||||
|
|
||||||
|
vi.mock("@sentry/nextjs", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@sentry/nextjs")>("@sentry/nextjs");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
replayIntegration: (options: any) => {
|
||||||
|
return {
|
||||||
|
name: "Replay",
|
||||||
|
id: "Replay",
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SentryProvider", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls Sentry.init when sentryDsn is provided", () => {
|
||||||
|
const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
|
||||||
|
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SentryProvider sentryDsn={sentryDsn}>
|
||||||
|
<div data-testid="child">Test Content</div>
|
||||||
|
</SentryProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// The useEffect runs after mount, so Sentry.init should have been called.
|
||||||
|
expect(initSpy).toHaveBeenCalled();
|
||||||
|
expect(initSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
dsn: sentryDsn,
|
||||||
|
tracesSampleRate: 1,
|
||||||
|
debug: false,
|
||||||
|
replaysOnErrorSampleRate: 1.0,
|
||||||
|
replaysSessionSampleRate: 0.1,
|
||||||
|
integrations: expect.any(Array),
|
||||||
|
beforeSend: expect.any(Function),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call Sentry.init when sentryDsn is not provided", () => {
|
||||||
|
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SentryProvider>
|
||||||
|
<div data-testid="child">Test Content</div>
|
||||||
|
</SentryProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(initSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders children", () => {
|
||||||
|
const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
|
||||||
|
render(
|
||||||
|
<SentryProvider sentryDsn={sentryDsn}>
|
||||||
|
<div data-testid="child">Test Content</div>
|
||||||
|
</SentryProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("processes beforeSend correctly", () => {
|
||||||
|
const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
|
||||||
|
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SentryProvider sentryDsn={sentryDsn}>
|
||||||
|
<div data-testid="child">Test Content</div>
|
||||||
|
</SentryProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = initSpy.mock.calls[0][0];
|
||||||
|
expect(config).toHaveProperty("beforeSend");
|
||||||
|
const beforeSend = config.beforeSend;
|
||||||
|
|
||||||
|
if (!beforeSend) {
|
||||||
|
throw new Error("beforeSend is not defined");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dummyEvent = { some: "event" } as unknown as Sentry.ErrorEvent;
|
||||||
|
|
||||||
|
const hintWithNextNotFound = { originalException: { digest: "NEXT_NOT_FOUND" } };
|
||||||
|
expect(beforeSend(dummyEvent, hintWithNextNotFound)).toBeNull();
|
||||||
|
|
||||||
|
const hintWithOtherError = { originalException: { digest: "OTHER_ERROR" } };
|
||||||
|
expect(beforeSend(dummyEvent, hintWithOtherError)).toEqual(dummyEvent);
|
||||||
|
|
||||||
|
const hintWithoutError = { originalException: undefined };
|
||||||
|
expect(beforeSend(dummyEvent, hintWithoutError)).toEqual(dummyEvent);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
interface SentryProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
sentryDsn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SentryProvider = ({ children, sentryDsn }: SentryProviderProps) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (sentryDsn) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: sentryDsn,
|
||||||
|
|
||||||
|
// Adjust this value in production, or use tracesSampler for greater control
|
||||||
|
tracesSampleRate: 1,
|
||||||
|
|
||||||
|
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||||
|
debug: false,
|
||||||
|
|
||||||
|
replaysOnErrorSampleRate: 1.0,
|
||||||
|
|
||||||
|
// This sets the sample rate to be 10%. You may want this to be 100% while
|
||||||
|
// in development and sample at a lower rate in production
|
||||||
|
replaysSessionSampleRate: 0.1,
|
||||||
|
|
||||||
|
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
|
||||||
|
integrations: [
|
||||||
|
Sentry.replayIntegration({
|
||||||
|
// Additional Replay configuration goes in here, for example:
|
||||||
|
maskAllText: true,
|
||||||
|
blockAllMedia: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
|
beforeSend(event, hint) {
|
||||||
|
const error = hint.originalException as Error;
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
if (error && error.digest === "NEXT_NOT_FOUND") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return event;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
@@ -57,8 +57,6 @@ CacheHandler.onCreation(async () => {
|
|||||||
timeoutMs: 1000,
|
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.
|
// Create the `redis-stack` Handler if the client is available and connected.
|
||||||
handler = await createRedisHandler(redisHandlerOptions);
|
handler = await createRedisHandler(redisHandlerOptions);
|
||||||
} else {
|
} else {
|
||||||
@@ -70,6 +68,11 @@ CacheHandler.onCreation(async () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
handlers: [handler],
|
handlers: [handler],
|
||||||
|
ttl: {
|
||||||
|
// We set the stale and the expire age to the same value, because the stale age is determined by the unstable_cache revalidation.
|
||||||
|
defaultStaleAge: (process.env.REDIS_URL && Number(process.env.REDIS_DEFAULT_TTL)) || 86400,
|
||||||
|
estimateExpireAge: (staleAge) => staleAge,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { env } from "@formbricks/lib/env";
|
import { PROMETHEUS_ENABLED, SENTRY_DSN } from "@formbricks/lib/constants";
|
||||||
|
|
||||||
// instrumentation.ts
|
// instrumentation.ts
|
||||||
export const register = async () => {
|
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");
|
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");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+43
-17
@@ -24,15 +24,27 @@ import { ipAddress } from "@vercel/functions";
|
|||||||
import { getToken } from "next-auth/jwt";
|
import { getToken } from "next-auth/jwt";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { E2E_TESTING, IS_PRODUCTION, RATE_LIMITING_DISABLED, WEBAPP_URL } from "@formbricks/lib/constants";
|
import {
|
||||||
|
E2E_TESTING,
|
||||||
|
IS_PRODUCTION,
|
||||||
|
RATE_LIMITING_DISABLED,
|
||||||
|
SURVEY_URL,
|
||||||
|
WEBAPP_URL,
|
||||||
|
} from "@formbricks/lib/constants";
|
||||||
import { isValidCallbackUrl } from "@formbricks/lib/utils/url";
|
import { isValidCallbackUrl } from "@formbricks/lib/utils/url";
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
|
|
||||||
const enforceHttps = (request: NextRequest): Response | null => {
|
const enforceHttps = (request: NextRequest): Response | null => {
|
||||||
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "http";
|
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "http";
|
||||||
if (IS_PRODUCTION && !E2E_TESTING && forwardedProto !== "https") {
|
if (IS_PRODUCTION && !E2E_TESTING && forwardedProto !== "https") {
|
||||||
const apiError: ApiErrorResponseV2 = {
|
const apiError: ApiErrorResponseV2 = {
|
||||||
type: "forbidden",
|
type: "forbidden",
|
||||||
details: [{ field: "", issue: "Only HTTPS connections are allowed on the management endpoint." }],
|
details: [
|
||||||
|
{
|
||||||
|
field: "",
|
||||||
|
issue: "Only HTTPS connections are allowed on the management and contacts bulk endpoints.",
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
logApiError(request, apiError);
|
logApiError(request, apiError);
|
||||||
return NextResponse.json(apiError, { status: 403 });
|
return NextResponse.json(apiError, { status: 403 });
|
||||||
@@ -78,7 +90,34 @@ const applyRateLimiting = (request: NextRequest, ip: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSurveyDomain = (request: NextRequest): Response | null => {
|
||||||
|
try {
|
||||||
|
if (!SURVEY_URL) return null;
|
||||||
|
|
||||||
|
const host = request.headers.get("host") || "";
|
||||||
|
const surveyDomain = SURVEY_URL ? new URL(SURVEY_URL).host : "";
|
||||||
|
if (host !== surveyDomain) return null;
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 404 });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error, "Error handling survey domain");
|
||||||
|
return new NextResponse(null, { status: 404 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSurveyRoute = (request: NextRequest) => {
|
||||||
|
return request.nextUrl.pathname.startsWith("/c/") || request.nextUrl.pathname.startsWith("/s/");
|
||||||
|
};
|
||||||
|
|
||||||
export const middleware = async (originalRequest: NextRequest) => {
|
export const middleware = async (originalRequest: NextRequest) => {
|
||||||
|
if (isSurveyRoute(originalRequest)) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle survey domain routing.
|
||||||
|
const surveyResponse = handleSurveyDomain(originalRequest);
|
||||||
|
if (surveyResponse) return surveyResponse;
|
||||||
|
|
||||||
// Create a new Request object to override headers and add a unique request ID header
|
// Create a new Request object to override headers and add a unique request ID header
|
||||||
const request = new NextRequest(originalRequest, {
|
const request = new NextRequest(originalRequest, {
|
||||||
headers: new Headers(originalRequest.headers),
|
headers: new Headers(originalRequest.headers),
|
||||||
@@ -88,6 +127,7 @@ export const middleware = async (originalRequest: NextRequest) => {
|
|||||||
request.headers.set("x-start-time", Date.now().toString());
|
request.headers.set("x-start-time", Date.now().toString());
|
||||||
|
|
||||||
// Create a new NextResponse object to forward the new request with headers
|
// Create a new NextResponse object to forward the new request with headers
|
||||||
|
|
||||||
const nextResponseWithCustomHeader = NextResponse.next({
|
const nextResponseWithCustomHeader = NextResponse.next({
|
||||||
request: {
|
request: {
|
||||||
headers: request.headers,
|
headers: request.headers,
|
||||||
@@ -132,20 +172,6 @@ export const middleware = async (originalRequest: NextRequest) => {
|
|||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
"/api/auth/callback/credentials",
|
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|js|css|images|fonts|icons|public|api/v1/og).*)", // Exclude the Open Graph image generation route from middleware
|
||||||
"/api/(.*)/client/:path*",
|
|
||||||
"/api/v1/js/actions",
|
|
||||||
"/api/v1/client/storage",
|
|
||||||
"/share/(.*)/:path",
|
|
||||||
"/environments/:path*",
|
|
||||||
"/setup/organization/:path*",
|
|
||||||
"/api/auth/signout",
|
|
||||||
"/auth/login",
|
|
||||||
"/auth/signup",
|
|
||||||
"/api/packages/:path*",
|
|
||||||
"/auth/verification-requested",
|
|
||||||
"/auth/forgot-password",
|
|
||||||
"/api/v1/management/:path*",
|
|
||||||
"/api/v2/management/:path*",
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { SurveyLinkDisplay } from "./components/SurveyLinkDisplay";
|
|||||||
|
|
||||||
interface ShareSurveyLinkProps {
|
interface ShareSurveyLinkProps {
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
webAppUrl: string;
|
surveyDomain: string;
|
||||||
surveyUrl: string;
|
surveyUrl: string;
|
||||||
setSurveyUrl: (url: string) => void;
|
setSurveyUrl: (url: string) => void;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
@@ -23,8 +23,8 @@ interface ShareSurveyLinkProps {
|
|||||||
|
|
||||||
export const ShareSurveyLink = ({
|
export const ShareSurveyLink = ({
|
||||||
survey,
|
survey,
|
||||||
webAppUrl,
|
|
||||||
surveyUrl,
|
surveyUrl,
|
||||||
|
surveyDomain,
|
||||||
setSurveyUrl,
|
setSurveyUrl,
|
||||||
locale,
|
locale,
|
||||||
}: ShareSurveyLinkProps) => {
|
}: ShareSurveyLinkProps) => {
|
||||||
@@ -32,7 +32,7 @@ export const ShareSurveyLink = ({
|
|||||||
const [language, setLanguage] = useState("default");
|
const [language, setLanguage] = useState("default");
|
||||||
|
|
||||||
const getUrl = useCallback(async () => {
|
const getUrl = useCallback(async () => {
|
||||||
let url = `${webAppUrl}/s/${survey.id}`;
|
let url = `${surveyDomain}/s/${survey.id}`;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
|
|
||||||
if (survey.singleUse?.enabled) {
|
if (survey.singleUse?.enabled) {
|
||||||
@@ -58,7 +58,9 @@ export const ShareSurveyLink = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSurveyUrl(url);
|
setSurveyUrl(url);
|
||||||
}, [survey, webAppUrl, language]);
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [survey, surveyDomain, language]);
|
||||||
|
|
||||||
const generateNewSingleUseLink = () => {
|
const generateNewSingleUseLink = () => {
|
||||||
getUrl();
|
getUrl();
|
||||||
|
|||||||
@@ -257,6 +257,34 @@ const successResponse = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const multiStatusResponse = ({
|
||||||
|
data,
|
||||||
|
meta,
|
||||||
|
cors = false,
|
||||||
|
cache = "private, no-store",
|
||||||
|
}: {
|
||||||
|
data: Object;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
cors?: boolean;
|
||||||
|
cache?: string;
|
||||||
|
}) => {
|
||||||
|
const headers = {
|
||||||
|
...(cors && corsHeaders),
|
||||||
|
"Cache-Control": cache,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
data,
|
||||||
|
meta,
|
||||||
|
} as ApiSuccessResponse,
|
||||||
|
{
|
||||||
|
status: 207,
|
||||||
|
headers,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const responses = {
|
export const responses = {
|
||||||
badRequestResponse,
|
badRequestResponse,
|
||||||
unauthorizedResponse,
|
unauthorizedResponse,
|
||||||
@@ -267,4 +295,5 @@ export const responses = {
|
|||||||
tooManyRequestsResponse,
|
tooManyRequestsResponse,
|
||||||
internalServerErrorResponse,
|
internalServerErrorResponse,
|
||||||
successResponse,
|
successResponse,
|
||||||
|
multiStatusResponse,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { responses } from "@/modules/api/v2/lib/response";
|
import { responses } from "@/modules/api/v2/lib/response";
|
||||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||||
import { ZodError } from "zod";
|
import { ZodCustomIssue, ZodIssue } from "zod";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
|
|
||||||
export const handleApiError = (request: Request, err: ApiErrorResponseV2): Response => {
|
export const handleApiError = (request: Request, err: ApiErrorResponseV2): Response => {
|
||||||
@@ -34,11 +34,16 @@ export const handleApiError = (request: Request, err: ApiErrorResponseV2): Respo
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatZodError = (error: ZodError) => {
|
export const formatZodError = (error: { issues: (ZodIssue | ZodCustomIssue)[] }) => {
|
||||||
return error.issues.map((issue) => ({
|
return error.issues.map((issue) => {
|
||||||
field: issue.path.join("."),
|
const issueParams = issue.code === "custom" ? issue.params : undefined;
|
||||||
issue: issue.message,
|
|
||||||
}));
|
return {
|
||||||
|
field: issue.path.join("."),
|
||||||
|
issue: issue.message ?? "An error occurred while processing your request. Please try again later.",
|
||||||
|
...(issueParams && { meta: issueParams }),
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const logApiRequest = (request: Request, responseStatus: number): void => {
|
export const logApiRequest = (request: Request, responseStatus: number): void => {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
|
|||||||
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
|
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
|
||||||
import { ZodRawShape, z } from "zod";
|
import { ZodRawShape, z } from "zod";
|
||||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||||
import { err } from "@formbricks/types/error-handlers";
|
|
||||||
import { authenticateRequest } from "./authenticate-request";
|
import { authenticateRequest } from "./authenticate-request";
|
||||||
|
|
||||||
export type HandlerFn<TInput = Record<string, unknown>> = ({
|
export type HandlerFn<TInput = Record<string, unknown>> = ({
|
||||||
@@ -41,65 +40,63 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
|
|||||||
rateLimit?: boolean;
|
rateLimit?: boolean;
|
||||||
handler: HandlerFn<ParsedSchemas<S>>;
|
handler: HandlerFn<ParsedSchemas<S>>;
|
||||||
}): Promise<Response> => {
|
}): Promise<Response> => {
|
||||||
try {
|
const authentication = await authenticateRequest(request);
|
||||||
const authentication = await authenticateRequest(request);
|
if (!authentication.ok) {
|
||||||
if (!authentication.ok) throw authentication.error;
|
return handleApiError(request, authentication.error);
|
||||||
|
|
||||||
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
|
|
||||||
|
|
||||||
if (schemas?.body) {
|
|
||||||
const bodyData = await request.json();
|
|
||||||
const bodyResult = schemas.body.safeParse(bodyData);
|
|
||||||
|
|
||||||
if (!bodyResult.success) {
|
|
||||||
throw err({
|
|
||||||
type: "forbidden",
|
|
||||||
details: formatZodError(bodyResult.error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
parsedInput.body = bodyResult.data as ParsedSchemas<S>["body"];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (schemas?.query) {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const queryObject = Object.fromEntries(url.searchParams.entries());
|
|
||||||
const queryResult = schemas.query.safeParse(queryObject);
|
|
||||||
if (!queryResult.success) {
|
|
||||||
throw err({
|
|
||||||
type: "unprocessable_entity",
|
|
||||||
details: formatZodError(queryResult.error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
parsedInput.query = queryResult.data as ParsedSchemas<S>["query"];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (schemas?.params) {
|
|
||||||
const paramsObject = (await externalParams) || {};
|
|
||||||
const paramsResult = schemas.params.safeParse(paramsObject);
|
|
||||||
if (!paramsResult.success) {
|
|
||||||
throw err({
|
|
||||||
type: "unprocessable_entity",
|
|
||||||
details: formatZodError(paramsResult.error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
parsedInput.params = paramsResult.data as ParsedSchemas<S>["params"];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rateLimit) {
|
|
||||||
const rateLimitResponse = await checkRateLimitAndThrowError({
|
|
||||||
identifier: authentication.data.hashedApiKey,
|
|
||||||
});
|
|
||||||
if (!rateLimitResponse.ok) {
|
|
||||||
throw rateLimitResponse.error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return handler({
|
|
||||||
authentication: authentication.data,
|
|
||||||
parsedInput,
|
|
||||||
request,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
return handleApiError(request, err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
|
||||||
|
|
||||||
|
if (schemas?.body) {
|
||||||
|
const bodyData = await request.json();
|
||||||
|
const bodyResult = schemas.body.safeParse(bodyData);
|
||||||
|
|
||||||
|
if (!bodyResult.success) {
|
||||||
|
return handleApiError(request, {
|
||||||
|
type: "unprocessable_entity",
|
||||||
|
details: formatZodError(bodyResult.error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
parsedInput.body = bodyResult.data as ParsedSchemas<S>["body"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schemas?.query) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const queryObject = Object.fromEntries(url.searchParams.entries());
|
||||||
|
const queryResult = schemas.query.safeParse(queryObject);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
return handleApiError(request, {
|
||||||
|
type: "unprocessable_entity",
|
||||||
|
details: formatZodError(queryResult.error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
parsedInput.query = queryResult.data as ParsedSchemas<S>["query"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schemas?.params) {
|
||||||
|
const paramsObject = (await externalParams) || {};
|
||||||
|
const paramsResult = schemas.params.safeParse(paramsObject);
|
||||||
|
if (!paramsResult.success) {
|
||||||
|
return handleApiError(request, {
|
||||||
|
type: "unprocessable_entity",
|
||||||
|
details: formatZodError(paramsResult.error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
parsedInput.params = paramsResult.data as ParsedSchemas<S>["params"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rateLimit) {
|
||||||
|
const rateLimitResponse = await checkRateLimitAndThrowError({
|
||||||
|
identifier: authentication.data.hashedApiKey,
|
||||||
|
});
|
||||||
|
if (!rateLimitResponse.ok) {
|
||||||
|
return handleApiError(request, rateLimitResponse.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler({
|
||||||
|
authentication: authentication.data,
|
||||||
|
parsedInput,
|
||||||
|
request,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const authenticateRequest = async (
|
|||||||
request: Request
|
request: Request
|
||||||
): Promise<Result<TAuthenticationApiKey, ApiErrorResponseV2>> => {
|
): Promise<Result<TAuthenticationApiKey, ApiErrorResponseV2>> => {
|
||||||
const apiKey = request.headers.get("x-api-key");
|
const apiKey = request.headers.get("x-api-key");
|
||||||
|
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
const environmentIdResult = await getEnvironmentIdFromApiKey(apiKey);
|
const environmentIdResult = await getEnvironmentIdFromApiKey(apiKey);
|
||||||
if (!environmentIdResult.ok) {
|
if (!environmentIdResult.ok) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { logApiRequest } from "@/modules/api/v2/lib/utils";
|
import { handleApiError, logApiRequest } from "@/modules/api/v2/lib/utils";
|
||||||
|
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||||
import { ExtendedSchemas, HandlerFn, ParsedSchemas, apiWrapper } from "./api-wrapper";
|
import { ExtendedSchemas, HandlerFn, ParsedSchemas, apiWrapper } from "./api-wrapper";
|
||||||
|
|
||||||
export const authenticatedApiClient = async <S extends ExtendedSchemas>({
|
export const authenticatedApiClient = async <S extends ExtendedSchemas>({
|
||||||
@@ -14,16 +15,28 @@ export const authenticatedApiClient = async <S extends ExtendedSchemas>({
|
|||||||
rateLimit?: boolean;
|
rateLimit?: boolean;
|
||||||
handler: HandlerFn<ParsedSchemas<S>>;
|
handler: HandlerFn<ParsedSchemas<S>>;
|
||||||
}): Promise<Response> => {
|
}): Promise<Response> => {
|
||||||
const response = await apiWrapper({
|
try {
|
||||||
request,
|
const response = await apiWrapper({
|
||||||
schemas,
|
request,
|
||||||
externalParams,
|
schemas,
|
||||||
rateLimit,
|
externalParams,
|
||||||
handler,
|
rateLimit,
|
||||||
});
|
handler,
|
||||||
if (response.ok) {
|
});
|
||||||
logApiRequest(request, response.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
if (response.ok) {
|
||||||
|
logApiRequest(request, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
if ("type" in err) {
|
||||||
|
return handleApiError(request, err as ApiErrorResponseV2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleApiError(request, {
|
||||||
|
type: "internal_server_error",
|
||||||
|
details: [{ field: "error", issue: "An error occurred while processing your request." }],
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ vi.mock("@/modules/api/v2/lib/utils", () => ({
|
|||||||
handleApiError: vi.fn(),
|
handleApiError: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/api/v2/lib/utils", () => ({
|
||||||
|
formatZodError: vi.fn(),
|
||||||
|
handleApiError: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("apiWrapper", () => {
|
describe("apiWrapper", () => {
|
||||||
it("should handle request and return response", async () => {
|
it("should handle request and return response", async () => {
|
||||||
const request = new Request("http://localhost", {
|
const request = new Request("http://localhost", {
|
||||||
|
|||||||
@@ -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 { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||||
import { Result, ok } from "@formbricks/types/error-handlers";
|
import { Result, ok } from "@formbricks/types/error-handlers";
|
||||||
|
|
||||||
@@ -14,3 +17,31 @@ export const getEnvironmentId = async (
|
|||||||
|
|
||||||
return ok(result.data.environmentId);
|
return ok(result.data.environmentId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that all surveys are in the same environment and return the environment id
|
||||||
|
* @param surveyIds array of survey ids from the same environment
|
||||||
|
* @returns the common environment id
|
||||||
|
*/
|
||||||
|
export const getEnvironmentIdFromSurveyIds = async (
|
||||||
|
surveyIds: string[]
|
||||||
|
): Promise<Result<string, ApiErrorResponseV2>> => {
|
||||||
|
const result = await fetchEnvironmentIdFromSurveyIds(surveyIds);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all items in the array are the same
|
||||||
|
if (new Set(result.data).size !== 1) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
type: "bad_request",
|
||||||
|
details: [{ field: "surveyIds", issue: "not all surveys are in the same environment" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(result.data[0]);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import {
|
|
||||||
deleteResponseEndpoint,
|
|
||||||
getResponseEndpoint,
|
|
||||||
updateResponseEndpoint,
|
|
||||||
} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
|
|
||||||
import {
|
|
||||||
createResponseEndpoint,
|
|
||||||
getResponsesEndpoint,
|
|
||||||
} from "@/modules/api/v2/management/responses/lib/openapi";
|
|
||||||
import { ZodOpenApiPathsObject } from "zod-openapi";
|
|
||||||
|
|
||||||
export const responsePaths: ZodOpenApiPathsObject = {
|
|
||||||
"/responses": {
|
|
||||||
get: getResponsesEndpoint,
|
|
||||||
post: createResponseEndpoint,
|
|
||||||
},
|
|
||||||
"/responses/{id}": {
|
|
||||||
get: getResponseEndpoint,
|
|
||||||
put: updateResponseEndpoint,
|
|
||||||
delete: deleteResponseEndpoint,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -41,3 +41,36 @@ export const fetchEnvironmentId = reactCache(async (id: string, isResponseId: bo
|
|||||||
}
|
}
|
||||||
)()
|
)()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const fetchEnvironmentIdFromSurveyIds = reactCache(async (surveyIds: string[]) =>
|
||||||
|
cache(
|
||||||
|
async (): Promise<Result<string[], ApiErrorResponseV2>> => {
|
||||||
|
try {
|
||||||
|
const results = await prisma.survey.findMany({
|
||||||
|
where: { id: { in: surveyIds } },
|
||||||
|
select: {
|
||||||
|
environmentId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (results.length !== surveyIds.length) {
|
||||||
|
return err({
|
||||||
|
type: "not_found",
|
||||||
|
details: [{ field: "survey", issue: "not found" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(results.map((result) => result.environmentId));
|
||||||
|
} catch (error) {
|
||||||
|
return err({
|
||||||
|
type: "internal_server_error",
|
||||||
|
details: [{ field: "survey", issue: error.message }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[`services-fetchEnvironmentIdFromSurveyIds-${surveyIds.join("-")}`],
|
||||||
|
{
|
||||||
|
tags: surveyIds.map((surveyId) => surveyCache.tag.byId(surveyId)),
|
||||||
|
}
|
||||||
|
)()
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
|
import { fetchEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/services";
|
||||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { err, ok } from "@formbricks/types/error-handlers";
|
import { err, ok } from "@formbricks/types/error-handlers";
|
||||||
import { getEnvironmentId } from "../helper";
|
import { getEnvironmentId, getEnvironmentIdFromSurveyIds } from "../helper";
|
||||||
import { fetchEnvironmentId } from "../services";
|
import { fetchEnvironmentId } from "../services";
|
||||||
|
|
||||||
vi.mock("../services", () => ({
|
vi.mock("../services", () => ({
|
||||||
fetchEnvironmentId: vi.fn(),
|
fetchEnvironmentId: vi.fn(),
|
||||||
|
fetchEnvironmentIdFromSurveyIds: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("Helper Functions", () => {
|
describe("Tests for getEnvironmentId", () => {
|
||||||
it("should return environmentId for surveyId", async () => {
|
it("should return environmentId for surveyId", async () => {
|
||||||
vi.mocked(fetchEnvironmentId).mockResolvedValue(ok({ environmentId: "env-id" }));
|
vi.mocked(fetchEnvironmentId).mockResolvedValue(ok({ environmentId: "env-id" }));
|
||||||
|
|
||||||
@@ -41,3 +44,42 @@ describe("Helper Functions", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getEnvironmentIdFromSurveyIds", () => {
|
||||||
|
const envId1 = createId();
|
||||||
|
const envId2 = createId();
|
||||||
|
|
||||||
|
it("returns the common environment id when all survey ids are in the same environment", async () => {
|
||||||
|
vi.mocked(fetchEnvironmentIdFromSurveyIds).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: [envId1, envId1],
|
||||||
|
});
|
||||||
|
const result = await getEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
|
||||||
|
expect(result).toEqual(ok(envId1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error when surveys are not in the same environment", async () => {
|
||||||
|
vi.mocked(fetchEnvironmentIdFromSurveyIds).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: [envId1, envId2],
|
||||||
|
});
|
||||||
|
const result = await getEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toEqual({
|
||||||
|
type: "bad_request",
|
||||||
|
details: [{ field: "surveyIds", issue: "not all surveys are in the same environment" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error when API call fails", async () => {
|
||||||
|
const apiError = {
|
||||||
|
type: "server_error",
|
||||||
|
details: [{ field: "api", issue: "failed" }],
|
||||||
|
} as unknown as ApiErrorResponseV2;
|
||||||
|
vi.mocked(fetchEnvironmentIdFromSurveyIds).mockResolvedValueOnce({ ok: false, error: apiError });
|
||||||
|
const result = await getEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
|
||||||
|
expect(result).toEqual({ ok: false, error: apiError });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { fetchEnvironmentId } from "../services";
|
import { fetchEnvironmentId, fetchEnvironmentIdFromSurveyIds } from "../services";
|
||||||
|
|
||||||
vi.mock("@formbricks/database", () => ({
|
vi.mock("@formbricks/database", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
survey: { findFirst: vi.fn() },
|
survey: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("Services", () => {
|
describe("Services", () => {
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getSurveyAndEnvironmentId", () => {
|
describe("getSurveyAndEnvironmentId", () => {
|
||||||
test("should return surveyId and environmentId for responseId", async () => {
|
test("should return surveyId and environmentId for responseId", async () => {
|
||||||
vi.mocked(prisma.survey.findFirst).mockResolvedValue({
|
vi.mocked(prisma.survey.findFirst).mockResolvedValue({
|
||||||
@@ -80,4 +79,36 @@ describe("Services", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("fetchEnvironmentIdFromSurveyIds", () => {
|
||||||
|
test("should return an array of environmentIds if all surveys exist", async () => {
|
||||||
|
vi.mocked(prisma.survey.findMany).mockResolvedValue([
|
||||||
|
{ environmentId: "env-1" },
|
||||||
|
{ environmentId: "env-2" },
|
||||||
|
]);
|
||||||
|
const result = await fetchEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.data).toEqual(["env-1", "env-2"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return not_found error if any survey is missing", async () => {
|
||||||
|
vi.mocked(prisma.survey.findMany).mockResolvedValue([{ environmentId: "env-1" }]);
|
||||||
|
const result = await fetchEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error.type).toBe("not_found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return internal_server_error if prisma query fails", async () => {
|
||||||
|
vi.mocked(prisma.survey.findMany).mockRejectedValue(new Error("Query failed"));
|
||||||
|
const result = await fetchEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error.type).toBe("internal_server_error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { hashApiKey } from "../utils";
|
import { buildCommonFilterQuery, hashApiKey, pickCommonFilter } from "../utils";
|
||||||
|
|
||||||
describe("hashApiKey", () => {
|
describe("hashApiKey", () => {
|
||||||
test("generate the correct sha256 hash for a given input", () => {
|
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;;
|
expect(result).toHaveLength(9); // mocked on the vitestSetup.ts file;;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("pickCommonFilter", () => {
|
||||||
|
test("picks the common filter fields correctly", () => {
|
||||||
|
const params = {
|
||||||
|
limit: 10,
|
||||||
|
skip: 5,
|
||||||
|
sortBy: "createdAt",
|
||||||
|
order: "asc",
|
||||||
|
startDate: new Date("2023-01-01"),
|
||||||
|
endDate: new Date("2023-12-31"),
|
||||||
|
} as TGetFilter;
|
||||||
|
const result = pickCommonFilter(params);
|
||||||
|
expect(result).toEqual(params);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles missing fields gracefully", () => {
|
||||||
|
const params = { limit: 10 } as TGetFilter;
|
||||||
|
const result = pickCommonFilter(params);
|
||||||
|
expect(result).toEqual({
|
||||||
|
limit: 10,
|
||||||
|
skip: undefined,
|
||||||
|
sortBy: undefined,
|
||||||
|
order: undefined,
|
||||||
|
startDate: undefined,
|
||||||
|
endDate: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildCommonFilterQuery", () => {
|
||||||
|
test("applies startDate and endDate when provided", () => {
|
||||||
|
const query: Prisma.WebhookFindManyArgs = { where: {} };
|
||||||
|
const params = {
|
||||||
|
startDate: new Date("2023-01-01"),
|
||||||
|
endDate: new Date("2023-12-31"),
|
||||||
|
} as TGetFilter;
|
||||||
|
const result = buildCommonFilterQuery(query, params);
|
||||||
|
expect(result.where?.createdAt?.gte).toEqual(params.startDate);
|
||||||
|
expect(result.where?.createdAt?.lte).toEqual(params.endDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applies sortBy and order when provided", () => {
|
||||||
|
const query: Prisma.WebhookFindManyArgs = { where: {} };
|
||||||
|
const params = { sortBy: "createdAt", order: "desc" } as TGetFilter;
|
||||||
|
const result = buildCommonFilterQuery(query, params);
|
||||||
|
expect(result.orderBy).toEqual({ createdAt: "desc" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applies limit (take) when provided", () => {
|
||||||
|
const query: Prisma.WebhookFindManyArgs = { where: {} };
|
||||||
|
const params = { limit: 5 } as TGetFilter;
|
||||||
|
const result = buildCommonFilterQuery(query, params);
|
||||||
|
expect(result.take).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applies skip when provided", () => {
|
||||||
|
const query: Prisma.WebhookFindManyArgs = { where: {} };
|
||||||
|
const params = { skip: 10 } as TGetFilter;
|
||||||
|
const result = buildCommonFilterQuery(query, params);
|
||||||
|
expect(result.skip).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles missing fields gracefully", () => {
|
||||||
|
const query = {};
|
||||||
|
const params = {} as TGetFilter;
|
||||||
|
const result = buildCommonFilterQuery(query, params);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,65 @@
|
|||||||
|
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
|
|
||||||
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
|
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
|
||||||
|
|
||||||
|
export function pickCommonFilter<T extends TGetFilter>(params: T) {
|
||||||
|
const { limit, skip, sortBy, order, startDate, endDate } = params;
|
||||||
|
return { limit, skip, sortBy, order, startDate, endDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
type HasFindMany = Prisma.WebhookFindManyArgs | Prisma.ResponseFindManyArgs;
|
||||||
|
|
||||||
|
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
|
||||||
|
const { limit, skip, sortBy, order, startDate, endDate } = params || {};
|
||||||
|
|
||||||
|
let filteredQuery = {
|
||||||
|
...query,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
filteredQuery = {
|
||||||
|
...filteredQuery,
|
||||||
|
where: {
|
||||||
|
...filteredQuery.where,
|
||||||
|
createdAt: {
|
||||||
|
...((filteredQuery.where?.createdAt as Prisma.DateTimeFilter) ?? {}),
|
||||||
|
gte: startDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
filteredQuery = {
|
||||||
|
...filteredQuery,
|
||||||
|
where: {
|
||||||
|
...filteredQuery.where,
|
||||||
|
createdAt: {
|
||||||
|
...((filteredQuery.where?.createdAt as Prisma.DateTimeFilter) ?? {}),
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortBy) {
|
||||||
|
filteredQuery = {
|
||||||
|
...filteredQuery,
|
||||||
|
orderBy: {
|
||||||
|
[sortBy]: order,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (limit) {
|
||||||
|
filteredQuery = { ...filteredQuery, take: limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skip) {
|
||||||
|
filteredQuery = { ...filteredQuery, skip };
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredQuery;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||||
import { displayCache } from "@formbricks/lib/display/cache";
|
import { displayCache } from "@formbricks/lib/display/cache";
|
||||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||||
|
|
||||||
@@ -26,7 +27,10 @@ export const deleteDisplay = async (displayId: string): Promise<Result<boolean,
|
|||||||
return ok(true);
|
return ok(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PrismaClientKnownRequestError) {
|
if (error instanceof PrismaClientKnownRequestError) {
|
||||||
if (error.code === "P2016" || error.code === "P2025") {
|
if (
|
||||||
|
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||||
|
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||||
|
) {
|
||||||
return err({
|
return err({
|
||||||
type: "not_found",
|
type: "not_found",
|
||||||
details: [{ field: "display", issue: "not found" }],
|
details: [{ field: "display", issue: "not found" }],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { responseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
|
import { responseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
|
||||||
|
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||||
import { ZResponse } from "@formbricks/database/zod/responses";
|
import { ZResponse } from "@formbricks/database/zod/responses";
|
||||||
@@ -19,7 +20,7 @@ export const getResponseEndpoint: ZodOpenApiOperationObject = {
|
|||||||
description: "Response retrieved successfully.",
|
description: "Response retrieved successfully.",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: ZResponse,
|
schema: makePartialSchema(ZResponse),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -41,7 +42,7 @@ export const deleteResponseEndpoint: ZodOpenApiOperationObject = {
|
|||||||
description: "Response deleted successfully.",
|
description: "Response deleted successfully.",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: ZResponse,
|
schema: makePartialSchema(ZResponse),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -72,7 +73,7 @@ export const updateResponseEndpoint: ZodOpenApiOperationObject = {
|
|||||||
description: "Response updated successfully.",
|
description: "Response updated successfully.",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: ZResponse,
|
schema: makePartialSchema(ZResponse),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
|||||||
import { cache as reactCache } from "react";
|
import { cache as reactCache } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||||
import { cache } from "@formbricks/lib/cache";
|
import { cache } from "@formbricks/lib/cache";
|
||||||
import { responseCache } from "@formbricks/lib/response/cache";
|
import { responseCache } from "@formbricks/lib/response/cache";
|
||||||
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
|
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
|
||||||
@@ -77,7 +78,10 @@ export const deleteResponse = async (responseId: string): Promise<Result<Respons
|
|||||||
return ok(deletedResponse);
|
return ok(deletedResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PrismaClientKnownRequestError) {
|
if (error instanceof PrismaClientKnownRequestError) {
|
||||||
if (error.code === "P2016" || error.code === "P2025") {
|
if (
|
||||||
|
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||||
|
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||||
|
) {
|
||||||
return err({
|
return err({
|
||||||
type: "not_found",
|
type: "not_found",
|
||||||
details: [{ field: "response", issue: "not found" }],
|
details: [{ field: "response", issue: "not found" }],
|
||||||
@@ -116,7 +120,10 @@ export const updateResponse = async (
|
|||||||
return ok(updatedResponse);
|
return ok(updatedResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PrismaClientKnownRequestError) {
|
if (error instanceof PrismaClientKnownRequestError) {
|
||||||
if (error.code === "P2016" || error.code === "P2025") {
|
if (
|
||||||
|
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||||
|
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||||
|
) {
|
||||||
return err({
|
return err({
|
||||||
type: "not_found",
|
type: "not_found",
|
||||||
details: [{ field: "response", issue: "not found" }],
|
details: [{ field: "response", issue: "not found" }],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { displayId, mockDisplay } from "./__mocks__/display.mock";
|
|||||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||||
import { deleteDisplay } from "../display";
|
import { deleteDisplay } from "../display";
|
||||||
|
|
||||||
vi.mock("@formbricks/database", () => ({
|
vi.mock("@formbricks/database", () => ({
|
||||||
@@ -39,7 +40,7 @@ describe("Display Lib", () => {
|
|||||||
test("return a not_found error when the display is not found", async () => {
|
test("return a not_found error when the display is not found", async () => {
|
||||||
vi.mocked(prisma.display.delete).mockRejectedValue(
|
vi.mocked(prisma.display.delete).mockRejectedValue(
|
||||||
new PrismaClientKnownRequestError("Display not found", {
|
new PrismaClientKnownRequestError("Display not found", {
|
||||||
code: "P2025",
|
code: PrismaErrorType.RelatedRecordDoesNotExist,
|
||||||
clientVersion: "1.0.0",
|
clientVersion: "1.0.0",
|
||||||
meta: {
|
meta: {
|
||||||
cause: "Display not found",
|
cause: "Display not found",
|
||||||
|
|||||||
+3
-2
@@ -2,6 +2,7 @@ import { response, responseId, responseInput, survey } from "./__mocks__/respons
|
|||||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||||
import { ok, okVoid } from "@formbricks/types/error-handlers";
|
import { ok, okVoid } from "@formbricks/types/error-handlers";
|
||||||
import { deleteDisplay } from "../display";
|
import { deleteDisplay } from "../display";
|
||||||
import { deleteResponse, getResponse, updateResponse } from "../response";
|
import { deleteResponse, getResponse, updateResponse } from "../response";
|
||||||
@@ -154,7 +155,7 @@ describe("Response Lib", () => {
|
|||||||
test("handle prisma client error code P2025", async () => {
|
test("handle prisma client error code P2025", async () => {
|
||||||
vi.mocked(prisma.response.delete).mockRejectedValue(
|
vi.mocked(prisma.response.delete).mockRejectedValue(
|
||||||
new PrismaClientKnownRequestError("Response not found", {
|
new PrismaClientKnownRequestError("Response not found", {
|
||||||
code: "P2025",
|
code: PrismaErrorType.RelatedRecordDoesNotExist,
|
||||||
clientVersion: "1.0.0",
|
clientVersion: "1.0.0",
|
||||||
meta: {
|
meta: {
|
||||||
cause: "Response not found",
|
cause: "Response not found",
|
||||||
@@ -192,7 +193,7 @@ describe("Response Lib", () => {
|
|||||||
test("return a not_found error when the response is not found", async () => {
|
test("return a not_found error when the response is not found", async () => {
|
||||||
vi.mocked(prisma.response.update).mockRejectedValue(
|
vi.mocked(prisma.response.update).mockRejectedValue(
|
||||||
new PrismaClientKnownRequestError("Response not found", {
|
new PrismaClientKnownRequestError("Response not found", {
|
||||||
code: "P2025",
|
code: PrismaErrorType.RelatedRecordDoesNotExist,
|
||||||
clientVersion: "1.0.0",
|
clientVersion: "1.0.0",
|
||||||
meta: {
|
meta: {
|
||||||
cause: "Response not found",
|
cause: "Response not found",
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI
|
|||||||
return handleApiError(request, response.error);
|
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 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 handleApiError(request, response.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return responses.successResponse({ data: response.data });
|
return responses.successResponse(response);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import {
|
|||||||
getResponseEndpoint,
|
getResponseEndpoint,
|
||||||
updateResponseEndpoint,
|
updateResponseEndpoint,
|
||||||
} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
|
} 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 { z } from "zod";
|
||||||
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||||
import { ZResponse, ZResponseInput } from "@formbricks/types/responses";
|
import { ZResponse } from "@formbricks/database/zod/responses";
|
||||||
|
|
||||||
export const getResponsesEndpoint: ZodOpenApiOperationObject = {
|
export const getResponsesEndpoint: ZodOpenApiOperationObject = {
|
||||||
operationId: "getResponses",
|
operationId: "getResponses",
|
||||||
@@ -21,7 +22,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
|
|||||||
description: "Responses retrieved successfully.",
|
description: "Responses retrieved successfully.",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: z.array(ZResponse),
|
schema: z.array(responseWithMetaSchema(makePartialSchema(ZResponse))),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -47,7 +48,7 @@ export const createResponseEndpoint: ZodOpenApiOperationObject = {
|
|||||||
description: "Response created successfully.",
|
description: "Response created successfully.",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: ZResponse,
|
schema: makePartialSchema(ZResponse),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const getOrganizationIdFromEnvironmentId = reactCache(async (environmentI
|
|||||||
|
|
||||||
export const getOrganizationBilling = reactCache(async (organizationId: string) =>
|
export const getOrganizationBilling = reactCache(async (organizationId: string) =>
|
||||||
cache(
|
cache(
|
||||||
async (): Promise<Result<Pick<Organization, "billing">, ApiErrorResponseV2>> => {
|
async (): Promise<Result<Organization["billing"], ApiErrorResponseV2>> => {
|
||||||
try {
|
try {
|
||||||
const organization = await prisma.organization.findFirst({
|
const organization = await prisma.organization.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -62,7 +62,8 @@ export const getOrganizationBilling = reactCache(async (organizationId: string)
|
|||||||
if (!organization) {
|
if (!organization) {
|
||||||
return err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] });
|
return err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] });
|
||||||
}
|
}
|
||||||
return ok(organization);
|
|
||||||
|
return ok(organization.billing);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return err({
|
return err({
|
||||||
type: "internal_server_error",
|
type: "internal_server_error",
|
||||||
@@ -126,26 +127,27 @@ export const getMonthlyOrganizationResponseCount = reactCache(async (organizatio
|
|||||||
cache(
|
cache(
|
||||||
async (): Promise<Result<number, ApiErrorResponseV2>> => {
|
async (): Promise<Result<number, ApiErrorResponseV2>> => {
|
||||||
try {
|
try {
|
||||||
const organization = await getOrganizationBilling(organizationId);
|
const billing = await getOrganizationBilling(organizationId);
|
||||||
if (!organization.ok) {
|
if (!billing.ok) {
|
||||||
return err(organization.error);
|
return err(billing.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the start date based on the plan type
|
// Determine the start date based on the plan type
|
||||||
let startDate: Date;
|
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
|
// For free plans, use the first day of the current calendar month
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
|
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
} else {
|
} else {
|
||||||
// For other plans, use the periodStart from billing
|
// For other plans, use the periodStart from billing
|
||||||
if (!organization.data.billing.periodStart) {
|
if (!billing.data.periodStart) {
|
||||||
return err({
|
return err({
|
||||||
type: "internal_server_error",
|
type: "internal_server_error",
|
||||||
details: [{ field: "organization", issue: "billing period start is not set" }],
|
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
|
// Get all environment IDs for the organization
|
||||||
|
|||||||
@@ -41,7 +41,14 @@ export const createResponse = async (
|
|||||||
} = responseInput;
|
} = responseInput;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
let ttc = {};
|
||||||
|
if (initialTtc) {
|
||||||
|
if (finished) {
|
||||||
|
ttc = calculateTtcTotal(initialTtc);
|
||||||
|
} else {
|
||||||
|
ttc = initialTtc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const prismaData: Prisma.ResponseCreateInput = {
|
const prismaData: Prisma.ResponseCreateInput = {
|
||||||
survey: {
|
survey: {
|
||||||
@@ -67,11 +74,11 @@ export const createResponse = async (
|
|||||||
return err(organizationIdResult.error);
|
return err(organizationIdResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const organizationResult = await getOrganizationBilling(organizationIdResult.data);
|
const billing = await getOrganizationBilling(organizationIdResult.data);
|
||||||
if (!organizationResult.ok) {
|
if (!billing.ok) {
|
||||||
return err(organizationResult.error);
|
return err(billing.error);
|
||||||
}
|
}
|
||||||
const organization = organizationResult.data;
|
const billingData = billing.data;
|
||||||
|
|
||||||
const response = await prisma.response.create({
|
const response = await prisma.response.create({
|
||||||
data: prismaData,
|
data: prismaData,
|
||||||
@@ -95,12 +102,12 @@ export const createResponse = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const responsesCount = responsesCountResult.data;
|
const responsesCount = responsesCountResult.data;
|
||||||
const responsesLimit = organization.billing.limits.monthly.responses;
|
const responsesLimit = billingData.limits?.monthly.responses;
|
||||||
|
|
||||||
if (responsesLimit && responsesCount >= responsesLimit) {
|
if (responsesLimit && responsesCount >= responsesLimit) {
|
||||||
try {
|
try {
|
||||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||||
plan: organization.billing.plan,
|
plan: billingData.plan,
|
||||||
limits: {
|
limits: {
|
||||||
projects: null,
|
projects: null,
|
||||||
monthly: {
|
monthly: {
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ describe("Organization Lib", () => {
|
|||||||
});
|
});
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
expect(result.data.billing).toEqual(organizationBilling);
|
expect(result.data).toEqual(organizationBilling);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ describe("Response Lib", () => {
|
|||||||
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
||||||
|
|
||||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
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));
|
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
|
||||||
|
|
||||||
const result = await createResponse(environmentId, responseInput);
|
const result = await createResponse(environmentId, responseInput);
|
||||||
@@ -70,7 +70,7 @@ describe("Response Lib", () => {
|
|||||||
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
||||||
|
|
||||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
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));
|
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
|
||||||
|
|
||||||
const result = await createResponse(environmentId, responseInputNotFinished);
|
const result = await createResponse(environmentId, responseInputNotFinished);
|
||||||
@@ -85,7 +85,7 @@ describe("Response Lib", () => {
|
|||||||
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
||||||
|
|
||||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
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));
|
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
|
||||||
|
|
||||||
const result = await createResponse(environmentId, responseInputWithoutTtc);
|
const result = await createResponse(environmentId, responseInputWithoutTtc);
|
||||||
@@ -100,7 +100,7 @@ describe("Response Lib", () => {
|
|||||||
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
||||||
|
|
||||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
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));
|
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
|
||||||
|
|
||||||
const result = await createResponse(environmentId, responseInputWithoutDisplay);
|
const result = await createResponse(environmentId, responseInputWithoutDisplay);
|
||||||
@@ -145,7 +145,7 @@ describe("Response Lib", () => {
|
|||||||
|
|
||||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
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));
|
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ describe("Response Lib", () => {
|
|||||||
|
|
||||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||||
|
|
||||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
|
||||||
|
|
||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(
|
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(
|
||||||
err({ type: "internal_server_error", details: [{ field: "organization", issue: "Aggregate error" }] })
|
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(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||||
|
|
||||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
|
||||||
|
|
||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
|
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
|
||||||
|
|
||||||
|
|||||||
@@ -1,97 +1,40 @@
|
|||||||
|
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
|
||||||
import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
|
import { 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";
|
import { getResponsesQuery } from "../utils";
|
||||||
|
|
||||||
|
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||||
|
pickCommonFilter: vi.fn(),
|
||||||
|
buildCommonFilterQuery: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("getResponsesQuery", () => {
|
describe("getResponsesQuery", () => {
|
||||||
const environmentId = "env_1";
|
it("adds surveyId to where clause if provided", () => {
|
||||||
const filters: TGetResponsesFilter = {
|
const result = getResponsesQuery("env-id", { surveyId: "survey123" } as TGetResponsesFilter);
|
||||||
limit: 10,
|
expect(result?.where?.surveyId).toBe("survey123");
|
||||||
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 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("add surveyId to the query when provided", () => {
|
it("adds contactId to where clause if provided", () => {
|
||||||
const query = getResponsesQuery(environmentId, { ...filters, surveyId: "survey_1" });
|
const result = getResponsesQuery("env-id", { contactId: "contact123" } as TGetResponsesFilter);
|
||||||
expect(query.where).toEqual({
|
expect(result?.where?.contactId).toBe("contact123");
|
||||||
survey: { environmentId },
|
|
||||||
surveyId: "survey_1",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("add startDate filter to the query", () => {
|
it("calls pickCommonFilter & buildCommonFilterQuery with correct arguments", () => {
|
||||||
const startDate = new Date("2023-01-01");
|
vi.mocked(pickCommonFilter).mockReturnValueOnce({ someFilter: true } as any);
|
||||||
const query = getResponsesQuery(environmentId, { ...filters, startDate });
|
vi.mocked(buildCommonFilterQuery).mockReturnValueOnce({ where: { combined: true } as any });
|
||||||
expect(query.where).toEqual({
|
|
||||||
survey: { environmentId },
|
|
||||||
createdAt: { gte: startDate },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("add endDate filter to the query", () => {
|
const result = getResponsesQuery("env-id", { surveyId: "test" } as TGetResponsesFilter);
|
||||||
const endDate = new Date("2023-01-31");
|
expect(pickCommonFilter).toHaveBeenCalledWith({ surveyId: "test" });
|
||||||
const query = getResponsesQuery(environmentId, { ...filters, endDate });
|
expect(buildCommonFilterQuery).toHaveBeenCalledWith(
|
||||||
expect(query.where).toEqual({
|
expect.objectContaining<Prisma.ResponseFindManyArgs>({
|
||||||
survey: { environmentId },
|
where: {
|
||||||
createdAt: { lte: endDate },
|
survey: { environmentId: "env-id" },
|
||||||
});
|
surveyId: "test",
|
||||||
});
|
},
|
||||||
|
}),
|
||||||
test("add sortBy and order to the query", () => {
|
{ someFilter: true }
|
||||||
const query = getResponsesQuery(environmentId, { ...filters, sortBy: "createdAt", order: "desc" });
|
);
|
||||||
expect(query.orderBy).toEqual({
|
expect(result).toEqual({ where: { combined: true } });
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
export const getResponsesQuery = (environmentId: string, params?: TGetResponsesFilter) => {
|
export const getResponsesQuery = (environmentId: string, params?: TGetResponsesFilter) => {
|
||||||
const { surveyId, limit, skip, sortBy, order, startDate, endDate, contactId } = params || {};
|
|
||||||
|
|
||||||
let query: Prisma.ResponseFindManyArgs = {
|
let query: Prisma.ResponseFindManyArgs = {
|
||||||
where: {
|
where: {
|
||||||
survey: {
|
survey: {
|
||||||
@@ -12,6 +11,10 @@ export const getResponsesQuery = (environmentId: string, params?: TGetResponsesF
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!params) return query;
|
||||||
|
|
||||||
|
const { surveyId, contactId } = params || {};
|
||||||
|
|
||||||
if (surveyId) {
|
if (surveyId) {
|
||||||
query = {
|
query = {
|
||||||
...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) {
|
if (contactId) {
|
||||||
query = {
|
query = {
|
||||||
...query,
|
...query,
|
||||||
@@ -81,5 +35,11 @@ export const getResponsesQuery = (environmentId: string, params?: TGetResponsesF
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseFilter = pickCommonFilter(params);
|
||||||
|
|
||||||
|
if (baseFilter) {
|
||||||
|
query = buildCommonFilterQuery<Prisma.ResponseFindManyArgs>(query, baseFilter);
|
||||||
|
}
|
||||||
|
|
||||||
return query;
|
return query;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,28 +1,21 @@
|
|||||||
|
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZResponse } from "@formbricks/database/zod/responses";
|
import { ZResponse } from "@formbricks/database/zod/responses";
|
||||||
|
|
||||||
export const ZGetResponsesFilter = z
|
export const ZGetResponsesFilter = ZGetFilter.extend({
|
||||||
.object({
|
surveyId: z.string().cuid2().optional(),
|
||||||
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
|
contactId: z.string().optional(),
|
||||||
skip: z.coerce.number().nonnegative().optional().default(0),
|
}).refine(
|
||||||
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
|
(data) => {
|
||||||
order: z.enum(["asc", "desc"]).optional().default("desc"),
|
if (data.startDate && data.endDate && data.startDate > data.endDate) {
|
||||||
startDate: z.coerce.date().optional(),
|
return false;
|
||||||
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",
|
|
||||||
}
|
}
|
||||||
);
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "startDate must be before endDate",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export type TGetResponsesFilter = z.infer<typeof ZGetResponsesFilter>;
|
export type TGetResponsesFilter = z.infer<typeof ZGetResponsesFilter>;
|
||||||
|
|
||||||
@@ -39,21 +32,16 @@ export const ZResponseInput = ZResponse.pick({
|
|||||||
variables: true,
|
variables: true,
|
||||||
ttc: true,
|
ttc: true,
|
||||||
meta: true,
|
meta: true,
|
||||||
})
|
}).partial({
|
||||||
.partial({
|
displayId: true,
|
||||||
displayId: true,
|
singleUseId: true,
|
||||||
singleUseId: true,
|
endingId: true,
|
||||||
endingId: true,
|
language: true,
|
||||||
language: true,
|
variables: true,
|
||||||
variables: true,
|
ttc: true,
|
||||||
ttc: true,
|
meta: true,
|
||||||
meta: true,
|
createdAt: true,
|
||||||
createdAt: true,
|
updatedAt: true,
|
||||||
updatedAt: true,
|
});
|
||||||
})
|
|
||||||
.openapi({
|
|
||||||
ref: "responseCreate",
|
|
||||||
description: "A response to create",
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TResponseInput = z.infer<typeof ZResponseInput>;
|
export type TResponseInput = z.infer<typeof ZResponseInput>;
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||||
|
|
||||||
|
export const getRolesEndpoint: ZodOpenApiOperationObject = {
|
||||||
|
operationId: "getRoles",
|
||||||
|
summary: "Get roles",
|
||||||
|
description: "Gets roles from the database.",
|
||||||
|
requestParams: {},
|
||||||
|
tags: ["Management API > Roles"],
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
description: "Roles retrieved successfully.",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.array(z.string()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rolePaths: ZodOpenApiPathsObject = {
|
||||||
|
"/roles": {
|
||||||
|
get: getRolesEndpoint,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||||
|
import { ApiResponse } from "@/modules/api/v2/types/api-success";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||||
|
|
||||||
|
export const getRoles = async (): Promise<Result<ApiResponse<string[]>, ApiErrorResponseV2>> => {
|
||||||
|
try {
|
||||||
|
// We use a raw query to get all the roles because we can't list enum options with prisma
|
||||||
|
const results = await prisma.$queryRaw<{ unnest: string }[]>`
|
||||||
|
SELECT unnest(enum_range(NULL::"OrganizationRole"));
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!results) {
|
||||||
|
// We set internal_server_error because it's an enum and we should always have the roles
|
||||||
|
return err({ type: "internal_server_error", details: [{ field: "roles", issue: "not found" }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles = results.map((row) => row.unnest);
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
data: roles,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return err({ type: "internal_server_error", details: [{ field: "roles", issue: error.message }] });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { getRoles } from "../roles";
|
||||||
|
|
||||||
|
// Mock prisma with a $queryRaw function
|
||||||
|
vi.mock("@formbricks/database", () => ({
|
||||||
|
prisma: {
|
||||||
|
$queryRaw: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("getRoles", () => {
|
||||||
|
it("returns roles on success", async () => {
|
||||||
|
(prisma.$queryRaw as any).mockResolvedValueOnce([{ unnest: "ADMIN" }, { unnest: "MEMBER" }]);
|
||||||
|
|
||||||
|
const result = await getRoles();
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.data.data).toEqual(["ADMIN", "MEMBER"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error if no results are found", async () => {
|
||||||
|
(prisma.$queryRaw as any).mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const result = await getRoles();
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error?.type).toBe("internal_server_error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error on exception", async () => {
|
||||||
|
vi.mocked(prisma.$queryRaw).mockRejectedValueOnce(new Error("Test DB error"));
|
||||||
|
|
||||||
|
const result = await getRoles();
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error.type).toBe("internal_server_error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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 { getRoles } from "@/modules/api/v2/management/roles/lib/roles";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
export const GET = async (request: NextRequest) =>
|
||||||
|
authenticatedApiClient({
|
||||||
|
request,
|
||||||
|
handler: async () => {
|
||||||
|
const res = await getRoles();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
return responses.successResponse(res.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleApiError(request, res.error);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { webhookIdSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||||
|
import { ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||||
|
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||||
|
import { ZWebhook } from "@formbricks/database/zod/webhooks";
|
||||||
|
|
||||||
|
export const getWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||||
|
operationId: "getWebhook",
|
||||||
|
summary: "Get a webhook",
|
||||||
|
description: "Gets a webhook from the database.",
|
||||||
|
requestParams: {
|
||||||
|
path: z.object({
|
||||||
|
webhookId: webhookIdSchema,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tags: ["Management API > Webhooks"],
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
description: "Webhook retrieved successfully.",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: makePartialSchema(ZWebhook),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||||
|
operationId: "deleteWebhook",
|
||||||
|
summary: "Delete a webhook",
|
||||||
|
description: "Deletes a webhook from the database.",
|
||||||
|
tags: ["Management API > Webhooks"],
|
||||||
|
requestParams: {
|
||||||
|
path: z.object({
|
||||||
|
webhookId: webhookIdSchema,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
description: "Webhook deleted successfully.",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: makePartialSchema(ZWebhook),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||||
|
operationId: "updateWebhook",
|
||||||
|
summary: "Update a webhook",
|
||||||
|
description: "Updates a webhook in the database.",
|
||||||
|
tags: ["Management API > Webhooks"],
|
||||||
|
requestParams: {
|
||||||
|
path: z.object({
|
||||||
|
webhookId: webhookIdSchema,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
description: "The webhook to update",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: ZWebhookInput,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
description: "Webhook updated successfully.",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: makePartialSchema(ZWebhook),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
import { WebhookSource } from "@prisma/client";
|
||||||
|
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||||
|
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||||
|
|
||||||
|
export const mockedPrismaWebhookUpdateReturn = {
|
||||||
|
id: "123",
|
||||||
|
url: "",
|
||||||
|
name: null,
|
||||||
|
createdAt: new Date("2025-03-24T07:27:36.850Z"),
|
||||||
|
updatedAt: new Date("2025-03-24T07:27:36.850Z"),
|
||||||
|
source: "user" as WebhookSource,
|
||||||
|
environmentId: "",
|
||||||
|
triggers: [],
|
||||||
|
surveyIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prismaNotFoundError = new PrismaClientKnownRequestError("Record does not exist", {
|
||||||
|
code: PrismaErrorType.RecordDoesNotExist,
|
||||||
|
clientVersion: "PrismaClient 4.0.0",
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { webhookCache } from "@/lib/cache/webhook";
|
||||||
|
import {
|
||||||
|
mockedPrismaWebhookUpdateReturn,
|
||||||
|
prismaNotFoundError,
|
||||||
|
} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock";
|
||||||
|
import { webhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { deleteWebhook, getWebhook, updateWebhook } from "../webhook";
|
||||||
|
|
||||||
|
vi.mock("@formbricks/database", () => ({
|
||||||
|
prisma: {
|
||||||
|
webhook: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/cache/webhook", () => ({
|
||||||
|
webhookCache: {
|
||||||
|
tag: {
|
||||||
|
byId: () => "mockTag",
|
||||||
|
},
|
||||||
|
revalidate: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("getWebhook", () => {
|
||||||
|
test("returns ok if webhook is found", async () => {
|
||||||
|
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce({ id: "123" });
|
||||||
|
const result = await getWebhook("123");
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.data).toEqual({ id: "123" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns err if webhook not found", async () => {
|
||||||
|
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce(null);
|
||||||
|
const result = await getWebhook("999");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error?.type).toBe("not_found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns err on Prisma error", async () => {
|
||||||
|
vi.mocked(prisma.webhook.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const result = await getWebhook("error");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error.type).toBe("internal_server_error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateWebhook", () => {
|
||||||
|
const mockedWebhookUpdateReturn = { url: "https://example.com" } as z.infer<typeof webhookUpdateSchema>;
|
||||||
|
|
||||||
|
test("returns ok on successful update", async () => {
|
||||||
|
vi.mocked(prisma.webhook.update).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn);
|
||||||
|
const result = await updateWebhook("123", mockedWebhookUpdateReturn);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.data).toEqual(mockedPrismaWebhookUpdateReturn);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(webhookCache.revalidate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns not_found if record does not exist", async () => {
|
||||||
|
vi.mocked(prisma.webhook.update).mockRejectedValueOnce(prismaNotFoundError);
|
||||||
|
const result = await updateWebhook("999", mockedWebhookUpdateReturn);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error?.type).toBe("not_found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns internal_server_error if other error occurs", async () => {
|
||||||
|
vi.mocked(prisma.webhook.update).mockRejectedValueOnce(new Error("Unknown error"));
|
||||||
|
const result = await updateWebhook("abc", mockedWebhookUpdateReturn);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error?.type).toBe("internal_server_error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteWebhook", () => {
|
||||||
|
test("returns ok on successful delete", async () => {
|
||||||
|
vi.mocked(prisma.webhook.delete).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn);
|
||||||
|
const result = await deleteWebhook("123");
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(webhookCache.revalidate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns not_found if record does not exist", async () => {
|
||||||
|
vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(prismaNotFoundError);
|
||||||
|
const result = await deleteWebhook("999");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error?.type).toBe("not_found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns internal_server_error on other errors", async () => {
|
||||||
|
vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(new Error("Delete error"));
|
||||||
|
const result = await deleteWebhook("abc");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error?.type).toBe("internal_server_error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { webhookCache } from "@/lib/cache/webhook";
|
||||||
|
import { webhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||||
|
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||||
|
import { Webhook } from "@prisma/client";
|
||||||
|
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||||
|
import { cache } from "@formbricks/lib/cache";
|
||||||
|
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||||
|
|
||||||
|
export const getWebhook = async (webhookId: string) =>
|
||||||
|
cache(
|
||||||
|
async (): Promise<Result<Webhook, ApiErrorResponseV2>> => {
|
||||||
|
try {
|
||||||
|
const webhook = await prisma.webhook.findUnique({
|
||||||
|
where: {
|
||||||
|
id: webhookId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!webhook) {
|
||||||
|
return err({
|
||||||
|
type: "not_found",
|
||||||
|
details: [{ field: "webhook", issue: "not found" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(webhook);
|
||||||
|
} catch (error) {
|
||||||
|
return err({
|
||||||
|
type: "internal_server_error",
|
||||||
|
details: [{ field: "webhook", issue: error.message }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[`management-getWebhook-${webhookId}`],
|
||||||
|
{
|
||||||
|
tags: [webhookCache.tag.byId(webhookId)],
|
||||||
|
}
|
||||||
|
)();
|
||||||
|
|
||||||
|
export const updateWebhook = async (
|
||||||
|
webhookId: string,
|
||||||
|
webhookInput: z.infer<typeof webhookUpdateSchema>
|
||||||
|
): Promise<Result<Webhook, ApiErrorResponseV2>> => {
|
||||||
|
try {
|
||||||
|
const updatedWebhook = await prisma.webhook.update({
|
||||||
|
where: {
|
||||||
|
id: webhookId,
|
||||||
|
},
|
||||||
|
data: webhookInput,
|
||||||
|
});
|
||||||
|
|
||||||
|
webhookCache.revalidate({
|
||||||
|
id: webhookId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ok(updatedWebhook);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PrismaClientKnownRequestError) {
|
||||||
|
if (
|
||||||
|
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||||
|
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||||
|
) {
|
||||||
|
return err({
|
||||||
|
type: "not_found",
|
||||||
|
details: [{ field: "webhook", issue: "not found" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err({
|
||||||
|
type: "internal_server_error",
|
||||||
|
details: [{ field: "webhook", issue: error.message }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteWebhook = async (webhookId: string): Promise<Result<Webhook, ApiErrorResponseV2>> => {
|
||||||
|
try {
|
||||||
|
const deletedWebhook = await prisma.webhook.delete({
|
||||||
|
where: {
|
||||||
|
id: webhookId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
webhookCache.revalidate({
|
||||||
|
id: deletedWebhook.id,
|
||||||
|
environmentId: deletedWebhook.environmentId,
|
||||||
|
source: deletedWebhook.source,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ok(deletedWebhook);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PrismaClientKnownRequestError) {
|
||||||
|
if (
|
||||||
|
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||||
|
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||||
|
) {
|
||||||
|
return err({
|
||||||
|
type: "not_found",
|
||||||
|
details: [{ field: "webhook", issue: "not found" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err({
|
||||||
|
type: "internal_server_error",
|
||||||
|
details: [{ field: "webhook", issue: error.message }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { responses } from "@/modules/api/v2/lib/response";
|
||||||
|
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||||
|
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
|
||||||
|
import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
|
||||||
|
import { getEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/helper";
|
||||||
|
import {
|
||||||
|
deleteWebhook,
|
||||||
|
getWebhook,
|
||||||
|
updateWebhook,
|
||||||
|
} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/webhook";
|
||||||
|
import {
|
||||||
|
webhookIdSchema,
|
||||||
|
webhookUpdateSchema,
|
||||||
|
} from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const GET = async (request: NextRequest, props: { params: Promise<{ webhookId: string }> }) =>
|
||||||
|
authenticatedApiClient({
|
||||||
|
request,
|
||||||
|
schemas: {
|
||||||
|
params: z.object({ webhookId: webhookIdSchema }),
|
||||||
|
},
|
||||||
|
externalParams: props.params,
|
||||||
|
handler: async ({ authentication, parsedInput }) => {
|
||||||
|
const { params } = parsedInput;
|
||||||
|
|
||||||
|
if (!params) {
|
||||||
|
return handleApiError(request, {
|
||||||
|
type: "bad_request",
|
||||||
|
details: [{ field: "params", issue: "missing" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhook = await getWebhook(params.webhookId);
|
||||||
|
|
||||||
|
if (!webhook.ok) {
|
||||||
|
return handleApiError(request, webhook.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkAuthorizationResult = await checkAuthorization({
|
||||||
|
authentication,
|
||||||
|
environmentId: webhook.ok ? webhook.data.environmentId : "",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!checkAuthorizationResult.ok) {
|
||||||
|
return handleApiError(request, checkAuthorizationResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses.successResponse(webhook);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PUT = async (request: NextRequest, props: { params: Promise<{ webhookId: string }> }) =>
|
||||||
|
authenticatedApiClient({
|
||||||
|
request,
|
||||||
|
schemas: {
|
||||||
|
params: z.object({ webhookId: webhookIdSchema }),
|
||||||
|
body: webhookUpdateSchema,
|
||||||
|
},
|
||||||
|
externalParams: props.params,
|
||||||
|
handler: async ({ authentication, parsedInput }) => {
|
||||||
|
const { params, body } = parsedInput;
|
||||||
|
|
||||||
|
if (!body || !params) {
|
||||||
|
return handleApiError(request, {
|
||||||
|
type: "bad_request",
|
||||||
|
details: [{ field: !body ? "body" : "params", issue: "missing" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// get surveys environment
|
||||||
|
const surveysEnvironmentId = await getEnvironmentIdFromSurveyIds(body.surveyIds);
|
||||||
|
|
||||||
|
if (!surveysEnvironmentId.ok) {
|
||||||
|
return handleApiError(request, surveysEnvironmentId.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// get webhook environment
|
||||||
|
const webhook = await getWebhook(params.webhookId);
|
||||||
|
|
||||||
|
if (!webhook.ok) {
|
||||||
|
return handleApiError(request, webhook.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// check webhook environment against the api key environment
|
||||||
|
const checkAuthorizationResult = await checkAuthorization({
|
||||||
|
authentication,
|
||||||
|
environmentId: webhook.ok ? webhook.data.environmentId : "",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!checkAuthorizationResult.ok) {
|
||||||
|
return handleApiError(request, checkAuthorizationResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if webhook environment matches the surveys environment
|
||||||
|
if (webhook.data.environmentId !== surveysEnvironmentId.data) {
|
||||||
|
return handleApiError(request, {
|
||||||
|
type: "bad_request",
|
||||||
|
details: [
|
||||||
|
{ field: "surveys id", issue: "webhook environment does not match the surveys environment" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedWebhook = await updateWebhook(params.webhookId, body);
|
||||||
|
|
||||||
|
if (!updatedWebhook.ok) {
|
||||||
|
return handleApiError(request, updatedWebhook.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses.successResponse(updatedWebhook);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DELETE = async (request: NextRequest, props: { params: Promise<{ webhookId: string }> }) =>
|
||||||
|
authenticatedApiClient({
|
||||||
|
request,
|
||||||
|
schemas: {
|
||||||
|
params: z.object({ webhookId: webhookIdSchema }),
|
||||||
|
},
|
||||||
|
externalParams: props.params,
|
||||||
|
handler: async ({ authentication, parsedInput }) => {
|
||||||
|
const { params } = parsedInput;
|
||||||
|
|
||||||
|
if (!params) {
|
||||||
|
return handleApiError(request, {
|
||||||
|
type: "bad_request",
|
||||||
|
details: [{ field: "params", issue: "missing" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhook = await getWebhook(params.webhookId);
|
||||||
|
|
||||||
|
if (!webhook.ok) {
|
||||||
|
return handleApiError(request, webhook.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkAuthorizationResult = await checkAuthorization({
|
||||||
|
authentication,
|
||||||
|
environmentId: webhook.ok ? webhook.data.environmentId : "",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!checkAuthorizationResult.ok) {
|
||||||
|
return handleApiError(request, checkAuthorizationResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedWebhook = await deleteWebhook(params.webhookId);
|
||||||
|
|
||||||
|
if (!deletedWebhook.ok) {
|
||||||
|
return handleApiError(request, deletedWebhook.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses.successResponse(deletedWebhook);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { extendZodWithOpenApi } from "zod-openapi";
|
||||||
|
import { ZWebhook } from "@formbricks/database/zod/webhooks";
|
||||||
|
|
||||||
|
extendZodWithOpenApi(z);
|
||||||
|
|
||||||
|
export const webhookIdSchema = z
|
||||||
|
.string()
|
||||||
|
.cuid2()
|
||||||
|
.openapi({
|
||||||
|
ref: "webhookId",
|
||||||
|
description: "The ID of the webhook",
|
||||||
|
param: {
|
||||||
|
name: "id",
|
||||||
|
in: "path",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const webhookUpdateSchema = ZWebhook.omit({
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
environmentId: true,
|
||||||
|
}).openapi({
|
||||||
|
ref: "webhookUpdate",
|
||||||
|
description: "A webhook to update.",
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
deleteWebhookEndpoint,
|
||||||
|
getWebhookEndpoint,
|
||||||
|
updateWebhookEndpoint,
|
||||||
|
} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/openapi";
|
||||||
|
import { ZGetWebhooksFilter, ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||||
|
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||||
|
import { ZWebhook } from "@formbricks/database/zod/webhooks";
|
||||||
|
|
||||||
|
export const getWebhooksEndpoint: ZodOpenApiOperationObject = {
|
||||||
|
operationId: "getWebhooks",
|
||||||
|
summary: "Get webhooks",
|
||||||
|
description: "Gets webhooks from the database.",
|
||||||
|
requestParams: {
|
||||||
|
query: ZGetWebhooksFilter.sourceType().required(),
|
||||||
|
},
|
||||||
|
tags: ["Management API > Webhooks"],
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
description: "Webhooks retrieved successfully.",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.array(responseWithMetaSchema(makePartialSchema(ZWebhook))),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||||
|
operationId: "createWebhook",
|
||||||
|
summary: "Create a webhook",
|
||||||
|
description: "Creates a webhook in the database.",
|
||||||
|
tags: ["Management API > Webhooks"],
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
description: "The webhook to create",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: ZWebhookInput,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
"201": {
|
||||||
|
description: "Webhook created successfully.",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: makePartialSchema(ZWebhook),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const webhookPaths: ZodOpenApiPathsObject = {
|
||||||
|
"/webhooks": {
|
||||||
|
get: getWebhooksEndpoint,
|
||||||
|
post: createWebhookEndpoint,
|
||||||
|
},
|
||||||
|
"/webhooks/{webhookId}": {
|
||||||
|
get: getWebhookEndpoint,
|
||||||
|
put: updateWebhookEndpoint,
|
||||||
|
delete: deleteWebhookEndpoint,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
|
||||||
|
import { TGetWebhooksFilter } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { getWebhooksQuery } from "../utils";
|
||||||
|
|
||||||
|
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||||
|
pickCommonFilter: vi.fn(),
|
||||||
|
buildCommonFilterQuery: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("getWebhooksQuery", () => {
|
||||||
|
const environmentId = "env-123";
|
||||||
|
|
||||||
|
it("adds surveyIds condition when provided", () => {
|
||||||
|
const params = { surveyIds: ["survey1"] } as TGetWebhooksFilter;
|
||||||
|
const result = getWebhooksQuery(environmentId, params);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result?.where).toMatchObject({
|
||||||
|
environmentId,
|
||||||
|
surveyIds: { hasSome: ["survey1"] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls pickCommonFilter and buildCommonFilterQuery when baseFilter is present", () => {
|
||||||
|
vi.mocked(pickCommonFilter).mockReturnValue({ someFilter: "test" } as any);
|
||||||
|
getWebhooksQuery(environmentId, { surveyIds: ["survey1"] } as TGetWebhooksFilter);
|
||||||
|
expect(pickCommonFilter).toHaveBeenCalled();
|
||||||
|
expect(buildCommonFilterQuery).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buildCommonFilterQuery is not called if no baseFilter is picked", () => {
|
||||||
|
vi.mocked(pickCommonFilter).mockReturnValue(undefined as any);
|
||||||
|
getWebhooksQuery(environmentId, {} as any);
|
||||||
|
expect(buildCommonFilterQuery).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { webhookCache } from "@/lib/cache/webhook";
|
||||||
|
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||||
|
import { WebhookSource } from "@prisma/client";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { captureTelemetry } from "@formbricks/lib/telemetry";
|
||||||
|
import { createWebhook, getWebhooks } from "../webhook";
|
||||||
|
|
||||||
|
vi.mock("@formbricks/database", () => ({
|
||||||
|
prisma: {
|
||||||
|
$transaction: vi.fn(),
|
||||||
|
webhook: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
count: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/cache/webhook", () => ({
|
||||||
|
webhookCache: {
|
||||||
|
revalidate: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@formbricks/lib/telemetry", () => ({
|
||||||
|
captureTelemetry: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("getWebhooks", () => {
|
||||||
|
const environmentId = "env1";
|
||||||
|
const params = {
|
||||||
|
limit: 10,
|
||||||
|
skip: 0,
|
||||||
|
};
|
||||||
|
const fakeWebhooks = [
|
||||||
|
{ id: "w1", environmentId, name: "Webhook One" },
|
||||||
|
{ id: "w2", environmentId, name: "Webhook Two" },
|
||||||
|
];
|
||||||
|
const count = fakeWebhooks.length;
|
||||||
|
|
||||||
|
it("returns ok response with webhooks and meta", async () => {
|
||||||
|
vi.mocked(prisma.$transaction).mockResolvedValueOnce([fakeWebhooks, count]);
|
||||||
|
|
||||||
|
const result = await getWebhooks(environmentId, params as TGetWebhooksFilter);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.data.data).toEqual(fakeWebhooks);
|
||||||
|
expect(result.data.meta).toEqual({
|
||||||
|
total: count,
|
||||||
|
limit: params.limit,
|
||||||
|
offset: params.skip,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error when prisma.$transaction throws", async () => {
|
||||||
|
vi.mocked(prisma.$transaction).mockRejectedValueOnce(new Error("Test error"));
|
||||||
|
|
||||||
|
const result = await getWebhooks(environmentId, params as TGetWebhooksFilter);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error?.type).toEqual("internal_server_error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createWebhook", () => {
|
||||||
|
const inputWebhook = {
|
||||||
|
environmentId: "env1",
|
||||||
|
name: "New Webhook",
|
||||||
|
url: "http://example.com",
|
||||||
|
source: "user" as WebhookSource,
|
||||||
|
triggers: ["trigger1"],
|
||||||
|
surveyIds: ["s1", "s2"],
|
||||||
|
} as unknown as TWebhookInput;
|
||||||
|
|
||||||
|
const createdWebhook = {
|
||||||
|
id: "w100",
|
||||||
|
environmentId: inputWebhook.environmentId,
|
||||||
|
name: inputWebhook.name,
|
||||||
|
url: inputWebhook.url,
|
||||||
|
source: inputWebhook.source,
|
||||||
|
triggers: inputWebhook.triggers,
|
||||||
|
surveyIds: inputWebhook.surveyIds,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("creates a webhook and revalidates cache", async () => {
|
||||||
|
vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook);
|
||||||
|
|
||||||
|
const result = await createWebhook(inputWebhook);
|
||||||
|
expect(captureTelemetry).toHaveBeenCalledWith("webhook_created");
|
||||||
|
expect(prisma.webhook.create).toHaveBeenCalled();
|
||||||
|
expect(webhookCache.revalidate).toHaveBeenCalledWith({
|
||||||
|
environmentId: createdWebhook.environmentId,
|
||||||
|
source: createdWebhook.source,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.data).toEqual(createdWebhook);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error when creation fails", async () => {
|
||||||
|
vi.mocked(prisma.webhook.create).mockRejectedValueOnce(new Error("Creation failed"));
|
||||||
|
|
||||||
|
const result = await createWebhook(inputWebhook);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error.type).toEqual("internal_server_error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user