Compare commits

...

8 Commits

Author SHA1 Message Date
Matthias Nannt
4a141171c5 chore: introduce ai package 2025-08-06 13:12:46 +02:00
Jonas Höbenreich
0cc2606ec6 fix: Remove rounded-lg Class from Company Logo (#6347) 2025-08-04 01:42:05 -07:00
Dhruwang Jariwala
0fada94b80 chore: Replace entity ids (#6317) 2025-08-04 04:10:41 +00:00
Piyush Gupta
a59ede20c7 fix: one leet security issues (#6303) 2025-08-01 14:35:11 +00:00
Piyush Gupta
84294f9df2 feat: adds debug logs (#6237)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-08-01 11:10:21 +00:00
Johannes
855e7c78ce docs: add quota docs (#6343) 2025-07-31 06:25:34 -07:00
Piotr Gaczkowski
6c506d90c7 fix: Make EKS endpoint private (#6333)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-07-31 13:08:18 +00:00
Piyush Gupta
53f6e02ca1 fix: XLSX security vulnerability | Update XLSX to SheetJS (#6321) 2025-07-31 12:12:17 +00:00
67 changed files with 2252 additions and 309 deletions

View File

@@ -62,10 +62,12 @@ runs:
shell: bash
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
env:
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
shell: bash
- run: |

View File

@@ -1,23 +1,23 @@
name: 'Upload Sentry Sourcemaps'
description: 'Extract sourcemaps from Docker image and upload to Sentry'
name: "Upload Sentry Sourcemaps"
description: "Extract sourcemaps from Docker image and upload to Sentry"
inputs:
docker_image:
description: 'Docker image to extract sourcemaps from'
description: "Docker image to extract sourcemaps from"
required: true
release_version:
description: 'Sentry release version (e.g., v1.2.3)'
description: "Sentry release version (e.g., v1.2.3)"
required: true
sentry_auth_token:
description: 'Sentry authentication token'
description: "Sentry authentication token"
required: true
environment:
description: 'Sentry environment (e.g., production, staging)'
description: "Sentry environment (e.g., production, staging)"
required: false
default: 'staging'
default: "staging"
runs:
using: 'composite'
using: "composite"
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -26,13 +26,12 @@ runs:
- name: Validate Sentry auth token
shell: bash
env:
SENTRY_TOKEN: ${{ inputs.sentry_auth_token }}
run: |
set -euo pipefail
echo "🔐 Validating Sentry authentication token..."
# Assign token to local variable for secure handling
SENTRY_TOKEN="${{ inputs.sentry_auth_token }}"
# Test the token by making a simple API call to Sentry
response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \
-H "Authorization: Bearer $SENTRY_TOKEN" \
@@ -57,13 +56,23 @@ runs:
- name: Extract sourcemaps from Docker image
shell: bash
env:
DOCKER_IMAGE: ${{ inputs.docker_image }}
run: |
set -euo pipefail
echo "📦 Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}"
# Validate docker image format (basic validation)
if [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+:[a-zA-Z0-9._-]+$ ]] && [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+@sha256:[A-Fa-f0-9]{64}$ ]]; then
echo "❌ Error: Invalid docker image format. Must be in format 'image:tag' or 'image@sha256:hash'"
echo "Provided: $DOCKER_IMAGE"
exit 1
fi
echo "📦 Extracting sourcemaps from Docker image: $DOCKER_IMAGE"
# Create temporary container from the image and capture its ID
echo "Creating temporary container..."
CONTAINER_ID=$(docker create "${{ inputs.docker_image }}")
CONTAINER_ID=$(docker create "$DOCKER_IMAGE")
echo "Container created with ID: $CONTAINER_ID"
# Set up cleanup function to ensure container is removed on script exit
@@ -82,7 +91,7 @@ runs:
# Exit with the original exit code to preserve script success/failure status
exit $original_exit_code
}
# Register cleanup function to run on script exit (success or failure)
trap cleanup_container EXIT
@@ -113,7 +122,7 @@ runs:
with:
environment: ${{ inputs.environment }}
version: ${{ inputs.release_version }}
sourcemaps: './extracted-next/'
sourcemaps: "./extracted-next/"
- name: Clean up extracted files
shell: bash

View File

@@ -106,15 +106,16 @@ jobs:
env:
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
ENVIRONMENT: ${{ inputs.ENVIRONMENT }}
run: |
# Set hostname based on environment
if [[ "${{ inputs.ENVIRONMENT }}" == "production" ]]; then
if [[ "$ENVIRONMENT" == "production" ]]; then
PURGE_HOST="app.formbricks.com"
else
PURGE_HOST="stage.app.formbricks.com"
fi
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: ${{ inputs.ENVIRONMENT }}, zone: $CF_ZONE_ID)"
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: $ENVIRONMENT, zone: $CF_ZONE_ID)"
# Prepare JSON payload for selective cache purge
json_payload=$(cat << EOF

View File

@@ -47,12 +47,14 @@ jobs:
- name: Build Docker Image
uses: docker/build-push-action@v6
env:
GITHUB_SHA: ${{ github.sha }}
with:
context: .
file: ./apps/web/Dockerfile
push: false
load: true
tags: formbricks-test:${{ github.sha }}
tags: formbricks-test:${{ env.GITHUB_SHA }}
cache-from: type=gha
cache-to: type=gha,mode=max
secrets: |
@@ -89,6 +91,9 @@ jobs:
- name: Test Docker Image with Health Check
shell: bash
env:
GITHUB_SHA: ${{ github.sha }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
run: |
echo "🧪 Testing if the Docker image starts correctly..."
@@ -100,8 +105,8 @@ jobs:
$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 }}
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
-d "formbricks-test:$GITHUB_SHA"
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
echo "🏥 Polling /health endpoint every 5 seconds for up to 5 minutes..."

View File

@@ -44,11 +44,11 @@ jobs:
- name: Generate SemVer version from branch or tag
id: generate_version
env:
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
# Get reference name and type
REF_NAME="${{ github.ref_name }}"
REF_TYPE="${{ github.ref_type }}"
# Get reference name and type from environment variables
echo "Reference type: $REF_TYPE"
echo "Reference name: $REF_NAME"

View File

@@ -9,7 +9,7 @@ on:
workflow_call:
inputs:
IS_PRERELEASE:
description: 'Whether this is a prerelease (affects latest tag)'
description: "Whether this is a prerelease (affects latest tag)"
required: false
type: boolean
default: false
@@ -52,9 +52,20 @@ jobs:
id: extract_release_tag
run: |
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
TAG=${{ github.ref }}
TAG="$GITHUB_REF"
TAG=${TAG#refs/tags/v}
# Validate the extracted tag format
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid release tag format after extraction. Must be semver (e.g., 1.2.3, 1.2.3-alpha)"
echo "Original ref: $GITHUB_REF"
echo "Extracted tag: $TAG"
exit 1
fi
# Safely add to environment variables
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
echo "Using tag-based version: $TAG"

View File

@@ -26,8 +26,23 @@ jobs:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Extract release version
run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
- name: Validate input version
env:
INPUT_VERSION: ${{ inputs.VERSION }}
run: |
set -euo pipefail
# Validate input version format (expects clean semver without 'v' prefix)
if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid version format. Must be clean semver (e.g., 1.2.3, 1.2.3-alpha)"
echo "Expected: clean version without 'v' prefix"
echo "Provided: $INPUT_VERSION"
exit 1
fi
# Store validated version in environment variable
echo "VERSION<<EOF" >> $GITHUB_ENV
echo "$INPUT_VERSION" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
@@ -35,15 +50,18 @@ jobs:
version: latest
- name: Log in to GitHub Container Registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}
run: printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
- name: Install YQ
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Update Chart.yaml with new version
run: |
yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml
- name: Package Helm chart
run: |
@@ -51,4 +69,4 @@ jobs:
- name: Push Helm chart to GitHub Container Registry
run: |
helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts
helm push "formbricks-$VERSION.tgz" oci://ghcr.io/formbricks/helm-charts

View File

@@ -27,10 +27,18 @@ jobs:
- name: Get source branch name
id: branch-name
env:
RAW_BRANCH: ${{ github.head_ref }}
run: |
RAW_BRANCH="${{ github.head_ref }}"
# Validate and sanitize branch name - only allow alphanumeric, dots, underscores, hyphens, and forward slashes
SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g')
# Additional validation - ensure branch name is not empty after sanitization
if [[ -z "$SOURCE_BRANCH" ]]; then
echo "❌ Error: Branch name is empty after sanitization"
echo "Original branch: $RAW_BRANCH"
exit 1
fi
# Safely add to environment variables using GitHub's recommended method
# This prevents environment variable injection attacks

View File

@@ -23,7 +23,7 @@ jobs:
upload-sourcemaps:
name: Upload Sourcemaps to Sentry
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
@@ -31,16 +31,13 @@ jobs:
fetch-depth: 0
- name: Set Docker Image
run: |
if [ -n "${{ inputs.tag_version }}" ]; then
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV
else
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV
fi
run: echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> $GITHUB_ENV
env:
DOCKER_IMAGE: ${{ inputs.docker_image }}:${{ inputs.tag_version != '' && inputs.tag_version || inputs.release_version }}
- name: Upload Sourcemaps to Sentry
uses: ./.github/actions/upload-sentry-sourcemaps
with:
docker_image: ${{ env.DOCKER_IMAGE }}
release_version: ${{ inputs.release_version }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -121,8 +121,9 @@ describe("ProfilePage", () => {
expect(screen.getByTestId("account-security")).toBeInTheDocument(); // Shown because 2FA license is enabled
expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
expect(screen.getByTestId("delete-account")).toBeInTheDocument();
// Use a regex to match the text content, allowing for variable whitespace
expect(screen.getByText(new RegExp(`common\\.profile\\s*:\\s*${mockUser.id}`))).toBeInTheDocument(); // SettingsId
// Check for IdBadge content
expect(screen.getByText("common.profile_id")).toBeInTheDocument();
expect(screen.getByText(mockUser.id)).toBeInTheDocument();
});
});

View File

@@ -5,9 +5,9 @@ import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/servi
import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getTranslate } from "@/tolgee/server";
import { SettingsCard } from "../../components/SettingsCard";
@@ -103,7 +103,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
isMultiOrgEnabled={isMultiOrgEnabled}
/>
</SettingsCard>
<SettingsId title={t("common.profile")} id={user.id}></SettingsId>
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
</div>
)}
</PageContentWrapper>

View File

@@ -5,7 +5,7 @@ import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/lice
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { getTranslate } from "@/tolgee/server";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -78,8 +78,8 @@ vi.mock("./components/DeleteOrganization", () => ({
DeleteOrganization: vi.fn(() => <div>DeleteOrganization</div>),
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: vi.fn(() => <div>SettingsId</div>),
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(() => <div>IdBadge</div>),
}));
describe("Page", () => {
@@ -156,10 +156,11 @@ describe("Page", () => {
},
undefined
);
expect(SettingsId).toHaveBeenCalledWith(
expect(IdBadge).toHaveBeenCalledWith(
{
title: "common.organization_id",
id: mockEnvironmentAuth.organization.id,
label: "common.organization_id",
variant: "column",
},
undefined
);

View File

@@ -4,9 +4,9 @@ import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization";
@@ -70,7 +70,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard>
)}
<SettingsId title={t("common.organization_id")} id={organization.id}></SettingsId>
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
</PageContentWrapper>
);
};

View File

