Compare commits

..

4 Commits

Author SHA1 Message Date
Dhruwang
734f1f4bf2 added cache no-store when formbricksDebug is enabled 2025-04-01 14:01:15 +07:00
pandeymangg
752cdd6ee9 fix tests 2025-03-31 18:11:33 +07:00
pandeymangg
1672eefe50 Merge branch 'main' into fix/hidden-fields-backwards-compatibility 2025-03-31 17:50:30 +07:00
pandeymangg
fecedb7d30 fix: hidden fields backwards compatibility 2025-03-31 17:50:04 +07:00
334 changed files with 5329 additions and 16890 deletions

View File

@@ -117,7 +117,7 @@ IMPRINT_URL=
IMPRINT_ADDRESS=
# Configure Turnstile in signup flow
# TURNSTILE_SITE_KEY=
# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
# TURNSTILE_SECRET_KEY=
# Configure Github Login

View File

@@ -1,163 +0,0 @@
name: Docker Build Validation
on:
pull_request:
branches:
- main
merge_group:
branches:
- main
workflow_dispatch:
permissions:
contents: read
jobs:
validate-docker-build:
name: Validate Docker Build
runs-on: ubuntu-latest
# Add PostgreSQL service container
services:
postgres:
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: formbricks
ports:
- 5432:5432
# Health check to ensure PostgreSQL is ready before using it
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker Image
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/web/Dockerfile
push: false
load: true
tags: formbricks-test:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
- name: Verify PostgreSQL Connection
run: |
echo "Verifying PostgreSQL connection..."
# Install PostgreSQL client to test connection
sudo apt-get update && sudo apt-get install -y postgresql-client
# Test connection using psql
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL"
# Show network configuration
echo "Network configuration:"
ip addr show
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
- name: Test Docker Image with Health Check
shell: bash
run: |
echo "🧪 Testing if the Docker image starts correctly..."
# Add extra docker run args to support host.docker.internal on Linux
DOCKER_RUN_ARGS="--add-host=host.docker.internal:host-gateway"
# Start the container with host.docker.internal pointing to the host
docker run --name formbricks-test \
$DOCKER_RUN_ARGS \
-p 3000:3000 \
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
-d formbricks-test:${{ github.sha }}
# Give it more time to start up
echo "Waiting 45 seconds for application to start..."
sleep 45
# Check if the container is running
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then
echo "❌ Container failed to start properly!"
docker logs formbricks-test
exit 1
else
echo "✅ Container started successfully!"
fi
# Try connecting to PostgreSQL from inside the container
echo "Testing PostgreSQL connection from inside container..."
docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"'
# Try to access the health endpoint
echo "🏥 Testing /health endpoint..."
MAX_RETRIES=10
RETRY_COUNT=0
HEALTH_CHECK_SUCCESS=false
set +e # Disable exit on error to allow for retries
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "Attempt $RETRY_COUNT of $MAX_RETRIES..."
# Show container logs before each attempt to help debugging
if [ $RETRY_COUNT -gt 1 ]; then
echo "📋 Current container logs:"
docker logs --tail 20 formbricks-test
fi
# Get detailed curl output for debugging
HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1)
CURL_EXIT_CODE=$?
echo "Curl exit code: $CURL_EXIT_CODE"
echo "Curl output: $HTTP_OUTPUT"
if [ $CURL_EXIT_CODE -eq 0 ]; then
STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+")
echo "Status code detected: $STATUS_CODE"
if [ "$STATUS_CODE" = "200" ]; then
echo "✅ Health check successful!"
HEALTH_CHECK_SUCCESS=true
break
else
echo "❌ Health check returned non-200 status code: $STATUS_CODE"
fi
else
echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE"
fi
echo "Waiting 15 seconds before next attempt..."
sleep 15
done
# Show full container logs for debugging
echo "📋 Full container logs:"
docker logs formbricks-test
# Clean up the container
echo "🧹 Cleaning up..."
docker rm -f formbricks-test
# Exit with failure if health check did not succeed
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
echo "❌ Health check failed after $MAX_RETRIES attempts"
exit 1
fi
echo "✨ Docker validation complete - all checks passed!"

View File

@@ -4,7 +4,7 @@ on:
push:
branches:
- main
pull_request_target:
pull_request:
types: [opened, synchronize, reopened]
merge_group:
permissions:
@@ -23,10 +23,10 @@ jobs:
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Setup Node.js 22.x
- name: Setup Node.js 20.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 22.x
node-version: 20.x
- name: Install pnpm
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
@@ -48,7 +48,7 @@ jobs:
run: |
pnpm test:coverage
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97
uses: SonarSource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

View File

@@ -18,7 +18,7 @@
"expo-status-bar": "2.0.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.78.2",
"react-native": "0.76.6",
"react-native-webview": "13.12.5"
},
"devDependencies": {

View File

@@ -27,7 +27,7 @@ const secondaryNavigation = [
export function Sidebar(): React.JSX.Element {
return (
<div className="flex grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5">
<div className="flex flex-grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5">
<nav
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
aria-label="Sidebar">
@@ -41,7 +41,7 @@ export function Sidebar(): React.JSX.Element {
"group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6"
)}
aria-current={item.current ? "page" : undefined}>
<item.icon className="mr-4 h-6 w-6 shrink-0 text-cyan-200" aria-hidden="true" />
<item.icon className="mr-4 h-6 w-6 flex-shrink-0 text-cyan-200" aria-hidden="true" />
{item.name}
</a>
))}

View File

@@ -1,23 +1,3 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@custom-variant dark (&:is(.dark *));
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -1,6 +1,6 @@
{
"name": "@formbricks/demo",
"version": "0.0.0",
"version": "0.1.0",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
@@ -12,14 +12,10 @@
},
"dependencies": {
"@formbricks/js": "workspace:*",
"@tailwindcss/forms": "0.5.9",
"@tailwindcss/postcss": "4.1.3",
"lucide-react": "0.486.0",
"next": "15.2.4",
"postcss": "8.5.3",
"lucide-react": "0.468.0",
"next": "15.2.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"tailwindcss": "4.1.3"
"react-dom": "19.0.0"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",

View File

@@ -96,7 +96,7 @@ export default function AppPage(): React.JSX.Element {
<p className="text-slate-700 dark:text-slate-300">
Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env
</p>
<Image src={fbsetup} alt="fb setup" className="rounded-xs mt-4" priority />
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded" priority />
<div className="mt-4 flex-col items-start text-sm text-slate-700 sm:flex sm:items-center sm:text-base dark:text-slate-300">
<p className="mb-1 sm:mb-0 sm:mr-2">You&apos;re connected with env:</p>

View File

@@ -1,5 +1,6 @@
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx}",
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
darkMode: "class",
theme: {
extend: {},
},
plugins: [require("@tailwindcss/forms")],
};

View File

@@ -11,30 +11,30 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"eslint-plugin-react-refresh": "0.4.19",
"react": "19.1.0",
"react-dom": "19.1.0"
"eslint-plugin-react-refresh": "0.4.16",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@chromatic-com/storybook": "3.2.6",
"@chromatic-com/storybook": "3.2.2",
"@formbricks/config-typescript": "workspace:*",
"@storybook/addon-a11y": "8.6.12",
"@storybook/addon-essentials": "8.6.12",
"@storybook/addon-interactions": "8.6.12",
"@storybook/addon-links": "8.6.12",
"@storybook/addon-onboarding": "8.6.12",
"@storybook/blocks": "8.6.12",
"@storybook/react": "8.6.12",
"@storybook/react-vite": "8.6.12",
"@storybook/test": "8.6.12",
"@typescript-eslint/eslint-plugin": "8.29.0",
"@typescript-eslint/parser": "8.29.0",
"@storybook/addon-a11y": "8.4.7",
"@storybook/addon-essentials": "8.4.7",
"@storybook/addon-interactions": "8.4.7",
"@storybook/addon-links": "8.4.7",
"@storybook/addon-onboarding": "8.4.7",
"@storybook/blocks": "8.4.7",
"@storybook/react": "8.4.7",
"@storybook/react-vite": "8.4.7",
"@storybook/test": "8.4.7",
"@typescript-eslint/eslint-plugin": "8.18.0",
"@typescript-eslint/parser": "8.18.0",
"@vitejs/plugin-react": "4.3.4",
"esbuild": "0.25.2",
"eslint-plugin-storybook": "0.12.0",
"esbuild": "0.25.1",
"eslint-plugin-storybook": "0.11.1",
"prop-types": "15.8.1",
"storybook": "8.6.12",
"tsup": "8.4.0",
"vite": "6.2.4"
"storybook": "8.4.7",
"tsup": "8.3.5",
"vite": "6.0.12"
}
}

View File

@@ -22,7 +22,7 @@ RUN npm install -g corepack@latest
RUN corepack enable
# Install necessary build tools and compilers
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
# BuildKit secret handling without hardcoded fallback values
# This approach relies entirely on secrets passed from GitHub Actions
@@ -40,6 +40,8 @@ RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
chmod +x /tmp/read-secrets.sh
ARG SENTRY_AUTH_TOKEN
# Increase Node.js memory limit as a regular build argument
ARG NODE_OPTIONS="--max_old_space_size=4096"
ENV NODE_OPTIONS=${NODE_OPTIONS}
@@ -85,60 +87,31 @@ RUN apk add --no-cache curl \
WORKDIR /home/nextjs
# Ensure no write permissions are assigned to the copied resources
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/standalone ./
RUN chmod -R 755 ./
COPY --from=installer /app/apps/web/next.config.mjs .
RUN chmod 644 ./next.config.mjs
COPY --from=installer /app/apps/web/package.json .
RUN chmod 644 ./package.json
# Leverage output traces to reduce image size
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/standalone ./
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/static ./apps/web/.next/static
RUN chmod -R 755 ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/public ./apps/web/public
RUN chmod -R 755 ./apps/web/public
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chmod 644 ./packages/database/schema.prisma
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json
RUN chmod 644 ./packages/database/package.json
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration
RUN chmod -R 755 ./packages/database/migration
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src
RUN chmod -R 755 ./packages/database/src
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/node_modules ./packages/database/node_modules
RUN chmod -R 755 ./packages/database/node_modules
COPY --from=installer --chown=nextjs:nextjs /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
RUN chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
# Copy Prisma-specific generated files
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chmod -R 755 ./node_modules/@prisma/client
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/.prisma ./node_modules/.prisma
RUN chmod -R 755 ./node_modules/.prisma
COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt .
RUN chmod 644 ./prisma_version.txt
COPY /docker/cronjobs /app/docker/cronjobs
RUN chmod -R 755 /app/docker/cronjobs
# Copy required dependencies
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
RUN npm install -g tsx typescript prisma pino-pretty

View File

@@ -7,7 +7,7 @@ import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-bann
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
import { getTranslate } from "@/tolgee/server";
import type { Session } from "next-auth";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
@@ -111,7 +111,6 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
organizationProjectsLimit={organizationProjectsLimit}
user={user}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membershipRole}
isMultiOrgEnabled={isMultiOrgEnabled}
isLicenseActive={active}

View File

