mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-29 18:00:26 -06:00
Compare commits
1 Commits
feautre/ai
...
feat/s3-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09494905fc |
4
.github/actions/cache-build-web/action.yml
vendored
4
.github/actions/cache-build-web/action.yml
vendored
@@ -62,12 +62,10 @@ 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=$E2E_TESTING_MODE" >> .env
|
||||
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
|
||||
shell: bash
|
||||
|
||||
- run: |
|
||||
|
||||
@@ -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,12 +26,13 @@ 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" \
|
||||
@@ -56,23 +57,13 @@ runs:
|
||||
|
||||
- name: Extract sourcemaps from Docker image
|
||||
shell: bash
|
||||
env:
|
||||
DOCKER_IMAGE: ${{ inputs.docker_image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# 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"
|
||||
echo "📦 Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}"
|
||||
|
||||
# Create temporary container from the image and capture its ID
|
||||
echo "Creating temporary container..."
|
||||
CONTAINER_ID=$(docker create "$DOCKER_IMAGE")
|
||||
CONTAINER_ID=$(docker create "${{ inputs.docker_image }}")
|
||||
echo "Container created with ID: $CONTAINER_ID"
|
||||
|
||||
# Set up cleanup function to ensure container is removed on script exit
|
||||
@@ -91,7 +82,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
|
||||
|
||||
@@ -122,7 +113,7 @@ runs:
|
||||
with:
|
||||
environment: ${{ inputs.environment }}
|
||||
version: ${{ inputs.release_version }}
|
||||
sourcemaps: "./extracted-next/"
|
||||
sourcemaps: './extracted-next/'
|
||||
|
||||
- name: Clean up extracted files
|
||||
shell: bash
|
||||
|
||||
@@ -106,16 +106,15 @@ 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 [[ "$ENVIRONMENT" == "production" ]]; then
|
||||
if [[ "${{ inputs.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: $ENVIRONMENT, zone: $CF_ZONE_ID)"
|
||||
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: ${{ inputs.ENVIRONMENT }}, zone: $CF_ZONE_ID)"
|
||||
|
||||
# Prepare JSON payload for selective cache purge
|
||||
json_payload=$(cat << EOF
|
||||
|
||||
11
.github/workflows/docker-build-validation.yml
vendored
11
.github/workflows/docker-build-validation.yml
vendored
@@ -47,14 +47,12 @@ 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:${{ env.GITHUB_SHA }}
|
||||
tags: formbricks-test:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
secrets: |
|
||||
@@ -91,9 +89,6 @@ 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..."
|
||||
|
||||
@@ -105,8 +100,8 @@ jobs:
|
||||
$DOCKER_RUN_ARGS \
|
||||
-p 3000:3000 \
|
||||
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
|
||||
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
|
||||
-d "formbricks-test:$GITHUB_SHA"
|
||||
-e ENCRYPTION_KEY="${{ secrets.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..."
|
||||
|
||||
@@ -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 from environment variables
|
||||
# Get reference name and type
|
||||
REF_NAME="${{ github.ref_name }}"
|
||||
REF_TYPE="${{ github.ref_type }}"
|
||||
|
||||
echo "Reference type: $REF_TYPE"
|
||||
echo "Reference name: $REF_NAME"
|
||||
|
||||
|
||||
15
.github/workflows/release-docker-github.yml
vendored
15
.github/workflows/release-docker-github.yml
vendored
@@ -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,20 +52,9 @@ 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"
|
||||
|
||||
|
||||
30
.github/workflows/release-helm-chart.yml
vendored
30
.github/workflows/release-helm-chart.yml
vendored
@@ -26,23 +26,8 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- 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: Extract release version
|
||||
run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
|
||||
@@ -50,18 +35,15 @@ jobs:
|
||||
version: latest
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
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
|
||||
run: echo "${{ secrets.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 = \"$VERSION\"" helm-chart/Chart.yaml
|
||||
yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml
|
||||
yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
|
||||
yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
|
||||
|
||||
- name: Package Helm chart
|
||||
run: |
|
||||
@@ -69,4 +51,4 @@ jobs:
|
||||
|
||||
- name: Push Helm chart to GitHub Container Registry
|
||||
run: |
|
||||
helm push "formbricks-$VERSION.tgz" oci://ghcr.io/formbricks/helm-charts
|
||||
helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts
|
||||
|
||||
10
.github/workflows/tolgee.yml
vendored
10
.github/workflows/tolgee.yml
vendored
@@ -27,18 +27,10 @@ jobs:
|
||||
|
||||
- name: Get source branch name
|
||||
id: branch-name
|
||||
env:
|
||||
RAW_BRANCH: ${{ github.head_ref }}
|
||||
run: |
|
||||
# Validate and sanitize branch name - only allow alphanumeric, dots, underscores, hyphens, and forward slashes
|
||||
RAW_BRANCH="${{ github.head_ref }}"
|
||||
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
|
||||
|
||||
13
.github/workflows/upload-sentry-sourcemaps.yml
vendored
13
.github/workflows/upload-sentry-sourcemaps.yml
vendored
@@ -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,13 +31,16 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set Docker Image
|
||||
run: echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> $GITHUB_ENV
|
||||
env:
|
||||
DOCKER_IMAGE: ${{ inputs.docker_image }}:${{ inputs.tag_version != '' && inputs.tag_version || inputs.release_version }}
|
||||
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
|
||||
|
||||
- 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 }}
|
||||
@@ -121,9 +121,8 @@ 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();
|
||||
// Check for IdBadge content
|
||||
expect(screen.getByText("common.profile_id")).toBeInTheDocument();
|
||||
expect(screen.getByText(mockUser.id)).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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
|
||||
<SettingsId title={t("common.profile")} id={user.id}></SettingsId>
|
||||
</div>
|
||||
)}
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -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 { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { SettingsId } from "@/modules/ui/components/settings-id";
|
||||
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/id-badge", () => ({
|
||||
IdBadge: vi.fn(() => <div>IdBadge</div>),
|
||||
vi.mock("@/modules/ui/components/settings-id", () => ({
|
||||
SettingsId: vi.fn(() => <div>SettingsId</div>),
|
||||
}));
|
||||
|
||||
describe("Page", () => {
|
||||
@@ -156,11 +156,10 @@ describe("Page", () => {
|
||||
},
|
||||
undefined
|
||||
);
|
||||
expect(IdBadge).toHaveBeenCalledWith(
|
||||
expect(SettingsId).toHaveBeenCalledWith(
|
||||
{
|
||||
title: "common.organization_id",
|
||||
id: mockEnvironmentAuth.organization.id,
|
||||
label: "common.organization_id",
|
||||
variant: "column",
|
||||
},
|
||||
undefined
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
|
||||
<SettingsId title={t("common.organization_id")} id={organization.id}></SettingsId>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 {
|
||||
@@ -59,7 +60,6 @@ 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,27 +104,6 @@ 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",
|
||||
@@ -157,28 +136,6 @@ 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,
|
||||
@@ -216,8 +173,6 @@ 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",
|
||||
@@ -540,281 +495,3 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"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";
|
||||
@@ -9,10 +8,8 @@ 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";
|
||||
@@ -64,42 +61,6 @@ 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) => {
|
||||
@@ -176,49 +137,6 @@ 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 [
|
||||
{
|
||||
@@ -399,6 +317,7 @@ export const generateResponseTableColumns = (
|
||||
};
|
||||
|
||||
// Combine the selection column with the dynamic question columns
|
||||
|
||||
const baseColumns = [
|
||||
personColumn,
|
||||
dateColumn,
|
||||
|
||||
@@ -27,10 +27,10 @@ vi.mock("@/modules/survey/lib/questions", () => ({
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/id-badge", () => ({
|
||||
IdBadge: ({ label, id }: { label: string; id: string }) => (
|
||||
<div data-testid="id-badge">
|
||||
{label}: {id}
|
||||
vi.mock("@/modules/ui/components/settings-id", () => ({
|
||||
SettingsId: ({ title, id }: { title: string; id: string }) => (
|
||||
<div data-testid="settings-id">
|
||||
{title}: {id}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -76,7 +76,7 @@ describe("QuestionSummaryHeader", () => {
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId("question-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("id-badge")).toHaveTextContent("common.question_id: q1");
|
||||
expect(screen.getByTestId("settings-id")).toHaveTextContent("common.question_id: q1");
|
||||
expect(screen.queryByText("environments.surveys.edit.optional")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { SettingsId } from "@/modules/ui/components/settings-id";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
@@ -55,7 +55,7 @@ export const QuestionSummaryHeader = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<IdBadge id={questionSummary.question.id} label={t("common.question_id")} />
|
||||
<SettingsId title={t("common.question_id")} id={questionSummary.question.id}></SettingsId>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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/id-badge", () => ({
|
||||
IdBadge: vi.fn(() => <div data-testid="id-badge"></div>),
|
||||
vi.mock("@/modules/ui/components/settings-id", () => ({
|
||||
SettingsId: vi.fn(() => <div data-testid="settings-id"></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("id-badge")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("settings-id")).toBeInTheDocument();
|
||||
|
||||
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(vi.mocked(getSurvey)).toHaveBeenCalledWith(mockSurveyId);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
<IdBadge id={surveyId} label={t("common.survey_id")} variant="column" />
|
||||
<SettingsId title={t("common.survey_id")} id={surveyId} />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
buildWhereClause,
|
||||
calculateTtcTotal,
|
||||
extracMetadataKeys,
|
||||
extractChoiceIdsFromResponse,
|
||||
extractSurveyDetails,
|
||||
generateAllPermutationsOfSubsets,
|
||||
getResponseContactAttributes,
|
||||
@@ -561,176 +555,3 @@ 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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,86 +1,20 @@
|
||||
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, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey } 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);
|
||||
@@ -556,17 +490,10 @@ 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"
|
||||
@@ -629,19 +556,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -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 { isStringUrl, isValidCallbackUrl, testURLmatch } from "./url";
|
||||
import { isValidCallbackUrl, testURLmatch } from "./url";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -91,13 +91,3 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,12 +49,3 @@ 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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -124,7 +124,6 @@
|
||||
"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",
|
||||
@@ -280,8 +279,6 @@
|
||||
"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",
|
||||
@@ -308,7 +305,6 @@
|
||||
"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",
|
||||
@@ -389,7 +385,6 @@
|
||||
"targeting": "Targeting",
|
||||
"team": "Team",
|
||||
"team_access": "Teamzugriff",
|
||||
"team_id": "Team-ID",
|
||||
"team_name": "Teamname",
|
||||
"teams": "Zugriffskontrolle",
|
||||
"teams_not_found": "Teams nicht gefunden",
|
||||
@@ -1316,7 +1311,7 @@
|
||||
"columns": "Spalten",
|
||||
"company": "Firma",
|
||||
"company_logo": "Firmenlogo",
|
||||
"completed_responses": "unvollständige oder vollständige Antworten.",
|
||||
"completed_responses": "abgeschlossene Antworten",
|
||||
"concat": "Verketten +",
|
||||
"conditional_logic": "Bedingte Logik",
|
||||
"confirm_default_language": "Standardsprache bestätigen",
|
||||
|
||||
@@ -124,7 +124,6 @@
|
||||
"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",
|
||||
@@ -280,8 +279,6 @@
|
||||
"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",
|
||||
@@ -308,7 +305,6 @@
|
||||
"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",
|
||||
@@ -389,7 +385,6 @@
|
||||
"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",
|
||||
@@ -1316,7 +1311,7 @@
|
||||
"columns": "Columns",
|
||||
"company": "Company",
|
||||
"company_logo": "Company logo",
|
||||
"completed_responses": "partial or completed responses.",
|
||||
"completed_responses": "completed responses.",
|
||||
"concat": "Concat +",
|
||||
"conditional_logic": "Conditional Logic",
|
||||
"confirm_default_language": "Confirm default language",
|
||||
|
||||
@@ -124,7 +124,6 @@
|
||||
"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",
|
||||
@@ -280,8 +279,6 @@
|
||||
"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",
|
||||
@@ -308,7 +305,6 @@
|
||||
"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",
|
||||
@@ -389,7 +385,6 @@
|
||||
"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",
|
||||
@@ -1207,7 +1202,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.",
|
||||
@@ -1316,7 +1311,7 @@
|
||||
"columns": "Colonnes",
|
||||
"company": "Société",
|
||||
"company_logo": "Logo de l'entreprise",
|
||||
"completed_responses": "des réponses partielles ou complètes.",
|
||||
"completed_responses": "réponses complètes.",
|
||||
"concat": "Concat +",
|
||||
"conditional_logic": "Logique conditionnelle",
|
||||
"confirm_default_language": "Confirmer la langue par défaut",
|
||||
|
||||
@@ -124,7 +124,6 @@
|
||||
"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",
|
||||
@@ -280,8 +279,6 @@
|
||||
"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",
|
||||
@@ -308,7 +305,6 @@
|
||||
"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",
|
||||
@@ -389,7 +385,6 @@
|
||||
"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",
|
||||
@@ -1316,7 +1311,7 @@
|
||||
"columns": "colunas",
|
||||
"company": "empresa",
|
||||
"company_logo": "Logo da empresa",
|
||||
"completed_responses": "respostas parciais ou completas.",
|
||||
"completed_responses": "respostas completas",
|
||||
"concat": "Concatenar +",
|
||||
"conditional_logic": "Lógica Condicional",
|
||||
"confirm_default_language": "Confirmar idioma padrão",
|
||||
|
||||
@@ -124,7 +124,6 @@
|
||||
"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",
|
||||
@@ -280,8 +279,6 @@
|
||||
"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",
|
||||
@@ -308,7 +305,6 @@
|
||||
"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",
|
||||
@@ -389,7 +385,6 @@
|
||||
"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",
|
||||
@@ -1316,7 +1311,7 @@
|
||||
"columns": "Colunas",
|
||||
"company": "Empresa",
|
||||
"company_logo": "Logotipo da empresa",
|
||||
"completed_responses": "respostas parciais ou completas",
|
||||
"completed_responses": "respostas concluídas",
|
||||
"concat": "Concatenar +",
|
||||
"conditional_logic": "Lógica Condicional",
|
||||
"confirm_default_language": "Confirmar idioma padrão",
|
||||
|
||||
@@ -124,7 +124,6 @@
|
||||
"add_action": "新增操作",
|
||||
"add_filter": "新增篩選器",
|
||||
"add_logo": "新增標誌",
|
||||
"add_member": "新增成員",
|
||||
"add_project": "新增專案",
|
||||
"add_to_team": "新增至團隊",
|
||||
"all": "全部",
|
||||
@@ -280,8 +279,6 @@
|
||||
"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",
|
||||
@@ -308,7 +305,6 @@
|
||||
"privacy": "隱私權政策",
|
||||
"product_manager": "產品經理",
|
||||
"profile": "個人資料",
|
||||
"profile_id": "個人資料 ID",
|
||||
"project_configuration": "專案組態",
|
||||
"project_creation_description": "組織調查 在 專案中以便更好地存取控制。",
|
||||
"project_id": "專案 ID",
|
||||
@@ -389,7 +385,6 @@
|
||||
"targeting": "目標設定",
|
||||
"team": "團隊",
|
||||
"team_access": "團隊存取權限",
|
||||
"team_id": "團隊 ID",
|
||||
"team_name": "團隊名稱",
|
||||
"teams": "存取控制",
|
||||
"teams_not_found": "找不到團隊",
|
||||
@@ -1316,7 +1311,7 @@
|
||||
"columns": "欄位",
|
||||
"company": "公司",
|
||||
"company_logo": "公司標誌",
|
||||
"completed_responses": "部分或完整答复。",
|
||||
"completed_responses": "完成的回應。",
|
||||
"concat": "串連 +",
|
||||
"conditional_logic": "條件邏輯",
|
||||
"confirm_default_language": "確認預設語言",
|
||||
|
||||
@@ -324,7 +324,6 @@ 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);
|
||||
|
||||
@@ -2,7 +2,6 @@ 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,
|
||||
@@ -14,6 +13,7 @@ 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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { deepDiff, redactPII, sanitizeUrlForLogging } from "./logger-helpers";
|
||||
import { deepDiff, redactPII } from "./utils";
|
||||
|
||||
// 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("../../modules/ee/audit-logs/lib/handler");
|
||||
const { withAuditLogging } = await import("./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("../../modules/ee/audit-logs/lib/handler");
|
||||
const { withAuditLogging } = await import("./handler");
|
||||
const wrapped = withAuditLogging("created", "survey", handler);
|
||||
const ctx = {
|
||||
user: {
|
||||
@@ -181,37 +181,3 @@ 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]");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import { isStringUrl } from "@/lib/utils/url";
|
||||
|
||||
const SENSITIVE_KEYS = [
|
||||
"email",
|
||||
"name",
|
||||
@@ -35,11 +33,8 @@ 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.
|
||||
@@ -50,10 +45,6 @@ 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);
|
||||
@@ -98,24 +89,3 @@ 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]";
|
||||
}
|
||||
};
|
||||
@@ -2,7 +2,6 @@ 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 }) => {
|
||||
@@ -43,7 +42,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 ? (
|
||||
<IdBadge id={attributes.userId} />
|
||||
<span>{attributes.userId}</span>
|
||||
) : (
|
||||
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
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";
|
||||
|
||||
@@ -27,7 +26,7 @@ export const generateContactTableColumns = (
|
||||
header: "User ID",
|
||||
cell: ({ row }) => {
|
||||
const userId = row.original.userId;
|
||||
return <IdBadge id={userId} showCopyIconOnHover={true} />;
|
||||
return <HighlightedText value={userId} searchValue={searchValue} />;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"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";
|
||||
@@ -53,7 +52,10 @@ export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<IdBadge id={currentSegment.id} label={t("environments.segments.segment_id")} variant="column" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ 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";
|
||||
@@ -32,36 +31,12 @@ 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;
|
||||
}
|
||||
|
||||
@@ -70,18 +45,12 @@ 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: {
|
||||
@@ -97,29 +66,12 @@ 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
|
||||
@@ -127,20 +79,9 @@ 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."
|
||||
);
|
||||
@@ -149,24 +90,13 @@ 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") {
|
||||
@@ -178,16 +108,6 @@ 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") {
|
||||
@@ -197,14 +117,6 @@ 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
|
||||
@@ -212,23 +124,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -241,10 +139,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -252,32 +146,16 @@ 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
|
||||
contextLogger.error(err, "Invalid callbackUrl");
|
||||
logger.error(err, "Invalid callbackUrl");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -285,12 +163,6 @@ 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 {
|
||||
@@ -298,29 +170,13 @@ 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) {
|
||||
contextLogger.debug(
|
||||
{
|
||||
reason: "insufficient_role_permissions",
|
||||
organizationId: organization.id,
|
||||
canDoRoleManagement,
|
||||
},
|
||||
"SSO callback rejected: insufficient role management permissions"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!canDoRoleManagement && !callbackUrl) return false;
|
||||
}
|
||||
|
||||
contextLogger.debug({ hasUserName: !!userName, identityProvider: provider }, "Creating new SSO user");
|
||||
|
||||
const userProfile = await createUser({
|
||||
name:
|
||||
userName ||
|
||||
@@ -335,28 +191,13 @@ 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) {
|
||||
contextLogger.debug(
|
||||
{ isMultiOrgEnabled, newUserId: userProfile.id },
|
||||
"Multi-org enabled, skipping organization assignment"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (isMultiOrgEnabled) 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,
|
||||
@@ -364,10 +205,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -389,7 +226,6 @@ export const handleSsoCallback = async ({
|
||||
// Without default organization assignment
|
||||
return true;
|
||||
}
|
||||
contextLogger.debug("SSO callback successful: default return");
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -85,13 +85,6 @@ 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(),
|
||||
};
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
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";
|
||||
|
||||
@@ -22,7 +21,6 @@ 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>
|
||||
@@ -42,9 +40,6 @@ 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>
|
||||
|
||||
@@ -26,7 +26,6 @@ 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,
|
||||
@@ -236,8 +235,6 @@ 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">
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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")}>
|
||||
<IdBadge id={params.environmentId} />
|
||||
<EnvironmentIdField environmentId={params.environmentId} />
|
||||
</SettingsCard>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -19,11 +19,10 @@ vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
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>
|
||||
vi.mock("@/modules/ui/components/settings-id", () => ({
|
||||
SettingsId: ({ title, id }: any) => (
|
||||
<div data-testid="settings-id">
|
||||
<p>{title}</p>:<p>{id}</p>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -105,7 +104,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("id-badge").length).toBe(2);
|
||||
expect(screen.getAllByTestId("settings-id").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();
|
||||
|
||||
@@ -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 className="space-y-2">
|
||||
<IdBadge id={project.id} label={t("common.project_id")} variant="column" />
|
||||
<div>
|
||||
<SettingsId title={t("common.project_id")} id={project.id}></SettingsId>
|
||||
{!IS_FORMBRICKS_CLOUD && !IS_DEVELOPMENT && (
|
||||
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
|
||||
<SettingsId title={t("common.formbricks_version")} id={packageJson.version}></SettingsId>
|
||||
)}
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -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 object-contain p-1 md:max-w-56"
|
||||
"w-auto max-w-40 rounded-lg object-contain p-1 md:max-w-56"
|
||||
)}
|
||||
width={256}
|
||||
height={64}
|
||||
|
||||
@@ -22,16 +22,6 @@ 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) {
|
||||
@@ -87,9 +77,7 @@ 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">
|
||||
{isOptionIdColumn ? getOptionIdColumnLabel() : getLabelFromColumnId()}
|
||||
</span>
|
||||
<span className="max-w-xs truncate">{getLabelFromColumnId()}</span>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
|
||||
@@ -24,16 +24,6 @@ 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();
|
||||
|
||||
35
apps/web/modules/ui/components/settings-id/index.test.tsx
Normal file
35
apps/web/modules/ui/components/settings-id/index.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
12
apps/web/modules/ui/components/settings-id/index.tsx
Normal file
12
apps/web/modules/ui/components/settings-id/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -531,3 +531,13 @@ module "formbricks_app_iam_role" {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module "account_public_access" {
|
||||
source = "terraform-aws-modules/s3-bucket/aws//modules/account-public-access"
|
||||
version = "4.6.0"
|
||||
|
||||
block_public_acls = true
|
||||
block_public_policy = true
|
||||
ignore_public_acls = true
|
||||
restrict_public_buckets = true
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ["@formbricks/eslint-config/library.js"],
|
||||
parserOptions: {
|
||||
project: "tsconfig.json",
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
rules: {
|
||||
"no-console": "off",
|
||||
},
|
||||
};
|
||||
@@ -1,198 +0,0 @@
|
||||
# @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
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// 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";
|
||||
@@ -1,102 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"],
|
||||
"extends": "@formbricks/config-typescript/js-library.json",
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
});
|
||||
@@ -136,7 +136,11 @@ export function WelcomeCard({
|
||||
<ScrollableContainer>
|
||||
<div>
|
||||
{fileUrl ? (
|
||||
<img src={fileUrl} className="fb-mb-8 fb-max-h-96 fb-w-1/4 fb-object-contain" alt="Company Logo" />
|
||||
<img
|
||||
src={fileUrl}
|
||||
className="fb-mb-8 fb-max-h-96 fb-w-1/4 fb-rounded-lg fb-object-contain"
|
||||
alt="Company Logo"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Headline
|
||||
|
||||
149
pnpm-lock.yaml
generated
149
pnpm-lock.yaml
generated
@@ -536,37 +536,6 @@ 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':
|
||||
@@ -855,44 +824,6 @@ 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'}
|
||||
@@ -3803,9 +3734,6 @@ 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==}
|
||||
|
||||
@@ -4749,12 +4677,6 @@ 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:
|
||||
@@ -5965,10 +5887,6 @@ 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'}
|
||||
@@ -6805,9 +6723,6 @@ 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==}
|
||||
|
||||
@@ -9575,11 +9490,6 @@ 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==}
|
||||
|
||||
@@ -9587,47 +9497,6 @@ 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':
|
||||
@@ -13637,8 +13506,6 @@ 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))':
|
||||
@@ -14757,14 +14624,6 @@ 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
|
||||
@@ -16184,8 +16043,6 @@ snapshots:
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
eventsource-parser@3.0.3: {}
|
||||
|
||||
expand-template@2.0.3: {}
|
||||
|
||||
expect-type@1.2.2: {}
|
||||
@@ -17069,8 +16926,6 @@ snapshots:
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
json-schema@0.4.0: {}
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||
|
||||
json5@1.0.2:
|
||||
@@ -20024,8 +19879,4 @@ 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: {}
|
||||
|
||||
Reference in New Issue
Block a user