@@ -1,10 +1,9 @@
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { cleanup } from "@testing-library/react";
import { AnyActionArg } from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses";
import {
@@ -60,6 +59,7 @@ vi.mock("@/modules/survey/lib/questions", () => ({
getQuestionIconMap: vi.fn(() => ({
[TSurveyQuestionTypeEnum.OpenText]: <span>OT</span>,
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: <span>MCS</span>,
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: <span>MCM</span>,
[TSurveyQuestionTypeEnum.Matrix]: <span>MX</span>,
[TSurveyQuestionTypeEnum.Address]: <span>AD</span>,
[TSurveyQuestionTypeEnum.ContactInfo]: <span>CI</span>,
@@ -104,6 +104,27 @@ vi.mock("lucide-react", () => ({
TagIcon: () => <span>Tag</span>,
}));
// Mock new dependencies
vi.mock("@/lib/response/utils", () => ({
extractChoiceIdsFromResponse: vi.fn((responseValue) => {
// Mock implementation that returns choice IDs based on response value
if (Array.isArray(responseValue)) {
return responseValue.map((_, index) => `choice-${index + 1}`);
} else if (typeof responseValue === "string") {
return [`choice-single`];
}
return [];
}),
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(({ id }) => <div data-testid="id-badge">{id}</div>),
}));
vi.mock("@/modules/ui/lib/utils", () => ({
cn: vi.fn((...classes) => classes.filter(Boolean).join(" ")),
}));
const mockSurvey = {
id: "survey1",
name: "Test Survey",
@@ -136,6 +157,28 @@ const mockSurvey = {
headline: { default: "Contact Info Question" },
required: false,
} as unknown as TSurveyQuestion,
{
id: "q5single",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Single Choice Question" },
required: false,
choices: [
{ id: "choice-1", label: { default: "Option 1" } },
{ id: "choice-2", label: { default: "Option 2" } },
{ id: "choice-3", label: { default: "Option 3" } },
],
} as unknown as TSurveyQuestion,
{
id: "q6multi",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Multi Choice Question" },
required: false,
choices: [
{ id: "choice-a", label: { default: "Choice A" } },
{ id: "choice-b", label: { default: "Choice B" } },
{ id: "choice-c", label: { default: "Choice C" } },
],
} as unknown as TSurveyQuestion,
],
variables: [
{ id: "var1", name: "User Segment", type: "text" } as TSurveyVariable,
@@ -173,6 +216,8 @@ const mockResponseData = {
firstName: "John",
email: "john.doe@example.com",
hf1: "Hidden Field 1 Value",
q5single: "Option 1", // Single choice response
q6multi: ["Choice A", "Choice C"], // Multi choice response
},
variables: {
var1: "Segment A",
@@ -495,3 +540,281 @@ describe("ResponseTableColumns - Column Implementations", () => {
expect(hfColumn).toBeUndefined();
});
});
describe("ResponseTableColumns - Multiple Choice Questions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("generates two columns for multipleChoiceSingle questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Should have main response column
const mainColumn = columns.find((col) => (col as any).accessorKey === "q5single");
expect(mainColumn).toBeDefined();
// Should have option IDs column
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
expect(optionIdsColumn).toBeDefined();
});
test("generates two columns for multipleChoiceMulti questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Should have main response column
const mainColumn = columns.find((col) => (col as any).accessorKey === "q6multi");
expect(mainColumn).toBeDefined();
// Should have option IDs column
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
expect(optionIdsColumn).toBeDefined();
});
test("multipleChoiceSingle main column renders RenderResponse component", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: "default",
},
};
const cellResult = mainColumn?.cell?.({ row: mockRow } as any);
// Check that RenderResponse component is returned
expect(cellResult).toBeDefined();
});
test("multipleChoiceMulti main column renders RenderResponse component", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q6multi");
const mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
const cellResult = mainColumn?.cell?.({ row: mockRow } as any);
// Check that RenderResponse component is returned
expect(cellResult).toBeDefined();
});
});
describe("ResponseTableColumns - Choice ID Columns", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("option IDs column calls extractChoiceIdsFromResponse for string response", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: "default",
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
"Option 1",
expect.objectContaining({ id: "q5single", type: "multipleChoiceSingle" }),
"default"
);
});
test("option IDs column calls extractChoiceIdsFromResponse for array response", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
const mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
["Choice A", "Choice C"],
expect.objectContaining({ id: "q6multi", type: "multipleChoiceMulti" }),
"default"
);
});
test("option IDs column renders IdBadge components for choice IDs", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
const mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
// Mock extractChoiceIdsFromResponse to return specific choice IDs
vi.mocked(extractChoiceIdsFromResponse).mockReturnValueOnce(["choice-1", "choice-3"]);
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
// Should render something for choice IDs
expect(cellResult).toBeDefined();
// Verify that extractChoiceIdsFromResponse was called
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalled();
});
test("option IDs column returns null for non-string/array response values", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: 123 }, // Invalid type
language: "default",
},
};
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeNull();
expect(vi.mocked(extractChoiceIdsFromResponse)).not.toHaveBeenCalled();
});
test("option IDs column returns null when no choice IDs found", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: "Non-existent option" },
language: "default",
},
};
// Mock extractChoiceIdsFromResponse to return empty array
vi.mocked(extractChoiceIdsFromResponse).mockReturnValueOnce([]);
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeNull();
});
test("option IDs column handles missing language gracefully", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: null, // No language
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
"Option 1",
expect.objectContaining({ id: "q5single" }),
undefined
);
});
});
describe("ResponseTableColumns - Helper Functions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("question headers are properly created for multiple choice questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
// Test main column header
const mainHeader = mainColumn?.header?.();
expect(mainHeader).toBeDefined();
expect(mainHeader?.props?.className).toContain("flex items-center justify-between");
// Test option IDs column header
const optionHeader = optionIdsColumn?.header?.();
expect(optionHeader).toBeDefined();
expect(optionHeader?.props?.className).toContain("flex items-center justify-between");
});
test("question headers include proper icons for multiple choice questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const singleChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const multiChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "q6multi");
// Headers should be functions that return JSX
expect(typeof singleChoiceColumn?.header).toBe("function");
expect(typeof multiChoiceColumn?.header).toBe("function");
// Call headers to ensure they don't throw
expect(() => singleChoiceColumn?.header?.()).not.toThrow();
expect(() => multiChoiceColumn?.header?.()).not.toThrow();
});
});
describe("ResponseTableColumns - Integration Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("multiple choice questions work end-to-end with real data", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Find all multiple choice related columns
const singleMainCol = columns.find((col) => (col as any).accessorKey === "q5single");
const singleIdsCol = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const multiMainCol = columns.find((col) => (col as any).accessorKey === "q6multi");
const multiIdsCol = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
expect(singleMainCol).toBeDefined();
expect(singleIdsCol).toBeDefined();
expect(multiMainCol).toBeDefined();
expect(multiIdsCol).toBeDefined();
// Test with actual mock response data
const mockRow = { original: mockResponseData };
// Test single choice main column
const singleMainResult = (singleMainCol?.cell as any)?.({ row: mockRow });
expect(singleMainResult).toBeDefined();
// Test multi choice main column
const multiMainResult = (multiMainCol?.cell as any)?.({ row: mockRow });
expect(multiMainResult).toBeDefined();
// Test that choice ID columns exist and can be called
const singleIdsResult = (singleIdsCol?.cell as any)?.({ row: mockRow });
const multiIdsResult = (multiIdsCol?.cell as any)?.({ row: mockRow });
// Should not error when calling the cell functions
expect(() => singleIdsResult).not.toThrow();
expect(() => multiIdsResult).not.toThrow();
});
});

View File

@@ -1,6 +1,7 @@
"use client";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
@@ -8,8 +9,10 @@ import { recallToHeadline } from "@/lib/utils/recall";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@/modules/survey/lib/questions";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { cn } from "@/modules/ui/lib/utils";
import { ColumnDef } from "@tanstack/react-table";
import { TFnType } from "@tolgee/react";
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
@@ -61,6 +64,42 @@ const getQuestionColumnsData = (
t: TFnType
): ColumnDef<TResponseTableData>[] => {
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
// Helper function to create consistent column headers
const createQuestionHeader = (questionType: string, headline: string, suffix?: string) => {
const title = suffix ? `${headline} - ${suffix}` : headline;
const QuestionHeader = () => (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{QUESTIONS_ICON_MAP[questionType]}</span>
<span className="truncate">{title}</span>
</div>
</div>
);
QuestionHeader.displayName = "QuestionHeader";
return QuestionHeader;
};
// Helper function to get localized question headline
const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => {
return getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default");
};
// Helper function to render choice ID badges
const renderChoiceIdBadges = (choiceIds: string[], isExpanded: boolean) => {
if (choiceIds.length === 0) return null;
const containerClasses = cn("flex gap-x-1 w-full", isExpanded && "flex-wrap gap-y-1");
return (
<div className={containerClasses}>
{choiceIds.map((choiceId, index) => (
<IdBadge key={choiceId || index} id={choiceId} />
))}
</div>
);
};
switch (question.type) {
case "matrix":
return question.rows.map((matrixRow) => {
@@ -137,6 +176,49 @@ const getQuestionColumnsData = (
};
});
case "multipleChoiceMulti":
case "multipleChoiceSingle":
case "ranking":
case "pictureSelection": {
const questionHeadline = getQuestionHeadline(question, survey);
return [
{
accessorKey: question.id,
header: createQuestionHeader(question.type, questionHeadline),
cell: ({ row }) => {
const responseValue = row.original.responseData[question.id];
const language = row.original.language;
return (
<RenderResponse
question={question}
survey={survey}
responseData={responseValue}
language={language}
isExpanded={isExpanded}
/>
);
},
},
{
accessorKey: question.id + "optionIds",
header: createQuestionHeader(question.type, questionHeadline, t("common.option_id")),
cell: ({ row }) => {
const responseValue = row.original.responseData[question.id];
// Type guard to ensure responseValue is the correct type
if (typeof responseValue === "string" || Array.isArray(responseValue)) {
const choiceIds = extractChoiceIdsFromResponse(
responseValue,
question,
row.original.language || undefined
);
return renderChoiceIdBadges(choiceIds, isExpanded);
}
return null;
},
},
];
}
default:
return [
{
@@ -317,7 +399,6 @@ export const generateResponseTableColumns = (
};
// Combine the selection column with the dynamic question columns
const baseColumns = [
personColumn,
dateColumn,

View File

@@ -27,10 +27,10 @@ vi.mock("@/modules/survey/lib/questions", () => ({
],
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: ({ title, id }: { title: string; id: string }) => (
<div data-testid="settings-id">
{title}: {id}
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ label, id }: { label: string; id: string }) => (
<div data-testid="id-badge">
{label}: {id}
</div>
),
}));
@@ -76,7 +76,7 @@ describe("QuestionSummaryHeader", () => {
).toBeInTheDocument();
expect(screen.getByTestId("question-icon")).toBeInTheDocument();
expect(screen.getByTestId("settings-id")).toHaveTextContent("common.question_id: q1");
expect(screen.getByTestId("id-badge")).toHaveTextContent("common.question_id: q1");
expect(screen.queryByText("environments.surveys.edit.optional")).not.toBeInTheDocument();
});

View File

@@ -3,7 +3,7 @@
import { recallToHeadline } from "@/lib/utils/recall";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
import type { JSX } from "react";
@@ -55,7 +55,7 @@ export const QuestionSummaryHeader = ({
</div>
)}
</div>
<SettingsId title={t("common.question_id")} id={questionSummary.question.id}></SettingsId>
<IdBadge id={questionSummary.question.id} label={t("common.question_id")} />
</div>
);
};

View File

@@ -98,8 +98,8 @@ vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ children }) => <div data-testid="page-header">{children}</div>),
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: vi.fn(() => <div data-testid="settings-id"></div>),
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(() => <div data-testid="id-badge"></div>),
}));
vi.mock("@/tolgee/server", () => ({
@@ -227,7 +227,7 @@ describe("SurveyPage", () => {
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("survey-analysis-navigation")).toBeInTheDocument();
expect(screen.getByTestId("summary-page")).toBeInTheDocument();
expect(screen.getByTestId("settings-id")).toBeInTheDocument();
expect(screen.getByTestId("id-badge")).toBeInTheDocument();
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getSurvey)).toHaveBeenCalledWith(mockSurveyId);

View File

@@ -9,9 +9,9 @@ import { getUser } from "@/lib/user/service";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
import { notFound } from "next/navigation";
@@ -74,7 +74,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
initialSurveySummary={initialSurveySummary}
/>
<SettingsId title={t("common.survey_id")} id={surveyId} />
<IdBadge id={surveyId} label={t("common.survey_id")} variant="column" />
</PageContentWrapper>
);
};

View File