@@ -63,7 +63,6 @@ interface NavigationProps {
projects: TProject[];
isMultiOrgEnabled: boolean;
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
organizationProjectsLimit: number;
isLicenseActive: boolean;
@@ -80,7 +79,6 @@ export const MainNavigation = ({
isFormbricksCloud,
organizationProjectsLimit,
isLicenseActive,
isDevelopment,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -298,7 +296,7 @@ export const MainNavigation = ({
<div>
{/* New Version Available */}
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && (
<Link
href="https://github.com/formbricks/formbricks/releases"
target="_blank"

View File

@@ -1,7 +1,9 @@
// PosthogIdentify.test.tsx
import "@testing-library/jest-dom/vitest";
import { cleanup, render } from "@testing-library/react";
import { Session } from "next-auth";
import { usePostHog } from "posthog-js/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";

View File

@@ -0,0 +1,3 @@
import { APIKeysLoading } from "@/modules/projects/settings/api-keys/loading";
export default APIKeysLoading;

View File

@@ -0,0 +1,3 @@
import { APIKeysPage } from "@/modules/projects/settings/api-keys/page";
export default APIKeysPage;

View File

@@ -1,6 +0,0 @@
import Loading from "@/modules/organization/settings/api-keys/loading";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
export default function LoadingPage() {
return <Loading isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
}

View File

@@ -1,3 +0,0 @@
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
export default APIKeysPage;

View File

@@ -54,12 +54,6 @@ export const OrganizationSettingsNavbar = ({
hidden: isFormbricksCloud || isPricingDisabled,
current: pathname?.includes("/enterprise"),
},
{
id: "api-keys",
label: t("common.api_keys"),
href: `/environments/${environmentId}/settings/api-keys`,
current: pathname?.includes("/api-keys"),
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;

View File

@@ -33,16 +33,12 @@ vi.mock("@formbricks/lib/constants", () => ({
WEBAPP_URL: "mock-webapp-url",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
AI_AZURE_LLM_RESSOURCE_NAME: "mock-ai-azure-llm-ressource-name",
AI_AZURE_LLM_API_KEY: "mock-ai",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-ai-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-ai-azure-embeddings-ressource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-ai-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-ai-azure-embeddings-deployment-id",
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
}));
vi.mock("@/tolgee/server", () => ({

View File

@@ -47,6 +47,12 @@ vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
vi.mock("@/modules/ui/components/no-mobile-overlay", () => ({
NoMobileOverlay: () => <div data-testid="no-mobile-overlay" />,
}));
vi.mock("@/modules/ui/components/post-hog-client", () => ({
PHProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="ph-provider">{children}</div>
),
PostHogPageview: () => <div data-testid="ph-pageview" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="toaster-client" />,
}));
@@ -68,6 +74,8 @@ describe("(app) AppLayout", () => {
render(element);
expect(screen.getByTestId("no-mobile-overlay")).toBeInTheDocument();
expect(screen.getByTestId("ph-pageview")).toBeInTheDocument();
expect(screen.getByTestId("ph-provider")).toBeInTheDocument();
expect(screen.getByTestId("mock-intercom-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children");

View File

@@ -1,7 +1,6 @@
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
@@ -14,11 +13,6 @@ const AppLayout = async ({ children }) => {
const session = await getServerSession(authOptions);
const user = session?.user?.id ? await getUser(session.user.id) : null;
// If user account is deactivated, log them out instead of rendering the app
if (user?.isActive === false) {
return <ClientLogout />;
}
return (
<>
<NoMobileOverlay />

View File

@@ -1,178 +0,0 @@
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import { authenticateRequest } from "./auth";
vi.mock("@formbricks/database", () => ({
prisma: {
apiKey: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
hashApiKey: vi.fn(),
}));
describe("getApiKeyWithPermissions", () => {
it("should return API key data with permissions when valid key is provided", async () => {
const mockApiKeyData = {
id: "api-key-id",
organizationId: "org-id",
hashedKey: "hashed-key",
createdAt: new Date(),
createdBy: "user-id",
lastUsedAt: null,
label: "Test API Key",
apiKeyEnvironments: [
{
environmentId: "env-1",
permission: "manage" as const,
environment: { id: "env-1" },
},
],
};
vi.mocked(hashApiKey).mockReturnValue("hashed-key");
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
const result = await getApiKeyWithPermissions("test-api-key");
expect(result).toEqual(mockApiKeyData);
expect(prisma.apiKey.update).toHaveBeenCalledWith({
where: { id: "api-key-id" },
data: { lastUsedAt: expect.any(Date) },
});
});
it("should return null when API key is not found", async () => {
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
const result = await getApiKeyWithPermissions("invalid-key");
expect(result).toBeNull();
});
});
describe("hasPermission", () => {
const permissions: TAPIKeyEnvironmentPermission[] = [
{
environmentId: "env-1",
permission: "manage",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
},
{
environmentId: "env-2",
permission: "write",
environmentType: "production",
projectId: "project-2",
projectName: "Project 2",
},
{
environmentId: "env-3",
permission: "read",
environmentType: "development",
projectId: "project-3",
projectName: "Project 3",
},
];
it("should return true for manage permission with any method", () => {
expect(hasPermission(permissions, "env-1", "GET")).toBe(true);
expect(hasPermission(permissions, "env-1", "POST")).toBe(true);
expect(hasPermission(permissions, "env-1", "DELETE")).toBe(true);
});
it("should handle write permission correctly", () => {
expect(hasPermission(permissions, "env-2", "GET")).toBe(true);
expect(hasPermission(permissions, "env-2", "POST")).toBe(true);
expect(hasPermission(permissions, "env-2", "DELETE")).toBe(false);
});
it("should handle read permission correctly", () => {
expect(hasPermission(permissions, "env-3", "GET")).toBe(true);
expect(hasPermission(permissions, "env-3", "POST")).toBe(false);
expect(hasPermission(permissions, "env-3", "DELETE")).toBe(false);
});
it("should return false for non-existent environment", () => {
expect(hasPermission(permissions, "env-4", "GET")).toBe(false);
});
});
describe("authenticateRequest", () => {
it("should return authentication data for valid API key", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
const mockApiKeyData = {
id: "api-key-id",
organizationId: "org-id",
hashedKey: "hashed-key",
createdAt: new Date(),
createdBy: "user-id",
lastUsedAt: null,
label: "Test API Key",
apiKeyEnvironments: [
{
environmentId: "env-1",
permission: "manage" as const,
environment: {
id: "env-1",
projectId: "project-1",
project: { name: "Project 1" },
type: "development",
},
},
],
};
vi.mocked(hashApiKey).mockReturnValue("hashed-key");
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
const result = await authenticateRequest(request);
expect(result).toEqual({
type: "apiKey",
environmentPermissions: [
{
environmentId: "env-1",
permission: "manage",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
},
],
hashedApiKey: "hashed-key",
apiKeyId: "api-key-id",
organizationId: "org-id",
});
});
it("should return null when no API key is provided", async () => {
const request = new Request("http://localhost");
const result = await authenticateRequest(request);
expect(result).toBeNull();
});
it("should return null when API key is invalid", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "invalid-api-key" },
});
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
const result = await authenticateRequest(request);
expect(result).toBeNull();
});
});

View File

@@ -1,38 +1,25 @@
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
import { responses } from "@/app/lib/api/response";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
export const authenticateRequest = async (request: Request): Promise<TAuthenticationApiKey | null> => {
const apiKey = request.headers.get("x-api-key");
if (!apiKey) return null;
// Get API key with permissions
const apiKeyData = await getApiKeyWithPermissions(apiKey);
if (!apiKeyData) return null;
// In the route handlers, we'll do more specific permission checks
const environmentIds = apiKeyData.apiKeyEnvironments.map((env) => env.environmentId);
if (environmentIds.length === 0) return null;
const hashedApiKey = hashApiKey(apiKey);
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
environmentId: env.environmentId,
environmentType: env.environment.type,
permission: env.permission,
projectId: env.environment.projectId,
projectName: env.environment.project.name,
})),
hashedApiKey,
apiKeyId: apiKeyData.id,
organizationId: apiKeyData.organizationId,
organizationAccess: apiKeyData.organizationAccess,
};
return authentication;
if (apiKey) {
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (environmentId) {
const hashedApiKey = hashApiKey(apiKey);
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentId,
hashedApiKey,
};
return authentication;
}
return null;
}
return null;
};
export const handleErrorResponse = (error: any): Response => {

View File

@@ -0,0 +1,49 @@
import { apiKeyCache } from "@/lib/cache/api-key";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { getHash } from "@formbricks/lib/crypto";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZString } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string): Promise<string | null> => {
const hashedKey = getHash(apiKey);
return cache(
async () => {
validateInputs([apiKey, ZString]);
if (!apiKey) {
throw new InvalidInputError("API key cannot be null or undefined.");
}
try {
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey,
},
select: {
environmentId: true,
},
});
if (!apiKeyData) {
throw new ResourceNotFoundError("apiKey", apiKey);
}
return apiKeyData.environmentId;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`management-api-getEnvironmentIdFromApiKey-${apiKey}`],
{
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
}
)();
});

View File

@@ -1,7 +1,6 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
@@ -9,20 +8,15 @@ import { TAuthenticationApiKey } from "@formbricks/types/auth";
const fetchAndAuthorizeActionClass = async (
authentication: TAuthenticationApiKey,
actionClassId: string,
method: "GET" | "POST" | "PUT" | "DELETE"
actionClassId: string
): Promise<TActionClass | null> => {
// Get the action class
const actionClass = await getActionClass(actionClassId);
if (!actionClass) {
return null;
}
// Check if API key has permission to access this environment with appropriate permissions
if (!hasPermission(authentication.environmentPermissions, actionClass.environmentId, method)) {
if (actionClass.environmentId !== authentication.environmentId) {
throw new Error("Unauthorized");
}
return actionClass;
};
@@ -34,7 +28,7 @@ export const GET = async (
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "GET");
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId);
if (actionClass) {
return responses.successResponse(actionClass);
}
@@ -52,7 +46,7 @@ export const PUT = async (
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT");
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId);
if (!actionClass) {
return responses.notFoundResponse("Action Class", params.actionClassId);
}
@@ -94,7 +88,7 @@ export const DELETE = async (
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE");
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId);
if (!actionClass) {
return responses.notFoundResponse("Action Class", params.actionClassId);
}

View File

@@ -1,88 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { getActionClasses } from "./action-classes";
// Mock the prisma client
vi.mock("@formbricks/database", () => ({
prisma: {
actionClass: {
findMany: vi.fn(),
},
},
}));
describe("getActionClasses", () => {
const mockEnvironmentIds = ["env1", "env2"];
const mockActionClasses = [
{
id: "action1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Action 1",
description: "Test Description 1",
type: "click",
key: "test-key-1",
noCodeConfig: {},
environmentId: "env1",
},
{
id: "action2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Action 2",
description: "Test Description 2",
type: "pageview",
key: "test-key-2",
noCodeConfig: {},
environmentId: "env2",
},
];
beforeEach(() => {
vi.clearAllMocks();
});
it("should successfully fetch action classes for given environment IDs", async () => {
// Mock the prisma findMany response
vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses);
const result = await getActionClasses(mockEnvironmentIds);
expect(result).toEqual(mockActionClasses);
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
where: {
environmentId: { in: mockEnvironmentIds },
},
select: expect.any(Object),
orderBy: {
createdAt: "asc",
},
});
});
it("should throw DatabaseError when prisma query fails", async () => {
// Mock the prisma findMany to throw an error
vi.mocked(prisma.actionClass.findMany).mockRejectedValue(new Error("Database error"));
await expect(getActionClasses(mockEnvironmentIds)).rejects.toThrow(DatabaseError);
});
it("should handle empty environment IDs array", async () => {
// Mock the prisma findMany response
vi.mocked(prisma.actionClass.findMany).mockResolvedValue([]);
const result = await getActionClasses([]);
expect(result).toEqual([]);
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
where: {
environmentId: { in: [] },
},
select: expect.any(Object),
orderBy: {
createdAt: "asc",
},
});
});
});

View File

@@ -1,51 +0,0 @@
"use server";
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { actionClassCache } from "@formbricks/lib/actionClass/cache";
import { cache } from "@formbricks/lib/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { TActionClass } from "@formbricks/types/action-classes";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
const selectActionClass = {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
key: true,
noCodeConfig: true,
environmentId: true,
} satisfies Prisma.ActionClassSelect;
export const getActionClasses = reactCache(
async (environmentIds: string[]): Promise<TActionClass[]> =>
cache(
async () => {
validateInputs([environmentIds, ZId.array()]);
try {
return await prisma.actionClass.findMany({
where: {
environmentId: { in: environmentIds },
},
select: selectActionClass,
orderBy: {
createdAt: "asc",
},
});
} catch (error) {
throw new DatabaseError(`Database error when fetching actions for environment ${environmentIds}`);
}
},
environmentIds.map((environmentId) => `getActionClasses-management-api-${environmentId}`),
{
tags: environmentIds.map((environmentId) => actionClassCache.tag.byEnvironmentId(environmentId)),
}
)()
);