@@ -1,11 +1,17 @@
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
TSurvey,
TSurveyOpenTextQuestion,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import {
buildWhereClause,
calculateTtcTotal,
extracMetadataKeys,
extractChoiceIdsFromResponse,
extractSurveyDetails,
generateAllPermutationsOfSubsets,
getResponseContactAttributes,
@@ -555,3 +561,176 @@ describe("Response Utils", () => {
});
});
});
describe("extractChoiceIdsFromResponse", () => {
const multipleChoiceMultiQuestion: TSurveyQuestion = {
id: "multi-choice-id",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Select multiple options" },
required: false,
choices: [
{
id: "choice-1",
label: { default: "Option 1", es: "Opción 1" },
},
{
id: "choice-2",
label: { default: "Option 2", es: "Opción 2" },
},
{
id: "choice-3",
label: { default: "Option 3", es: "Opción 3" },
},
],
};
const multipleChoiceSingleQuestion: TSurveyQuestion = {
id: "single-choice-id",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Select one option" },
required: false,
choices: [
{
id: "choice-a",
label: { default: "Choice A", fr: "Choix A" },
},
{
id: "choice-b",
label: { default: "Choice B", fr: "Choix B" },
},
],
};
const textQuestion: TSurveyOpenTextQuestion = {
id: "text-id",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "What do you think?" },
required: false,
inputType: "text",
charLimit: { enabled: false, min: 0, max: 0 },
};
describe("multipleChoiceMulti questions", () => {
test("should extract choice IDs from array response with default language", () => {
const responseValue = ["Option 1", "Option 3"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual(["choice-1", "choice-3"]);
});
test("should extract choice IDs from array response with specific language", () => {
const responseValue = ["Opción 1", "Opción 2"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "es");
expect(result).toEqual(["choice-1", "choice-2"]);
});
test("should fall back to checking all language values when exact language match fails", () => {
const responseValue = ["Opción 1", "Option 2"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual(["choice-1", "choice-2"]);
});
test("should render other option when non-matching choice is selected", () => {
const responseValue = ["Option 1", "Non-existent option", "Option 3"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual(["choice-1", "other", "choice-3"]);
});
test("should return empty array for empty response", () => {
const responseValue: string[] = [];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual([]);
});
});
describe("multipleChoiceSingle questions", () => {
test("should extract choice ID from string response with default language", () => {
const responseValue = "Choice A";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default");
expect(result).toEqual(["choice-a"]);
});
test("should extract choice ID from string response with specific language", () => {
const responseValue = "Choix B";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "fr");
expect(result).toEqual(["choice-b"]);
});
test("should fall back to checking all language values for single choice", () => {
const responseValue = "Choix A";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default");
expect(result).toEqual(["choice-a"]);
});
test("should return empty array for empty string response", () => {
const responseValue = "";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default");
expect(result).toEqual([]);
});
});
describe("edge cases", () => {
test("should return empty array for non-multiple choice questions", () => {
const responseValue = "Some text response";
const result = extractChoiceIdsFromResponse(responseValue, textQuestion, "default");
expect(result).toEqual([]);
});
test("should handle missing language parameter by defaulting to 'default'", () => {
const responseValue = "Option 1";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion);
expect(result).toEqual(["choice-1"]);
});
test("should handle numeric or other types by returning empty array", () => {
const responseValue = 123;
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual([]);
});
test("should handle object responses by returning empty array", () => {
const responseValue = { invalid: "object" };
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual([]);
});
});
describe("language handling", () => {
test("should use provided language parameter", () => {
const responseValue = ["Opción 1"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "es");
expect(result).toEqual(["choice-1"]);
});
test("should handle null language parameter by defaulting to 'default'", () => {
const responseValue = ["Option 1"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, null as any);
expect(result).toEqual(["choice-1"]);
});
test("should handle undefined language parameter by defaulting to 'default'", () => {
const responseValue = ["Option 1"];
const result = extractChoiceIdsFromResponse(
responseValue,
multipleChoiceMultiQuestion,
undefined as any
);
expect(result).toEqual(["choice-1"]);
});
});
});

View File