View File

@@ -1,24 +1,16 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { createActionClass } from "@formbricks/lib/actionClass/service";
import { createActionClass, getActionClasses } from "@formbricks/lib/actionClass/service";
import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors";
import { getActionClasses } from "./lib/action-classes";
export const GET = async (request: Request) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const actionClasses = await getActionClasses(environmentIds);
const actionClasses: TActionClass[] = await getActionClasses(authentication.environmentId!);
return responses.successResponse(actionClasses);
} catch (error) {
if (error instanceof DatabaseError) {
@@ -43,12 +35,6 @@ export const POST = async (request: Request): Promise<Response> => {
const inputValidation = ZActionClassInput.safeParse(actionClassInput);
const environmentId = actionClassInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
@@ -57,7 +43,10 @@ export const POST = async (request: Request): Promise<Response> => {
);
}
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
const actionClass: TActionClass = await createActionClass(
authentication.environmentId!,
inputValidation.data
);
return responses.successResponse(actionClass);
} catch (error) {
if (error instanceof DatabaseError) {

View File

@@ -12,56 +12,29 @@ export const GET = async () => {
hashedKey: hashApiKey(apiKey),
},
select: {
apiKeyEnvironments: {
environment: {
select: {
environment: {
id: true,
createdAt: true,
updatedAt: true,
type: true,
project: {
select: {
id: true,
type: true,
createdAt: true,
updatedAt: true,
projectId: true,
widgetSetupCompleted: true,
project: {
select: {
id: true,
name: true,
},
},
name: true,
},
},
permission: true,
appSetupCompleted: true,
},
},
},
});
if (!apiKeyData) {
return new Response("Not authenticated", {
status: 401,
});
}
if (
apiKeyData.apiKeyEnvironments.length === 1 &&
apiKeyData.apiKeyEnvironments[0].permission === "manage"
) {
return Response.json({
id: apiKeyData.apiKeyEnvironments[0].environment.id,
type: apiKeyData.apiKeyEnvironments[0].environment.type,
createdAt: apiKeyData.apiKeyEnvironments[0].environment.createdAt,
updatedAt: apiKeyData.apiKeyEnvironments[0].environment.updatedAt,
widgetSetupCompleted: apiKeyData.apiKeyEnvironments[0].environment.widgetSetupCompleted,
project: {
id: apiKeyData.apiKeyEnvironments[0].environment.projectId,
name: apiKeyData.apiKeyEnvironments[0].environment.project.name,
},
});
} else {
return new Response("You can't use this method with this API key", {
status: 400,
});
}
return Response.json(apiKeyData.environment);
} else {
const sessionUser = await getSessionUser();
if (!sessionUser) {

View File

@@ -1,33 +1,32 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { logger } from "@formbricks/logger";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
import { TResponse, ZResponseUpdateInput } from "@formbricks/types/responses";
async function fetchAndAuthorizeResponse(
responseId: string,
authentication: any,
requiredPermission: "GET" | "PUT" | "DELETE"
) {
const fetchAndValidateResponse = async (authentication: any, responseId: string): Promise<TResponse> => {
const response = await getResponse(responseId);
if (!response) {
return { error: responses.notFoundResponse("Response", responseId) };
if (!response || !(await canUserAccessResponse(authentication, response))) {
throw new Error("Unauthorized");
}
return response;
};
const canUserAccessResponse = async (authentication: any, response: TResponse): Promise<boolean> => {
const survey = await getSurvey(response.surveyId);
if (!survey) {
return { error: responses.notFoundResponse("Survey", response.surveyId, true) };
}
if (!survey) return false;
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, requiredPermission)) {
return { error: responses.unauthorizedResponse() };
if (authentication.type === "session") {
return await hasUserEnvironmentAccess(authentication.session.user.id, survey.environmentId);
} else if (authentication.type === "apiKey") {
return survey.environmentId === authentication.environmentId;
} else {
throw Error("Unknown authentication type");
}
return { response };
}
};
export const GET = async (
request: Request,
@@ -37,11 +36,11 @@ export const GET = async (
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET");
if (result.error) return result.error;
return responses.successResponse(result.response);
const response = await fetchAndValidateResponse(authentication, params.responseId);
if (response) {
return responses.successResponse(response);
}
return responses.notFoundResponse("Response", params.responseId);
} catch (error) {
return handleErrorResponse(error);
}
@@ -55,10 +54,10 @@ export const DELETE = async (
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE");
if (result.error) return result.error;
const response = await fetchAndValidateResponse(authentication, params.responseId);
if (!response) {
return responses.notFoundResponse("Response", params.responseId);
}
const deletedResponse = await deleteResponse(params.responseId);
return responses.successResponse(deletedResponse);
} catch (error) {
@@ -74,10 +73,7 @@ export const PUT = async (
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT");
if (result.error) return result.error;
await fetchAndValidateResponse(authentication, params.responseId);
let responseUpdate;
try {
responseUpdate = await request.json();

View File

@@ -1,8 +1,6 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import {
getMonthlyOrganizationResponseCount,
@@ -10,13 +8,11 @@ import {
} from "@formbricks/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer";
import { responseCache } from "@formbricks/lib/response/cache";
import { getResponseContact } from "@formbricks/lib/response/service";
import { calculateTtcTotal } from "@formbricks/lib/response/utils";
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
@@ -29,7 +25,6 @@ export const responseSelection = {
updatedAt: true,
surveyId: true,
finished: true,
endingId: true,
data: true,
meta: true,
ttc: true,
@@ -198,53 +193,3 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
throw error;
}
};
export const getResponsesByEnvironmentIds = reactCache(
async (environmentIds: string[], limit?: number, offset?: number): Promise<TResponse[]> =>
cache(
async () => {
validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
try {
const responses = await prisma.response.findMany({
where: {
survey: {
environmentId: { in: environmentIds },
},
},
select: responseSelection,
orderBy: [
{
createdAt: "desc",
},
],
take: limit ? limit : undefined,
skip: offset ? offset : undefined,
});
const transformedResponses: TResponse[] = await Promise.all(
responses.map((responsePrisma) => {
return {
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
})
);
return transformedResponses;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
environmentIds.map(
(environmentId) => `getResponses-management-api-${environmentId}-${limit}-${offset}`
),
{
tags: environmentIds.map((environmentId) => responseCache.tag.byEnvironmentId(environmentId)),
}
)()
);

View File

@@ -1,14 +1,13 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { getResponses } from "@formbricks/lib/response/service";
import { getResponses, getResponsesByEnvironmentId } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { createResponse, getResponsesByEnvironmentIds } from "./lib/response";
import { createResponse } from "./lib/response";
export const GET = async (request: NextRequest) => {
const searchParams = request.nextUrl.searchParams;
@@ -19,26 +18,14 @@ export const GET = async (request: NextRequest) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
let allResponses: TResponse[] = [];
let environmentResponses: TResponse[] = [];
if (surveyId) {
const survey = await getSurvey(surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", surveyId, true);
}
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
return responses.unauthorizedResponse();
}
const surveyResponses = await getResponses(surveyId, limit, offset);
allResponses.push(...surveyResponses);
environmentResponses = await getResponses(surveyId, limit, offset);
} else {
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset);
allResponses.push(...environmentResponses);
environmentResponses = await getResponsesByEnvironmentId(authentication.environmentId, limit, offset);
}
return responses.successResponse(allResponses);
return responses.successResponse(environmentResponses);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
@@ -52,6 +39,8 @@ export const POST = async (request: Request): Promise<Response> => {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const environmentId = authentication.environmentId;
let jsonInput;
try {
@@ -61,6 +50,9 @@ export const POST = async (request: Request): Promise<Response> => {
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
// add environmentId to response
jsonInput.environmentId = environmentId;
const inputValidation = ZResponseInput.safeParse(jsonInput);
if (!inputValidation.success) {
@@ -73,12 +65,6 @@ export const POST = async (request: Request): Promise<Response> => {
const responseInput = inputValidation.data;
const environmentId = responseInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
// get and check survey
const survey = await getSurvey(responseInput.surveyId);
if (!survey) {

View File

@@ -3,28 +3,21 @@ import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/sur
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
import { TSurvey, ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
const fetchAndAuthorizeSurvey = async (
surveyId: string,
authentication: TAuthenticationApiKey,
requiredPermission: "GET" | "PUT" | "DELETE"
) => {
const fetchAndAuthorizeSurvey = async (authentication: any, surveyId: string): Promise<TSurvey | null> => {
const survey = await getSurvey(surveyId);
if (!survey) {
return { error: responses.notFoundResponse("Survey", surveyId) };
return null;
}
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, requiredPermission)) {
return { error: responses.unauthorizedResponse() };
if (survey.environmentId !== authentication.environmentId) {
throw new Error("Unauthorized");
}
return { survey };
return survey;
};
export const GET = async (
@@ -35,9 +28,11 @@ export const GET = async (
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "GET");
if (result.error) return result.error;
return responses.successResponse(result.survey);
const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId);
if (survey) {
return responses.successResponse(survey);
}
return responses.notFoundResponse("Survey", params.surveyId);
} catch (error) {
return handleErrorResponse(error);
}
@@ -51,8 +46,10 @@ export const DELETE = async (
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE");
if (result.error) return result.error;
const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", params.surveyId);
}
const deletedSurvey = await deleteSurvey(params.surveyId);
return responses.successResponse(deletedSurvey);
} catch (error) {
@@ -68,10 +65,13 @@ export const PUT = async (
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "PUT");
if (result.error) return result.error;
const organization = await getOrganizationByEnvironmentId(result.survey.environmentId);
const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", params.surveyId);
}
const organization = await getOrganizationByEnvironmentId(authentication.environmentId);
if (!organization) {
return responses.notFoundResponse("Organization", null);
}
@@ -85,7 +85,7 @@ export const PUT = async (
}
const inputValidation = ZSurveyUpdateInput.safeParse({
...result.survey,
...survey,
...surveyUpdate,
});

View File

@@ -1,6 +1,5 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
import { getSurvey } from "@formbricks/lib/survey/service";
@@ -18,8 +17,8 @@ export const GET = async (
if (!survey) {
return responses.notFoundResponse("Survey", params.surveyId);
}
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
return responses.unauthorizedResponse();
if (survey.environmentId !== authentication.environmentId) {
throw new Error("Unauthorized");
}
if (!survey.singleUse || !survey.singleUse.enabled) {

View File

@@ -1,48 +0,0 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { selectSurvey } from "@formbricks/lib/survey/service";
import { transformPrismaSurvey } from "@formbricks/lib/survey/utils";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { logger } from "@formbricks/logger";
import { ZOptionalNumber } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
export const getSurveys = reactCache(
async (environmentIds: string[], limit?: number, offset?: number): Promise<TSurvey[]> =>
cache(
async () => {
validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
try {
const surveysPrisma = await prisma.survey.findMany({
where: {
environmentId: { in: environmentIds },
},
select: selectSurvey,
orderBy: {
updatedAt: "desc",
},
take: limit,
skip: offset,
});
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error getting surveys");
throw new DatabaseError(error.message);
}
throw error;
}
},
environmentIds.map((environmentId) => `getSurveys-management-api-${environmentId}-${limit}-${offset}`),
{
tags: environmentIds.map((environmentId) => surveyCache.tag.byEnvironmentId(environmentId)),
}
)()
);

View File

@@ -2,14 +2,12 @@ import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { createSurvey } from "@formbricks/lib/survey/service";
import { createSurvey, getSurveys } from "@formbricks/lib/survey/service";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
import { getSurveys } from "./lib/surveys";
import { ZSurveyCreateInput } from "@formbricks/types/surveys/types";
export const GET = async (request: Request) => {
try {
@@ -20,11 +18,7 @@ export const GET = async (request: Request) => {
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const surveys = await getSurveys(environmentIds, limit, offset);
const surveys = await getSurveys(authentication.environmentId!, limit, offset);
return responses.successResponse(surveys);
} catch (error) {
if (error instanceof DatabaseError) {
@@ -39,6 +33,11 @@ export const POST = async (request: Request): Promise<Response> => {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const organization = await getOrganizationByEnvironmentId(authentication.environmentId);
if (!organization) {
return responses.notFoundResponse("Organization", null);
}
let surveyInput;
try {
surveyInput = await request.json();
@@ -46,7 +45,8 @@ export const POST = async (request: Request): Promise<Response> => {
logger.error({ error, url: request.url }, "Error parsing JSON");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
const inputValidation = ZSurveyCreateInput.safeParse(surveyInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
@@ -56,18 +56,8 @@ export const POST = async (request: Request): Promise<Response> => {
);
}
const environmentId = inputValidation.data.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
return responses.notFoundResponse("Organization", null);
}
const surveyData = { ...inputValidation.data, environmentId };
const environmentId = authentication.environmentId;
const surveyData = { ...inputValidation.data, environmentId: undefined };
if (surveyData.followUps?.length) {
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
@@ -83,7 +73,7 @@ export const POST = async (request: Request): Promise<Response> => {
}
}
const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined });
const survey = await createSurvey(environmentId, surveyData);
return responses.successResponse(survey);
} catch (error) {
if (error instanceof DatabaseError) {

View File

@@ -1,19 +1,18 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
import { deleteWebhook, getWebhook } from "@/app/api/v1/webhooks/[webhookId]/lib/webhook";
import { responses } from "@/app/lib/api/response";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { headers } from "next/headers";
import { logger } from "@formbricks/logger";
export const GET = async (request: Request, props: { params: Promise<{ webhookId: string }> }) => {
export const GET = async (_: Request, props: { params: Promise<{ webhookId: string }> }) => {
const params = await props.params;
const headersList = await headers();
const apiKey = headersList.get("x-api-key");
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const authentication = await authenticateRequest(request);
if (!authentication) {
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentId) {
return responses.notAuthenticatedResponse();
}
@@ -22,7 +21,7 @@ export const GET = async (request: Request, props: { params: Promise<{ webhookId
if (!webhook) {
return responses.notFoundResponse("Webhook", params.webhookId);
}
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "GET")) {
if (webhook.environmentId !== environmentId) {
return responses.unauthorizedResponse();
}
return responses.successResponse(webhook);
@@ -35,8 +34,8 @@ export const DELETE = async (request: Request, props: { params: Promise<{ webhoo
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const authentication = await authenticateRequest(request);
if (!authentication) {
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentId) {
return responses.notAuthenticatedResponse();
}
@@ -45,7 +44,7 @@ export const DELETE = async (request: Request, props: { params: Promise<{ webhoo
if (!webhook) {
return responses.notFoundResponse("Webhook", params.webhookId);
}
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "DELETE")) {
if (webhook.environmentId !== environmentId) {
return responses.unauthorizedResponse();
}

View File

@@ -8,20 +8,17 @@ import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
validateInputs([webhookInput, ZWebhookInput]);
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<Webhook> => {
validateInputs([environmentId, ZId], [webhookInput, ZWebhookInput]);
try {
const createdWebhook = await prisma.webhook.create({
data: {
url: webhookInput.url,
name: webhookInput.name,
source: webhookInput.source,
...webhookInput,
surveyIds: webhookInput.surveyIds || [],
triggers: webhookInput.triggers || [],
environment: {
connect: {
id: webhookInput.environmentId,
id: environmentId,
},
},
},
@@ -40,24 +37,22 @@ export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhoo
}
if (!(error instanceof InvalidInputError)) {
throw new DatabaseError(
`Database error when creating webhook for environment ${webhookInput.environmentId}`
);
throw new DatabaseError(`Database error when creating webhook for environment ${environmentId}`);
}
throw error;
}
};
export const getWebhooks = (environmentIds: string[], page?: number): Promise<Webhook[]> =>
export const getWebhooks = (environmentId: string, page?: number): Promise<Webhook[]> =>
cache(
async () => {
validateInputs([environmentIds, ZId.array()], [page, ZOptionalNumber]);
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
try {
const webhooks = await prisma.webhook.findMany({
where: {
environmentId: { in: environmentIds },
environmentId: environmentId,
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
@@ -71,8 +66,8 @@ export const getWebhooks = (environmentIds: string[], page?: number): Promise<We
throw error;
}
},
environmentIds.map((environmentId) => `getWebhooks-${environmentId}-${page}`),
[`getWebhooks-${environmentId}-${page}`],
{
tags: environmentIds.map((environmentId) => webhookCache.tag.byEnvironmentId(environmentId)),
tags: [webhookCache.tag.byEnvironmentId(environmentId)],
}
)();

View File

@@ -1,33 +1,42 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { headers } from "next/headers";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
export const GET = async (request: Request) => {
const authentication = await authenticateRequest(request);
if (!authentication) {
export const GET = async () => {
const headersList = await headers();
const apiKey = headersList.get("x-api-key");
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentId) {
return responses.notAuthenticatedResponse();
}
// get webhooks from database
try {
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const webhooks = await getWebhooks(environmentIds);
return responses.successResponse(webhooks);
const webhooks = await getWebhooks(environmentId);
return Response.json({ data: webhooks });
} catch (error) {
if (error instanceof DatabaseError) {
return responses.internalServerErrorResponse(error.message);
return responses.badRequestResponse(error.message);
}
throw error;
return responses.internalServerErrorResponse(error.message);
}
};
export const POST = async (request: Request) => {
const authentication = await authenticateRequest(request);
if (!authentication) {
const headersList = await headers();
const apiKey = headersList.get("x-api-key");
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentId) {
return responses.notAuthenticatedResponse();
}
const webhookInput = await request.json();
@@ -41,19 +50,9 @@ export const POST = async (request: Request) => {
);
}
const environmentId = inputValidation.data.environmentId;
if (!environmentId) {
return responses.badRequestResponse("Environment ID is required");
}
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
// add webhook to database
try {
const webhook = await createWebhook(inputValidation.data);
const webhook = await createWebhook(environmentId, inputValidation.data);
return responses.successResponse(webhook);
} catch (error) {
if (error instanceof InvalidInputError) {

View File

@@ -11,7 +11,6 @@ export const ZWebhookInput = ZWebhook.partial({
surveyIds: true,
triggers: true,
url: true,
environmentId: true,
});
export type TWebhookInput = z.infer<typeof ZWebhookInput>;

View File

@@ -0,0 +1,3 @@
import { GET } from "@/modules/api/v2/management/roles/route";
export { GET };

View File

@@ -1,3 +0,0 @@
import { GET } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/route";
export { GET };

View File

@@ -1,3 +0,0 @@
import { GET } from "@/modules/api/v2/me/route";
export { GET };

View File

@@ -1,3 +0,0 @@
import { DELETE, GET, POST, PUT } from "@/modules/api/v2/organizations/[organizationId]/project-teams/route";
export { GET, POST, PUT, DELETE };

View File

@@ -1,3 +0,0 @@
import { DELETE, GET, PUT } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route";
export { GET, PUT, DELETE };

View File

@@ -1,3 +0,0 @@
import { GET, POST } from "@/modules/api/v2/organizations/[organizationId]/teams/route";
export { GET, POST };

View File

@@ -1,3 +0,0 @@
import { GET, PATCH, POST } from "@/modules/api/v2/organizations/[organizationId]/users/route";
export { GET, POST, PATCH };

View File

@@ -1,3 +0,0 @@
import { GET } from "@/modules/api/v2/roles/route";
export { GET };

View File

@@ -40,6 +40,10 @@ vi.mock("@/tolgee/server", () => ({
getTolgee: vi.fn(),
}));
vi.mock("@vercel/speed-insights/next", () => ({
SpeedInsights: () => <div data-testid="speed-insights">SpeedInsights</div>,
}));
vi.mock("@/modules/ui/components/post-hog-client", () => ({
PHProvider: ({ children, posthogEnabled }: { children: React.ReactNode; posthogEnabled: boolean }) => (
<div data-testid="ph-provider">
@@ -97,6 +101,11 @@ describe("RootLayout", () => {
const element = await RootLayout({ children });
render(element);
// log env vercel
console.log("vercel", process.env.VERCEL);
expect(screen.getByTestId("speed-insights")).toBeInTheDocument();
expect(screen.getByTestId("ph-provider")).toBeInTheDocument();
expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument();
expect(screen.getByTestId("sentry-provider")).toBeInTheDocument();
expect(screen.getByTestId("child")).toHaveTextContent("Child Content");

View File

@@ -1,11 +1,13 @@
import { SentryProvider } from "@/app/sentry/SentryProvider";
import { PHProvider } from "@/modules/ui/components/post-hog-client";
import { TolgeeNextProvider } from "@/tolgee/client";
import { getLocale } from "@/tolgee/language";
import { getTolgee } from "@/tolgee/server";
import { TolgeeStaticData } from "@tolgee/react";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { Metadata } from "next";
import React from "react";
import { SENTRY_DSN } from "@formbricks/lib/constants";
import { IS_POSTHOG_CONFIGURED, SENTRY_DSN } from "@formbricks/lib/constants";
import "../modules/ui/globals.css";
export const metadata: Metadata = {
@@ -25,10 +27,13 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
return (
<html lang={locale} translate="no">
<body className="flex h-dvh flex-col transition-all ease-in-out">
{process.env.VERCEL === "1" && <SpeedInsights sampleRate={0.1} />}
<SentryProvider sentryDsn={SENTRY_DSN}>
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
{children}
</TolgeeNextProvider>
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
{children}
</TolgeeNextProvider>
</PHProvider>
</SentryProvider>
</body>
</html>

View File

@@ -1,6 +1,6 @@
import * as Sentry from "@sentry/nextjs";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SentryProvider } from "./SentryProvider";
vi.mock("@sentry/nextjs", async () => {

View File

@@ -19,7 +19,7 @@ export const getFile = async (
headers: {
"Content-Type": metaData.contentType,
"Content-Disposition": "attachment",
"Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
"Cache-Control": "public, max-age=1200, s-maxage=1200, stale-while-revalidate=300",
Vary: "Accept-Encoding",
},
});
@@ -35,7 +35,10 @@ export const getFile = async (
status: 302,
headers: {
Location: signedUrl,
"Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
"Cache-Control":
accessType === "public"
? `public, max-age=3600, s-maxage=3600, stale-while-revalidate=300`
: `public, max-age=600, s-maxage=3600, stale-while-revalidate=300`,
},
});
} catch (error: unknown) {

View File

@@ -2,8 +2,8 @@ import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
environmentId?: string;
hashedKey?: string;
organizationId?: string;
}
export const apiKeyCache = {
@@ -11,24 +11,24 @@ export const apiKeyCache = {
byId(id: string) {
return `apiKeys-${id}`;
},
byEnvironmentId(environmentId: string) {
return `environments-${environmentId}-apiKeys`;
},
byHashedKey(hashedKey: string) {
return `apiKeys-${hashedKey}-apiKey`;
},
byOrganizationId(organizationId: string) {
return `organizations-${organizationId}-apiKeys`;
},
},
revalidate({ id, hashedKey, organizationId }: RevalidateProps): void {
revalidate({ id, environmentId, hashedKey }: RevalidateProps): void {
if (id) {
revalidateTag(this.tag.byId(id));
}
if (environmentId) {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
if (hashedKey) {
revalidateTag(this.tag.byHashedKey(hashedKey));
}
if (organizationId) {
revalidateTag(this.tag.byOrganizationId(organizationId));
}
},
};

View File

@@ -155,7 +155,7 @@ export const getOrganizationIdFromApiKeyId = async (apiKeyId: string) => {
throw new ResourceNotFoundError("apiKey", apiKeyId);
}
return apiKeyFromServer.organizationId;
return await getOrganizationIdFromEnvironmentId(apiKeyFromServer.environmentId);
};
export const getOrganizationIdFromInviteId = async (inviteId: string) => {
@@ -240,6 +240,15 @@ export const getProjectIdFromSegmentId = async (segmentId: string) => {
return await getProjectIdFromEnvironmentId(segment.environmentId);
};
export const getProjectIdFromApiKeyId = async (apiKeyId: string) => {
const apiKey = await getApiKey(apiKeyId);
if (!apiKey) {
throw new ResourceNotFoundError("apiKey", apiKeyId);
}
return await getProjectIdFromEnvironmentId(apiKey.environmentId);
};
export const getProjectIdFromActionClassId = async (actionClassId: string) => {
const actionClass = await getActionClass(actionClassId);
if (!actionClass) {

View File

@@ -51,7 +51,7 @@ export const getActionClass = reactCache(
);
export const getApiKey = reactCache(
async (apiKeyId: string): Promise<{ organizationId: string } | null> =>
async (apiKeyId: string): Promise<{ environmentId: string } | null> =>
cache(
async () => {
validateInputs([apiKeyId, ZString]);
@@ -66,7 +66,7 @@ export const getApiKey = reactCache(
id: apiKeyId,
},
select: {
organizationId: true,
environmentId: true,
},
});

View File

@@ -1,34 +0,0 @@
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const authenticateRequest = async (
request: Request
): Promise<Result<TAuthenticationApiKey, ApiErrorResponseV2>> => {
const apiKey = request.headers.get("x-api-key");
if (!apiKey) return err({ type: "unauthorized" });
const apiKeyData = await getApiKeyWithPermissions(apiKey);
if (!apiKeyData) return err({ type: "unauthorized" });
const hashedApiKey = hashApiKey(apiKey);
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
environmentId: env.environmentId,
environmentType: env.environment.type,
permission: env.permission,
projectId: env.environment.projectId,
projectName: env.environment.project.name,
})),
hashedApiKey,
apiKeyId: apiKeyData.id,
organizationId: apiKeyData.organizationId,
organizationAccess: apiKeyData.organizationAccess,
};
return ok(authentication);
};

View File

@@ -1,114 +0,0 @@
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { authenticateRequest } from "../authenticate-request";
vi.mock("@formbricks/database", () => ({
prisma: {
apiKey: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
hashApiKey: vi.fn(),
}));
describe("authenticateRequest", () => {
it("should return authentication data if apiKey is valid", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
const mockApiKeyData = {
id: "api-key-id",
organizationId: "org-id",
createdAt: new Date(),
createdBy: "user-id",
lastUsedAt: null,
label: "Test API Key",
hashedKey: "hashed-api-key",
apiKeyEnvironments: [
{
environmentId: "env-id-1",
permission: "manage",
environment: {
id: "env-id-1",
projectId: "project-id-1",
type: "development",
project: { name: "Project 1" },
},
},
{
environmentId: "env-id-2",
permission: "read",
environment: {
id: "env-id-2",
projectId: "project-id-2",
type: "production",
project: { name: "Project 2" },
},
},
],
};
vi.mocked(hashApiKey).mockReturnValue("hashed-api-key");
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
const result = await authenticateRequest(request);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
type: "apiKey",
environmentPermissions: [
{
environmentId: "env-id-1",
permission: "manage",
environmentType: "development",
projectId: "project-id-1",
projectName: "Project 1",
},
{
environmentId: "env-id-2",
permission: "read",
environmentType: "production",
projectId: "project-id-2",
projectName: "Project 2",
},
],
hashedApiKey: "hashed-api-key",
apiKeyId: "api-key-id",
organizationId: "org-id",
});
}
});
it("should return unauthorized error if apiKey is not found", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "invalid-api-key" },
});
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
const result = await authenticateRequest(request);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "unauthorized" });
}
});
it("should return unauthorized error if apiKey is missing", async () => {
const request = new Request("http://localhost");
const result = await authenticateRequest(request);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "unauthorized" });
}
});
});

View File

@@ -122,11 +122,9 @@ const notFoundResponse = ({
const conflictResponse = ({
cors = false,
cache = "private, no-store",
details = [],
}: {
cors?: boolean;
cache?: string;
details?: ApiErrorDetails;
} = {}) => {
const headers = {
...(cors && corsHeaders),
@@ -138,7 +136,6 @@ const conflictResponse = ({
error: {
code: 409,
message: "Conflict",
details,
},
},
{
@@ -235,7 +232,7 @@ const internalServerErrorResponse = ({
const successResponse = ({
data,
meta,
cors = true,
cors = false,
cache = "private, no-store",
}: {
data: Object;

View File

@@ -85,15 +85,13 @@ describe("API Responses", () => {
describe("conflictResponse", () => {
test("return a 409 response", async () => {
const details = [{ field: "resource", issue: "already exists" }];
const res = responses.conflictResponse({ details });
const res = responses.conflictResponse();
expect(res.status).toBe(409);
const body = await res.json();
expect(body).toEqual({
error: {
code: 409,
message: "Conflict",
details,
},
});
});

View File

@@ -16,7 +16,7 @@ export const handleApiError = (request: Request, err: ApiErrorResponseV2): Respo
case "not_found":
return responses.notFoundResponse({ details: err.details });
case "conflict":
return responses.conflictResponse({ details: err.details });
return responses.conflictResponse();
case "unprocessable_entity":
return responses.unprocessableEntityResponse({ details: err.details });
case "too_many_requests":

View File

@@ -0,0 +1,34 @@
import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const authenticateRequest = async (
request: Request
): Promise<Result<TAuthenticationApiKey, ApiErrorResponseV2>> => {
const apiKey = request.headers.get("x-api-key");
if (apiKey) {
const environmentIdResult = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentIdResult.ok) {
return err(environmentIdResult.error);
}
const environmentId = environmentIdResult.data;
const hashedApiKey = hashApiKey(apiKey);
if (environmentId) {
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentId,
hashedApiKey,
};
return ok(authentication);
}
return err({
type: "forbidden",
});
}
return err({
type: "unauthorized",
});
};

View File

@@ -0,0 +1,18 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { Result, err, okVoid } from "@formbricks/types/error-handlers";
export const checkAuthorization = ({
authentication,
environmentId,
}: {
authentication: TAuthenticationApiKey;
environmentId: string;
}): Result<void, ApiErrorResponseV2> => {
if (authentication.type === "apiKey" && authentication.environmentId !== environmentId) {
return err({
type: "unauthorized",
});
}
return okVoid();
};

View File

@@ -1,7 +1,7 @@
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { apiWrapper } from "@/modules/api/v2/management/auth/api-wrapper";
import { authenticateRequest } from "@/modules/api/v2/management/auth/authenticate-request";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";

View File

@@ -0,0 +1,73 @@
import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { describe, expect, it, vi } from "vitest";
import { err, ok } from "@formbricks/types/error-handlers";
import { authenticateRequest } from "../authenticate-request";
vi.mock("@/modules/api/v2/management/lib/api-key", () => ({
getEnvironmentIdFromApiKey: vi.fn(),
}));
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
hashApiKey: vi.fn(),
}));
describe("authenticateRequest", () => {
it("should return authentication data if apiKey is valid", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(ok("env-id"));
vi.mocked(hashApiKey).mockReturnValue("hashed-api-key");
const result = await authenticateRequest(request);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
});
}
});
it("should return forbidden error if environmentId is not found", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "invalid-api-key" },
});
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(err({ type: "forbidden" }));
const result = await authenticateRequest(request);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "forbidden" });
}
});
it("should return forbidden error if environmentId is empty", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "invalid-api-key" },
});
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(ok(""));
const result = await authenticateRequest(request);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "forbidden" });
}
});
it("should return unauthorized error if apiKey is missing", async () => {
const request = new Request("http://localhost");
const result = await authenticateRequest(request);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "unauthorized" });
}
});
});

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { checkAuthorization } from "../check-authorization";
describe("checkAuthorization", () => {
it("should return ok if authentication is valid", () => {
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
};
const result = checkAuthorization({ authentication, environmentId: "env-id" });
expect(result.ok).toBe(true);
});
it("should return unauthorized error if environmentId does not match", () => {
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
};
const result = checkAuthorization({ authentication, environmentId: "different-env-id" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "unauthorized" });
}
});
});

View File

@@ -0,0 +1,44 @@
import { apiKeyCache } from "@/lib/cache/api-key";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string) => {
const hashedKey = hashApiKey(apiKey);
return cache(
async (): Promise<Result<string, ApiErrorResponseV2>> => {
if (!apiKey) {
return err({
type: "bad_request",
details: [{ field: "apiKey", issue: "API key cannot be null or undefined." }],
});
}
try {
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey,
},
select: {
environmentId: true,
},
});
if (!apiKeyData) {
return err({ type: "not_found", details: [{ field: "apiKey", issue: "not found" }] });
}
return ok(apiKeyData.environmentId);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "apiKey", issue: error.message }] });
}
},
[`management-api-getEnvironmentIdFromApiKey-${hashedKey}`],
{
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
}
)();
});

View File

@@ -0,0 +1,81 @@
import { apiKey, environmentId } from "./__mocks__/api-key.mock";
import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
vi.mock("@formbricks/database", () => ({
prisma: {
apiKey: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
hashApiKey: vi.fn((input: string) => `hashed-${input}`),
}));
describe("getEnvironmentIdFromApiKey", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns a bad_request error if apiKey is empty", async () => {
const result = await getEnvironmentIdFromApiKey("");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("bad_request");
expect(result.error.details).toEqual([
{ field: "apiKey", issue: "API key cannot be null or undefined." },
]);
}
});
test("returns a not_found error when no apiKey record is found in the database", async () => {
vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`);
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
const result = await getEnvironmentIdFromApiKey(apiKey);
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
where: { hashedKey: `hashed-${apiKey}` },
select: { environmentId: true },
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("not_found");
expect(result.error.details).toEqual([{ field: "apiKey", issue: "not found" }]);
}
});
test("returns ok with environmentId when a valid apiKey record is found", async () => {
vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`);
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue({ environmentId });
const result = await getEnvironmentIdFromApiKey(apiKey);
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
where: { hashedKey: `hashed-${apiKey}` },
select: { environmentId: true },
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBe(environmentId);
}
});
test("returns internal_server_error when an exception occurs during the database lookup", async () => {
vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`);
vi.mocked(prisma.apiKey.findUnique).mockRejectedValue(new Error("Database failure"));
const result = await getEnvironmentIdFromApiKey(apiKey);
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
where: { hashedKey: `hashed-${apiKey}` },
select: { environmentId: true },
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([{ field: "apiKey", issue: "Database failure" }]);
}
});
});

View File

@@ -9,12 +9,7 @@ export function pickCommonFilter<T extends TGetFilter>(params: T) {
return { limit, skip, sortBy, order, startDate, endDate };
}
type HasFindMany =
| Prisma.WebhookFindManyArgs
| Prisma.ResponseFindManyArgs
| Prisma.TeamFindManyArgs
| Prisma.ProjectTeamFindManyArgs
| Prisma.UserFindManyArgs;
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 || {};

View File

@@ -1,4 +1,4 @@
import { ZResponseIdSchema } 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 { ZodOpenApiOperationObject } from "zod-openapi";
@@ -11,7 +11,7 @@ export const getResponseEndpoint: ZodOpenApiOperationObject = {
description: "Gets a response from the database.",
requestParams: {
path: z.object({
id: ZResponseIdSchema,
id: responseIdSchema,
}),
},
tags: ["Management API > Responses"],
@@ -34,7 +34,7 @@ export const deleteResponseEndpoint: ZodOpenApiOperationObject = {
tags: ["Management API > Responses"],
requestParams: {
path: z.object({
id: ZResponseIdSchema,
id: responseIdSchema,
}),
},
responses: {
@@ -56,7 +56,7 @@ export const updateResponseEndpoint: ZodOpenApiOperationObject = {
tags: ["Management API > Responses"],
requestParams: {
path: z.object({
id: ZResponseIdSchema,
id: responseIdSchema,
}),
},
requestBody: {

View File

@@ -1,7 +1,7 @@
import { deleteDisplay } from "@/modules/api/v2/management/responses/[responseId]/lib/display";
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
import { findAndDeleteUploadedFilesInResponse } from "@/modules/api/v2/management/responses/[responseId]/lib/utils";
import { ZResponseUpdateSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
import { responseUpdateSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Response } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
@@ -98,7 +98,7 @@ export const deleteResponse = async (responseId: string): Promise<Result<Respons
export const updateResponse = async (
responseId: string,
responseInput: z.infer<typeof ZResponseUpdateSchema>
responseInput: z.infer<typeof responseUpdateSchema>
): Promise<Result<Response, ApiErrorResponseV2>> => {
try {
const updatedResponse = await prisma.response.update({

View File

@@ -1,21 +1,21 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
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 { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import {
deleteResponse,
getResponse,
updateResponse,
} from "@/modules/api/v2/management/responses/[responseId]/lib/response";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { z } from "zod";
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
import { responseIdSchema, responseUpdateSchema } from "./types/responses";
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ responseId: ZResponseIdSchema }),
params: z.object({ responseId: responseIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
@@ -33,10 +33,13 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI
return handleApiError(request, environmentIdResult.error);
}
if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "GET")) {
return handleApiError(request, {
type: "unauthorized",
});
const checkAuthorizationResult = await checkAuthorization({
authentication,
environmentId: environmentIdResult.data,
});
if (!checkAuthorizationResult.ok) {
return handleApiError(request, checkAuthorizationResult.error);
}
const response = await getResponse(params.responseId);
@@ -52,7 +55,7 @@ export const DELETE = async (request: Request, props: { params: Promise<{ respon
authenticatedApiClient({
request,
schemas: {
params: z.object({ responseId: ZResponseIdSchema }),
params: z.object({ responseId: responseIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
@@ -70,10 +73,13 @@ export const DELETE = async (request: Request, props: { params: Promise<{ respon
return handleApiError(request, environmentIdResult.error);
}
if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "DELETE")) {
return handleApiError(request, {
type: "unauthorized",
});
const checkAuthorizationResult = await checkAuthorization({
authentication,
environmentId: environmentIdResult.data,
});
if (!checkAuthorizationResult.ok) {
return handleApiError(request, checkAuthorizationResult.error);
}
const response = await deleteResponse(params.responseId);
@@ -91,8 +97,8 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
request,
externalParams: props.params,
schemas: {
params: z.object({ responseId: ZResponseIdSchema }),
body: ZResponseUpdateSchema,
params: z.object({ responseId: responseIdSchema }),
body: responseUpdateSchema,
},
handler: async ({ authentication, parsedInput }) => {
const { body, params } = parsedInput;
@@ -109,10 +115,13 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
return handleApiError(request, environmentIdResult.error);
}
if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "PUT")) {
return handleApiError(request, {
type: "unauthorized",
});
const checkAuthorizationResult = await checkAuthorization({
authentication,
environmentId: environmentIdResult.data,
});
if (!checkAuthorizationResult.ok) {
return handleApiError(request, checkAuthorizationResult.error);
}
const response = await updateResponse(params.responseId, body);

View File

@@ -4,7 +4,7 @@ import { ZResponse } from "@formbricks/database/zod/responses";
extendZodWithOpenApi(z);
export const ZResponseIdSchema = z
export const responseIdSchema = z
.string()
.cuid2()
.openapi({
@@ -16,7 +16,7 @@ export const ZResponseIdSchema = z
},
});
export const ZResponseUpdateSchema = ZResponse.omit({
export const responseUpdateSchema = ZResponse.omit({
id: true,
surveyId: true,
}).openapi({

View File

@@ -5,6 +5,7 @@ import {
} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZResponse } from "@formbricks/database/zod/responses";
@@ -13,7 +14,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
summary: "Get responses",
description: "Gets responses from the database.",
requestParams: {
query: ZGetResponsesFilter.sourceType(),
query: ZGetResponsesFilter.sourceType().required(),
},
tags: ["Management API > Responses"],
responses: {
@@ -21,7 +22,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
description: "Responses retrieved successfully.",
content: {
"application/json": {
schema: responseWithMetaSchema(makePartialSchema(ZResponse)),
schema: z.array(responseWithMetaSchema(makePartialSchema(ZResponse))),
},
},
},

View File

@@ -130,18 +130,16 @@ export const createResponse = async (
};
export const getResponses = async (
environmentIds: string[],
environmentId: string,
params: TGetResponsesFilter
): Promise<Result<ApiResponseWithMeta<Response[]>, ApiErrorResponseV2>> => {
try {
const query = getResponsesQuery(environmentIds, params);
const [responses, count] = await prisma.$transaction([
prisma.response.findMany({
...query,
...getResponsesQuery(environmentId, params),
}),
prisma.response.count({
where: query.where,
where: getResponsesQuery(environmentId, params).where,
}),
]);

View File

@@ -11,12 +11,12 @@ vi.mock("@/modules/api/v2/management/lib/utils", () => ({
describe("getResponsesQuery", () => {
it("adds surveyId to where clause if provided", () => {
const result = getResponsesQuery(["env-id"], { surveyId: "survey123" } as TGetResponsesFilter);
const result = getResponsesQuery("env-id", { surveyId: "survey123" } as TGetResponsesFilter);
expect(result?.where?.surveyId).toBe("survey123");
});
it("adds contactId to where clause if provided", () => {
const result = getResponsesQuery(["env-id"], { contactId: "contact123" } as TGetResponsesFilter);
const result = getResponsesQuery("env-id", { contactId: "contact123" } as TGetResponsesFilter);
expect(result?.where?.contactId).toBe("contact123");
});
@@ -24,12 +24,12 @@ describe("getResponsesQuery", () => {
vi.mocked(pickCommonFilter).mockReturnValueOnce({ someFilter: true } as any);
vi.mocked(buildCommonFilterQuery).mockReturnValueOnce({ where: { combined: true } as any });
const result = getResponsesQuery(["env-id"], { surveyId: "test" } as TGetResponsesFilter);
const result = getResponsesQuery("env-id", { surveyId: "test" } as TGetResponsesFilter);
expect(pickCommonFilter).toHaveBeenCalledWith({ surveyId: "test" });
expect(buildCommonFilterQuery).toHaveBeenCalledWith(
expect.objectContaining<Prisma.ResponseFindManyArgs>({
where: {
survey: { environmentId: { in: ["env-id"] } },
survey: { environmentId: "env-id" },
surveyId: "test",
},
}),

View File

@@ -2,11 +2,11 @@ import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/manag
import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
import { Prisma } from "@prisma/client";
export const getResponsesQuery = (environmentIds: string[], params?: TGetResponsesFilter) => {
export const getResponsesQuery = (environmentId: string, params?: TGetResponsesFilter) => {
let query: Prisma.ResponseFindManyArgs = {
where: {
survey: {
environmentId: { in: environmentIds },
environmentId,
},
},
};

View File

@@ -1,10 +1,9 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
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 { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { Response } from "@prisma/client";
import { NextRequest } from "next/server";
import { createResponse, getResponses } from "./lib/response";
@@ -24,20 +23,15 @@ export const GET = async (request: NextRequest) =>
});
}
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const environmentId = authentication.environmentId;
const environmentResponses: Response[] = [];
const res = await getResponses(environmentIds, query);
const res = await getResponses(environmentId, query);
if (!res.ok) {
return handleApiError(request, res.error);
if (res.ok) {
return responses.successResponse(res.data);
}
environmentResponses.push(...res.data.data);
return responses.successResponse({ data: environmentResponses });
return handleApiError(request, res.error);
},
});
@@ -65,10 +59,13 @@ export const POST = async (request: Request) =>
const environmentId = environmentIdResult.data;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return handleApiError(request, {
type: "unauthorized",
});
const checkAuthorizationResult = await checkAuthorization({
authentication,
environmentId,
});
if (!checkAuthorizationResult.ok) {
return handleApiError(request, checkAuthorizationResult.error);
}
// if there is a createdAt but no updatedAt, set updatedAt to createdAt
@@ -81,6 +78,6 @@ export const POST = async (request: Request) =>
return handleApiError(request, createResponseResult.error);
}
return responses.successResponse({ data: createResponseResult.data });
return responses.successResponse({ data: createResponseResult.data, cors: true });
},
});

View File

@@ -1,19 +1,18 @@
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZRoles } from "@formbricks/database/zod/roles";
export const getRolesEndpoint: ZodOpenApiOperationObject = {
operationId: "getRoles",
summary: "Get roles",
description: "Gets roles from the database.",
requestParams: {},
tags: ["Roles"],
tags: ["Management API > Roles"],
responses: {
"200": {
description: "Roles retrieved successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZRoles),
schema: z.array(z.string()),
},
},
},

View File

@@ -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 }] });
}
};

View File

@@ -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");
}
});
});

View File

@@ -1,14 +1,14 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { getRoles } from "@/modules/api/v2/roles/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 = getRoles();
const res = await getRoles();
if (res.ok) {
return responses.successResponse(res.data);

View File

@@ -1,12 +1,12 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
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 { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { getContact } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/contacts";
import { getResponse } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/response";
import { getSurvey } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/surveys";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
@@ -43,10 +43,13 @@ export const GET = async (
const environmentId = environmentIdResult.data;
if (!hasPermission(authentication.environmentPermissions, environmentId, "GET")) {
return handleApiError(request, {
type: "unauthorized",
});
const checkAuthorizationResult = await checkAuthorization({
authentication,
environmentId,
});
if (!checkAuthorizationResult.ok) {
return handleApiError(request, checkAuthorizationResult.error);
}
const surveyResult = await getSurvey(params.surveyId);

View File

@@ -1,33 +0,0 @@
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getContactAttributeKeys = reactCache((environmentId: string) =>
cache(
async (): Promise<Result<string[], ApiErrorResponseV2>> => {
try {
const contactAttributeKeys = await prisma.contactAttributeKey.findMany({
where: { environmentId },
select: {
key: true,
},
});
const keys = contactAttributeKeys.map((key) => key.key);
return ok(keys);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "contact attribute keys", issue: error.message }],
});
}
},
[`getContactAttributeKeys-contact-links-${environmentId}`],
{
tags: [contactAttributeKeyCache.tag.byEnvironmentId(environmentId)],
}
)()
);

View File

@@ -1,147 +0,0 @@
import { getContactAttributeKeys } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact-attribute-key";
import { getSegment } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/segment";
import { getSurvey } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/surveys";
import { TContactWithAttributes } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/types/contact";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { segmentCache } from "@formbricks/lib/cache/segment";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { logger } from "@formbricks/logger";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getContactsInSegment = reactCache(
(surveyId: string, segmentId: string, limit: number, skip: number, attributeKeys?: string) =>
cache(
async (): Promise<Result<ApiResponseWithMeta<TContactWithAttributes[]>, ApiErrorResponseV2>> => {
try {
const surveyResult = await getSurvey(surveyId);
if (!surveyResult.ok) {
return err(surveyResult.error);
}
const survey = surveyResult.data;
if (survey.type !== "link" || survey.status !== "inProgress") {
logger.error({ surveyId, segmentId }, "Survey is not a link survey or is not in progress");
const error: ApiErrorResponseV2 = {
type: "forbidden",
details: [{ field: "surveyId", issue: "Invalid survey" }],
};
return err(error);
}
const segmentResult = await getSegment(segmentId);
if (!segmentResult.ok) {
return err(segmentResult.error);
}
const segment = segmentResult.data;
if (survey.environmentId !== segment.environmentId) {
logger.error({ surveyId, segmentId }, "Survey and segment are not in the same environment");
const error: ApiErrorResponseV2 = {
type: "bad_request",
details: [{ field: "segmentId", issue: "Environment mismatch" }],
};
return err(error);
}
const segmentFilterToPrismaQueryResult = await segmentFilterToPrismaQuery(
segment.id,
segment.filters,
segment.environmentId
);
if (!segmentFilterToPrismaQueryResult.ok) {
return err(segmentFilterToPrismaQueryResult.error);
}
const { whereClause } = segmentFilterToPrismaQueryResult.data;
const contactAttributeKeysResult = await getContactAttributeKeys(segment.environmentId);
if (!contactAttributeKeysResult.ok) {
return err(contactAttributeKeysResult.error);
}
const allAttributeKeys = contactAttributeKeysResult.data;
const fieldArray = (attributeKeys || "").split(",").map((field) => field.trim());
const attributesToInclude = fieldArray.filter((field) => allAttributeKeys.includes(field));
const allowedAttributes = attributesToInclude.slice(0, 20);
const [totalContacts, contacts] = await prisma.$transaction([
prisma.contact.count({
where: whereClause,
}),
prisma.contact.findMany({
where: whereClause,
select: {
id: true,
attributes: {
where: {
attributeKey: {
key: {
in: allowedAttributes,
},
},
},
select: {
attributeKey: {
select: {
key: true,
},
},
value: true,
},
},
},
take: limit,
skip: skip,
orderBy: {
createdAt: "desc",
},
}),
]);
const contactsWithAttributes = contacts.map((contact) => {
const attributes = contact.attributes.reduce(
(acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
},
{} as Record<string, string>
);
return {
contactId: contact.id,
...(Object.keys(attributes).length > 0 ? { attributes } : {}),
};
});
return ok({
data: contactsWithAttributes,
meta: {
total: totalContacts,
limit: limit,
offset: skip,
},
});
} catch (error) {
logger.error({ error, surveyId, segmentId }, "Error getting contacts in segment");
const apiError: ApiErrorResponseV2 = {
type: "internal_server_error",
};
return err(apiError);
}
},
[`getContactsInSegment-${surveyId}-${segmentId}-${attributeKeys}-${limit}-${skip}`],
{
tags: [segmentCache.tag.byId(segmentId), surveyCache.tag.byId(surveyId)],
}
)()
);

View File

@@ -1,29 +0,0 @@
import {
ZContactLinkResponse,
ZContactLinksBySegmentParams,
ZContactLinksBySegmentQuery,
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/types/contact";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
export const getContactLinksBySegmentEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactLinksBySegment",
summary: "Get survey links for contacts in a segment",
description: "Generates personalized survey links for contacts in a segment.",
tags: ["Management API > Surveys > Contact Links"],
requestParams: {
path: ZContactLinksBySegmentParams,
query: ZContactLinksBySegmentQuery,
},
responses: {
"200": {
description: "Contact links generated successfully.",
content: {
"application/json": {
schema: z.array(responseWithMetaSchema(makePartialSchema(ZContactLinkResponse))),
},
},
},
},
};

View File

@@ -1,36 +0,0 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Segment } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { segmentCache } from "@formbricks/lib/cache/segment";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getSegment = reactCache(async (segmentId: string) =>
cache(
async (): Promise<Result<Pick<Segment, "id" | "environmentId" | "filters">, ApiErrorResponseV2>> => {
try {
const segment = await prisma.segment.findUnique({
where: { id: segmentId },
select: {
id: true,
environmentId: true,
filters: true,
},
});
if (!segment) {
return err({ type: "not_found", details: [{ field: "segment", issue: "not found" }] });
}
return ok(segment);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "segment", issue: error.message }] });
}
},
[`contact-link-getSegment-${segmentId}`],
{
tags: [segmentCache.tag.byId(segmentId)],
}
)()
);

View File

@@ -1,39 +0,0 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Survey } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getSurvey = reactCache(async (surveyId: string) =>
cache(
async (): Promise<
Result<Pick<Survey, "id" | "environmentId" | "type" | "status">, ApiErrorResponseV2>
> => {
try {
const survey = await prisma.survey.findUnique({
where: { id: surveyId },
select: {
id: true,
environmentId: true,
type: true,
status: true,
},
});
if (!survey) {
return err({ type: "not_found", details: [{ field: "survey", issue: "not found" }] });
}
return ok(survey);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "survey", issue: error.message }] });
}
},
[`contact-link-getSurvey-${surveyId}`],
{
tags: [surveyCache.tag.byId(surveyId)],
}
)()
);

View File

@@ -1,52 +0,0 @@
import { getContactAttributeKeys } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact-attribute-key";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
contactAttributeKey: {
findMany: vi.fn(),
},
},
}));
describe("getContactAttributeKeys", () => {
const mockEnvironmentId = "mock-env-123";
const mockContactAttributeKeys = [{ key: "email" }, { key: "name" }, { key: "userId" }];
beforeEach(() => {
vi.clearAllMocks();
});
test("successfully retrieves contact attribute keys", async () => {
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue(mockContactAttributeKeys);
const result = await getContactAttributeKeys(mockEnvironmentId);
expect(prisma.contactAttributeKey.findMany).toHaveBeenCalledWith({
where: { environmentId: mockEnvironmentId },
select: { key: true },
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(["email", "name", "userId"]);
}
});
test("handles database error gracefully", async () => {
const mockError = new Error("Database error");
vi.mocked(prisma.contactAttributeKey.findMany).mockRejectedValue(mockError);
const result = await getContactAttributeKeys(mockEnvironmentId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "contact attribute keys", issue: mockError.message }],
});
}
});
});

View File

@@ -1,515 +0,0 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { SurveyStatus, SurveyType } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import type { TBaseFilters } from "@formbricks/types/segment";
import { getContactsInSegment } from "../contact";
import { getSegment } from "../segment";
import { getSurvey } from "../surveys";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findMany: vi.fn(),
count: vi.fn(),
},
contactAttributeKey: {
findMany: vi.fn(),
},
$transaction: vi.fn(),
},
}));
vi.mock("../segment", () => ({
getSegment: vi.fn(),
}));
vi.mock("../surveys", () => ({
getSurvey: vi.fn(),
}));
describe("getContactsInSegment", () => {
const mockSurveyId = "survey-123";
const mockSegmentId = "segment-456";
const mockLimit = 10;
const mockSkip = 0;
const mockEnvironmentId = "env-789";
const mockSurvey = {
id: mockSurveyId,
environmentId: mockEnvironmentId,
type: "link" as SurveyType,
status: "inProgress" as SurveyStatus,
};
// Define filters as a TBaseFilters array with correct structure
const mockFilters: TBaseFilters = [
{
id: "filter-1",
connector: null,
resource: {
id: "resource-1",
root: {
type: "attribute",
contactAttributeKey: "email",
},
value: "test@example.com",
qualifier: {
operator: "equals",
},
},
},
];
const mockSegment = {
id: mockSegmentId,
environmentId: mockEnvironmentId,
filters: mockFilters,
};
const mockContacts = [
{
id: "contact-1",
attributes: [
{ attributeKey: { key: "email" }, value: "test@example.com" },
{ attributeKey: { key: "name" }, value: "Test User" },
],
},
{
id: "contact-2",
attributes: [
{ attributeKey: { key: "email" }, value: "another@example.com" },
{ attributeKey: { key: "name" }, value: "Another User" },
],
},
];
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSurvey).mockResolvedValue({
ok: true,
data: mockSurvey,
});
vi.mocked(getSegment).mockResolvedValue({
ok: true,
data: mockSegment,
});
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([{ key: "email" }, { key: "name" }]);
vi.mocked(prisma.contact.count).mockResolvedValue(2);
vi.mocked(prisma.contact.findMany).mockResolvedValue(mockContacts);
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return contacts when all operations succeed", async () => {
vi.mocked(prisma.$transaction).mockResolvedValue([mockContacts.length, mockContacts]);
const attributeKeys = "email,name";
const result = await getContactsInSegment(
mockSurveyId,
mockSegmentId,
mockLimit,
mockSkip,
attributeKeys
);
const whereClause = {
AND: [
{
environmentId: "env-789",
},
{
AND: [
{
attributes: {
some: {
attributeKey: {
key: "email",
},
value: { equals: "test@example.com", mode: "insensitive" },
},
},
},
],
},
],
};
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
expect(prisma.contactAttributeKey.findMany).toHaveBeenCalledWith({
where: {
environmentId: mockEnvironmentId,
},
select: {
key: true,
},
});
expect(prisma.contact.count).toHaveBeenCalledWith({
where: whereClause,
});
expect(prisma.contact.findMany).toHaveBeenCalledWith({
where: whereClause,
select: {
id: true,
attributes: {
select: {
attributeKey: {
select: {
key: true,
},
},
value: true,
},
where: {
attributeKey: {
key: {
in: ["email", "name"],
},
},
},
},
},
take: mockLimit,
skip: mockSkip,
orderBy: {
createdAt: "desc",
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
data: [
{
contactId: "contact-1",
attributes: {
email: "test@example.com",
name: "Test User",
},
},
{
contactId: "contact-2",
attributes: {
email: "another@example.com",
name: "Another User",
},
},
],
meta: {
total: 2,
limit: 10,
offset: 0,
},
});
}
});
test("should filter contact attributes when fields parameter is provided", async () => {
const filteredMockContacts = [
{
id: "contact-1",
attributes: [{ attributeKey: { key: "email" }, value: "test@example.com" }],
},
{
id: "contact-2",
attributes: [{ attributeKey: { key: "email" }, value: "another@example.com" }],
},
];
vi.mocked(prisma.$transaction).mockResolvedValue([filteredMockContacts.length, filteredMockContacts]);
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip, "email");
const whereClause = {
AND: [
{
environmentId: "env-789",
},
{
AND: [
{
attributes: {
some: {
attributeKey: {
key: "email",
},
value: { equals: "test@example.com", mode: "insensitive" },
},
},
},
],
},
],
};
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
expect(prisma.contact.count).toHaveBeenCalledWith({
where: whereClause,
});
expect(prisma.contact.findMany).toHaveBeenCalledWith({
where: whereClause,
select: {
id: true,
attributes: {
where: {
attributeKey: {
key: {
in: ["email"],
},
},
},
select: {
attributeKey: {
select: {
key: true,
},
},
value: true,
},
},
},
take: mockLimit,
skip: mockSkip,
orderBy: {
createdAt: "desc",
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
data: [
{
contactId: "contact-1",
attributes: {
email: "test@example.com",
},
},
{
contactId: "contact-2",
attributes: {
email: "another@example.com",
},
},
],
meta: {
total: 2,
limit: 10,
offset: 0,
},
});
}
});
test("should handle multiple fields when fields parameter has comma-separated values", async () => {
vi.mocked(prisma.$transaction).mockResolvedValue([mockContacts.length, mockContacts]);
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip, "email,name");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
data: [
{
contactId: "contact-1",
attributes: {
email: "test@example.com",
name: "Test User",
},
},
{
contactId: "contact-2",
attributes: {
email: "another@example.com",
name: "Another User",
},
},
],
meta: {
total: 2,
limit: 10,
offset: 0,
},
});
}
});
test("should return no attributes but still return contacts when fields parameter is empty", async () => {
const mockContactsWithoutAttributes = mockContacts.map((contact) => ({
...contact,
attributes: [],
}));
vi.mocked(prisma.$transaction).mockResolvedValue([
mockContactsWithoutAttributes.length,
mockContactsWithoutAttributes,
]);
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip, "");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
data: mockContacts.map((contact) => ({
contactId: contact.id,
})),
meta: {
total: 2,
limit: 10,
offset: 0,
},
});
}
});
test("should return error when survey is not a link survey", async () => {
const surveyError: ApiErrorResponseV2 = {
type: "forbidden",
details: [{ field: "surveyId", issue: "Invalid survey" }],
};
vi.mocked(getSurvey).mockResolvedValue({
ok: true,
data: {
...mockSurvey,
type: "web" as SurveyType,
},
});
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).not.toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual(surveyError);
}
});
test("should return error when survey is not active", async () => {
const surveyError: ApiErrorResponseV2 = {
type: "forbidden",
details: [{ field: "surveyId", issue: "Invalid survey" }],
};
vi.mocked(getSurvey).mockResolvedValue({
ok: true,
data: {
...mockSurvey,
status: "completed" as SurveyStatus,
},
});
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).not.toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual(surveyError);
}
});
test("should return error when survey is not found", async () => {
const surveyError: ApiErrorResponseV2 = {
type: "not_found",
details: [{ field: "survey", issue: "not found" }],
};
vi.mocked(getSurvey).mockResolvedValue({
ok: false,
error: surveyError,
});
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).not.toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual(surveyError);
}
});
test("should return error when segment is not found", async () => {
const segmentError: ApiErrorResponseV2 = {
type: "not_found",
details: [{ field: "segment", issue: "not found" }],
};
vi.mocked(getSegment).mockResolvedValue({
ok: false,
error: segmentError,
});
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
expect(prisma.contact.count).not.toHaveBeenCalled();
expect(prisma.contact.findMany).not.toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual(segmentError);
}
});
test("should return error when survey and segment are in different environments", async () => {
const mockSegmentWithDifferentEnv = {
...mockSegment,
environmentId: "different-env",
};
vi.mocked(getSegment).mockResolvedValue({
ok: true,
data: mockSegmentWithDifferentEnv,
});
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
expect(prisma.contact.count).not.toHaveBeenCalled();
expect(prisma.contact.findMany).not.toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "bad_request",
details: [{ field: "segmentId", issue: "Environment mismatch" }],
});
}
});
test("should return error when database operation fails", async () => {
const dbError = new Error("Database connection failed");
vi.mocked(prisma.contact.count).mockRejectedValue(dbError);
const result = await getContactsInSegment(mockSurveyId, mockSegmentId, mockLimit, mockSkip);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getSegment).toHaveBeenCalledWith(mockSegmentId);
expect(prisma.contact.count).toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
});
}
});
});

View File

@@ -1,129 +0,0 @@
import { Segment } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { segmentCache } from "@formbricks/lib/cache/segment";
import { getSegment } from "../segment";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
segment: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@formbricks/lib/cache", () => ({
cache: vi.fn((fn) => fn),
}));
vi.mock("@formbricks/lib/cache/segment", () => ({
segmentCache: {
tag: {
byId: vi.fn((id) => `segment-${id}`),
},
},
}));
describe("getSegment", () => {
const mockSegmentId = "segment-123";
const mockSegment: Pick<Segment, "id" | "environmentId" | "filters"> = {
id: mockSegmentId,
environmentId: "env-123",
filters: [
{
id: "filter-123",
connector: null,
resource: {
id: "attr_1",
root: {
type: "attribute",
contactAttributeKey: "email",
},
value: "test@example.com",
qualifier: { operator: "equals" },
},
},
],
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return segment data when segment is found", async () => {
vi.mocked(prisma.segment.findUnique).mockResolvedValueOnce(mockSegment);
const result = await getSegment(mockSegmentId);
expect(prisma.segment.findUnique).toHaveBeenCalledWith({
where: { id: mockSegmentId },
select: {
id: true,
environmentId: true,
filters: true,
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockSegment);
}
expect(segmentCache.tag.byId).toHaveBeenCalledWith(mockSegmentId);
});
test("should return not_found error when segment doesn't exist", async () => {
vi.mocked(prisma.segment.findUnique).mockResolvedValueOnce(null);
const result = await getSegment(mockSegmentId);
expect(prisma.segment.findUnique).toHaveBeenCalledWith({
where: { id: mockSegmentId },
select: {
id: true,
environmentId: true,
filters: true,
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "segment", issue: "not found" }],
});
}
});
test("should return internal_server_error when database throws an error", async () => {
const mockError = new Error("Database connection failed");
vi.mocked(prisma.segment.findUnique).mockRejectedValueOnce(mockError);
const result = await getSegment(mockSegmentId);
expect(prisma.segment.findUnique).toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "segment", issue: "Database connection failed" }],
});
}
});
test("should use correct cache key", async () => {
vi.mocked(prisma.segment.findUnique).mockResolvedValueOnce(mockSegment);
await getSegment(mockSegmentId);
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSegment-${mockSegmentId}`], {
tags: [`segment-${mockSegmentId}`],
});
});
});