@@ -1,20 +1,86 @@
import "server-only";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { Prisma } from "@prisma/client";
import {
TResponse,
TResponseDataValue,
TResponseFilterCriteria,
TResponseHiddenFieldsFilter,
TResponseTtc,
TSurveyContactAttributes,
TSurveyMetaFieldFilter,
} from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { processResponseData } from "../responses";
import { getTodaysDateTimeFormatted } from "../time";
import { getFormattedDateTimeString } from "../utils/datetime";
import { sanitizeString } from "../utils/strings";
/**
* Extracts choice IDs from response values for multiple choice questions
* @param responseValue - The response value (string for single choice, array for multi choice)
* @param question - The survey question containing choices
* @param language - The language to match against (defaults to "default")
* @returns Array of choice IDs
*/
export const extractChoiceIdsFromResponse = (
responseValue: TResponseDataValue,
question: TSurveyQuestion,
language: string = "default"
): string[] => {
// Type guard to ensure the question has choices
if (
question.type !== "multipleChoiceMulti" &&
question.type !== "multipleChoiceSingle" &&
question.type !== "ranking" &&
question.type !== "pictureSelection"
) {
return [];
}
const isPictureSelection = question.type === "pictureSelection";
if (!responseValue) {
return [];
}
// For picture selection questions, the response value is already choice ID(s)
if (isPictureSelection) {
if (Array.isArray(responseValue)) {
// Multi-selection: array of choice IDs
return responseValue.filter((id): id is string => typeof id === "string");
} else if (typeof responseValue === "string") {
// Single selection: single choice ID
return [responseValue];
}
return [];
}
const defaultLanguage = language ?? "default";
// Helper function to find choice by label - eliminates duplication
const findChoiceByLabel = (choiceLabel: string): string | null => {
const targetChoice = question.choices.find((c) => {
// Try exact language match first
if (c.label[defaultLanguage] === choiceLabel) {
return true;
}
// Fall back to checking all language values
return Object.values(c.label).includes(choiceLabel);
});
return targetChoice?.id || "other";
};
if (Array.isArray(responseValue)) {
// Multiple choice case - response is an array of selected choice labels
return responseValue.map(findChoiceByLabel).filter((choiceId): choiceId is string => choiceId !== null);
} else if (typeof responseValue === "string") {
// Single choice case - response is a single choice label
const choiceId = findChoiceByLabel(responseValue);
return choiceId ? [choiceId] : [];
}
return [];
};
export const calculateTtcTotal = (ttc: TResponseTtc) => {
const result = { ...ttc };
result._total = Object.values(result).reduce((acc: number, val: number) => acc + val, 0);
@@ -490,10 +556,17 @@ export const extractSurveyDetails = (survey: TSurvey, responses: TResponse[]) =>
return question.rows.map((row) => {
return `${idx + 1}. ${headline} - ${getLocalizedValue(row, "default")}`;
});
} else if (
question.type === "multipleChoiceMulti" ||
question.type === "multipleChoiceSingle" ||
question.type === "ranking"
) {
return [`${idx + 1}. ${headline}`, `${idx + 1}. ${headline} - Option ID`];
} else {
return [`${idx + 1}. ${headline}`];
}
});
const hiddenFields = survey.hiddenFields?.fieldIds || [];
const userAttributes =
survey.type === "app"
@@ -556,6 +629,19 @@ export const getResponsesJson = (
}
}
});
} else if (
question.type === "multipleChoiceMulti" ||
question.type === "multipleChoiceSingle" ||
question.type === "ranking"
) {
// Set the main response value
jsonData[idx][questionHeadline[0]] = processResponseData(answer);
// Set the option IDs using the reusable function
if (questionHeadline[1]) {
const choiceIds = extractChoiceIdsFromResponse(answer, question, response.language || "default");
jsonData[idx][questionHeadline[1]] = choiceIds.join(", ");
}
} else {
jsonData[idx][questionHeadline[0]] = processResponseData(answer);
}

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { deepDiff, redactPII } from "./utils";
import { deepDiff, redactPII, sanitizeUrlForLogging } from "./logger-helpers";
// Patch redis multi before any imports
beforeEach(async () => {
@@ -104,7 +104,7 @@ describe("withAuditLogging", () => {
});
test("logs audit event for successful handler", async () => {
const handler = vi.fn().mockResolvedValue("ok");
const { withAuditLogging } = await import("./handler");
const { withAuditLogging } = await import("../../modules/ee/audit-logs/lib/handler");
const wrapped = withAuditLogging("created", "survey", handler);
const ctx = {
user: {
@@ -143,7 +143,7 @@ describe("withAuditLogging", () => {
});
test("logs audit event for failed handler and throws", async () => {
const handler = vi.fn().mockRejectedValue(new Error("fail"));
const { withAuditLogging } = await import("./handler");
const { withAuditLogging } = await import("../../modules/ee/audit-logs/lib/handler");
const wrapped = withAuditLogging("created", "survey", handler);
const ctx = {
user: {
@@ -181,3 +181,37 @@ describe("withAuditLogging", () => {
expect(handler).toHaveBeenCalled();
});
});
describe("sanitizeUrlForLogging", () => {
test("returns sanitized URL with token", () => {
expect(sanitizeUrlForLogging("https://example.com?token=1234567890")).toBe(
"https://example.com/?token=********"
);
});
test("returns sanitized URL with code", () => {
expect(sanitizeUrlForLogging("https://example.com?code=1234567890")).toBe(
"https://example.com/?code=********"
);
});
test("returns sanitized URL with state", () => {
expect(sanitizeUrlForLogging("https://example.com?state=1234567890")).toBe(
"https://example.com/?state=********"
);
});
test("returns sanitized URL with multiple keys", () => {
expect(
sanitizeUrlForLogging("https://example.com?token=1234567890&code=1234567890&state=1234567890")
).toBe("https://example.com/?token=********&code=********&state=********");
});
test("returns sanitized URL without query params", () => {
expect(sanitizeUrlForLogging("https://example.com")).toBe("https://example.com/");
});
test("returns sanitized URL with invalid URL", () => {
expect(sanitizeUrlForLogging("not-a-valid-url")).toBe("[invalid-url]");
});
});

View File

@@ -1,3 +1,5 @@
import { isStringUrl } from "@/lib/utils/url";
const SENSITIVE_KEYS = [
"email",
"name",
@@ -33,8 +35,11 @@ const SENSITIVE_KEYS = [
"image",
"stripeCustomerId",
"fileName",
"state",
];
const URL_SENSITIVE_KEYS = ["token", "code", "state"];
/**
* Redacts sensitive data from the object by replacing the sensitive keys with "********".
* @param obj - The object to redact.
@@ -45,6 +50,10 @@ export const redactPII = (obj: any, seen: WeakSet<any> = new WeakSet()): any =>
return obj.toISOString();
}
if (typeof obj === "string" && isStringUrl(obj)) {
return sanitizeUrlForLogging(obj);
}
if (obj && typeof obj === "object") {
if (seen.has(obj)) return "[Circular]";
seen.add(obj);
@@ -89,3 +98,24 @@ export const deepDiff = (oldObj: any, newObj: any): any => {
}
return Object.keys(diff).length > 0 ? diff : undefined;
};
/**
* Sanitizes a URL for logging by redacting sensitive parameters.
* @param url - The URL to sanitize.
* @returns The sanitized URL.
*/
export const sanitizeUrlForLogging = (url: string): string => {
try {
const urlObj = new URL(url);
URL_SENSITIVE_KEYS.forEach((key) => {
if (urlObj.searchParams.has(key)) {
urlObj.searchParams.set(key, "********");
}
});
return urlObj.origin + urlObj.pathname + (urlObj.search ? `${urlObj.search}` : "");
} catch {
return "[invalid-url]";
}
};

View File

@@ -1,7 +1,7 @@
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TActionClassPageUrlRule } from "@formbricks/types/action-classes";
import { isValidCallbackUrl, testURLmatch } from "./url";
import { isStringUrl, isValidCallbackUrl, testURLmatch } from "./url";
afterEach(() => {
cleanup();
@@ -91,3 +91,13 @@ describe("isValidCallbackUrl", () => {
expect(isValidCallbackUrl("not-a-valid-url", WEBAPP_URL)).toBe(false);
});
});
describe("isStringUrl", () => {
test("returns true for valid URL", () => {
expect(isStringUrl("https://example.com")).toBe(true);
});
test("returns false for invalid URL", () => {
expect(isStringUrl("not-a-valid-url")).toBe(false);
});
});

View File

@@ -49,3 +49,12 @@ export const isValidCallbackUrl = (url: string, WEBAPP_URL: string): boolean =>
return false;
}
};
export const isStringUrl = (url: string): boolean => {
try {
new URL(url);
return true;
} catch {
return false;
}
};

View File

@@ -124,6 +124,7 @@
"add_action": "Aktion hinzufügen",
"add_filter": "Filter hinzufügen",
"add_logo": "Logo hinzufügen",
"add_member": "Mitglied hinzufügen",
"add_project": "Projekt hinzufügen",
"add_to_team": "Zum Team hinzufügen",
"all": "Alle",
@@ -279,6 +280,8 @@
"on": "An",
"only_one_file_allowed": "Es ist nur eine Datei erlaubt",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.",
"option_id": "Option-ID",
"option_ids": "Option-IDs",
"or": "oder",
"organization": "Organisation",
"organization_id": "Organisations-ID",
@@ -305,6 +308,7 @@
"privacy": "Datenschutz",
"product_manager": "Produktmanager",
"profile": "Profil",
"profile_id": "Profil-ID",
"project_configuration": "Projektkonfiguration",
"project_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
"project_id": "Projekt-ID",
@@ -385,6 +389,7 @@
"targeting": "Targeting",
"team": "Team",
"team_access": "Teamzugriff",
"team_id": "Team-ID",
"team_name": "Teamname",
"teams": "Zugriffskontrolle",
"teams_not_found": "Teams nicht gefunden",
@@ -1311,7 +1316,7 @@
"columns": "Spalten",
"company": "Firma",
"company_logo": "Firmenlogo",
"completed_responses": "abgeschlossene Antworten",
"completed_responses": "unvollständige oder vollständige Antworten.",
"concat": "Verketten +",
"conditional_logic": "Bedingte Logik",
"confirm_default_language": "Standardsprache bestätigen",
@@ -1813,7 +1818,6 @@
"last_quarter": "Letztes Quartal",
"last_year": "Letztes Jahr",
"no_responses_found": "Keine Antworten gefunden",
"only_completed": "Nur vollständige Antworten",
"other_values_found": "Andere Werte gefunden",
"overall": "Insgesamt",
"qr_code": "QR-Code",

View File

@@ -124,6 +124,7 @@
"add_action": "Add action",
"add_filter": "Add filter",
"add_logo": "Add logo",
"add_member": "Add member",
"add_project": "Add project",
"add_to_team": "Add to team",
"all": "All",
@@ -279,6 +280,8 @@
"on": "On",
"only_one_file_allowed": "Only one file is allowed",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
"option_id": "Option ID",
"option_ids": "Option IDs",
"or": "or",
"organization": "Organization",
"organization_id": "Organization ID",
@@ -305,6 +308,7 @@
"privacy": "Privacy Policy",
"product_manager": "Product Manager",
"profile": "Profile",
"profile_id": "Profile ID",
"project_configuration": "Project's Configuration",
"project_creation_description": "Organize surveys in projects for better access control.",
"project_id": "Project ID",
@@ -385,6 +389,7 @@
"targeting": "Targeting",
"team": "Team",
"team_access": "Team Access",
"team_id": "Team ID",
"team_name": "Team name",
"teams": "Access Control",
"teams_not_found": "Teams not found",
@@ -1311,7 +1316,7 @@
"columns": "Columns",
"company": "Company",
"company_logo": "Company logo",
"completed_responses": "completed responses.",
"completed_responses": "partial or completed responses.",
"concat": "Concat +",
"conditional_logic": "Conditional Logic",
"confirm_default_language": "Confirm default language",
@@ -1813,7 +1818,6 @@
"last_quarter": "Last quarter",
"last_year": "Last year",
"no_responses_found": "No responses found",
"only_completed": "Only completed",
"other_values_found": "Other values found",
"overall": "Overall",
"qr_code": "QR code",

View File

@@ -124,6 +124,7 @@
"add_action": "Ajouter une action",
"add_filter": "Ajouter un filtre",
"add_logo": "Ajouter un logo",
"add_member": "Ajouter un membre",
"add_project": "Ajouter un projet",
"add_to_team": "Ajouter à l'équipe",
"all": "Tout",
@@ -279,6 +280,8 @@
"on": "Sur",
"only_one_file_allowed": "Un seul fichier est autorisé",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.",
"option_id": "Identifiant de l'option",
"option_ids": "Identifiants des options",
"or": "ou",
"organization": "Organisation",
"organization_id": "ID de l'organisation",
@@ -305,6 +308,7 @@
"privacy": "Politique de confidentialité",
"product_manager": "Chef de produit",
"profile": "Profil",
"profile_id": "Identifiant de profil",
"project_configuration": "Configuration du projet",
"project_creation_description": "Organisez les enquêtes en projets pour un meilleur contrôle d'accès.",
"project_id": "ID de projet",
@@ -385,6 +389,7 @@
"targeting": "Ciblage",
"team": "Équipe",
"team_access": "Accès Équipe",
"team_id": "Équipe ID",
"team_name": "Nom de l'équipe",
"teams": "Contrôle d'accès",
"teams_not_found": "Équipes non trouvées",
@@ -1202,7 +1207,7 @@
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Choisissez la langue par défaut pour ce sondage :",
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques :",
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques:",
"add": "Ajouter +",
"add_a_delay_or_auto_close_the_survey": "Ajouter un délai ou fermer automatiquement l'enquête",
"add_a_four_digit_pin": "Ajoutez un code PIN à quatre chiffres.",
@@ -1311,7 +1316,7 @@
"columns": "Colonnes",
"company": "Société",
"company_logo": "Logo de l'entreprise",
"completed_responses": "réponses complètes.",
"completed_responses": "des réponses partielles ou complètes.",
"concat": "Concat +",
"conditional_logic": "Logique conditionnelle",
"confirm_default_language": "Confirmer la langue par défaut",
@@ -1813,7 +1818,6 @@
"last_quarter": "dernier trimestre",
"last_year": "l'année dernière",
"no_responses_found": "Aucune réponse trouvée",
"only_completed": "Uniquement terminé",
"other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement",
"qr_code": "Code QR",

View File

@@ -124,6 +124,7 @@
"add_action": "Adicionar ação",
"add_filter": "Adicionar filtro",
"add_logo": "Adicionar logo",
"add_member": "Adicionar membro",
"add_project": "Adicionar projeto",
"add_to_team": "Adicionar à equipe",
"all": "Todos",
@@ -279,6 +280,8 @@
"on": "ligado",
"only_one_file_allowed": "É permitido apenas um arquivo",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.",
"option_id": "ID da opção",
"option_ids": "IDs da Opção",
"or": "ou",
"organization": "organização",
"organization_id": "ID da Organização",
@@ -305,6 +308,7 @@
"privacy": "Política de Privacidade",
"product_manager": "Gerente de Produto",
"profile": "Perfil",
"profile_id": "ID de Perfil",
"project_configuration": "Configuração do Projeto",
"project_creation_description": "Organize pesquisas em projetos para melhor controle de acesso.",
"project_id": "ID do Projeto",
@@ -385,6 +389,7 @@
"targeting": "mirando",
"team": "Time",
"team_access": "Acesso da equipe",
"team_id": "ID da Equipe",
"team_name": "Nome da equipe",
"teams": "Controle de Acesso",
"teams_not_found": "Equipes não encontradas",
@@ -1311,7 +1316,7 @@
"columns": "colunas",
"company": "empresa",
"company_logo": "Logo da empresa",
"completed_responses": "respostas completas",
"completed_responses": "respostas parciais ou completas.",
"concat": "Concatenar +",
"conditional_logic": "Lógica Condicional",
"confirm_default_language": "Confirmar idioma padrão",
@@ -1813,7 +1818,6 @@
"last_quarter": "Último trimestre",
"last_year": "Último ano",
"no_responses_found": "Nenhuma resposta encontrada",
"only_completed": "Somente concluído",
"other_values_found": "Outros valores encontrados",
"overall": "No geral",
"qr_code": "Código QR",

View File

@@ -124,6 +124,7 @@
"add_action": "Adicionar ação",
"add_filter": "Adicionar filtro",
"add_logo": "Adicionar logótipo",
"add_member": "Adicionar membro",
"add_project": "Adicionar projeto",
"add_to_team": "Adicionar à equipa",
"all": "Todos",
@@ -279,6 +280,8 @@
"on": "Ligado",
"only_one_file_allowed": "Apenas um ficheiro é permitido",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.",
"option_id": "ID de Opção",
"option_ids": "IDs de Opção",
"or": "ou",
"organization": "Organização",
"organization_id": "ID da Organização",
@@ -305,6 +308,7 @@
"privacy": "Política de Privacidade",
"product_manager": "Gestor de Produto",
"profile": "Perfil",
"profile_id": "ID do Perfil",
"project_configuration": "Configuração do Projeto",
"project_creation_description": "Organize questionários em projetos para um melhor controlo de acesso.",
"project_id": "ID do Projeto",
@@ -385,6 +389,7 @@
"targeting": "Segmentação",
"team": "Equipa",
"team_access": "Acesso da Equipa",
"team_id": "ID da Equipa",
"team_name": "Nome da equipa",
"teams": "Controlo de Acesso",
"teams_not_found": "Equipas não encontradas",
@@ -1311,7 +1316,7 @@
"columns": "Colunas",
"company": "Empresa",
"company_logo": "Logotipo da empresa",
"completed_responses": "respostas concluídas",
"completed_responses": "respostas parciais ou completas",
"concat": "Concatenar +",
"conditional_logic": "Lógica Condicional",
"confirm_default_language": "Confirmar idioma padrão",
@@ -1813,7 +1818,6 @@
"last_quarter": "Último trimestre",
"last_year": "Ano passado",
"no_responses_found": "Nenhuma resposta encontrada",
"only_completed": "Apenas concluído",
"other_values_found": "Outros valores encontrados",
"overall": "Geral",
"qr_code": "Código QR",

View File

@@ -124,6 +124,7 @@
"add_action": "新增操作",
"add_filter": "新增篩選器",
"add_logo": "新增標誌",
"add_member": "新增成員",
"add_project": "新增專案",
"add_to_team": "新增至團隊",
"all": "全部",
@@ -279,6 +280,8 @@
"on": "開啟",
"only_one_file_allowed": "僅允許一個檔案",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。",
"option_id": "選項 ID",
"option_ids": "選項 IDs",
"or": "或",
"organization": "組織",
"organization_id": "組織 ID",
@@ -305,6 +308,7 @@
"privacy": "隱私權政策",
"product_manager": "產品經理",
"profile": "個人資料",
"profile_id": "個人資料 ID",
"project_configuration": "專案組態",
"project_creation_description": "組織調查 在 專案中以便更好地存取控制。",
"project_id": "專案 ID",
@@ -385,6 +389,7 @@
"targeting": "目標設定",
"team": "團隊",
"team_access": "團隊存取權限",
"team_id": "團隊 ID",
"team_name": "團隊名稱",
"teams": "存取控制",
"teams_not_found": "找不到團隊",
@@ -1311,7 +1316,7 @@
"columns": "欄位",
"company": "公司",
"company_logo": "公司標誌",
"completed_responses": "完成的回應。",
"completed_responses": "部分或完整答复。",
"concat": "串連 +",
"conditional_logic": "條件邏輯",
"confirm_default_language": "確認預設語言",
@@ -1813,7 +1818,6 @@
"last_quarter": "上一季",
"last_year": "去年",
"no_responses_found": "找不到回應",
"only_completed": "僅已完成",
"other_values_found": "找到其他值",
"overall": "整體",
"qr_code": "QR 碼",

View File

@@ -324,6 +324,7 @@ export const authOptions: NextAuthOptions = {
if (account?.provider === "credentials" || account?.provider === "token") {
// check if user's email is verified or not
if (!user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
logger.error("Email Verification is Pending");
throw new Error("Email Verification is Pending");
}
await updateUserLastLoginAt(user.email);

View File

@@ -2,6 +2,7 @@ import { AUDIT_LOG_ENABLED, AUDIT_LOG_GET_USER_IP } from "@/lib/constants";
import { ActionClientCtx, AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { deepDiff, redactPII } from "@/lib/utils/logger-helpers";
import { logAuditEvent } from "@/modules/ee/audit-logs/lib/service";
import {
TActor,
@@ -13,7 +14,6 @@ import {
} from "@/modules/ee/audit-logs/types/audit-log";
import { getIsAuditLogsEnabled } from "@/modules/ee/license-check/lib/utils";
import { logger } from "@formbricks/logger";
import { deepDiff, redactPII } from "./utils";
/**
* Builds an audit event and logs it.

View File

@@ -2,6 +2,7 @@ import { getResponsesByContactId } from "@/lib/response/service";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { getTranslate } from "@/tolgee/server";
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
@@ -42,7 +43,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
<dt className="text-sm font-medium text-slate-500">{t("common.user_id")}</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.userId ? (
<span>{attributes.userId}</span>
<IdBadge id={attributes.userId} />
) : (
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
)}

View File

@@ -2,6 +2,7 @@
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { ColumnDef } from "@tanstack/react-table";
import { TContactTableData } from "../types/contact";
@@ -26,7 +27,7 @@ export const generateContactTableColumns = (
header: "User ID",
cell: ({ row }) => {
const userId = row.original.userId;
return <HighlightedText value={userId} searchValue={searchValue} />;
return <IdBadge id={userId} showCopyIconOnHover={true} />;
},
};

View File

@@ -1,6 +1,7 @@
"use client";
import { convertDateTimeStringShort } from "@/lib/time";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Label } from "@/modules/ui/components/label";
import { useTranslate } from "@tolgee/react";
import { TSegment } from "@formbricks/types/segment";
@@ -52,10 +53,7 @@ export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps)
</p>
</div>
<div>
<Label className="text-xs font-normal text-slate-500">
{t("environments.segments.segment_id")}
</Label>
<p className="text-xs text-slate-700">{currentSegment.id.toString()}</p>
<IdBadge id={currentSegment.id} label={t("environments.segments.segment_id")} variant="column" />
</div>
</div>
</div>

View File

@@ -4,6 +4,7 @@ import { getIsFreshInstance } from "@/lib/instance/service";
import { verifyInviteToken } from "@/lib/jwt";
import { createMembership } from "@/lib/membership/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { redactPII } from "@/lib/utils/logger-helpers";
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
import { createUser, getUserByEmail, updateUser } from "@/modules/auth/lib/user";
import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite";
@@ -31,12 +32,36 @@ export const handleSsoCallback = async ({
account: Account;
callbackUrl: string;
}) => {
const contextLogger = logger.withContext({
correlationId: crypto.randomUUID(),
name: "formbricks",
});
contextLogger.debug(
{
...redactPII({ user, account, callbackUrl }),
hasEmail: !!user.email,
hasName: !!user.name,
},
"SSO callback initiated"
);
const isSsoEnabled = await getIsSsoEnabled();
if (!isSsoEnabled) {
contextLogger.debug({ isSsoEnabled }, "SSO not enabled");
return false;
}
if (!user.email || account.type !== "oauth") {
contextLogger.debug(
{
hasEmail: !!user.email,
accountType: account.type,
reason: !user.email ? "missing_email" : "invalid_account_type",
},
"SSO callback rejected: missing email or invalid account type"
);
return false;
}
@@ -45,12 +70,18 @@ export const handleSsoCallback = async ({
if (provider === "saml") {
const isSamlSsoEnabled = await getIsSamlSsoEnabled();
if (!isSamlSsoEnabled) {
contextLogger.debug({ provider: "saml" }, "SSO callback rejected: SAML not enabled in license");
return false;
}
}
if (account.provider) {
// check if accounts for this provider / account Id already exists
contextLogger.debug(
{ lookupType: "sso_provider_account" },
"Checking for existing user with SSO provider"
);
const existingUserWithAccount = await prisma.user.findFirst({
include: {
accounts: {
@@ -66,12 +97,29 @@ export const handleSsoCallback = async ({
});
if (existingUserWithAccount) {
contextLogger.debug(
{
existingUserId: existingUserWithAccount.id,
emailMatches: existingUserWithAccount.email === user.email,
},
"Found existing user with SSO provider"
);
// User with this provider found
// check if email still the same
if (existingUserWithAccount.email === user.email) {
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id },
"SSO callback successful: existing user, email matches"
);
return true;
}
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id },
"Email changed in SSO provider, checking for conflicts"
);
// user seemed to change his email within the provider
// check if user with this email already exist
// if not found just update user with new email address
@@ -79,9 +127,20 @@ export const handleSsoCallback = async ({
const otherUserWithEmail = await getUserByEmail(user.email);
if (!otherUserWithEmail) {
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id, action: "email_update" },
"No other user with this email found, updating user email after SSO provider change"
);
await updateUser(existingUserWithAccount.id, { email: user.email });
return true;
}
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id, conflictingUserId: otherUserWithEmail.id },
"SSO callback failed: email conflict after provider change"
);
throw new Error(
"Looks like you updated your email somewhere else. A user with this new email exists already."
);
@@ -90,13 +149,24 @@ export const handleSsoCallback = async ({
// There is no existing account for this identity provider / account id
// check if user account with this email already exists
// if user already exists throw error and request password login
contextLogger.debug({ lookupType: "email" }, "No existing SSO account found, checking for user by email");
const existingUserWithEmail = await getUserByEmail(user.email);
if (existingUserWithEmail) {
contextLogger.debug(
{ existingUserId: existingUserWithEmail.id, action: "existing_user_login" },
"SSO callback successful: existing user found by email"
);
// Sign in the user with the existing account
return true;
}
contextLogger.debug(
{ action: "new_user_creation" },
"No existing user found, proceeding with new user creation"
);
let userName = user.name;
if (provider === "openid") {
@@ -108,6 +178,16 @@ export const handleSsoCallback = async ({
} else if (oidcUser.preferred_username) {
userName = oidcUser.preferred_username;
}
contextLogger.debug(
{
hasName: !!oidcUser.name,
hasGivenName: !!oidcUser.given_name,
hasFamilyName: !!oidcUser.family_name,
hasPreferredUsername: !!oidcUser.preferred_username,
},
"Extracted OIDC user name"
);
}
if (provider === "saml") {
@@ -117,6 +197,14 @@ export const handleSsoCallback = async ({
} else if (samlUser.firstName || samlUser.lastName) {
userName = `${samlUser.firstName} ${samlUser.lastName}`;
}
contextLogger.debug(
{
hasName: !!samlUser.name,
hasFirstName: !!samlUser.firstName,
hasLastName: !!samlUser.lastName,
},
"Extracted SAML user name"
);
}
// Get multi-org license status
@@ -124,9 +212,23 @@ export const handleSsoCallback = async ({
const isFirstUser = await getIsFreshInstance();
contextLogger.debug(
{
isMultiOrgEnabled,
isFirstUser,
skipInviteForSso: SKIP_INVITE_FOR_SSO,
hasDefaultTeamId: !!DEFAULT_TEAM_ID,
},
"License and instance configuration checked"
);
// Additional security checks for self-hosted instances without auto-provisioning and no multi-org enabled
if (!isFirstUser && !SKIP_INVITE_FOR_SSO && !isMultiOrgEnabled) {
if (!callbackUrl) {
contextLogger.debug(
{ reason: "missing_callback_url" },
"SSO callback rejected: missing callback URL for invite validation"
);
return false;
}
@@ -139,6 +241,10 @@ export const handleSsoCallback = async ({
// Allow sign-in if multi-org is enabled, otherwise check for invite token
if (source === "signin" && !inviteToken) {
contextLogger.debug(
{ reason: "signin_without_invite_token" },
"SSO callback rejected: signin without invite token"
);
return false;
}
@@ -146,16 +252,32 @@ export const handleSsoCallback = async ({
// Verify invite token and check email match
const { email, inviteId } = verifyInviteToken(inviteToken);
if (email !== user.email) {
contextLogger.debug(
{ reason: "invite_email_mismatch", inviteId },
"SSO callback rejected: invite token email mismatch"
);
return false;
}
// Check if invite token is still valid
const isValidInviteToken = await getIsValidInviteToken(inviteId);
if (!isValidInviteToken) {
contextLogger.debug(
{ reason: "invalid_invite_token", inviteId },
"SSO callback rejected: invalid or expired invite token"
);
return false;
}
contextLogger.debug({ inviteId }, "Invite token validation successful");
} catch (err) {
contextLogger.debug(
{
reason: "invite_token_validation_error",
error: err instanceof Error ? err.message : "unknown_error",
},
"SSO callback rejected: invite token validation failed"
);
// Log and reject on any validation errors
logger.error(err, "Invalid callbackUrl");
contextLogger.error(err, "Invalid callbackUrl");
return false;
}
}
@@ -163,6 +285,12 @@ export const handleSsoCallback = async ({
let organization: Organization | null = null;
if (!isFirstUser && !isMultiOrgEnabled) {
contextLogger.debug(
{
assignmentStrategy: SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID ? "default_team" : "first_organization",
},
"Determining organization assignment"
);
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
organization = await getOrganizationByTeamId(DEFAULT_TEAM_ID);
} else {
@@ -170,13 +298,29 @@ export const handleSsoCallback = async ({
}
if (!organization) {
contextLogger.debug(
{ reason: "no_organization_found" },
"SSO callback rejected: no organization found for assignment"
);
return false;
}
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!canDoRoleManagement && !callbackUrl) return false;
if (!canDoRoleManagement && !callbackUrl) {
contextLogger.debug(
{
reason: "insufficient_role_permissions",
organizationId: organization.id,
canDoRoleManagement,
},
"SSO callback rejected: insufficient role management permissions"
);
return false;
}
}
contextLogger.debug({ hasUserName: !!userName, identityProvider: provider }, "Creating new SSO user");
const userProfile = await createUser({
name:
userName ||
@@ -191,13 +335,28 @@ export const handleSsoCallback = async ({
locale: await findMatchingLocale(),
});
contextLogger.debug(
{ newUserId: userProfile.id, identityProvider: provider },
"New SSO user created successfully"
);
// send new user to brevo
createBrevoCustomer({ id: userProfile.id, email: userProfile.email });
if (isMultiOrgEnabled) return true;
if (isMultiOrgEnabled) {
contextLogger.debug(
{ isMultiOrgEnabled, newUserId: userProfile.id },
"Multi-org enabled, skipping organization assignment"
);
return true;
}
// Default organization assignment if env variable is set
if (organization) {
contextLogger.debug(
{ newUserId: userProfile.id, organizationId: organization.id, role: "member" },
"Assigning user to organization"
);
await createMembership(organization.id, userProfile.id, { role: "member", accepted: true });
await createAccount({
...account,
@@ -205,6 +364,10 @@ export const handleSsoCallback = async ({
});
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
contextLogger.debug(
{ newUserId: userProfile.id, defaultTeamId: DEFAULT_TEAM_ID },
"Creating default team membership"
);
await createDefaultTeamMembership(userProfile.id);
}
@@ -226,6 +389,7 @@ export const handleSsoCallback = async ({
// Without default organization assignment
return true;
}
contextLogger.debug("SSO callback successful: default return");
return true;
};

View File

@@ -85,6 +85,13 @@ vi.mock("@formbricks/lib/jwt", () => ({
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
debug: vi.fn(),
withContext: (context: Record<string, any>) => {
return {
...context,
debug: vi.fn(),
};
},
},
}));

View File

@@ -2,6 +2,7 @@
import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
import { TeamPermissionMapping } from "@/modules/ee/teams/utils/teams";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { useTranslate } from "@tolgee/react";
@@ -21,6 +22,7 @@ export const AccessTable = ({ teams }: AccessTableProps) => {
{t("environments.project.teams.team_name")}
</TableHead>
<TableHead className="font-medium text-slate-500">{t("common.size")}</TableHead>
<TableHead className="font-medium text-slate-500">{t("common.team_id")}</TableHead>
<TableHead className="font-medium text-slate-500">
{t("environments.project.teams.permission")}
</TableHead>
@@ -40,6 +42,9 @@ export const AccessTable = ({ teams }: AccessTableProps) => {
<TableCell>
{team.memberCount} {team.memberCount === 1 ? t("common.member") : t("common.members")}
</TableCell>
<TableCell>
<IdBadge id={team.id} showCopyIconOnHover={true} />
</TableCell>
<TableCell>
<p className="capitalize">{TeamPermissionMapping[team.permission]}</p>
</TableCell>

View File

@@ -26,6 +26,7 @@ import {
DialogTitle,
} from "@/modules/ui/components/dialog";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Input } from "@/modules/ui/components/input";
import {
Select,
@@ -235,6 +236,8 @@ export const TeamSettingsModal = ({
)}
/>
<IdBadge id={team.id} label={t("common.team_id")} variant="column" />
{/* Members Section */}
<div className="space-y-2">
<div className="flex flex-col space-y-1">

View File

@@ -313,32 +313,32 @@ export async function PreviewEmailTemplate({
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.PictureSelection:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Section className="mx-0 mt-4">
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
key={choice.id}
src={choice.imageUrl}
/>
) : (
<Link
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
key={choice.id}
target="_blank">
<Img className="rounded-custom h-full w-full" src={choice.imageUrl} />
</Link>
)
)}
</Section>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.PictureSelection:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Section className="mx-0 mt-4">
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
key={choice.id}
src={choice.imageUrl}
/>
) : (
<Link
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
key={choice.id}
target="_blank">
<Img className="rounded-custom h-full w-full" src={choice.imageUrl} />
</Link>
)
)}
</Section>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.Cal:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>

View File

@@ -2,10 +2,10 @@ import { WidgetStatusIndicator } from "@/app/(app)/environments/[environmentId]/
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { EnvironmentIdField } from "@/modules/projects/settings/(setup)/components/environment-id-field";
import { SetupInstructions } from "@/modules/projects/settings/(setup)/components/setup-instructions";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { EnvironmentNotice } from "@/modules/ui/components/environment-notice";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
@@ -38,7 +38,7 @@ export const AppConnectionPage = async (props) => {
<SettingsCard
title={t("environments.project.app-connection.environment_id")}
description={t("environments.project.app-connection.environment_id_description")}>
<EnvironmentIdField environmentId={params.environmentId} />
<IdBadge id={params.environmentId} />
</SettingsCard>
</div>
</PageContentWrapper>

View File

@@ -1,38 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { EnvironmentIdField } from "./environment-id-field";
vi.mock("@/modules/ui/components/code-block", () => ({
CodeBlock: ({ children, language }: any) => (
<pre data-testid="code-block" data-language={language}>
{children}
</pre>
),
}));
describe("EnvironmentIdField", () => {
afterEach(() => {
cleanup();
});
test("renders the environment id in a code block", () => {
const envId = "env-123";
render(<EnvironmentIdField environmentId={envId} />);
const codeBlock = screen.getByTestId("code-block");
expect(codeBlock).toBeInTheDocument();
expect(codeBlock).toHaveAttribute("data-language", "js");
expect(codeBlock).toHaveTextContent(envId);
});
test("applies the correct wrapper class", () => {
render(<EnvironmentIdField environmentId="env-abc" />);
const wrapper = codeBlockParent();
expect(wrapper).toHaveClass("prose");
expect(wrapper).toHaveClass("prose-slate");
expect(wrapper).toHaveClass("-mt-3");
});
});
function codeBlockParent() {
return screen.getByTestId("code-block").parentElement as HTMLElement;
}

View File

@@ -1,11 +0,0 @@
"use client";
import { CodeBlock } from "@/modules/ui/components/code-block";
export const EnvironmentIdField = ({ environmentId }: { environmentId: string }) => {
return (
<div className="prose prose-slate -mt-3">
<CodeBlock language="js">{environmentId}</CodeBlock>
</div>
);
};

View File

@@ -19,10 +19,11 @@ vi.mock("@/modules/ui/components/page-header", () => ({
</div>
),
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: ({ title, id }: any) => (
<div data-testid="settings-id">
<p>{title}</p>:<p>{id}</p>
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ id, label, variant }: any) => (
<div data-testid="id-badge" data-variant={variant}>
<span data-testid="id-badge-label">{label}</span>
<span data-testid="id-badge-id">{id}</span>
</div>
),
}));
@@ -104,7 +105,7 @@ describe("GeneralSettingsPage", () => {
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("project-config-navigation")).toBeInTheDocument();
expect(screen.getAllByTestId("settings-id").length).toBe(2);
expect(screen.getAllByTestId("id-badge").length).toBe(2);
expect(screen.getByTestId("edit-project-name-form")).toBeInTheDocument();
expect(screen.getByTestId("edit-waiting-time-form")).toBeInTheDocument();
expect(screen.getByTestId("delete-project")).toBeInTheDocument();

View File

@@ -3,9 +3,9 @@ import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getProjects } from "@/lib/project/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import packageJson from "@/package.json";
import { getTranslate } from "@/tolgee/server";
import { DeleteProject } from "./components/delete-project";
@@ -49,10 +49,10 @@ export const GeneralSettingsPage = async (props: { params: Promise<{ environment
isOwnerOrManager={isOwnerOrManager}
/>
</SettingsCard>
<div>
<SettingsId title={t("common.project_id")} id={project.id}></SettingsId>
<div className="space-y-2">
<IdBadge id={project.id} label={t("common.project_id")} variant="column" />
{!IS_FORMBRICKS_CLOUD && !IS_DEVELOPMENT && (
<SettingsId title={t("common.formbricks_version")} id={packageJson.version}></SettingsId>
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
)}
</div>
</PageContentWrapper>

View File

@@ -35,7 +35,7 @@ export const ClientLogo = ({ environmentId, projectLogo, previewSurvey = false }
src={projectLogo?.url}
className={cn(
previewSurvey ? "max-h-12" : "max-h-16 md:max-h-20",
"w-auto max-w-40 rounded-lg object-contain p-1 md:max-w-56"
"w-auto max-w-40 object-contain p-1 md:max-w-56"
)}
width={256}
height={64}

View File

@@ -22,6 +22,16 @@ export const DataTableSettingsModalItem = <T,>({ column, survey }: DataTableSett
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: column.id,
});
const isOptionIdColumn = column.id.endsWith("optionIds");
const getOptionIdColumnLabel = () => {
const questionId = column.id.split("optionIds")[0];
const question = survey?.questions.find((q) => q.id === questionId);
if (question) {
return `${getLocalizedValue(question.headline, "default")} - ${t("common.option_id")}`;
}
return null;
};
const getLabelFromColumnId = () => {
switch (column.id) {
@@ -77,7 +87,9 @@ export const DataTableSettingsModalItem = <T,>({ column, survey }: DataTableSett
<span className="max-w-xs truncate">{getLocalizedValue(question.headline, "default")}</span>
</div>
) : (
<span className="max-w-xs truncate">{getLabelFromColumnId()}</span>
<span className="max-w-xs truncate">
{isOptionIdColumn ? getOptionIdColumnLabel() : getLabelFromColumnId()}
</span>
)}
</div>
<Switch

View File

@@ -24,6 +24,16 @@ vi.mock("@tolgee/react", () => ({
}),
}));
// Mock lucide-react icons used in IdBadge
vi.mock("lucide-react", async () => {
const actual = await vi.importActual<typeof import("lucide-react")>("lucide-react");
return {
...actual,
Copy: () => "Copy Icon",
Check: () => "Check Icon",
};
});
describe("IdBadge", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -1,35 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { SettingsId } from "./index";
describe("SettingsId", () => {
afterEach(() => {
cleanup();
});
test("renders the title and id correctly", () => {
render(<SettingsId title="Survey ID" id="survey-123" />);
const element = screen.getByText(/Survey ID: survey-123/);
expect(element).toBeInTheDocument();
expect(element.tagName.toLowerCase()).toBe("p");
});
test("applies correct styling", () => {
render(<SettingsId title="Environment ID" id="env-456" />);
const element = screen.getByText(/Environment ID: env-456/);
expect(element).toHaveClass("py-1");
expect(element).toHaveClass("text-xs");
expect(element).toHaveClass("text-slate-400");
});
test("renders with very long id", () => {
const longId = "a".repeat(100);
render(<SettingsId title="API Key" id={longId} />);
const element = screen.getByText(`API Key: ${longId}`);
expect(element).toBeInTheDocument();
});
});

View File

@@ -1,12 +0,0 @@
interface SettingsIdProps {
title: string;
id: string;
}
export const SettingsId = ({ title, id }: SettingsIdProps) => {
return (
<p className="py-1 text-xs text-slate-400">
{title}: {id}
</p>
);
};

View File

@@ -132,7 +132,7 @@
"ua-parser-js": "2.0.3",
"uuid": "11.1.0",
"webpack": "5.99.8",
"xlsx": "0.18.5",
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
"zod": "3.24.4",
"zod-openapi": "4.2.4"
},

BIN
apps/web/vendor/xlsx-0.20.3.tgz vendored Normal file

Binary file not shown.

View File

@@ -49,6 +49,7 @@
"xm-and-surveys/surveys/general-features/variables",
"xm-and-surveys/surveys/general-features/hide-back-button",
"xm-and-surveys/surveys/general-features/email-followups",
"xm-and-surveys/surveys/general-features/quota-management",
"xm-and-surveys/surveys/general-features/spam-protection"
]
},

View File

@@ -0,0 +1,129 @@
---
title: "Quota Management"
description: "Control response collection by setting limits on specific segments to ensure balanced and representative survey datasets."
icon: "chart-pie"
---
## Overview
Quota Management allows you to set limits on the number of responses collected for specific segments or criteria in your survey. This feature helps ensure you collect a balanced and representative dataset while preventing oversaturation of certain response types.
<Note type="warning">
Quota Management is currently in beta and only available to select customers.
</Note>
<Note>
Quota Management is part of the [Enterprise Edition](/self-hosting/advanced/license).
</Note>
### Key benefits
- **Balanced Data Collection**: Ensure your survey responses are evenly distributed across different segments
- **Cost Control**: Prevent collecting more responses than needed from specific groups
- **Quality Assurance**: Maintain data quality by avoiding homogeneous response patterns
- **Automated Management**: Automatically stop collecting responses when quotas are met
### How Quota Management works
When you set up quotas for your survey, Formbricks automatically tracks responses against your defined limits. Once a quota is reached, the system can:
- Prevent new responses from that segment
- Skip respondents to the end of the survey
- Redirect respondents to a custom end screen
## Setting up Quotas
In the first step, you need to define the criteria for the quota:
<Steps>
<Step title="Name the quota">
Create a Quota and label it e.g. "Mobile Phone Users in Europe"
</Step>
<Step title="Set quota limit">
Set numerical limits for each hidden field value combination e.g. 500
</Step>
<Step title="Define inclusion criteria">
Choose a distinct set of answers to survey questions, variable values or hidden fields. Responses who match this set will be included in the quota.
</Step>
<Step title="Configure actions">
Choose what happens when this Quota is met (e.g. skip to specific end screen)
</Step>
</Steps>
## Quota actions
Configure what happens when a quota reaches its limit:
<Tabs>
<Tab title="Skip to End">
Jump respondents directly to the survey completion page
</Tab>
<Tab title="Custom Redirect (soon)">
Redirect respondents to a custom thank you page or alternative survey
</Tab>
</Tabs>
## Counting against Quotas
### 1. Count by Hidden Field value
Determine if a response falls in or out of a Quota based on hidden field values passed through URL parameters:
```
https://your-survey-url.com/s/abc123?product=credit-card&region=europe
```
### 2. Quota by survey responses
Create quotas based on specific answers to survey questions:
<Tabs>
<Tab title="Single Question Quota">
Set quotas for individual answer options:
- Question: "What is your gender?"
- Quota: 500 responses for "Male", 500 responses for "Female"
</Tab>
<Tab title="Multi-Question Quota">
Combine multiple question responses:
- Criteria: Age group "25-34" AND Location "Urban"
- Quota: 200 responses matching both criteria
</Tab>
</Tabs>
### 3. Multi-criteria quotas
Create complex quotas using multiple conditions:
<CodeGroup>
```example "Hidden Field + Response Combination"
Hidden Field: product = "mobile"
AND
Question Response: satisfaction = "very satisfied"
```
```example "Multiple Response Criteria"
Question 1: age_group = "18-25"
AND
Question 2: location = "urban"
AND
Question 3: income = "high"
Quota Limit: 50 responses
```
</CodeGroup>
### Partial vs. complete responses
<Info>
By default, Quota Management includes partial responses in quota counts. You can change this behavior by configuring the quota to only count complete responses.
</Info>
This means if a respondent starts but doesn't complete the survey, they may still count toward your quota if they've answered the qualifying questions.
## Quota monitoring
<Card title="Live Quota Status" icon="chart-line">
Monitor your quotas in real-time through the dashboard in the survey summary:
- **Current Count**: See how many responses each quota has collected
- **Progress Bars**: Visual representation of quota completion
- **Status Indicators**: Active, completed, or paused quota status
</Card>

View File

@@ -137,8 +137,7 @@ module "eks" {
cluster_version = "1.32"
enable_cluster_creator_admin_permissions = false
cluster_endpoint_public_access = true # TEMPORARY: Enable for deployments until Tailscale is fixed by new DevOps engineer
cluster_endpoint_public_access_cidrs = ["0.0.0.0/0"] # TEMPORARY: Will revert to private + Tailscale access
cluster_endpoint_public_access = false
cloudwatch_log_group_retention_in_days = 365
cluster_addons = {

10
packages/ai/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,10 @@
module.exports = {
extends: ["@formbricks/eslint-config/library.js"],
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: __dirname,
},
rules: {
"no-console": "off",
},
};

198
packages/ai/README.md Normal file
View File

@@ -0,0 +1,198 @@
# @formbricks/ai
A model-agnostic AI package for Formbricks, providing a unified interface for LLM operations across different providers.
## Features
- **Multi-Provider Support**: OpenAI and Anthropic models with easy switching
- **Type-Safe**: Full TypeScript support with schema validation
- **Environment-Based Configuration**: Automatic provider selection via environment variables
- **Structured Output**: Generate validated JSON objects from prompts using schemas
- **Helper Functions**: Built-in summarization and translation utilities
## Installation
This package is part of the Formbricks monorepo and is intended for internal use.
```bash
pnpm install @formbricks/ai
```
## Quick Start
### Environment Configuration
Set up your environment variables:
```bash
# Provider selection (defaults to "openai")
AI_PROVIDER=openai # or "anthropic"
# Model selection (uses sensible defaults if not specified)
AI_MODEL=gpt-4 # or "claude-3-sonnet-20240229"
# API Keys
OPENAI_API_KEY=your_openai_key
ANTHROPIC_API_KEY=your_anthropic_key
# Optional: Custom base URL
AI_BASE_URL=https://your-custom-endpoint.com
```
### Basic Usage
#### Text Generation
```typescript
import { generateText } from "@formbricks/ai";
const result = await generateText({
prompt: "Explain quantum computing in simple terms",
system: "You are a helpful science teacher",
temperature: 0.7,
maxTokens: 200,
});
console.log(result.text);
```
#### Structured Object Generation
```typescript
import { z } from "zod";
import { generateObject } from "@formbricks/ai";
const analysisSchema = z.object({
sentiment: z.enum(["positive", "negative", "neutral"]),
summary: z.string(),
keyTopics: z.array(z.string()),
confidence: z.number().min(0).max(1),
});
const result = await generateObject({
prompt: "Analyze this customer feedback: 'The product is amazing but delivery was slow'",
schema: analysisSchema,
temperature: 0.3,
});
console.log(result.object.sentiment); // Type-safe access
console.log(result.object.keyTopics);
```
#### Helper Functions
```typescript
import { summarizeText, translateText } from "@formbricks/ai";
// Summarization
const summary = await summarizeText(longText, 150);
// Translation
const translated = await translateText("Hello, how are you?", "Spanish", "English");
```
## Configuration
### Programmatic Configuration
You can override environment configuration programmatically:
```typescript
import { createAIModel, generateText } from "@formbricks/ai";
const customConfig = {
provider: "anthropic" as const,
model: "claude-3-haiku-20240307",
apiKey: "your-api-key",
};
// Use custom config for specific calls
const result = await generateText(
{
prompt: "Hello world",
},
customConfig
);
// Or create a reusable model instance
const aiModel = createAIModel(customConfig);
```
### Supported Models
#### OpenAI
- `gpt-4` (default)
- `gpt-4-turbo`
- `gpt-3.5-turbo`
#### Anthropic
- `claude-3-sonnet-20240229` (default)
- `claude-3-haiku-20240307`
- `claude-3-opus-20240229`
## Error Handling
The package provides clear error messages for common issues:
```typescript
import { generateText, isAIConfigured } from "@formbricks/ai";
// Check if AI is properly configured
if (!isAIConfigured()) {
throw new Error("AI is not properly configured. Please check your environment variables.");
}
try {
const result = await generateText({
prompt: "Your prompt here",
});
} catch (error) {
console.error("AI generation failed:", error.message);
}
```
## Usage in Formbricks
This package is designed to be used across the Formbricks ecosystem:
- **NextJS API Routes**: For server-side AI operations
- **Background Jobs**: For processing surveys and responses
- **Future NestJS Backend**: Modular design allows easy integration
## Development
### Building
```bash
pnpm build
```
### Development Mode
```bash
pnpm dev
```
### Code Quality
```bash
pnpm lint
```
## Architecture
The package follows a layered architecture:
1. **Types Layer** (`types.ts`): TypeScript definitions and interfaces
2. **Configuration Layer** (`config.ts`): Provider setup and validation
3. **Abstraction Layer** (`ai.ts`): Main API functions
4. **Export Layer** (`index.ts`): Public API exports
This design ensures:
- Easy testing and mocking
- Provider-agnostic implementation
- Type safety throughout
- Consistent error handling

39
packages/ai/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "@formbricks/ai",
"packageManager": "pnpm@9.15.9",
"private": true,
"version": "0.1.0",
"main": "./dist/index.cjs",
"types": "./dist/index.d.ts",
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"clean": "rimraf .turbo node_modules dist",
"build": "vite build",
"dev": "vite build --watch",
"lint": "eslint ./src --fix",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@ai-sdk/anthropic": "^1.0.6",
"@ai-sdk/openai": "^1.0.20",
"ai": "^5.0.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"typescript": "5.8.3",
"vite": "6.3.5",
"vite-plugin-dts": "4.5.3"
}
}

196
packages/ai/src/ai.ts Normal file
View File

@@ -0,0 +1,196 @@
import { generateObject as aiGenerateObject, generateText as aiGenerateText } from "ai";
import type { z } from "zod";
import { createAIModel } from "./config";
import type {
GenerateObjectOptions,
GenerateObjectResult,
GenerateTextOptions,
GenerateTextResult,
ProviderConfig,
} from "./types";
/**
* Singleton AI model instance for reuse across calls
*/
let aiModelInstance: ReturnType<typeof createAIModel> | null = null;
/**
* Get or create the AI model instance
*/
function getAIModel(customConfig?: ProviderConfig) {
if (!aiModelInstance || customConfig) {
aiModelInstance = createAIModel(customConfig);
}
return aiModelInstance;
}
/**
* Generate text using the configured AI model
*
* @param options - Text generation options
* @returns Promise resolving to generated text and usage information
*
* @example
* ```typescript
* const result = await generateText({
* prompt: "Summarize the following text: Lorem ipsum...",
* system: "You are a helpful assistant that provides concise summaries.",
* temperature: 0.7,
* maxTokens: 150
* });
*
* console.log(result.text);
* ```
*/
export async function generateText(
options: GenerateTextOptions,
customConfig?: ProviderConfig
): Promise<GenerateTextResult> {
const { model } = getAIModel(customConfig);
try {
const result = await aiGenerateText({
model,
prompt: options.prompt,
system: options.system,
temperature: options.temperature,
...(options.maxTokens && { maxTokens: options.maxTokens }),
});
return {
text: result.text,
usage: result.usage
? {
inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens,
totalTokens: result.usage.totalTokens,
}
: undefined,
};
} catch (error) {
throw new Error(`Failed to generate text: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Generate a structured object using the configured AI model
*
* @param options - Object generation options including Zod schema
* @returns Promise resolving to generated object and usage information
*
* @example
* ```typescript
* import { z } from "zod";
*
* const summarySchema = z.object({
* title: z.string(),
* summary: z.string(),
* keyPoints: z.array(z.string()),
* sentiment: z.enum(['positive', 'negative', 'neutral'])
* });
*
* const result = await generateObject({
* prompt: "Analyze the following article: Lorem ipsum...",
* schema: summarySchema,
* system: "You are an expert content analyzer.",
* temperature: 0.3
* });
*
* console.log(result.object.title);
* console.log(result.object.keyPoints);
* ```
*/
export async function generateObject<T extends z.ZodSchema>(
options: GenerateObjectOptions<T>,
customConfig?: ProviderConfig
): Promise<GenerateObjectResult<z.infer<T>>> {
const { model } = getAIModel(customConfig);
try {
const result = await aiGenerateObject({
model,
prompt: options.prompt,
schema: options.schema,
system: options.system,
temperature: options.temperature,
...(options.maxTokens && { maxTokens: options.maxTokens }),
});
return {
object: result.object,
usage: result.usage
? {
inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens,
totalTokens: result.usage.totalTokens,
}
: undefined,
};
} catch (error) {
throw new Error(`Failed to generate object: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Helper function for text summarization
*
* @param text - Text to summarize
* @param maxLength - Optional maximum length for the summary
* @returns Promise resolving to the summary
*/
export async function summarizeText(
text: string,
maxLength?: number,
customConfig?: ProviderConfig
): Promise<string> {
const prompt = `Summarize the following text${maxLength ? ` in approximately ${maxLength} characters` : ""}:\n\n${text}`;
const result = await generateText(
{
prompt,
system:
"You are a helpful assistant that creates clear, concise summaries. Focus on the key points and main ideas.",
temperature: 0.3,
maxTokens: maxLength ? Math.ceil(maxLength / 3) : undefined, // Rough token estimate
},
customConfig
);
return result.text;
}
/**
* Helper function for text translation
*
* @param text - Text to translate
* @param targetLanguage - Target language for translation
* @param sourceLanguage - Optional source language (auto-detected if not provided)
* @returns Promise resolving to the translated text
*/
export async function translateText(
text: string,
targetLanguage: string,
sourceLanguage?: string,
customConfig?: ProviderConfig
): Promise<string> {
const sourceText = sourceLanguage ? `from ${sourceLanguage} ` : "";
const prompt = `Translate the following text ${sourceText}to ${targetLanguage}:\n\n${text}`;
const result = await generateText(
{
prompt,
system: `You are a professional translator. Provide only the translated text without any additional commentary or explanations. Maintain the original tone and style.`,
temperature: 0.1, // Low temperature for consistency
},
customConfig
);
return result.text;
}
/**
* Reset the AI model instance (useful for testing or when configuration changes)
*/
export function resetAIModel(): void {
aiModelInstance = null;
}

168
packages/ai/src/config.ts Normal file
View File

@@ -0,0 +1,168 @@
import { anthropic } from "@ai-sdk/anthropic";
import { openai } from "@ai-sdk/openai";
import type { LanguageModelV1 } from "ai";
import type {
AIEnvironmentConfig,
AIModelInstance,
AIProvider,
AnthropicConfig,
OpenAIConfig,
ProviderConfig,
} from "./types";
/**
* Default models for each provider
*/
const DEFAULT_MODELS: Record<AIProvider, string> = {
openai: "gpt-4",
anthropic: "claude-3-sonnet-20240229",
};
/**
* Get environment configuration from process.env
*/
function getEnvironmentConfig(): AIEnvironmentConfig {
return {
AI_PROVIDER: process.env.AI_PROVIDER,
AI_MODEL: process.env.AI_MODEL,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
AI_BASE_URL: process.env.AI_BASE_URL,
};
}
/**
* Create provider configuration from environment variables
*/
function createProviderConfigFromEnv(): ProviderConfig {
const env = getEnvironmentConfig();
// Determine provider (default to openai if not specified)
const provider = (env.AI_PROVIDER as AIProvider) || "openai";
// Get model for the provider
const model = env.AI_MODEL || DEFAULT_MODELS[provider];
// Create configuration based on provider
switch (provider) {
case "openai": {
const config: OpenAIConfig = {
provider: "openai",
model,
apiKey: env.OPENAI_API_KEY,
baseURL: env.AI_BASE_URL,
};
return config;
}
case "anthropic": {
const config: AnthropicConfig = {
provider: "anthropic",
model,
apiKey: env.ANTHROPIC_API_KEY,
baseURL: env.AI_BASE_URL,
};
return config;
}
default:
throw new Error(`Unsupported AI provider: ${provider}`);
}
}
/**
* Create a language model instance from provider configuration
*/
function createModelFromConfig(config: ProviderConfig): LanguageModelV1 {
switch (config.provider) {
case "openai": {
const options: any = {};
if (config.apiKey) {
options.apiKey = config.apiKey;
}
if (config.baseURL) {
options.baseURL = config.baseURL;
}
return openai(config.model, options);
}
case "anthropic": {
const options: any = {};
if (config.apiKey) {
options.apiKey = config.apiKey;
}
if (config.baseURL) {
options.baseURL = config.baseURL;
}
return anthropic(config.model, options);
}
default:
throw new Error(`Unsupported provider: ${(config as ProviderConfig).provider}`);
}
}
/**
* Validate that required API keys are present for the configured provider
*/
function validateConfiguration(config: ProviderConfig): void {
switch (config.provider) {
case "openai":
if (!config.apiKey && !process.env.OPENAI_API_KEY) {
throw new Error(
"OpenAI API key is required. Set OPENAI_API_KEY environment variable or provide apiKey in configuration."
);
}
break;
case "anthropic":
if (!config.apiKey && !process.env.ANTHROPIC_API_KEY) {
throw new Error(
"Anthropic API key is required. Set ANTHROPIC_API_KEY environment variable or provide apiKey in configuration."
);
}
break;
default:
throw new Error(`Unsupported provider: ${(config as ProviderConfig).provider}`);
}
}
/**
* Create and configure the AI model instance
*/
export function createAIModel(customConfig?: ProviderConfig): AIModelInstance {
// Use custom config or create from environment
const config = customConfig || createProviderConfigFromEnv();
// Validate the configuration
validateConfiguration(config);
// Create the model instance
const model = createModelFromConfig(config);
return {
model,
config,
};
}
/**
* Get the current provider configuration without creating a model
*/
export function getProviderConfig(): ProviderConfig {
return createProviderConfigFromEnv();
}
/**
* Check if AI is properly configured
*/
export function isAIConfigured(): boolean {
try {
const config = createProviderConfigFromEnv();
validateConfiguration(config);
return true;
} catch {
return false;
}
}

20
packages/ai/src/index.ts Normal file
View File

@@ -0,0 +1,20 @@
// Main AI functions
export { generateText, generateObject, summarizeText, translateText, resetAIModel } from "./ai";
// Configuration functions
export { createAIModel, getProviderConfig, isAIConfigured } from "./config";
// Types
export type {
AIProvider,
AIProviderConfig,
OpenAIConfig,
AnthropicConfig,
ProviderConfig,
AIEnvironmentConfig,
GenerateTextOptions,
GenerateObjectOptions,
GenerateTextResult,
GenerateObjectResult,
AIModelInstance,
} from "./types";

102
packages/ai/src/types.ts Normal file
View File

@@ -0,0 +1,102 @@
import type { LanguageModelV1 } from "ai";
import type { z } from "zod";
/**
* Supported AI providers
*/
export type AIProvider = "openai" | "anthropic";
/**
* Configuration for different AI providers
*/
export interface AIProviderConfig {
provider: AIProvider;
model: string;
apiKey?: string;
baseURL?: string;
}
/**
* OpenAI specific configuration
*/
export interface OpenAIConfig extends AIProviderConfig {
provider: "openai";
model: string; // e.g., "gpt-4", "gpt-3.5-turbo"
}
/**
* Anthropic specific configuration
*/
export interface AnthropicConfig extends AIProviderConfig {
provider: "anthropic";
model: string; // e.g., "claude-3-sonnet-20240229", "claude-3-haiku-20240307"
}
/**
* Union type for all provider configurations
*/
export type ProviderConfig = OpenAIConfig | AnthropicConfig;
/**
* Environment variables for AI configuration
*/
export interface AIEnvironmentConfig {
AI_PROVIDER?: string;
AI_MODEL?: string;
OPENAI_API_KEY?: string;
ANTHROPIC_API_KEY?: string;
AI_BASE_URL?: string;
}
/**
* Options for text generation
*/
export interface GenerateTextOptions {
prompt: string;
system?: string;
temperature?: number;
maxTokens?: number;
}
/**
* Options for object generation
*/
export interface GenerateObjectOptions<T extends z.ZodSchema> {
prompt: string;
schema: T;
system?: string;
temperature?: number;
maxTokens?: number;
}
/**
* Result from text generation
*/
export interface GenerateTextResult {
text: string;
usage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
};
}
/**
* Result from object generation
*/
export interface GenerateObjectResult<T> {
object: T;
usage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
};
}
/**
* Internal type for the language model instance
*/
export interface AIModelInstance {
model: LanguageModelV1;
config: ProviderConfig;
}

12
packages/ai/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist",
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"],
"extends": "@formbricks/config-typescript/js-library.json",
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,49 @@
import { resolve } from "path";
import { UserConfig, defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig((): UserConfig => {
return {
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
build: {
rollupOptions: {
input: {
index: resolve(__dirname, "src/index.ts"),
},
output: [
{
format: "esm",
entryFileNames: "[name].js",
chunkFileNames: "[name].js",
},
{
format: "cjs",
entryFileNames: "[name].cjs",
chunkFileNames: "[name].cjs",
},
],
external: [
// External dependencies that should not be bundled
"@ai-sdk/anthropic",
"@ai-sdk/openai",
"ai",
"zod",
],
},
emptyOutDir: true,
ssr: true, // Server-side rendering mode for Node.js
},
plugins: [
dts({
rollupTypes: false,
include: ["src/**/*"],
exclude: ["src/**/*.test.ts", "src/**/*.spec.ts"],
insertTypesEntry: true,
}),
],
};
});

View File

@@ -136,11 +136,7 @@ export function WelcomeCard({
<ScrollableContainer>
<div>
{fileUrl ? (
<img
src={fileUrl}
className="fb-mb-8 fb-max-h-96 fb-w-1/4 fb-rounded-lg fb-object-contain"
alt="Company Logo"
/>
<img src={fileUrl} className="fb-mb-8 fb-max-h-96 fb-w-1/4 fb-object-contain" alt="Company Logo" />
) : null}
<Headline

222
pnpm-lock.yaml generated
View File

@@ -442,8 +442,8 @@ importers:
specifier: 5.99.8
version: 5.99.8(esbuild@0.25.4)
xlsx:
specifier: 0.18.5
version: 0.18.5
specifier: file:vendor/xlsx-0.20.3.tgz
version: file:apps/web/vendor/xlsx-0.20.3.tgz
zod:
specifier: 3.24.4
version: 3.24.4
@@ -536,6 +536,37 @@ importers:
specifier: 3.1.0
version: 3.1.0(typescript@5.8.3)(vitest@3.1.3(@types/node@22.15.18)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.0))
packages/ai:
dependencies:
'@ai-sdk/anthropic':
specifier: ^1.0.6
version: 1.2.12(zod@3.24.4)
'@ai-sdk/openai':
specifier: ^1.0.20
version: 1.3.23(zod@3.24.4)
ai:
specifier: ^5.0.2
version: 5.0.2(zod@3.24.4)
zod:
specifier: 3.24.4
version: 3.24.4
devDependencies:
'@formbricks/config-typescript':
specifier: workspace:*
version: link:../config-typescript
'@formbricks/eslint-config':
specifier: workspace:*
version: link:../config-eslint
typescript:
specifier: 5.8.3
version: 5.8.3
vite:
specifier: 6.3.5
version: 6.3.5(@types/node@22.15.18)(jiti@2.4.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.0)
vite-plugin-dts:
specifier: 4.5.3
version: 4.5.3(@types/node@22.15.18)(rollup@4.46.1)(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.18)(jiti@2.4.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.0))
packages/config-eslint:
devDependencies:
'@next/eslint-plugin-next':
@@ -824,6 +855,44 @@ packages:
'@adobe/css-tools@4.4.3':
resolution: {integrity: sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==}
'@ai-sdk/anthropic@1.2.12':
resolution: {integrity: sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
'@ai-sdk/gateway@1.0.0':
resolution: {integrity: sha512-VEm87DyRx1yIPywbTy8ntoyh4jEDv1rJ88m+2I7zOm08jJI5BhFtAWh0OF6YzZu1Vu4NxhOWO4ssGdsqydDQ3A==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4
'@ai-sdk/openai@1.3.23':
resolution: {integrity: sha512-86U7rFp8yacUAOE/Jz8WbGcwMCqWvjK33wk5DXkfnAOEn3mx2r7tNSJdjukQFZbAK97VMXGPPHxF+aEARDXRXQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
'@ai-sdk/provider-utils@2.2.8':
resolution: {integrity: sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.23.8
'@ai-sdk/provider-utils@3.0.0':
resolution: {integrity: sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4
'@ai-sdk/provider@1.1.3':
resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==}
engines: {node: '>=18'}
'@ai-sdk/provider@2.0.0':
resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==}
engines: {node: '>=18'}
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@@ -3734,6 +3803,9 @@ packages:
'@sqltools/formatter@1.2.5':
resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==}
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
@@ -4661,10 +4733,6 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
@@ -4681,6 +4749,12 @@ packages:
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
engines: {node: '>=8'}
ai@5.0.2:
resolution: {integrity: sha512-Uk4lmwlr2b/4G9DUYCWYKcWz93xQ6p6AEeRZN+/AO9NbOyCm9axrDru26c83Ax8OB8IHUvoseA3CqaZkg9Z0Kg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4
ajv-draft-04@1.0.0:
resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==}
peerDependencies:
@@ -5043,10 +5117,6 @@ packages:
caniuse-lite@1.0.30001731:
resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==}
cfb@1.2.2:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
chai@5.2.1:
resolution: {integrity: sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==}
engines: {node: '>=18'}
@@ -5148,10 +5218,6 @@ packages:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
@@ -5254,11 +5320,6 @@ packages:
typescript:
optional: true
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
@@ -5904,6 +5965,10 @@ packages:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
eventsource-parser@3.0.3:
resolution: {integrity: sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA==}
engines: {node: '>=20.0.0'}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
@@ -6054,10 +6119,6 @@ packages:
forwarded-parse@2.1.2:
resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==}
frac@1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
@@ -6744,6 +6805,9 @@ packages:
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@@ -8502,10 +8566,6 @@ packages:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'}
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
ssri@8.0.1:
resolution: {integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==}
engines: {node: '>= 8'}
@@ -9369,18 +9429,10 @@ packages:
wide-align@1.1.5:
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
@@ -9416,8 +9468,9 @@ packages:
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
engines: {node: '>=18'}
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
xlsx@file:apps/web/vendor/xlsx-0.20.3.tgz:
resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: file:apps/web/vendor/xlsx-0.20.3.tgz}
version: 0.20.3
engines: {node: '>=0.8'}
hasBin: true
@@ -9522,6 +9575,11 @@ packages:
peerDependencies:
zod: ^3.21.4
zod-to-json-schema@3.24.6:
resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==}
peerDependencies:
zod: ^3.24.1
zod@3.24.4:
resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==}
@@ -9529,6 +9587,47 @@ snapshots:
'@adobe/css-tools@4.4.3': {}
'@ai-sdk/anthropic@1.2.12(zod@3.24.4)':
dependencies:
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.8(zod@3.24.4)
zod: 3.24.4
'@ai-sdk/gateway@1.0.0(zod@3.24.4)':
dependencies:
'@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.0(zod@3.24.4)
zod: 3.24.4
'@ai-sdk/openai@1.3.23(zod@3.24.4)':
dependencies:
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.8(zod@3.24.4)
zod: 3.24.4
'@ai-sdk/provider-utils@2.2.8(zod@3.24.4)':
dependencies:
'@ai-sdk/provider': 1.1.3
nanoid: 3.3.11
secure-json-parse: 2.7.0
zod: 3.24.4
'@ai-sdk/provider-utils@3.0.0(zod@3.24.4)':
dependencies:
'@ai-sdk/provider': 2.0.0
'@standard-schema/spec': 1.0.0
eventsource-parser: 3.0.3
zod: 3.24.4
zod-to-json-schema: 3.24.6(zod@3.24.4)
'@ai-sdk/provider@1.1.3':
dependencies:
json-schema: 0.4.0
'@ai-sdk/provider@2.0.0':
dependencies:
json-schema: 0.4.0
'@alloc/quick-lru@5.2.0': {}
'@ampproject/remapping@2.3.0':
@@ -13538,6 +13637,8 @@ snapshots:
'@sqltools/formatter@1.2.5': {}
'@standard-schema/spec@1.0.0': {}
'@standard-schema/utils@0.3.0': {}
'@storybook/addon-a11y@9.0.15(storybook@9.0.15(@testing-library/dom@8.20.1)(prettier@3.5.3))':
@@ -14637,8 +14738,6 @@ snapshots:
acorn@8.15.0: {}
adler-32@1.3.1: {}
agent-base@6.0.2:
dependencies:
debug: 4.4.1
@@ -14658,6 +14757,14 @@ snapshots:
indent-string: 4.0.0
optional: true
ai@5.0.2(zod@3.24.4):
dependencies:
'@ai-sdk/gateway': 1.0.0(zod@3.24.4)
'@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.0(zod@3.24.4)
'@opentelemetry/api': 1.9.0
zod: 3.24.4
ajv-draft-04@1.0.0(ajv@8.13.0):
optionalDependencies:
ajv: 8.13.0
@@ -15049,11 +15156,6 @@ snapshots:
caniuse-lite@1.0.30001731: {}
cfb@1.2.2:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
chai@5.2.1:
dependencies:
assertion-error: 2.0.1
@@ -15156,8 +15258,6 @@ snapshots:
- '@types/react'
- '@types/react-dom'
codepage@1.15.0: {}
color-convert@1.9.3:
dependencies:
color-name: 1.1.3
@@ -15249,8 +15349,6 @@ snapshots:
optionalDependencies:
typescript: 5.8.3
crc-32@1.2.2: {}
create-require@1.1.1: {}
cross-spawn@7.0.6:
@@ -16086,6 +16184,8 @@ snapshots:
events@3.3.0: {}
eventsource-parser@3.0.3: {}
expand-template@2.0.3: {}
expect-type@1.2.2: {}
@@ -16222,8 +16322,6 @@ snapshots:
forwarded-parse@2.1.2: {}
frac@1.1.2: {}
fraction.js@4.3.7: {}
framer-motion@12.10.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
@@ -16971,6 +17069,8 @@ snapshots:
json-schema-traverse@1.0.0: {}
json-schema@0.4.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
json5@1.0.2:
@@ -18850,10 +18950,6 @@ snapshots:
sqlstring@2.3.3: {}
ssf@0.11.2:
dependencies:
frac: 1.1.2
ssri@8.0.1:
dependencies:
minipass: 3.3.6
@@ -19799,12 +19895,8 @@ snapshots:
string-width: 4.2.3
optional: true
wmf@1.0.2: {}
word-wrap@1.2.5: {}
word@0.3.0: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
@@ -19837,15 +19929,7 @@ snapshots:
dependencies:
is-wsl: 3.1.0
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
xlsx@file:apps/web/vendor/xlsx-0.20.3.tgz: {}
xml-crypto@6.1.2:
dependencies:
@@ -19940,4 +20024,8 @@ snapshots:
dependencies:
zod: 3.24.4
zod-to-json-schema@3.24.6(zod@3.24.4):
dependencies:
zod: 3.24.4
zod@3.24.4: {}