View File

@@ -1,120 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { getSurvey } from "../surveys";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@formbricks/lib/cache", () => ({
cache: vi.fn((fn) => fn),
}));
vi.mock("@formbricks/lib/survey/cache", () => ({
surveyCache: {
tag: {
byId: vi.fn((id) => `survey-${id}`),
},
},
}));
describe("getSurvey", () => {
const mockSurveyId = "survey-123";
const mockEnvironmentId = "env-456";
const mockSurvey = {
id: mockSurveyId,
environmentId: mockEnvironmentId,
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return survey data when survey is found", async () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(mockSurvey);
const result = await getSurvey(mockSurveyId);
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { id: mockSurveyId },
select: {
id: true,
environmentId: true,
status: true,
type: true,
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockSurvey);
}
expect(surveyCache.tag.byId).toHaveBeenCalledWith(mockSurveyId);
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSurvey-${mockSurveyId}`], {
tags: [`survey-${mockSurveyId}`],
});
});
test("should return not_found error when survey doesn't exist", async () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(null);
const result = await getSurvey(mockSurveyId);
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { id: mockSurveyId },
select: {
id: true,
environmentId: true,
status: true,
type: true,
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "not_found",
details: [{ field: "survey", issue: "not found" }],
});
}
});
test("should return internal_server_error when database throws an error", async () => {
const mockError = new Error("Database connection failed");
vi.mocked(prisma.survey.findUnique).mockRejectedValueOnce(mockError);
const result = await getSurvey(mockSurveyId);
expect(prisma.survey.findUnique).toHaveBeenCalled();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "internal_server_error",
details: [{ field: "survey", issue: "Database connection failed" }],
});
}
});
test("should use correct cache key and tags", async () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(mockSurvey);
await getSurvey(mockSurveyId);
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSurvey-${mockSurveyId}`], {
tags: [`survey-${mockSurveyId}`],
});
expect(surveyCache.tag.byId).toHaveBeenCalledWith(mockSurveyId);
});
});

View File

@@ -1,116 +0,0 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { getContactsInSegment } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact";
import {
ZContactLinksBySegmentParams,
ZContactLinksBySegmentQuery,
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/types/contact";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { logger } from "@formbricks/logger";
export const GET = async (
request: Request,
props: { params: Promise<{ surveyId: string; segmentId: string }> }
) =>
authenticatedApiClient({
request,
externalParams: props.params,
schemas: {
params: ZContactLinksBySegmentParams,
query: ZContactLinksBySegmentQuery,
},
handler: async ({ authentication, parsedInput }) => {
const { params, query } = parsedInput;
if (!params) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
});
}
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return handleApiError(request, {
type: "forbidden",
details: [
{ field: "contacts", issue: "Contacts are only enabled for Enterprise Edition, please upgrade." },
],
});
}
const environmentIdResult = await getEnvironmentId(params.surveyId, false);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error);
}
const environmentId = environmentIdResult.data;
if (!hasPermission(authentication.environmentPermissions, environmentId, "GET")) {
return handleApiError(request, {
type: "unauthorized",
});
}
// Get contacts based on segment
const contactsResult = await getContactsInSegment(
params.surveyId,
params.segmentId,
query?.limit || 10,
query?.skip || 0,
query?.attributeKeys
);
if (!contactsResult.ok) {
return handleApiError(request, contactsResult.error);
}
const { data: contacts, meta } = contactsResult.data;
// Calculate expiration date based on expirationDays
let expiresAt: string | null = null;
if (query?.expirationDays) {
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + query.expirationDays);
expiresAt = expirationDate.toISOString();
}
// Generate survey links for each contact
const contactLinks = contacts
.map((contact) => {
const { contactId, attributes } = contact;
const surveyUrlResult = getContactSurveyLink(
contactId,
params.surveyId,
query?.expirationDays || undefined
);
if (!surveyUrlResult.ok) {
logger.error(
{ error: surveyUrlResult.error, contactId: contactId, surveyId: params.surveyId },
"Failed to generate survey URL for contact"
);
return null;
}
return {
contactId,
attributes,
surveyUrl: surveyUrlResult.data,
expiresAt,
};
})
.filter(Boolean);
return responses.successResponse({
data: contactLinks,
meta,
});
},
});

View File

@@ -1,43 +0,0 @@
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
import { z } from "zod";
export const ZContactLinksBySegmentParams = z.object({
surveyId: z.string().cuid2().describe("The ID of the survey"),
segmentId: z.string().cuid2().describe("The ID of the segment"),
});
export const ZContactLinksBySegmentQuery = ZGetFilter.pick({
limit: true,
skip: true,
}).extend({
expirationDays: z.coerce
.number()
.min(1)
.max(365)
.nullish()
.default(null)
.describe("Number of days until the generated JWT expires. If not provided, there is no expiration."),
attributeKeys: z
.string()
.optional()
.describe(
"Comma-separated list of contact attribute keys to include in the response. You can have max 20 keys. If not provided, no attributes will be included."
)
.refine((fields) => {
if (!fields) return true;
const fieldsArray = fields.split(",");
return fieldsArray.length <= 20;
}, "You can have max 20 keys."),
});
export type TContactWithAttributes = {
contactId: string;
attributes?: Record<string, string>;
};
export const ZContactLinkResponse = z.object({
contactId: z.string().describe("The ID of the contact"),
surveyUrl: z.string().url().describe("Personalized survey link"),
expiresAt: z.string().nullable().describe("The date and time the link expires, null if no expiration"),
attributes: z.record(z.string(), z.string()).describe("The attributes of the contact"),
});

View File

@@ -1,8 +0,0 @@
import { getContactLinksBySegmentEndpoint } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/openapi";
import { ZodOpenApiPathsObject } from "zod-openapi";
export const surveyContactLinksBySegmentPaths: ZodOpenApiPathsObject = {
"/surveys/{surveyId}/contact-links/segments/{segmentId}": {
get: getContactLinksBySegmentEndpoint,
},
};

View File

@@ -1,4 +1,4 @@
import { ZWebhookIdSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
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";
@@ -11,7 +11,7 @@ export const getWebhookEndpoint: ZodOpenApiOperationObject = {
description: "Gets a webhook from the database.",
requestParams: {
path: z.object({
id: ZWebhookIdSchema,
webhookId: webhookIdSchema,
}),
},
tags: ["Management API > Webhooks"],
@@ -34,7 +34,7 @@ export const deleteWebhookEndpoint: ZodOpenApiOperationObject = {
tags: ["Management API > Webhooks"],
requestParams: {
path: z.object({
id: ZWebhookIdSchema,
webhookId: webhookIdSchema,
}),
},
responses: {
@@ -56,7 +56,7 @@ export const updateWebhookEndpoint: ZodOpenApiOperationObject = {
tags: ["Management API > Webhooks"],
requestParams: {
path: z.object({
id: ZWebhookIdSchema,
webhookId: webhookIdSchema,
}),
},
requestBody: {

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