mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
Compare commits
8 Commits
chore/dont
...
feautre/ai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a141171c5 | ||
|
|
0cc2606ec6 | ||
|
|
0fada94b80 | ||
|
|
a59ede20c7 | ||
|
|
84294f9df2 | ||
|
|
855e7c78ce | ||
|
|
6c506d90c7 | ||
|
|
53f6e02ca1 |
4
.github/actions/cache-build-web/action.yml
vendored
4
.github/actions/cache-build-web/action.yml
vendored
@@ -62,10 +62,12 @@ runs:
|
||||
shell: bash
|
||||
|
||||
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
||||
env:
|
||||
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
|
||||
run: |
|
||||
RANDOM_KEY=$(openssl rand -hex 32)
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
|
||||
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
|
||||
shell: bash
|
||||
|
||||
- run: |
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
name: 'Upload Sentry Sourcemaps'
|
||||
description: 'Extract sourcemaps from Docker image and upload to Sentry'
|
||||
name: "Upload Sentry Sourcemaps"
|
||||
description: "Extract sourcemaps from Docker image and upload to Sentry"
|
||||
|
||||
inputs:
|
||||
docker_image:
|
||||
description: 'Docker image to extract sourcemaps from'
|
||||
description: "Docker image to extract sourcemaps from"
|
||||
required: true
|
||||
release_version:
|
||||
description: 'Sentry release version (e.g., v1.2.3)'
|
||||
description: "Sentry release version (e.g., v1.2.3)"
|
||||
required: true
|
||||
sentry_auth_token:
|
||||
description: 'Sentry authentication token'
|
||||
description: "Sentry authentication token"
|
||||
required: true
|
||||
environment:
|
||||
description: 'Sentry environment (e.g., production, staging)'
|
||||
description: "Sentry environment (e.g., production, staging)"
|
||||
required: false
|
||||
default: 'staging'
|
||||
default: "staging"
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -26,13 +26,12 @@ runs:
|
||||
|
||||
- name: Validate Sentry auth token
|
||||
shell: bash
|
||||
env:
|
||||
SENTRY_TOKEN: ${{ inputs.sentry_auth_token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "🔐 Validating Sentry authentication token..."
|
||||
|
||||
# Assign token to local variable for secure handling
|
||||
SENTRY_TOKEN="${{ inputs.sentry_auth_token }}"
|
||||
|
||||
# Test the token by making a simple API call to Sentry
|
||||
response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \
|
||||
-H "Authorization: Bearer $SENTRY_TOKEN" \
|
||||
@@ -57,13 +56,23 @@ runs:
|
||||
|
||||
- name: Extract sourcemaps from Docker image
|
||||
shell: bash
|
||||
env:
|
||||
DOCKER_IMAGE: ${{ inputs.docker_image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "📦 Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}"
|
||||
|
||||
# Validate docker image format (basic validation)
|
||||
if [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+:[a-zA-Z0-9._-]+$ ]] && [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+@sha256:[A-Fa-f0-9]{64}$ ]]; then
|
||||
echo "❌ Error: Invalid docker image format. Must be in format 'image:tag' or 'image@sha256:hash'"
|
||||
echo "Provided: $DOCKER_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📦 Extracting sourcemaps from Docker image: $DOCKER_IMAGE"
|
||||
|
||||
# Create temporary container from the image and capture its ID
|
||||
echo "Creating temporary container..."
|
||||
CONTAINER_ID=$(docker create "${{ inputs.docker_image }}")
|
||||
CONTAINER_ID=$(docker create "$DOCKER_IMAGE")
|
||||
echo "Container created with ID: $CONTAINER_ID"
|
||||
|
||||
# Set up cleanup function to ensure container is removed on script exit
|
||||
@@ -82,7 +91,7 @@ runs:
|
||||
# Exit with the original exit code to preserve script success/failure status
|
||||
exit $original_exit_code
|
||||
}
|
||||
|
||||
|
||||
# Register cleanup function to run on script exit (success or failure)
|
||||
trap cleanup_container EXIT
|
||||
|
||||
@@ -113,7 +122,7 @@ runs:
|
||||
with:
|
||||
environment: ${{ inputs.environment }}
|
||||
version: ${{ inputs.release_version }}
|
||||
sourcemaps: './extracted-next/'
|
||||
sourcemaps: "./extracted-next/"
|
||||
|
||||
- name: Clean up extracted files
|
||||
shell: bash
|
||||
|
||||
@@ -106,15 +106,16 @@ jobs:
|
||||
env:
|
||||
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
ENVIRONMENT: ${{ inputs.ENVIRONMENT }}
|
||||
run: |
|
||||
# Set hostname based on environment
|
||||
if [[ "${{ inputs.ENVIRONMENT }}" == "production" ]]; then
|
||||
if [[ "$ENVIRONMENT" == "production" ]]; then
|
||||
PURGE_HOST="app.formbricks.com"
|
||||
else
|
||||
PURGE_HOST="stage.app.formbricks.com"
|
||||
fi
|
||||
|
||||
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: ${{ inputs.ENVIRONMENT }}, zone: $CF_ZONE_ID)"
|
||||
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: $ENVIRONMENT, zone: $CF_ZONE_ID)"
|
||||
|
||||
# Prepare JSON payload for selective cache purge
|
||||
json_payload=$(cat << EOF
|
||||
|
||||
17
.github/workflows/docker-build-validation.yml
vendored
17
.github/workflows/docker-build-validation.yml
vendored
@@ -4,15 +4,9 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- helm-chart/**
|
||||
- infra/**
|
||||
merge_group:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- helm-chart/**
|
||||
- infra/**
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -53,12 +47,14 @@ jobs:
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
with:
|
||||
context: .
|
||||
file: ./apps/web/Dockerfile
|
||||
push: false
|
||||
load: true
|
||||
tags: formbricks-test:${{ github.sha }}
|
||||
tags: formbricks-test:${{ env.GITHUB_SHA }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
secrets: |
|
||||
@@ -95,6 +91,9 @@ jobs:
|
||||
|
||||
- name: Test Docker Image with Health Check
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
run: |
|
||||
echo "🧪 Testing if the Docker image starts correctly..."
|
||||
|
||||
@@ -106,8 +105,8 @@ jobs:
|
||||
$DOCKER_RUN_ARGS \
|
||||
-p 3000:3000 \
|
||||
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
|
||||
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
|
||||
-d formbricks-test:${{ github.sha }}
|
||||
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
|
||||
-d "formbricks-test:$GITHUB_SHA"
|
||||
|
||||
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
|
||||
echo "🏥 Polling /health endpoint every 5 seconds for up to 5 minutes..."
|
||||
|
||||
@@ -44,11 +44,11 @@ jobs:
|
||||
|
||||
- name: Generate SemVer version from branch or tag
|
||||
id: generate_version
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
run: |
|
||||
# Get reference name and type
|
||||
REF_NAME="${{ github.ref_name }}"
|
||||
REF_TYPE="${{ github.ref_type }}"
|
||||
|
||||
# Get reference name and type from environment variables
|
||||
echo "Reference type: $REF_TYPE"
|
||||
echo "Reference name: $REF_NAME"
|
||||
|
||||
|
||||
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,9 +52,20 @@ jobs:
|
||||
id: extract_release_tag
|
||||
run: |
|
||||
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
|
||||
TAG=${{ github.ref }}
|
||||
TAG="$GITHUB_REF"
|
||||
TAG=${TAG#refs/tags/v}
|
||||
|
||||
# Validate the extracted tag format
|
||||
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "❌ Error: Invalid release tag format after extraction. Must be semver (e.g., 1.2.3, 1.2.3-alpha)"
|
||||
echo "Original ref: $GITHUB_REF"
|
||||
echo "Extracted tag: $TAG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Safely add to environment variables
|
||||
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
||||
|
||||
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "Using tag-based version: $TAG"
|
||||
|
||||
|
||||
30
.github/workflows/release-helm-chart.yml
vendored
30
.github/workflows/release-helm-chart.yml
vendored
@@ -26,8 +26,23 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Extract release version
|
||||
run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
|
||||
- name: Validate input version
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Validate input version format (expects clean semver without 'v' prefix)
|
||||
if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "❌ Error: Invalid version format. Must be clean semver (e.g., 1.2.3, 1.2.3-alpha)"
|
||||
echo "Expected: clean version without 'v' prefix"
|
||||
echo "Provided: $INPUT_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Store validated version in environment variable
|
||||
echo "VERSION<<EOF" >> $GITHUB_ENV
|
||||
echo "$INPUT_VERSION" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
|
||||
@@ -35,15 +50,18 @@ jobs:
|
||||
version: latest
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_ACTOR: ${{ github.actor }}
|
||||
run: printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
|
||||
|
||||
- name: Install YQ
|
||||
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
|
||||
|
||||
- name: Update Chart.yaml with new version
|
||||
run: |
|
||||
yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
|
||||
yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
|
||||
yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml
|
||||
yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml
|
||||
|
||||
- name: Package Helm chart
|
||||
run: |
|
||||
@@ -51,4 +69,4 @@ jobs:
|
||||
|
||||
- name: Push Helm chart to GitHub Container Registry
|
||||
run: |
|
||||
helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts
|
||||
helm push "formbricks-$VERSION.tgz" oci://ghcr.io/formbricks/helm-charts
|
||||
|
||||
10
.github/workflows/tolgee.yml
vendored
10
.github/workflows/tolgee.yml
vendored
@@ -27,10 +27,18 @@ jobs:
|
||||
|
||||
- name: Get source branch name
|
||||
id: branch-name
|
||||
env:
|
||||
RAW_BRANCH: ${{ github.head_ref }}
|
||||
run: |
|
||||
RAW_BRANCH="${{ github.head_ref }}"
|
||||
# Validate and sanitize branch name - only allow alphanumeric, dots, underscores, hyphens, and forward slashes
|
||||
SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g')
|
||||
|
||||
# Additional validation - ensure branch name is not empty after sanitization
|
||||
if [[ -z "$SOURCE_BRANCH" ]]; then
|
||||
echo "❌ Error: Branch name is empty after sanitization"
|
||||
echo "Original branch: $RAW_BRANCH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Safely add to environment variables using GitHub's recommended method
|
||||
# This prevents environment variable injection attacks
|
||||
|
||||
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,16 +31,13 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set Docker Image
|
||||
run: |
|
||||
if [ -n "${{ inputs.tag_version }}" ]; then
|
||||
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV
|
||||
fi
|
||||
run: echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> $GITHUB_ENV
|
||||
env:
|
||||
DOCKER_IMAGE: ${{ inputs.docker_image }}:${{ inputs.tag_version != '' && inputs.tag_version || inputs.release_version }}
|
||||
|
||||
- name: Upload Sourcemaps to Sentry
|
||||
uses: ./.github/actions/upload-sentry-sourcemaps
|
||||
with:
|
||||
docker_image: ${{ env.DOCKER_IMAGE }}
|
||||
release_version: ${{ inputs.release_version }}
|
||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
@@ -121,8 +121,9 @@ describe("ProfilePage", () => {
|
||||
expect(screen.getByTestId("account-security")).toBeInTheDocument(); // Shown because 2FA license is enabled
|
||||
expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("delete-account")).toBeInTheDocument();
|
||||
// Use a regex to match the text content, allowing for variable whitespace
|
||||
expect(screen.getByText(new RegExp(`common\\.profile\\s*:\\s*${mockUser.id}`))).toBeInTheDocument(); // SettingsId
|
||||
// Check for IdBadge content
|
||||
expect(screen.getByText("common.profile_id")).toBeInTheDocument();
|
||||
expect(screen.getByText(mockUser.id)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/servi
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SettingsId } from "@/modules/ui/components/settings-id";
|
||||
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { SettingsCard } from "../../components/SettingsCard";
|
||||
@@ -103,7 +103,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<SettingsId title={t("common.profile")} id={user.id}></SettingsId>
|
||||
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
|
||||
</div>
|
||||
)}
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/lice
|
||||
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||
import { SettingsId } from "@/modules/ui/components/settings-id";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
@@ -78,8 +78,8 @@ vi.mock("./components/DeleteOrganization", () => ({
|
||||
DeleteOrganization: vi.fn(() => <div>DeleteOrganization</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/settings-id", () => ({
|
||||
SettingsId: vi.fn(() => <div>SettingsId</div>),
|
||||
vi.mock("@/modules/ui/components/id-badge", () => ({
|
||||
IdBadge: vi.fn(() => <div>IdBadge</div>),
|
||||
}));
|
||||
|
||||
describe("Page", () => {
|
||||
@@ -156,10 +156,11 @@ describe("Page", () => {
|
||||
},
|
||||
undefined
|
||||
);
|
||||
expect(SettingsId).toHaveBeenCalledWith(
|
||||
expect(IdBadge).toHaveBeenCalledWith(
|
||||
{
|
||||
title: "common.organization_id",
|
||||
id: mockEnvironmentAuth.organization.id,
|
||||
label: "common.organization_id",
|
||||
variant: "column",
|
||||
},
|
||||
undefined
|
||||
);
|
||||
|
||||
@@ -4,9 +4,9 @@ import { getUser } from "@/lib/user/service";
|
||||
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SettingsId } from "@/modules/ui/components/settings-id";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||
@@ -70,7 +70,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
<SettingsId title={t("common.organization_id")} id={organization.id}></SettingsId>
|
||||
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
||||
import { processResponseData } from "@/lib/responses";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { ResponseBadges } from "@/modules/ui/components/response-badges";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { AnyActionArg } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses";
|
||||
import {
|
||||
@@ -60,6 +59,7 @@ vi.mock("@/modules/survey/lib/questions", () => ({
|
||||
getQuestionIconMap: vi.fn(() => ({
|
||||
[TSurveyQuestionTypeEnum.OpenText]: <span>OT</span>,
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: <span>MCS</span>,
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: <span>MCM</span>,
|
||||
[TSurveyQuestionTypeEnum.Matrix]: <span>MX</span>,
|
||||
[TSurveyQuestionTypeEnum.Address]: <span>AD</span>,
|
||||
[TSurveyQuestionTypeEnum.ContactInfo]: <span>CI</span>,
|
||||
@@ -104,6 +104,27 @@ vi.mock("lucide-react", () => ({
|
||||
TagIcon: () => <span>Tag</span>,
|
||||
}));
|
||||
|
||||
// Mock new dependencies
|
||||
vi.mock("@/lib/response/utils", () => ({
|
||||
extractChoiceIdsFromResponse: vi.fn((responseValue) => {
|
||||
// Mock implementation that returns choice IDs based on response value
|
||||
if (Array.isArray(responseValue)) {
|
||||
return responseValue.map((_, index) => `choice-${index + 1}`);
|
||||
} else if (typeof responseValue === "string") {
|
||||
return [`choice-single`];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/id-badge", () => ({
|
||||
IdBadge: vi.fn(({ id }) => <div data-testid="id-badge">{id}</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/lib/utils", () => ({
|
||||
cn: vi.fn((...classes) => classes.filter(Boolean).join(" ")),
|
||||
}));
|
||||
|
||||
const mockSurvey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
@@ -136,6 +157,28 @@ const mockSurvey = {
|
||||
headline: { default: "Contact Info Question" },
|
||||
required: false,
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q5single",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Single Choice Question" },
|
||||
required: false,
|
||||
choices: [
|
||||
{ id: "choice-1", label: { default: "Option 1" } },
|
||||
{ id: "choice-2", label: { default: "Option 2" } },
|
||||
{ id: "choice-3", label: { default: "Option 3" } },
|
||||
],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q6multi",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Multi Choice Question" },
|
||||
required: false,
|
||||
choices: [
|
||||
{ id: "choice-a", label: { default: "Choice A" } },
|
||||
{ id: "choice-b", label: { default: "Choice B" } },
|
||||
{ id: "choice-c", label: { default: "Choice C" } },
|
||||
],
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
variables: [
|
||||
{ id: "var1", name: "User Segment", type: "text" } as TSurveyVariable,
|
||||
@@ -173,6 +216,8 @@ const mockResponseData = {
|
||||
firstName: "John",
|
||||
email: "john.doe@example.com",
|
||||
hf1: "Hidden Field 1 Value",
|
||||
q5single: "Option 1", // Single choice response
|
||||
q6multi: ["Choice A", "Choice C"], // Multi choice response
|
||||
},
|
||||
variables: {
|
||||
var1: "Segment A",
|
||||
@@ -495,3 +540,281 @@ describe("ResponseTableColumns - Column Implementations", () => {
|
||||
expect(hfColumn).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResponseTableColumns - Multiple Choice Questions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("generates two columns for multipleChoiceSingle questions", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
|
||||
// Should have main response column
|
||||
const mainColumn = columns.find((col) => (col as any).accessorKey === "q5single");
|
||||
expect(mainColumn).toBeDefined();
|
||||
|
||||
// Should have option IDs column
|
||||
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
|
||||
expect(optionIdsColumn).toBeDefined();
|
||||
});
|
||||
|
||||
test("generates two columns for multipleChoiceMulti questions", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
|
||||
// Should have main response column
|
||||
const mainColumn = columns.find((col) => (col as any).accessorKey === "q6multi");
|
||||
expect(mainColumn).toBeDefined();
|
||||
|
||||
// Should have option IDs column
|
||||
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
|
||||
expect(optionIdsColumn).toBeDefined();
|
||||
});
|
||||
|
||||
test("multipleChoiceSingle main column renders RenderResponse component", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
|
||||
|
||||
const mockRow = {
|
||||
original: {
|
||||
responseData: { q5single: "Option 1" },
|
||||
language: "default",
|
||||
},
|
||||
};
|
||||
|
||||
const cellResult = mainColumn?.cell?.({ row: mockRow } as any);
|
||||
// Check that RenderResponse component is returned
|
||||
expect(cellResult).toBeDefined();
|
||||
});
|
||||
|
||||
test("multipleChoiceMulti main column renders RenderResponse component", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q6multi");
|
||||
|
||||
const mockRow = {
|
||||
original: {
|
||||
responseData: { q6multi: ["Choice A", "Choice C"] },
|
||||
language: "default",
|
||||
},
|
||||
};
|
||||
|
||||
const cellResult = mainColumn?.cell?.({ row: mockRow } as any);
|
||||
// Check that RenderResponse component is returned
|
||||
expect(cellResult).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResponseTableColumns - Choice ID Columns", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("option IDs column calls extractChoiceIdsFromResponse for string response", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
|
||||
|
||||
const mockRow = {
|
||||
original: {
|
||||
responseData: { q5single: "Option 1" },
|
||||
language: "default",
|
||||
},
|
||||
};
|
||||
|
||||
optionIdsColumn?.cell?.({ row: mockRow } as any);
|
||||
|
||||
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
|
||||
"Option 1",
|
||||
expect.objectContaining({ id: "q5single", type: "multipleChoiceSingle" }),
|
||||
"default"
|
||||
);
|
||||
});
|
||||
|
||||
test("option IDs column calls extractChoiceIdsFromResponse for array response", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
|
||||
|
||||
const mockRow = {
|
||||
original: {
|
||||
responseData: { q6multi: ["Choice A", "Choice C"] },
|
||||
language: "default",
|
||||
},
|
||||
};
|
||||
|
||||
optionIdsColumn?.cell?.({ row: mockRow } as any);
|
||||
|
||||
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
|
||||
["Choice A", "Choice C"],
|
||||
expect.objectContaining({ id: "q6multi", type: "multipleChoiceMulti" }),
|
||||
"default"
|
||||
);
|
||||
});
|
||||
|
||||
test("option IDs column renders IdBadge components for choice IDs", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
|
||||
|
||||
const mockRow = {
|
||||
original: {
|
||||
responseData: { q6multi: ["Choice A", "Choice C"] },
|
||||
language: "default",
|
||||
},
|
||||
};
|
||||
|
||||
// Mock extractChoiceIdsFromResponse to return specific choice IDs
|
||||
vi.mocked(extractChoiceIdsFromResponse).mockReturnValueOnce(["choice-1", "choice-3"]);
|
||||
|
||||
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
|
||||
|
||||
// Should render something for choice IDs
|
||||
expect(cellResult).toBeDefined();
|
||||
// Verify that extractChoiceIdsFromResponse was called
|
||||
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("option IDs column returns null for non-string/array response values", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
|
||||
|
||||
const mockRow = {
|
||||
original: {
|
||||
responseData: { q5single: 123 }, // Invalid type
|
||||
language: "default",
|
||||
},
|
||||
};
|
||||
|
||||
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
|
||||
|
||||
expect(cellResult).toBeNull();
|
||||
expect(vi.mocked(extractChoiceIdsFromResponse)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("option IDs column returns null when no choice IDs found", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
|
||||
|
||||
const mockRow = {
|
||||
original: {
|
||||
responseData: { q5single: "Non-existent option" },
|
||||
language: "default",
|
||||
},
|
||||
};
|
||||
|
||||
// Mock extractChoiceIdsFromResponse to return empty array
|
||||
vi.mocked(extractChoiceIdsFromResponse).mockReturnValueOnce([]);
|
||||
|
||||
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
|
||||
|
||||
expect(cellResult).toBeNull();
|
||||
});
|
||||
|
||||
test("option IDs column handles missing language gracefully", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
|
||||
|
||||
const mockRow = {
|
||||
original: {
|
||||
responseData: { q5single: "Option 1" },
|
||||
language: null, // No language
|
||||
},
|
||||
};
|
||||
|
||||
optionIdsColumn?.cell?.({ row: mockRow } as any);
|
||||
|
||||
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
|
||||
"Option 1",
|
||||
expect.objectContaining({ id: "q5single" }),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResponseTableColumns - Helper Functions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("question headers are properly created for multiple choice questions", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
|
||||
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
|
||||
|
||||
// Test main column header
|
||||
const mainHeader = mainColumn?.header?.();
|
||||
expect(mainHeader).toBeDefined();
|
||||
expect(mainHeader?.props?.className).toContain("flex items-center justify-between");
|
||||
|
||||
// Test option IDs column header
|
||||
const optionHeader = optionIdsColumn?.header?.();
|
||||
expect(optionHeader).toBeDefined();
|
||||
expect(optionHeader?.props?.className).toContain("flex items-center justify-between");
|
||||
});
|
||||
|
||||
test("question headers include proper icons for multiple choice questions", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const singleChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
|
||||
const multiChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "q6multi");
|
||||
|
||||
// Headers should be functions that return JSX
|
||||
expect(typeof singleChoiceColumn?.header).toBe("function");
|
||||
expect(typeof multiChoiceColumn?.header).toBe("function");
|
||||
|
||||
// Call headers to ensure they don't throw
|
||||
expect(() => singleChoiceColumn?.header?.()).not.toThrow();
|
||||
expect(() => multiChoiceColumn?.header?.()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResponseTableColumns - Integration Tests", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("multiple choice questions work end-to-end with real data", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
|
||||
// Find all multiple choice related columns
|
||||
const singleMainCol = columns.find((col) => (col as any).accessorKey === "q5single");
|
||||
const singleIdsCol = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
|
||||
const multiMainCol = columns.find((col) => (col as any).accessorKey === "q6multi");
|
||||
const multiIdsCol = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
|
||||
|
||||
expect(singleMainCol).toBeDefined();
|
||||
expect(singleIdsCol).toBeDefined();
|
||||
expect(multiMainCol).toBeDefined();
|
||||
expect(multiIdsCol).toBeDefined();
|
||||
|
||||
// Test with actual mock response data
|
||||
const mockRow = { original: mockResponseData };
|
||||
|
||||
// Test single choice main column
|
||||
const singleMainResult = (singleMainCol?.cell as any)?.({ row: mockRow });
|
||||
expect(singleMainResult).toBeDefined();
|
||||
|
||||
// Test multi choice main column
|
||||
const multiMainResult = (multiMainCol?.cell as any)?.({ row: mockRow });
|
||||
expect(multiMainResult).toBeDefined();
|
||||
|
||||
// Test that choice ID columns exist and can be called
|
||||
const singleIdsResult = (singleIdsCol?.cell as any)?.({ row: mockRow });
|
||||
const multiIdsResult = (multiIdsCol?.cell as any)?.({ row: mockRow });
|
||||
|
||||
// Should not error when calling the cell functions
|
||||
expect(() => singleIdsResult).not.toThrow();
|
||||
expect(() => multiIdsResult).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
||||
import { processResponseData } from "@/lib/responses";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||
@@ -8,8 +9,10 @@ import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
|
||||
import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@/modules/survey/lib/questions";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { ResponseBadges } from "@/modules/ui/components/response-badges";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
||||
@@ -61,6 +64,42 @@ const getQuestionColumnsData = (
|
||||
t: TFnType
|
||||
): ColumnDef<TResponseTableData>[] => {
|
||||
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
|
||||
|
||||
// Helper function to create consistent column headers
|
||||
const createQuestionHeader = (questionType: string, headline: string, suffix?: string) => {
|
||||
const title = suffix ? `${headline} - ${suffix}` : headline;
|
||||
const QuestionHeader = () => (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{QUESTIONS_ICON_MAP[questionType]}</span>
|
||||
<span className="truncate">{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
QuestionHeader.displayName = "QuestionHeader";
|
||||
return QuestionHeader;
|
||||
};
|
||||
|
||||
// Helper function to get localized question headline
|
||||
const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => {
|
||||
return getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default");
|
||||
};
|
||||
|
||||
// Helper function to render choice ID badges
|
||||
const renderChoiceIdBadges = (choiceIds: string[], isExpanded: boolean) => {
|
||||
if (choiceIds.length === 0) return null;
|
||||
|
||||
const containerClasses = cn("flex gap-x-1 w-full", isExpanded && "flex-wrap gap-y-1");
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{choiceIds.map((choiceId, index) => (
|
||||
<IdBadge key={choiceId || index} id={choiceId} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
switch (question.type) {
|
||||
case "matrix":
|
||||
return question.rows.map((matrixRow) => {
|
||||
@@ -137,6 +176,49 @@ const getQuestionColumnsData = (
|
||||
};
|
||||
});
|
||||
|
||||
case "multipleChoiceMulti":
|
||||
case "multipleChoiceSingle":
|
||||
case "ranking":
|
||||
case "pictureSelection": {
|
||||
const questionHeadline = getQuestionHeadline(question, survey);
|
||||
return [
|
||||
{
|
||||
accessorKey: question.id,
|
||||
header: createQuestionHeader(question.type, questionHeadline),
|
||||
cell: ({ row }) => {
|
||||
const responseValue = row.original.responseData[question.id];
|
||||
const language = row.original.language;
|
||||
return (
|
||||
<RenderResponse
|
||||
question={question}
|
||||
survey={survey}
|
||||
responseData={responseValue}
|
||||
language={language}
|
||||
isExpanded={isExpanded}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: question.id + "optionIds",
|
||||
header: createQuestionHeader(question.type, questionHeadline, t("common.option_id")),
|
||||
cell: ({ row }) => {
|
||||
const responseValue = row.original.responseData[question.id];
|
||||
// Type guard to ensure responseValue is the correct type
|
||||
if (typeof responseValue === "string" || Array.isArray(responseValue)) {
|
||||
const choiceIds = extractChoiceIdsFromResponse(
|
||||
responseValue,
|
||||
question,
|
||||
row.original.language || undefined
|
||||
);
|
||||
return renderChoiceIdBadges(choiceIds, isExpanded);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
default:
|
||||
return [
|
||||
{
|
||||
@@ -317,7 +399,6 @@ export const generateResponseTableColumns = (
|
||||
};
|
||||
|
||||
// Combine the selection column with the dynamic question columns
|
||||
|
||||
const baseColumns = [
|
||||
personColumn,
|
||||
dateColumn,
|
||||
|
||||
@@ -27,10 +27,10 @@ vi.mock("@/modules/survey/lib/questions", () => ({
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/settings-id", () => ({
|
||||
SettingsId: ({ title, id }: { title: string; id: string }) => (
|
||||
<div data-testid="settings-id">
|
||||
{title}: {id}
|
||||
vi.mock("@/modules/ui/components/id-badge", () => ({
|
||||
IdBadge: ({ label, id }: { label: string; id: string }) => (
|
||||
<div data-testid="id-badge">
|
||||
{label}: {id}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -76,7 +76,7 @@ describe("QuestionSummaryHeader", () => {
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId("question-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("settings-id")).toHaveTextContent("common.question_id: q1");
|
||||
expect(screen.getByTestId("id-badge")).toHaveTextContent("common.question_id: q1");
|
||||
expect(screen.queryByText("environments.surveys.edit.optional")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||
import { SettingsId } from "@/modules/ui/components/settings-id";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
@@ -55,7 +55,7 @@ export const QuestionSummaryHeader = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SettingsId title={t("common.question_id")} id={questionSummary.question.id}></SettingsId>
|
||||
<IdBadge id={questionSummary.question.id} label={t("common.question_id")} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -98,8 +98,8 @@ vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
PageHeader: vi.fn(({ children }) => <div data-testid="page-header">{children}</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/settings-id", () => ({
|
||||
SettingsId: vi.fn(() => <div data-testid="settings-id"></div>),
|
||||
vi.mock("@/modules/ui/components/id-badge", () => ({
|
||||
IdBadge: vi.fn(() => <div data-testid="id-badge"></div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
@@ -227,7 +227,7 @@ describe("SurveyPage", () => {
|
||||
expect(screen.getByTestId("page-header")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("survey-analysis-navigation")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("summary-page")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("settings-id")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("id-badge")).toBeInTheDocument();
|
||||
|
||||
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(vi.mocked(getSurvey)).toHaveBeenCalledWith(mockSurveyId);
|
||||
|
||||
@@ -9,9 +9,9 @@ import { getUser } from "@/lib/user/service";
|
||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SettingsId } from "@/modules/ui/components/settings-id";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
@@ -74,7 +74,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
initialSurveySummary={initialSurveySummary}
|
||||
/>
|
||||
|
||||
<SettingsId title={t("common.survey_id")} id={surveyId} />
|
||||
<IdBadge id={surveyId} label={t("common.survey_id")} variant="column" />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
buildWhereClause,
|
||||
calculateTtcTotal,
|
||||
extracMetadataKeys,
|
||||
extractChoiceIdsFromResponse,
|
||||
extractSurveyDetails,
|
||||
generateAllPermutationsOfSubsets,
|
||||
getResponseContactAttributes,
|
||||
@@ -555,3 +561,176 @@ describe("Response Utils", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractChoiceIdsFromResponse", () => {
|
||||
const multipleChoiceMultiQuestion: TSurveyQuestion = {
|
||||
id: "multi-choice-id",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Select multiple options" },
|
||||
required: false,
|
||||
choices: [
|
||||
{
|
||||
id: "choice-1",
|
||||
label: { default: "Option 1", es: "Opción 1" },
|
||||
},
|
||||
{
|
||||
id: "choice-2",
|
||||
label: { default: "Option 2", es: "Opción 2" },
|
||||
},
|
||||
{
|
||||
id: "choice-3",
|
||||
label: { default: "Option 3", es: "Opción 3" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const multipleChoiceSingleQuestion: TSurveyQuestion = {
|
||||
id: "single-choice-id",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Select one option" },
|
||||
required: false,
|
||||
choices: [
|
||||
{
|
||||
id: "choice-a",
|
||||
label: { default: "Choice A", fr: "Choix A" },
|
||||
},
|
||||
{
|
||||
id: "choice-b",
|
||||
label: { default: "Choice B", fr: "Choix B" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const textQuestion: TSurveyOpenTextQuestion = {
|
||||
id: "text-id",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "What do you think?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false, min: 0, max: 0 },
|
||||
};
|
||||
|
||||
describe("multipleChoiceMulti questions", () => {
|
||||
test("should extract choice IDs from array response with default language", () => {
|
||||
const responseValue = ["Option 1", "Option 3"];
|
||||
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
|
||||
|
||||
expect(result).toEqual(["choice-1", "choice-3"]);
|
||||
});
|
||||
|
||||
test("should extract choice IDs from array response with specific language", () => {
|
||||
const responseValue = ["Opción 1", "Opción 2"];
|
||||
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "es");
|
||||
|
||||
expect(result).toEqual(["choice-1", "choice-2"]);
|
||||
});
|
||||
|
||||
test("should fall back to checking all language values when exact language match fails", () => {
|
||||
const responseValue = ["Opción 1", "Option 2"];
|
||||
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
|
||||
|
||||
expect(result).toEqual(["choice-1", "choice-2"]);
|
||||
});
|
||||
|
||||
test("should render other option when non-matching choice is selected", () => {
|
||||
const responseValue = ["Option 1", "Non-existent option", "Option 3"];
|
||||
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
|
||||
|
||||
expect(result).toEqual(["choice-1", "other", "choice-3"]);
|
||||
});
|
||||
|
||||
test("should return empty array for empty response", () => {
|
||||
const responseValue: string[] = [];
|
||||
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multipleChoiceSingle questions", () => {
|
||||
test("should extract choice ID from string response with default language", () => {
|
||||
const responseValue = "Choice A";
|
||||
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default");
|
||||
|
||||
expect(result).toEqual(["choice-a"]);
|
||||
});
|
||||
|
||||
test("should extract choice ID from string response with specific language", () => {
|
||||
const responseValue = "Choix B";
|
||||
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "fr");
|
||||
|
||||
expect(result).toEqual(["choice-b"]);
|
||||
});
|
||||
|
||||
test("should fall back to checking all language values for single choice", () => {
|
||||
const responseValue = "Choix A";
|
||||
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default");
|
||||
|
||||
expect(result).toEqual(["choice-a"]);
|
||||
});
|
||||
|
||||
test("should return empty array for empty string response", () => {
|
||||
const responseValue = "";
|
||||
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("should return empty array for non-multiple choice questions", () => {
|
||||
const responseValue = "Some text response";
|
||||
const result = extractChoiceIdsFromResponse(responseValue, textQuestion, "default");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("should handle missing language parameter by defaulting to 'default'", () => {
|
||||
const responseValue = "Option 1";
|
||||
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion);
|
||||
|
||||
expect(result).toEqual(["choice-1"]);
|
||||
});
|
||||
|
||||
test("should handle numeric or other types by returning empty array", () => {
|
||||
const responseValue = 123;
|
||||
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("should handle object responses by returning empty array", () => {
|
||||
const responseValue = { invalid: "object" };
|
||||
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("language handling", () => {
|
||||
test("should use provided language parameter", () => {
|
||||
const responseValue = ["Opción 1"];
|
||||
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "es");
|
||||
|
||||
expect(result).toEqual(["choice-1"]);
|
||||
});
|
||||
|
||||
test("should handle null language parameter by defaulting to 'default'", () => {
|
||||
const responseValue = ["Option 1"];
|
||||
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, null as any);
|
||||
|
||||
expect(result).toEqual(["choice-1"]);
|
||||
});
|
||||
|
||||
test("should handle undefined language parameter by defaulting to 'default'", () => {
|
||||
const responseValue = ["Option 1"];
|
||||
const result = extractChoiceIdsFromResponse(
|
||||
responseValue,
|
||||
multipleChoiceMultiQuestion,
|
||||
undefined as any
|
||||
);
|
||||
|
||||
expect(result).toEqual(["choice-1"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,86 @@
|
||||
import "server-only";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import {
|
||||
TResponse,
|
||||
TResponseDataValue,
|
||||
TResponseFilterCriteria,
|
||||
TResponseHiddenFieldsFilter,
|
||||
TResponseTtc,
|
||||
TSurveyContactAttributes,
|
||||
TSurveyMetaFieldFilter,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { processResponseData } from "../responses";
|
||||
import { getTodaysDateTimeFormatted } from "../time";
|
||||
import { getFormattedDateTimeString } from "../utils/datetime";
|
||||
import { sanitizeString } from "../utils/strings";
|
||||
|
||||
/**
|
||||
* Extracts choice IDs from response values for multiple choice questions
|
||||
* @param responseValue - The response value (string for single choice, array for multi choice)
|
||||
* @param question - The survey question containing choices
|
||||
* @param language - The language to match against (defaults to "default")
|
||||
* @returns Array of choice IDs
|
||||
*/
|
||||
export const extractChoiceIdsFromResponse = (
|
||||
responseValue: TResponseDataValue,
|
||||
question: TSurveyQuestion,
|
||||
language: string = "default"
|
||||
): string[] => {
|
||||
// Type guard to ensure the question has choices
|
||||
if (
|
||||
question.type !== "multipleChoiceMulti" &&
|
||||
question.type !== "multipleChoiceSingle" &&
|
||||
question.type !== "ranking" &&
|
||||
question.type !== "pictureSelection"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const isPictureSelection = question.type === "pictureSelection";
|
||||
|
||||
if (!responseValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// For picture selection questions, the response value is already choice ID(s)
|
||||
if (isPictureSelection) {
|
||||
if (Array.isArray(responseValue)) {
|
||||
// Multi-selection: array of choice IDs
|
||||
return responseValue.filter((id): id is string => typeof id === "string");
|
||||
} else if (typeof responseValue === "string") {
|
||||
// Single selection: single choice ID
|
||||
return [responseValue];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const defaultLanguage = language ?? "default";
|
||||
|
||||
// Helper function to find choice by label - eliminates duplication
|
||||
const findChoiceByLabel = (choiceLabel: string): string | null => {
|
||||
const targetChoice = question.choices.find((c) => {
|
||||
// Try exact language match first
|
||||
if (c.label[defaultLanguage] === choiceLabel) {
|
||||
return true;
|
||||
}
|
||||
// Fall back to checking all language values
|
||||
return Object.values(c.label).includes(choiceLabel);
|
||||
});
|
||||
return targetChoice?.id || "other";
|
||||
};
|
||||
|
||||
if (Array.isArray(responseValue)) {
|
||||
// Multiple choice case - response is an array of selected choice labels
|
||||
return responseValue.map(findChoiceByLabel).filter((choiceId): choiceId is string => choiceId !== null);
|
||||
} else if (typeof responseValue === "string") {
|
||||
// Single choice case - response is a single choice label
|
||||
const choiceId = findChoiceByLabel(responseValue);
|
||||
return choiceId ? [choiceId] : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
export const calculateTtcTotal = (ttc: TResponseTtc) => {
|
||||
const result = { ...ttc };
|
||||
result._total = Object.values(result).reduce((acc: number, val: number) => acc + val, 0);
|
||||
@@ -490,10 +556,17 @@ export const extractSurveyDetails = (survey: TSurvey, responses: TResponse[]) =>
|
||||
return question.rows.map((row) => {
|
||||
return `${idx + 1}. ${headline} - ${getLocalizedValue(row, "default")}`;
|
||||
});
|
||||
} else if (
|
||||
question.type === "multipleChoiceMulti" ||
|
||||
question.type === "multipleChoiceSingle" ||
|
||||
question.type === "ranking"
|
||||
) {
|
||||
return [`${idx + 1}. ${headline}`, `${idx + 1}. ${headline} - Option ID`];
|
||||
} else {
|
||||
return [`${idx + 1}. ${headline}`];
|
||||
}
|
||||
});
|
||||
|
||||
const hiddenFields = survey.hiddenFields?.fieldIds || [];
|
||||
const userAttributes =
|
||||
survey.type === "app"
|
||||
@@ -556,6 +629,19 @@ export const getResponsesJson = (
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (
|
||||
question.type === "multipleChoiceMulti" ||
|
||||
question.type === "multipleChoiceSingle" ||
|
||||
question.type === "ranking"
|
||||
) {
|
||||
// Set the main response value
|
||||
jsonData[idx][questionHeadline[0]] = processResponseData(answer);
|
||||
|
||||
// Set the option IDs using the reusable function
|
||||
if (questionHeadline[1]) {
|
||||
const choiceIds = extractChoiceIdsFromResponse(answer, question, response.language || "default");
|
||||
jsonData[idx][questionHeadline[1]] = choiceIds.join(", ");
|
||||
}
|
||||
} else {
|
||||
jsonData[idx][questionHeadline[0]] = processResponseData(answer);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { deepDiff, redactPII } from "./utils";
|
||||
import { deepDiff, redactPII, sanitizeUrlForLogging } from "./logger-helpers";
|
||||
|
||||
// Patch redis multi before any imports
|
||||
beforeEach(async () => {
|
||||
@@ -104,7 +104,7 @@ describe("withAuditLogging", () => {
|
||||
});
|
||||
test("logs audit event for successful handler", async () => {
|
||||
const handler = vi.fn().mockResolvedValue("ok");
|
||||
const { withAuditLogging } = await import("./handler");
|
||||
const { withAuditLogging } = await import("../../modules/ee/audit-logs/lib/handler");
|
||||
const wrapped = withAuditLogging("created", "survey", handler);
|
||||
const ctx = {
|
||||
user: {
|
||||
@@ -143,7 +143,7 @@ describe("withAuditLogging", () => {
|
||||
});
|
||||
test("logs audit event for failed handler and throws", async () => {
|
||||
const handler = vi.fn().mockRejectedValue(new Error("fail"));
|
||||
const { withAuditLogging } = await import("./handler");
|
||||
const { withAuditLogging } = await import("../../modules/ee/audit-logs/lib/handler");
|
||||
const wrapped = withAuditLogging("created", "survey", handler);
|
||||
const ctx = {
|
||||
user: {
|
||||
@@ -181,3 +181,37 @@ describe("withAuditLogging", () => {
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeUrlForLogging", () => {
|
||||
test("returns sanitized URL with token", () => {
|
||||
expect(sanitizeUrlForLogging("https://example.com?token=1234567890")).toBe(
|
||||
"https://example.com/?token=********"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns sanitized URL with code", () => {
|
||||
expect(sanitizeUrlForLogging("https://example.com?code=1234567890")).toBe(
|
||||
"https://example.com/?code=********"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns sanitized URL with state", () => {
|
||||
expect(sanitizeUrlForLogging("https://example.com?state=1234567890")).toBe(
|
||||
"https://example.com/?state=********"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns sanitized URL with multiple keys", () => {
|
||||
expect(
|
||||
sanitizeUrlForLogging("https://example.com?token=1234567890&code=1234567890&state=1234567890")
|
||||
).toBe("https://example.com/?token=********&code=********&state=********");
|
||||
});
|
||||
|
||||
test("returns sanitized URL without query params", () => {
|
||||
expect(sanitizeUrlForLogging("https://example.com")).toBe("https://example.com/");
|
||||
});
|
||||
|
||||
test("returns sanitized URL with invalid URL", () => {
|
||||
expect(sanitizeUrlForLogging("not-a-valid-url")).toBe("[invalid-url]");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isStringUrl } from "@/lib/utils/url";
|
||||
|
||||
const SENSITIVE_KEYS = [
|
||||
"email",
|
||||
"name",
|
||||
@@ -33,8 +35,11 @@ const SENSITIVE_KEYS = [
|
||||
"image",
|
||||
"stripeCustomerId",
|
||||
"fileName",
|
||||
"state",
|
||||
];
|
||||
|
||||
const URL_SENSITIVE_KEYS = ["token", "code", "state"];
|
||||
|
||||
/**
|
||||
* Redacts sensitive data from the object by replacing the sensitive keys with "********".
|
||||
* @param obj - The object to redact.
|
||||
@@ -45,6 +50,10 @@ export const redactPII = (obj: any, seen: WeakSet<any> = new WeakSet()): any =>
|
||||
return obj.toISOString();
|
||||
}
|
||||
|
||||
if (typeof obj === "string" && isStringUrl(obj)) {
|
||||
return sanitizeUrlForLogging(obj);
|
||||
}
|
||||
|
||||
if (obj && typeof obj === "object") {
|
||||
if (seen.has(obj)) return "[Circular]";
|
||||
seen.add(obj);
|
||||
@@ -89,3 +98,24 @@ export const deepDiff = (oldObj: any, newObj: any): any => {
|
||||
}
|
||||
return Object.keys(diff).length > 0 ? diff : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitizes a URL for logging by redacting sensitive parameters.
|
||||
* @param url - The URL to sanitize.
|
||||
* @returns The sanitized URL.
|
||||
*/
|
||||
export const sanitizeUrlForLogging = (url: string): string => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
URL_SENSITIVE_KEYS.forEach((key) => {
|
||||
if (urlObj.searchParams.has(key)) {
|
||||
urlObj.searchParams.set(key, "********");
|
||||
}
|
||||
});
|
||||
|
||||
return urlObj.origin + urlObj.pathname + (urlObj.search ? `${urlObj.search}` : "");
|
||||
} catch {
|
||||
return "[invalid-url]";
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { TActionClassPageUrlRule } from "@formbricks/types/action-classes";
|
||||
import { isValidCallbackUrl, testURLmatch } from "./url";
|
||||
import { isStringUrl, isValidCallbackUrl, testURLmatch } from "./url";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -91,3 +91,13 @@ describe("isValidCallbackUrl", () => {
|
||||
expect(isValidCallbackUrl("not-a-valid-url", WEBAPP_URL)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isStringUrl", () => {
|
||||
test("returns true for valid URL", () => {
|
||||
expect(isStringUrl("https://example.com")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for invalid URL", () => {
|
||||
expect(isStringUrl("not-a-valid-url")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,3 +49,12 @@ export const isValidCallbackUrl = (url: string, WEBAPP_URL: string): boolean =>
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const isStringUrl = (url: string): boolean => {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"add_action": "Aktion hinzufügen",
|
||||
"add_filter": "Filter hinzufügen",
|
||||
"add_logo": "Logo hinzufügen",
|
||||
"add_member": "Mitglied hinzufügen",
|
||||
"add_project": "Projekt hinzufügen",
|
||||
"add_to_team": "Zum Team hinzufügen",
|
||||
"all": "Alle",
|
||||
@@ -279,6 +280,8 @@
|
||||
"on": "An",
|
||||
"only_one_file_allowed": "Es ist nur eine Datei erlaubt",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.",
|
||||
"option_id": "Option-ID",
|
||||
"option_ids": "Option-IDs",
|
||||
"or": "oder",
|
||||
"organization": "Organisation",
|
||||
"organization_id": "Organisations-ID",
|
||||
@@ -305,6 +308,7 @@
|
||||
"privacy": "Datenschutz",
|
||||
"product_manager": "Produktmanager",
|
||||
"profile": "Profil",
|
||||
"profile_id": "Profil-ID",
|
||||
"project_configuration": "Projektkonfiguration",
|
||||
"project_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
|
||||
"project_id": "Projekt-ID",
|
||||
@@ -385,6 +389,7 @@
|
||||
"targeting": "Targeting",
|
||||
"team": "Team",
|
||||
"team_access": "Teamzugriff",
|
||||
"team_id": "Team-ID",
|
||||
"team_name": "Teamname",
|
||||
"teams": "Zugriffskontrolle",
|
||||
"teams_not_found": "Teams nicht gefunden",
|
||||
@@ -1311,7 +1316,7 @@
|
||||
"columns": "Spalten",
|
||||
"company": "Firma",
|
||||
"company_logo": "Firmenlogo",
|
||||
"completed_responses": "abgeschlossene Antworten",
|
||||
"completed_responses": "unvollständige oder vollständige Antworten.",
|
||||
"concat": "Verketten +",
|
||||
"conditional_logic": "Bedingte Logik",
|
||||
"confirm_default_language": "Standardsprache bestätigen",
|
||||
@@ -1813,7 +1818,6 @@
|
||||
"last_quarter": "Letztes Quartal",
|
||||
"last_year": "Letztes Jahr",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
"only_completed": "Nur vollständige Antworten",
|
||||
"other_values_found": "Andere Werte gefunden",
|
||||
"overall": "Insgesamt",
|
||||
"qr_code": "QR-Code",
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"add_action": "Add action",
|
||||
"add_filter": "Add filter",
|
||||
"add_logo": "Add logo",
|
||||
"add_member": "Add member",
|
||||
"add_project": "Add project",
|
||||
"add_to_team": "Add to team",
|
||||
"all": "All",
|
||||
@@ -279,6 +280,8 @@
|
||||
"on": "On",
|
||||
"only_one_file_allowed": "Only one file is allowed",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
|
||||
"option_id": "Option ID",
|
||||
"option_ids": "Option IDs",
|
||||
"or": "or",
|
||||
"organization": "Organization",
|
||||
"organization_id": "Organization ID",
|
||||
@@ -305,6 +308,7 @@
|
||||
"privacy": "Privacy Policy",
|
||||
"product_manager": "Product Manager",
|
||||
"profile": "Profile",
|
||||
"profile_id": "Profile ID",
|
||||
"project_configuration": "Project's Configuration",
|
||||
"project_creation_description": "Organize surveys in projects for better access control.",
|
||||
"project_id": "Project ID",
|
||||
@@ -385,6 +389,7 @@
|
||||
"targeting": "Targeting",
|
||||
"team": "Team",
|
||||
"team_access": "Team Access",
|
||||
"team_id": "Team ID",
|
||||
"team_name": "Team name",
|
||||
"teams": "Access Control",
|
||||
"teams_not_found": "Teams not found",
|
||||
@@ -1311,7 +1316,7 @@
|
||||
"columns": "Columns",
|
||||
"company": "Company",
|
||||
"company_logo": "Company logo",
|
||||
"completed_responses": "completed responses.",
|
||||
"completed_responses": "partial or completed responses.",
|
||||
"concat": "Concat +",
|
||||
"conditional_logic": "Conditional Logic",
|
||||
"confirm_default_language": "Confirm default language",
|
||||
@@ -1813,7 +1818,6 @@
|
||||
"last_quarter": "Last quarter",
|
||||
"last_year": "Last year",
|
||||
"no_responses_found": "No responses found",
|
||||
"only_completed": "Only completed",
|
||||
"other_values_found": "Other values found",
|
||||
"overall": "Overall",
|
||||
"qr_code": "QR code",
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"add_action": "Ajouter une action",
|
||||
"add_filter": "Ajouter un filtre",
|
||||
"add_logo": "Ajouter un logo",
|
||||
"add_member": "Ajouter un membre",
|
||||
"add_project": "Ajouter un projet",
|
||||
"add_to_team": "Ajouter à l'équipe",
|
||||
"all": "Tout",
|
||||
@@ -279,6 +280,8 @@
|
||||
"on": "Sur",
|
||||
"only_one_file_allowed": "Un seul fichier est autorisé",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.",
|
||||
"option_id": "Identifiant de l'option",
|
||||
"option_ids": "Identifiants des options",
|
||||
"or": "ou",
|
||||
"organization": "Organisation",
|
||||
"organization_id": "ID de l'organisation",
|
||||
@@ -305,6 +308,7 @@
|
||||
"privacy": "Politique de confidentialité",
|
||||
"product_manager": "Chef de produit",
|
||||
"profile": "Profil",
|
||||
"profile_id": "Identifiant de profil",
|
||||
"project_configuration": "Configuration du projet",
|
||||
"project_creation_description": "Organisez les enquêtes en projets pour un meilleur contrôle d'accès.",
|
||||
"project_id": "ID de projet",
|
||||
@@ -385,6 +389,7 @@
|
||||
"targeting": "Ciblage",
|
||||
"team": "Équipe",
|
||||
"team_access": "Accès Équipe",
|
||||
"team_id": "Équipe ID",
|
||||
"team_name": "Nom de l'équipe",
|
||||
"teams": "Contrôle d'accès",
|
||||
"teams_not_found": "Équipes non trouvées",
|
||||
@@ -1202,7 +1207,7 @@
|
||||
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?",
|
||||
"edit": {
|
||||
"1_choose_the_default_language_for_this_survey": "1. Choisissez la langue par défaut pour ce sondage :",
|
||||
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques :",
|
||||
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques:",
|
||||
"add": "Ajouter +",
|
||||
"add_a_delay_or_auto_close_the_survey": "Ajouter un délai ou fermer automatiquement l'enquête",
|
||||
"add_a_four_digit_pin": "Ajoutez un code PIN à quatre chiffres.",
|
||||
@@ -1311,7 +1316,7 @@
|
||||
"columns": "Colonnes",
|
||||
"company": "Société",
|
||||
"company_logo": "Logo de l'entreprise",
|
||||
"completed_responses": "réponses complètes.",
|
||||
"completed_responses": "des réponses partielles ou complètes.",
|
||||
"concat": "Concat +",
|
||||
"conditional_logic": "Logique conditionnelle",
|
||||
"confirm_default_language": "Confirmer la langue par défaut",
|
||||
@@ -1813,7 +1818,6 @@
|
||||
"last_quarter": "dernier trimestre",
|
||||
"last_year": "l'année dernière",
|
||||
"no_responses_found": "Aucune réponse trouvée",
|
||||
"only_completed": "Uniquement terminé",
|
||||
"other_values_found": "D'autres valeurs trouvées",
|
||||
"overall": "Globalement",
|
||||
"qr_code": "Code QR",
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"add_action": "Adicionar ação",
|
||||
"add_filter": "Adicionar filtro",
|
||||
"add_logo": "Adicionar logo",
|
||||
"add_member": "Adicionar membro",
|
||||
"add_project": "Adicionar projeto",
|
||||
"add_to_team": "Adicionar à equipe",
|
||||
"all": "Todos",
|
||||
@@ -279,6 +280,8 @@
|
||||
"on": "ligado",
|
||||
"only_one_file_allowed": "É permitido apenas um arquivo",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.",
|
||||
"option_id": "ID da opção",
|
||||
"option_ids": "IDs da Opção",
|
||||
"or": "ou",
|
||||
"organization": "organização",
|
||||
"organization_id": "ID da Organização",
|
||||
@@ -305,6 +308,7 @@
|
||||
"privacy": "Política de Privacidade",
|
||||
"product_manager": "Gerente de Produto",
|
||||
"profile": "Perfil",
|
||||
"profile_id": "ID de Perfil",
|
||||
"project_configuration": "Configuração do Projeto",
|
||||
"project_creation_description": "Organize pesquisas em projetos para melhor controle de acesso.",
|
||||
"project_id": "ID do Projeto",
|
||||
@@ -385,6 +389,7 @@
|
||||
"targeting": "mirando",
|
||||
"team": "Time",
|
||||
"team_access": "Acesso da equipe",
|
||||
"team_id": "ID da Equipe",
|
||||
"team_name": "Nome da equipe",
|
||||
"teams": "Controle de Acesso",
|
||||
"teams_not_found": "Equipes não encontradas",
|
||||
@@ -1311,7 +1316,7 @@
|
||||
"columns": "colunas",
|
||||
"company": "empresa",
|
||||
"company_logo": "Logo da empresa",
|
||||
"completed_responses": "respostas completas",
|
||||
"completed_responses": "respostas parciais ou completas.",
|
||||
"concat": "Concatenar +",
|
||||
"conditional_logic": "Lógica Condicional",
|
||||
"confirm_default_language": "Confirmar idioma padrão",
|
||||
@@ -1813,7 +1818,6 @@
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Último ano",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"only_completed": "Somente concluído",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "No geral",
|
||||
"qr_code": "Código QR",
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"add_action": "Adicionar ação",
|
||||
"add_filter": "Adicionar filtro",
|
||||
"add_logo": "Adicionar logótipo",
|
||||
"add_member": "Adicionar membro",
|
||||
"add_project": "Adicionar projeto",
|
||||
"add_to_team": "Adicionar à equipa",
|
||||
"all": "Todos",
|
||||
@@ -279,6 +280,8 @@
|
||||
"on": "Ligado",
|
||||
"only_one_file_allowed": "Apenas um ficheiro é permitido",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.",
|
||||
"option_id": "ID de Opção",
|
||||
"option_ids": "IDs de Opção",
|
||||
"or": "ou",
|
||||
"organization": "Organização",
|
||||
"organization_id": "ID da Organização",
|
||||
@@ -305,6 +308,7 @@
|
||||
"privacy": "Política de Privacidade",
|
||||
"product_manager": "Gestor de Produto",
|
||||
"profile": "Perfil",
|
||||
"profile_id": "ID do Perfil",
|
||||
"project_configuration": "Configuração do Projeto",
|
||||
"project_creation_description": "Organize questionários em projetos para um melhor controlo de acesso.",
|
||||
"project_id": "ID do Projeto",
|
||||
@@ -385,6 +389,7 @@
|
||||
"targeting": "Segmentação",
|
||||
"team": "Equipa",
|
||||
"team_access": "Acesso da Equipa",
|
||||
"team_id": "ID da Equipa",
|
||||
"team_name": "Nome da equipa",
|
||||
"teams": "Controlo de Acesso",
|
||||
"teams_not_found": "Equipas não encontradas",
|
||||
@@ -1311,7 +1316,7 @@
|
||||
"columns": "Colunas",
|
||||
"company": "Empresa",
|
||||
"company_logo": "Logotipo da empresa",
|
||||
"completed_responses": "respostas concluídas",
|
||||
"completed_responses": "respostas parciais ou completas",
|
||||
"concat": "Concatenar +",
|
||||
"conditional_logic": "Lógica Condicional",
|
||||
"confirm_default_language": "Confirmar idioma padrão",
|
||||
@@ -1813,7 +1818,6 @@
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Ano passado",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"only_completed": "Apenas concluído",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "Geral",
|
||||
"qr_code": "Código QR",
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"add_action": "新增操作",
|
||||
"add_filter": "新增篩選器",
|
||||
"add_logo": "新增標誌",
|
||||
"add_member": "新增成員",
|
||||
"add_project": "新增專案",
|
||||
"add_to_team": "新增至團隊",
|
||||
"all": "全部",
|
||||
@@ -279,6 +280,8 @@
|
||||
"on": "開啟",
|
||||
"only_one_file_allowed": "僅允許一個檔案",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。",
|
||||
"option_id": "選項 ID",
|
||||
"option_ids": "選項 IDs",
|
||||
"or": "或",
|
||||
"organization": "組織",
|
||||
"organization_id": "組織 ID",
|
||||
@@ -305,6 +308,7 @@
|
||||
"privacy": "隱私權政策",
|
||||
"product_manager": "產品經理",
|
||||
"profile": "個人資料",
|
||||
"profile_id": "個人資料 ID",
|
||||
"project_configuration": "專案組態",
|
||||
"project_creation_description": "組織調查 在 專案中以便更好地存取控制。",
|
||||
"project_id": "專案 ID",
|
||||
@@ -385,6 +389,7 @@
|
||||
"targeting": "目標設定",
|
||||
"team": "團隊",
|
||||
"team_access": "團隊存取權限",
|
||||
"team_id": "團隊 ID",
|
||||
"team_name": "團隊名稱",
|
||||
"teams": "存取控制",
|
||||
"teams_not_found": "找不到團隊",
|
||||
@@ -1311,7 +1316,7 @@
|
||||
"columns": "欄位",
|
||||
"company": "公司",
|
||||
"company_logo": "公司標誌",
|
||||
"completed_responses": "完成的回應。",
|
||||
"completed_responses": "部分或完整答复。",
|
||||
"concat": "串連 +",
|
||||
"conditional_logic": "條件邏輯",
|
||||
"confirm_default_language": "確認預設語言",
|
||||
@@ -1813,7 +1818,6 @@
|
||||
"last_quarter": "上一季",
|
||||
"last_year": "去年",
|
||||
"no_responses_found": "找不到回應",
|
||||
"only_completed": "僅已完成",
|
||||
"other_values_found": "找到其他值",
|
||||
"overall": "整體",
|
||||
"qr_code": "QR 碼",
|
||||
|
||||
@@ -324,6 +324,7 @@ export const authOptions: NextAuthOptions = {
|
||||
if (account?.provider === "credentials" || account?.provider === "token") {
|
||||
// check if user's email is verified or not
|
||||
if (!user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
|
||||
logger.error("Email Verification is Pending");
|
||||
throw new Error("Email Verification is Pending");
|
||||
}
|
||||
await updateUserLastLoginAt(user.email);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AUDIT_LOG_ENABLED, AUDIT_LOG_GET_USER_IP } from "@/lib/constants";
|
||||
import { ActionClientCtx, AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { deepDiff, redactPII } from "@/lib/utils/logger-helpers";
|
||||
import { logAuditEvent } from "@/modules/ee/audit-logs/lib/service";
|
||||
import {
|
||||
TActor,
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
} from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { getIsAuditLogsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { deepDiff, redactPII } from "./utils";
|
||||
|
||||
/**
|
||||
* Builds an audit event and logs it.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getResponsesByContactId } from "@/lib/response/service";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
|
||||
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
|
||||
@@ -42,7 +43,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
|
||||
<dt className="text-sm font-medium text-slate-500">{t("common.user_id")}</dt>
|
||||
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
|
||||
{attributes.userId ? (
|
||||
<span>{attributes.userId}</span>
|
||||
<IdBadge id={attributes.userId} />
|
||||
) : (
|
||||
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { TContactTableData } from "../types/contact";
|
||||
|
||||
@@ -26,7 +27,7 @@ export const generateContactTableColumns = (
|
||||
header: "User ID",
|
||||
cell: ({ row }) => {
|
||||
const userId = row.original.userId;
|
||||
return <HighlightedText value={userId} searchValue={searchValue} />;
|
||||
return <IdBadge id={userId} showCopyIconOnHover={true} />;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { convertDateTimeStringShort } from "@/lib/time";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
@@ -52,10 +53,7 @@ export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-normal text-slate-500">
|
||||
{t("environments.segments.segment_id")}
|
||||
</Label>
|
||||
<p className="text-xs text-slate-700">{currentSegment.id.toString()}</p>
|
||||
<IdBadge id={currentSegment.id} label={t("environments.segments.segment_id")} variant="column" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getIsFreshInstance } from "@/lib/instance/service";
|
||||
import { verifyInviteToken } from "@/lib/jwt";
|
||||
import { createMembership } from "@/lib/membership/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { redactPII } from "@/lib/utils/logger-helpers";
|
||||
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
import { createUser, getUserByEmail, updateUser } from "@/modules/auth/lib/user";
|
||||
import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite";
|
||||
@@ -31,12 +32,36 @@ export const handleSsoCallback = async ({
|
||||
account: Account;
|
||||
callbackUrl: string;
|
||||
}) => {
|
||||
const contextLogger = logger.withContext({
|
||||
correlationId: crypto.randomUUID(),
|
||||
name: "formbricks",
|
||||
});
|
||||
|
||||
contextLogger.debug(
|
||||
{
|
||||
...redactPII({ user, account, callbackUrl }),
|
||||
hasEmail: !!user.email,
|
||||
hasName: !!user.name,
|
||||
},
|
||||
"SSO callback initiated"
|
||||
);
|
||||
|
||||
const isSsoEnabled = await getIsSsoEnabled();
|
||||
if (!isSsoEnabled) {
|
||||
contextLogger.debug({ isSsoEnabled }, "SSO not enabled");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!user.email || account.type !== "oauth") {
|
||||
contextLogger.debug(
|
||||
{
|
||||
hasEmail: !!user.email,
|
||||
accountType: account.type,
|
||||
reason: !user.email ? "missing_email" : "invalid_account_type",
|
||||
},
|
||||
"SSO callback rejected: missing email or invalid account type"
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -45,12 +70,18 @@ export const handleSsoCallback = async ({
|
||||
if (provider === "saml") {
|
||||
const isSamlSsoEnabled = await getIsSamlSsoEnabled();
|
||||
if (!isSamlSsoEnabled) {
|
||||
contextLogger.debug({ provider: "saml" }, "SSO callback rejected: SAML not enabled in license");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (account.provider) {
|
||||
// check if accounts for this provider / account Id already exists
|
||||
contextLogger.debug(
|
||||
{ lookupType: "sso_provider_account" },
|
||||
"Checking for existing user with SSO provider"
|
||||
);
|
||||
|
||||
const existingUserWithAccount = await prisma.user.findFirst({
|
||||
include: {
|
||||
accounts: {
|
||||
@@ -66,12 +97,29 @@ export const handleSsoCallback = async ({
|
||||
});
|
||||
|
||||
if (existingUserWithAccount) {
|
||||
contextLogger.debug(
|
||||
{
|
||||
existingUserId: existingUserWithAccount.id,
|
||||
emailMatches: existingUserWithAccount.email === user.email,
|
||||
},
|
||||
"Found existing user with SSO provider"
|
||||
);
|
||||
|
||||
// User with this provider found
|
||||
// check if email still the same
|
||||
if (existingUserWithAccount.email === user.email) {
|
||||
contextLogger.debug(
|
||||
{ existingUserId: existingUserWithAccount.id },
|
||||
"SSO callback successful: existing user, email matches"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
contextLogger.debug(
|
||||
{ existingUserId: existingUserWithAccount.id },
|
||||
"Email changed in SSO provider, checking for conflicts"
|
||||
);
|
||||
|
||||
// user seemed to change his email within the provider
|
||||
// check if user with this email already exist
|
||||
// if not found just update user with new email address
|
||||
@@ -79,9 +127,20 @@ export const handleSsoCallback = async ({
|
||||
const otherUserWithEmail = await getUserByEmail(user.email);
|
||||
|
||||
if (!otherUserWithEmail) {
|
||||
contextLogger.debug(
|
||||
{ existingUserId: existingUserWithAccount.id, action: "email_update" },
|
||||
"No other user with this email found, updating user email after SSO provider change"
|
||||
);
|
||||
|
||||
await updateUser(existingUserWithAccount.id, { email: user.email });
|
||||
return true;
|
||||
}
|
||||
|
||||
contextLogger.debug(
|
||||
{ existingUserId: existingUserWithAccount.id, conflictingUserId: otherUserWithEmail.id },
|
||||
"SSO callback failed: email conflict after provider change"
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
"Looks like you updated your email somewhere else. A user with this new email exists already."
|
||||
);
|
||||
@@ -90,13 +149,24 @@ export const handleSsoCallback = async ({
|
||||
// There is no existing account for this identity provider / account id
|
||||
// check if user account with this email already exists
|
||||
// if user already exists throw error and request password login
|
||||
contextLogger.debug({ lookupType: "email" }, "No existing SSO account found, checking for user by email");
|
||||
|
||||
const existingUserWithEmail = await getUserByEmail(user.email);
|
||||
|
||||
if (existingUserWithEmail) {
|
||||
contextLogger.debug(
|
||||
{ existingUserId: existingUserWithEmail.id, action: "existing_user_login" },
|
||||
"SSO callback successful: existing user found by email"
|
||||
);
|
||||
// Sign in the user with the existing account
|
||||
return true;
|
||||
}
|
||||
|
||||
contextLogger.debug(
|
||||
{ action: "new_user_creation" },
|
||||
"No existing user found, proceeding with new user creation"
|
||||
);
|
||||
|
||||
let userName = user.name;
|
||||
|
||||
if (provider === "openid") {
|
||||
@@ -108,6 +178,16 @@ export const handleSsoCallback = async ({
|
||||
} else if (oidcUser.preferred_username) {
|
||||
userName = oidcUser.preferred_username;
|
||||
}
|
||||
|
||||
contextLogger.debug(
|
||||
{
|
||||
hasName: !!oidcUser.name,
|
||||
hasGivenName: !!oidcUser.given_name,
|
||||
hasFamilyName: !!oidcUser.family_name,
|
||||
hasPreferredUsername: !!oidcUser.preferred_username,
|
||||
},
|
||||
"Extracted OIDC user name"
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === "saml") {
|
||||
@@ -117,6 +197,14 @@ export const handleSsoCallback = async ({
|
||||
} else if (samlUser.firstName || samlUser.lastName) {
|
||||
userName = `${samlUser.firstName} ${samlUser.lastName}`;
|
||||
}
|
||||
contextLogger.debug(
|
||||
{
|
||||
hasName: !!samlUser.name,
|
||||
hasFirstName: !!samlUser.firstName,
|
||||
hasLastName: !!samlUser.lastName,
|
||||
},
|
||||
"Extracted SAML user name"
|
||||
);
|
||||
}
|
||||
|
||||
// Get multi-org license status
|
||||
@@ -124,9 +212,23 @@ export const handleSsoCallback = async ({
|
||||
|
||||
const isFirstUser = await getIsFreshInstance();
|
||||
|
||||
contextLogger.debug(
|
||||
{
|
||||
isMultiOrgEnabled,
|
||||
isFirstUser,
|
||||
skipInviteForSso: SKIP_INVITE_FOR_SSO,
|
||||
hasDefaultTeamId: !!DEFAULT_TEAM_ID,
|
||||
},
|
||||
"License and instance configuration checked"
|
||||
);
|
||||
|
||||
// Additional security checks for self-hosted instances without auto-provisioning and no multi-org enabled
|
||||
if (!isFirstUser && !SKIP_INVITE_FOR_SSO && !isMultiOrgEnabled) {
|
||||
if (!callbackUrl) {
|
||||
contextLogger.debug(
|
||||
{ reason: "missing_callback_url" },
|
||||
"SSO callback rejected: missing callback URL for invite validation"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -139,6 +241,10 @@ export const handleSsoCallback = async ({
|
||||
|
||||
// Allow sign-in if multi-org is enabled, otherwise check for invite token
|
||||
if (source === "signin" && !inviteToken) {
|
||||
contextLogger.debug(
|
||||
{ reason: "signin_without_invite_token" },
|
||||
"SSO callback rejected: signin without invite token"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -146,16 +252,32 @@ export const handleSsoCallback = async ({
|
||||
// Verify invite token and check email match
|
||||
const { email, inviteId } = verifyInviteToken(inviteToken);
|
||||
if (email !== user.email) {
|
||||
contextLogger.debug(
|
||||
{ reason: "invite_email_mismatch", inviteId },
|
||||
"SSO callback rejected: invite token email mismatch"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Check if invite token is still valid
|
||||
const isValidInviteToken = await getIsValidInviteToken(inviteId);
|
||||
if (!isValidInviteToken) {
|
||||
contextLogger.debug(
|
||||
{ reason: "invalid_invite_token", inviteId },
|
||||
"SSO callback rejected: invalid or expired invite token"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
contextLogger.debug({ inviteId }, "Invite token validation successful");
|
||||
} catch (err) {
|
||||
contextLogger.debug(
|
||||
{
|
||||
reason: "invite_token_validation_error",
|
||||
error: err instanceof Error ? err.message : "unknown_error",
|
||||
},
|
||||
"SSO callback rejected: invite token validation failed"
|
||||
);
|
||||
// Log and reject on any validation errors
|
||||
logger.error(err, "Invalid callbackUrl");
|
||||
contextLogger.error(err, "Invalid callbackUrl");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -163,6 +285,12 @@ export const handleSsoCallback = async ({
|
||||
let organization: Organization | null = null;
|
||||
|
||||
if (!isFirstUser && !isMultiOrgEnabled) {
|
||||
contextLogger.debug(
|
||||
{
|
||||
assignmentStrategy: SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID ? "default_team" : "first_organization",
|
||||
},
|
||||
"Determining organization assignment"
|
||||
);
|
||||
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
|
||||
organization = await getOrganizationByTeamId(DEFAULT_TEAM_ID);
|
||||
} else {
|
||||
@@ -170,13 +298,29 @@ export const handleSsoCallback = async ({
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
contextLogger.debug(
|
||||
{ reason: "no_organization_found" },
|
||||
"SSO callback rejected: no organization found for assignment"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
|
||||
if (!canDoRoleManagement && !callbackUrl) return false;
|
||||
if (!canDoRoleManagement && !callbackUrl) {
|
||||
contextLogger.debug(
|
||||
{
|
||||
reason: "insufficient_role_permissions",
|
||||
organizationId: organization.id,
|
||||
canDoRoleManagement,
|
||||
},
|
||||
"SSO callback rejected: insufficient role management permissions"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
contextLogger.debug({ hasUserName: !!userName, identityProvider: provider }, "Creating new SSO user");
|
||||
|
||||
const userProfile = await createUser({
|
||||
name:
|
||||
userName ||
|
||||
@@ -191,13 +335,28 @@ export const handleSsoCallback = async ({
|
||||
locale: await findMatchingLocale(),
|
||||
});
|
||||
|
||||
contextLogger.debug(
|
||||
{ newUserId: userProfile.id, identityProvider: provider },
|
||||
"New SSO user created successfully"
|
||||
);
|
||||
|
||||
// send new user to brevo
|
||||
createBrevoCustomer({ id: userProfile.id, email: userProfile.email });
|
||||
|
||||
if (isMultiOrgEnabled) return true;
|
||||
if (isMultiOrgEnabled) {
|
||||
contextLogger.debug(
|
||||
{ isMultiOrgEnabled, newUserId: userProfile.id },
|
||||
"Multi-org enabled, skipping organization assignment"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Default organization assignment if env variable is set
|
||||
if (organization) {
|
||||
contextLogger.debug(
|
||||
{ newUserId: userProfile.id, organizationId: organization.id, role: "member" },
|
||||
"Assigning user to organization"
|
||||
);
|
||||
await createMembership(organization.id, userProfile.id, { role: "member", accepted: true });
|
||||
await createAccount({
|
||||
...account,
|
||||
@@ -205,6 +364,10 @@ export const handleSsoCallback = async ({
|
||||
});
|
||||
|
||||
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
|
||||
contextLogger.debug(
|
||||
{ newUserId: userProfile.id, defaultTeamId: DEFAULT_TEAM_ID },
|
||||
"Creating default team membership"
|
||||
);
|
||||
await createDefaultTeamMembership(userProfile.id);
|
||||
}
|
||||
|
||||
@@ -226,6 +389,7 @@ export const handleSsoCallback = async ({
|
||||
// Without default organization assignment
|
||||
return true;
|
||||
}
|
||||
contextLogger.debug("SSO callback successful: default return");
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -85,6 +85,13 @@ vi.mock("@formbricks/lib/jwt", () => ({
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
withContext: (context: Record<string, any>) => {
|
||||
return {
|
||||
...context,
|
||||
debug: vi.fn(),
|
||||
};
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
|
||||
import { TeamPermissionMapping } from "@/modules/ee/teams/utils/teams";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
|
||||
@@ -21,6 +22,7 @@ export const AccessTable = ({ teams }: AccessTableProps) => {
|
||||
{t("environments.project.teams.team_name")}
|
||||
</TableHead>
|
||||
<TableHead className="font-medium text-slate-500">{t("common.size")}</TableHead>
|
||||
<TableHead className="font-medium text-slate-500">{t("common.team_id")}</TableHead>
|
||||
<TableHead className="font-medium text-slate-500">
|
||||
{t("environments.project.teams.permission")}
|
||||
</TableHead>
|
||||
@@ -40,6 +42,9 @@ export const AccessTable = ({ teams }: AccessTableProps) => {
|
||||
<TableCell>
|
||||
{team.memberCount} {team.memberCount === 1 ? t("common.member") : t("common.members")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IdBadge id={team.id} showCopyIconOnHover={true} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="capitalize">{TeamPermissionMapping[team.permission]}</p>
|
||||
</TableCell>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -235,6 +236,8 @@ export const TeamSettingsModal = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<IdBadge id={team.id} label={t("common.team_id")} variant="column" />
|
||||
|
||||
{/* Members Section */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col space-y-1">
|
||||
|
||||
@@ -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")}>
|
||||
<EnvironmentIdField environmentId={params.environmentId} />
|
||||
<IdBadge id={params.environmentId} />
|
||||
</SettingsCard>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { EnvironmentIdField } from "./environment-id-field";
|
||||
|
||||
vi.mock("@/modules/ui/components/code-block", () => ({
|
||||
CodeBlock: ({ children, language }: any) => (
|
||||
<pre data-testid="code-block" data-language={language}>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("EnvironmentIdField", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders the environment id in a code block", () => {
|
||||
const envId = "env-123";
|
||||
render(<EnvironmentIdField environmentId={envId} />);
|
||||
const codeBlock = screen.getByTestId("code-block");
|
||||
expect(codeBlock).toBeInTheDocument();
|
||||
expect(codeBlock).toHaveAttribute("data-language", "js");
|
||||
expect(codeBlock).toHaveTextContent(envId);
|
||||
});
|
||||
|
||||
test("applies the correct wrapper class", () => {
|
||||
render(<EnvironmentIdField environmentId="env-abc" />);
|
||||
const wrapper = codeBlockParent();
|
||||
expect(wrapper).toHaveClass("prose");
|
||||
expect(wrapper).toHaveClass("prose-slate");
|
||||
expect(wrapper).toHaveClass("-mt-3");
|
||||
});
|
||||
});
|
||||
|
||||
function codeBlockParent() {
|
||||
return screen.getByTestId("code-block").parentElement as HTMLElement;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CodeBlock } from "@/modules/ui/components/code-block";
|
||||
|
||||
export const EnvironmentIdField = ({ environmentId }: { environmentId: string }) => {
|
||||
return (
|
||||
<div className="prose prose-slate -mt-3">
|
||||
<CodeBlock language="js">{environmentId}</CodeBlock>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -19,10 +19,11 @@ vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/settings-id", () => ({
|
||||
SettingsId: ({ title, id }: any) => (
|
||||
<div data-testid="settings-id">
|
||||
<p>{title}</p>:<p>{id}</p>
|
||||
vi.mock("@/modules/ui/components/id-badge", () => ({
|
||||
IdBadge: ({ id, label, variant }: any) => (
|
||||
<div data-testid="id-badge" data-variant={variant}>
|
||||
<span data-testid="id-badge-label">{label}</span>
|
||||
<span data-testid="id-badge-id">{id}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -104,7 +105,7 @@ describe("GeneralSettingsPage", () => {
|
||||
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-header")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("project-config-navigation")).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("settings-id").length).toBe(2);
|
||||
expect(screen.getAllByTestId("id-badge").length).toBe(2);
|
||||
expect(screen.getByTestId("edit-project-name-form")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("edit-waiting-time-form")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("delete-project")).toBeInTheDocument();
|
||||
|
||||
@@ -3,9 +3,9 @@ import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getProjects } from "@/lib/project/service";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SettingsId } from "@/modules/ui/components/settings-id";
|
||||
import packageJson from "@/package.json";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { DeleteProject } from "./components/delete-project";
|
||||
@@ -49,10 +49,10 @@ export const GeneralSettingsPage = async (props: { params: Promise<{ environment
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<div>
|
||||
<SettingsId title={t("common.project_id")} id={project.id}></SettingsId>
|
||||
<div className="space-y-2">
|
||||
<IdBadge id={project.id} label={t("common.project_id")} variant="column" />
|
||||
{!IS_FORMBRICKS_CLOUD && !IS_DEVELOPMENT && (
|
||||
<SettingsId title={t("common.formbricks_version")} id={packageJson.version}></SettingsId>
|
||||
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
|
||||
)}
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -35,7 +35,7 @@ export const ClientLogo = ({ environmentId, projectLogo, previewSurvey = false }
|
||||
src={projectLogo?.url}
|
||||
className={cn(
|
||||
previewSurvey ? "max-h-12" : "max-h-16 md:max-h-20",
|
||||
"w-auto max-w-40 rounded-lg object-contain p-1 md:max-w-56"
|
||||
"w-auto max-w-40 object-contain p-1 md:max-w-56"
|
||||
)}
|
||||
width={256}
|
||||
height={64}
|
||||
|
||||
@@ -22,6 +22,16 @@ export const DataTableSettingsModalItem = <T,>({ column, survey }: DataTableSett
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: column.id,
|
||||
});
|
||||
const isOptionIdColumn = column.id.endsWith("optionIds");
|
||||
|
||||
const getOptionIdColumnLabel = () => {
|
||||
const questionId = column.id.split("optionIds")[0];
|
||||
const question = survey?.questions.find((q) => q.id === questionId);
|
||||
if (question) {
|
||||
return `${getLocalizedValue(question.headline, "default")} - ${t("common.option_id")}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getLabelFromColumnId = () => {
|
||||
switch (column.id) {
|
||||
@@ -77,7 +87,9 @@ export const DataTableSettingsModalItem = <T,>({ column, survey }: DataTableSett
|
||||
<span className="max-w-xs truncate">{getLocalizedValue(question.headline, "default")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="max-w-xs truncate">{getLabelFromColumnId()}</span>
|
||||
<span className="max-w-xs truncate">
|
||||
{isOptionIdColumn ? getOptionIdColumnLabel() : getLabelFromColumnId()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
|
||||
@@ -24,6 +24,16 @@ vi.mock("@tolgee/react", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons used in IdBadge
|
||||
vi.mock("lucide-react", async () => {
|
||||
const actual = await vi.importActual<typeof import("lucide-react")>("lucide-react");
|
||||
return {
|
||||
...actual,
|
||||
Copy: () => "Copy Icon",
|
||||
Check: () => "Check Icon",
|
||||
};
|
||||
});
|
||||
|
||||
describe("IdBadge", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { SettingsId } from "./index";
|
||||
|
||||
describe("SettingsId", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders the title and id correctly", () => {
|
||||
render(<SettingsId title="Survey ID" id="survey-123" />);
|
||||
|
||||
const element = screen.getByText(/Survey ID: survey-123/);
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element.tagName.toLowerCase()).toBe("p");
|
||||
});
|
||||
|
||||
test("applies correct styling", () => {
|
||||
render(<SettingsId title="Environment ID" id="env-456" />);
|
||||
|
||||
const element = screen.getByText(/Environment ID: env-456/);
|
||||
expect(element).toHaveClass("py-1");
|
||||
expect(element).toHaveClass("text-xs");
|
||||
expect(element).toHaveClass("text-slate-400");
|
||||
});
|
||||
|
||||
test("renders with very long id", () => {
|
||||
const longId = "a".repeat(100);
|
||||
render(<SettingsId title="API Key" id={longId} />);
|
||||
|
||||
const element = screen.getByText(`API Key: ${longId}`);
|
||||
expect(element).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
interface SettingsIdProps {
|
||||
title: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const SettingsId = ({ title, id }: SettingsIdProps) => {
|
||||
return (
|
||||
<p className="py-1 text-xs text-slate-400">
|
||||
{title}: {id}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
@@ -132,7 +132,7 @@
|
||||
"ua-parser-js": "2.0.3",
|
||||
"uuid": "11.1.0",
|
||||
"webpack": "5.99.8",
|
||||
"xlsx": "0.18.5",
|
||||
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
|
||||
"zod": "3.24.4",
|
||||
"zod-openapi": "4.2.4"
|
||||
},
|
||||
|
||||
BIN
apps/web/vendor/xlsx-0.20.3.tgz
vendored
Normal file
BIN
apps/web/vendor/xlsx-0.20.3.tgz
vendored
Normal file
Binary file not shown.
@@ -49,6 +49,7 @@
|
||||
"xm-and-surveys/surveys/general-features/variables",
|
||||
"xm-and-surveys/surveys/general-features/hide-back-button",
|
||||
"xm-and-surveys/surveys/general-features/email-followups",
|
||||
"xm-and-surveys/surveys/general-features/quota-management",
|
||||
"xm-and-surveys/surveys/general-features/spam-protection"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
title: "Quota Management"
|
||||
description: "Control response collection by setting limits on specific segments to ensure balanced and representative survey datasets."
|
||||
icon: "chart-pie"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Quota Management allows you to set limits on the number of responses collected for specific segments or criteria in your survey. This feature helps ensure you collect a balanced and representative dataset while preventing oversaturation of certain response types.
|
||||
|
||||
<Note type="warning">
|
||||
Quota Management is currently in beta and only available to select customers.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
Quota Management is part of the [Enterprise Edition](/self-hosting/advanced/license).
|
||||
</Note>
|
||||
|
||||
### Key benefits
|
||||
|
||||
- **Balanced Data Collection**: Ensure your survey responses are evenly distributed across different segments
|
||||
- **Cost Control**: Prevent collecting more responses than needed from specific groups
|
||||
- **Quality Assurance**: Maintain data quality by avoiding homogeneous response patterns
|
||||
- **Automated Management**: Automatically stop collecting responses when quotas are met
|
||||
|
||||
### How Quota Management works
|
||||
|
||||
When you set up quotas for your survey, Formbricks automatically tracks responses against your defined limits. Once a quota is reached, the system can:
|
||||
|
||||
- Prevent new responses from that segment
|
||||
- Skip respondents to the end of the survey
|
||||
- Redirect respondents to a custom end screen
|
||||
|
||||
## Setting up Quotas
|
||||
In the first step, you need to define the criteria for the quota:
|
||||
|
||||
<Steps>
|
||||
<Step title="Name the quota">
|
||||
Create a Quota and label it e.g. "Mobile Phone Users in Europe"
|
||||
</Step>
|
||||
<Step title="Set quota limit">
|
||||
Set numerical limits for each hidden field value combination e.g. 500
|
||||
</Step>
|
||||
<Step title="Define inclusion criteria">
|
||||
Choose a distinct set of answers to survey questions, variable values or hidden fields. Responses who match this set will be included in the quota.
|
||||
</Step>
|
||||
<Step title="Configure actions">
|
||||
Choose what happens when this Quota is met (e.g. skip to specific end screen)
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Quota actions
|
||||
Configure what happens when a quota reaches its limit:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Skip to End">
|
||||
Jump respondents directly to the survey completion page
|
||||
</Tab>
|
||||
<Tab title="Custom Redirect (soon)">
|
||||
Redirect respondents to a custom thank you page or alternative survey
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
|
||||
## Counting against Quotas
|
||||
|
||||
### 1. Count by Hidden Field value
|
||||
|
||||
Determine if a response falls in or out of a Quota based on hidden field values passed through URL parameters:
|
||||
|
||||
```
|
||||
https://your-survey-url.com/s/abc123?product=credit-card®ion=europe
|
||||
```
|
||||
|
||||
### 2. Quota by survey responses
|
||||
|
||||
Create quotas based on specific answers to survey questions:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Single Question Quota">
|
||||
Set quotas for individual answer options:
|
||||
- Question: "What is your gender?"
|
||||
- Quota: 500 responses for "Male", 500 responses for "Female"
|
||||
</Tab>
|
||||
<Tab title="Multi-Question Quota">
|
||||
Combine multiple question responses:
|
||||
- Criteria: Age group "25-34" AND Location "Urban"
|
||||
- Quota: 200 responses matching both criteria
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### 3. Multi-criteria quotas
|
||||
|
||||
Create complex quotas using multiple conditions:
|
||||
|
||||
<CodeGroup>
|
||||
```example "Hidden Field + Response Combination"
|
||||
Hidden Field: product = "mobile"
|
||||
AND
|
||||
Question Response: satisfaction = "very satisfied"
|
||||
```
|
||||
|
||||
```example "Multiple Response Criteria"
|
||||
Question 1: age_group = "18-25"
|
||||
AND
|
||||
Question 2: location = "urban"
|
||||
AND
|
||||
Question 3: income = "high"
|
||||
Quota Limit: 50 responses
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Partial vs. complete responses
|
||||
|
||||
<Info>
|
||||
By default, Quota Management includes partial responses in quota counts. You can change this behavior by configuring the quota to only count complete responses.
|
||||
</Info>
|
||||
|
||||
This means if a respondent starts but doesn't complete the survey, they may still count toward your quota if they've answered the qualifying questions.
|
||||
|
||||
## Quota monitoring
|
||||
|
||||
<Card title="Live Quota Status" icon="chart-line">
|
||||
Monitor your quotas in real-time through the dashboard in the survey summary:
|
||||
|
||||
- **Current Count**: See how many responses each quota has collected
|
||||
- **Progress Bars**: Visual representation of quota completion
|
||||
- **Status Indicators**: Active, completed, or paused quota status
|
||||
</Card>
|
||||
@@ -137,8 +137,7 @@ module "eks" {
|
||||
cluster_version = "1.32"
|
||||
|
||||
enable_cluster_creator_admin_permissions = false
|
||||
cluster_endpoint_public_access = true # TEMPORARY: Enable for deployments until Tailscale is fixed by new DevOps engineer
|
||||
cluster_endpoint_public_access_cidrs = ["0.0.0.0/0"] # TEMPORARY: Will revert to private + Tailscale access
|
||||
cluster_endpoint_public_access = false
|
||||
cloudwatch_log_group_retention_in_days = 365
|
||||
|
||||
cluster_addons = {
|
||||
|
||||
10
packages/ai/.eslintrc.cjs
Normal file
10
packages/ai/.eslintrc.cjs
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
extends: ["@formbricks/eslint-config/library.js"],
|
||||
parserOptions: {
|
||||
project: "tsconfig.json",
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
rules: {
|
||||
"no-console": "off",
|
||||
},
|
||||
};
|
||||
198
packages/ai/README.md
Normal file
198
packages/ai/README.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# @formbricks/ai
|
||||
|
||||
A model-agnostic AI package for Formbricks, providing a unified interface for LLM operations across different providers.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-Provider Support**: OpenAI and Anthropic models with easy switching
|
||||
- **Type-Safe**: Full TypeScript support with schema validation
|
||||
- **Environment-Based Configuration**: Automatic provider selection via environment variables
|
||||
- **Structured Output**: Generate validated JSON objects from prompts using schemas
|
||||
- **Helper Functions**: Built-in summarization and translation utilities
|
||||
|
||||
## Installation
|
||||
|
||||
This package is part of the Formbricks monorepo and is intended for internal use.
|
||||
|
||||
```bash
|
||||
pnpm install @formbricks/ai
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
Set up your environment variables:
|
||||
|
||||
```bash
|
||||
# Provider selection (defaults to "openai")
|
||||
AI_PROVIDER=openai # or "anthropic"
|
||||
|
||||
# Model selection (uses sensible defaults if not specified)
|
||||
AI_MODEL=gpt-4 # or "claude-3-sonnet-20240229"
|
||||
|
||||
# API Keys
|
||||
OPENAI_API_KEY=your_openai_key
|
||||
ANTHROPIC_API_KEY=your_anthropic_key
|
||||
|
||||
# Optional: Custom base URL
|
||||
AI_BASE_URL=https://your-custom-endpoint.com
|
||||
```
|
||||
|
||||
### Basic Usage
|
||||
|
||||
#### Text Generation
|
||||
|
||||
```typescript
|
||||
import { generateText } from "@formbricks/ai";
|
||||
|
||||
const result = await generateText({
|
||||
prompt: "Explain quantum computing in simple terms",
|
||||
system: "You are a helpful science teacher",
|
||||
temperature: 0.7,
|
||||
maxTokens: 200,
|
||||
});
|
||||
|
||||
console.log(result.text);
|
||||
```
|
||||
|
||||
#### Structured Object Generation
|
||||
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
import { generateObject } from "@formbricks/ai";
|
||||
|
||||
const analysisSchema = z.object({
|
||||
sentiment: z.enum(["positive", "negative", "neutral"]),
|
||||
summary: z.string(),
|
||||
keyTopics: z.array(z.string()),
|
||||
confidence: z.number().min(0).max(1),
|
||||
});
|
||||
|
||||
const result = await generateObject({
|
||||
prompt: "Analyze this customer feedback: 'The product is amazing but delivery was slow'",
|
||||
schema: analysisSchema,
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
console.log(result.object.sentiment); // Type-safe access
|
||||
console.log(result.object.keyTopics);
|
||||
```
|
||||
|
||||
#### Helper Functions
|
||||
|
||||
```typescript
|
||||
import { summarizeText, translateText } from "@formbricks/ai";
|
||||
|
||||
// Summarization
|
||||
const summary = await summarizeText(longText, 150);
|
||||
|
||||
// Translation
|
||||
const translated = await translateText("Hello, how are you?", "Spanish", "English");
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Programmatic Configuration
|
||||
|
||||
You can override environment configuration programmatically:
|
||||
|
||||
```typescript
|
||||
import { createAIModel, generateText } from "@formbricks/ai";
|
||||
|
||||
const customConfig = {
|
||||
provider: "anthropic" as const,
|
||||
model: "claude-3-haiku-20240307",
|
||||
apiKey: "your-api-key",
|
||||
};
|
||||
|
||||
// Use custom config for specific calls
|
||||
const result = await generateText(
|
||||
{
|
||||
prompt: "Hello world",
|
||||
},
|
||||
customConfig
|
||||
);
|
||||
|
||||
// Or create a reusable model instance
|
||||
const aiModel = createAIModel(customConfig);
|
||||
```
|
||||
|
||||
### Supported Models
|
||||
|
||||
#### OpenAI
|
||||
|
||||
- `gpt-4` (default)
|
||||
- `gpt-4-turbo`
|
||||
- `gpt-3.5-turbo`
|
||||
|
||||
#### Anthropic
|
||||
|
||||
- `claude-3-sonnet-20240229` (default)
|
||||
- `claude-3-haiku-20240307`
|
||||
- `claude-3-opus-20240229`
|
||||
|
||||
## Error Handling
|
||||
|
||||
The package provides clear error messages for common issues:
|
||||
|
||||
```typescript
|
||||
import { generateText, isAIConfigured } from "@formbricks/ai";
|
||||
|
||||
// Check if AI is properly configured
|
||||
if (!isAIConfigured()) {
|
||||
throw new Error("AI is not properly configured. Please check your environment variables.");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateText({
|
||||
prompt: "Your prompt here",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("AI generation failed:", error.message);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage in Formbricks
|
||||
|
||||
This package is designed to be used across the Formbricks ecosystem:
|
||||
|
||||
- **NextJS API Routes**: For server-side AI operations
|
||||
- **Background Jobs**: For processing surveys and responses
|
||||
- **Future NestJS Backend**: Modular design allows easy integration
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Development Mode
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The package follows a layered architecture:
|
||||
|
||||
1. **Types Layer** (`types.ts`): TypeScript definitions and interfaces
|
||||
2. **Configuration Layer** (`config.ts`): Provider setup and validation
|
||||
3. **Abstraction Layer** (`ai.ts`): Main API functions
|
||||
4. **Export Layer** (`index.ts`): Public API exports
|
||||
|
||||
This design ensures:
|
||||
|
||||
- Easy testing and mocking
|
||||
- Provider-agnostic implementation
|
||||
- Type safety throughout
|
||||
- Consistent error handling
|
||||
39
packages/ai/package.json
Normal file
39
packages/ai/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@formbricks/ai",
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"main": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules dist",
|
||||
"build": "vite build",
|
||||
"dev": "vite build --watch",
|
||||
"lint": "eslint ./src --fix",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^1.0.6",
|
||||
"@ai-sdk/openai": "^1.0.20",
|
||||
"ai": "^5.0.2",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "6.3.5",
|
||||
"vite-plugin-dts": "4.5.3"
|
||||
}
|
||||
}
|
||||
196
packages/ai/src/ai.ts
Normal file
196
packages/ai/src/ai.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { generateObject as aiGenerateObject, generateText as aiGenerateText } from "ai";
|
||||
import type { z } from "zod";
|
||||
import { createAIModel } from "./config";
|
||||
import type {
|
||||
GenerateObjectOptions,
|
||||
GenerateObjectResult,
|
||||
GenerateTextOptions,
|
||||
GenerateTextResult,
|
||||
ProviderConfig,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Singleton AI model instance for reuse across calls
|
||||
*/
|
||||
let aiModelInstance: ReturnType<typeof createAIModel> | null = null;
|
||||
|
||||
/**
|
||||
* Get or create the AI model instance
|
||||
*/
|
||||
function getAIModel(customConfig?: ProviderConfig) {
|
||||
if (!aiModelInstance || customConfig) {
|
||||
aiModelInstance = createAIModel(customConfig);
|
||||
}
|
||||
return aiModelInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate text using the configured AI model
|
||||
*
|
||||
* @param options - Text generation options
|
||||
* @returns Promise resolving to generated text and usage information
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await generateText({
|
||||
* prompt: "Summarize the following text: Lorem ipsum...",
|
||||
* system: "You are a helpful assistant that provides concise summaries.",
|
||||
* temperature: 0.7,
|
||||
* maxTokens: 150
|
||||
* });
|
||||
*
|
||||
* console.log(result.text);
|
||||
* ```
|
||||
*/
|
||||
export async function generateText(
|
||||
options: GenerateTextOptions,
|
||||
customConfig?: ProviderConfig
|
||||
): Promise<GenerateTextResult> {
|
||||
const { model } = getAIModel(customConfig);
|
||||
|
||||
try {
|
||||
const result = await aiGenerateText({
|
||||
model,
|
||||
prompt: options.prompt,
|
||||
system: options.system,
|
||||
temperature: options.temperature,
|
||||
...(options.maxTokens && { maxTokens: options.maxTokens }),
|
||||
});
|
||||
|
||||
return {
|
||||
text: result.text,
|
||||
usage: result.usage
|
||||
? {
|
||||
inputTokens: result.usage.inputTokens,
|
||||
outputTokens: result.usage.outputTokens,
|
||||
totalTokens: result.usage.totalTokens,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to generate text: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a structured object using the configured AI model
|
||||
*
|
||||
* @param options - Object generation options including Zod schema
|
||||
* @returns Promise resolving to generated object and usage information
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { z } from "zod";
|
||||
*
|
||||
* const summarySchema = z.object({
|
||||
* title: z.string(),
|
||||
* summary: z.string(),
|
||||
* keyPoints: z.array(z.string()),
|
||||
* sentiment: z.enum(['positive', 'negative', 'neutral'])
|
||||
* });
|
||||
*
|
||||
* const result = await generateObject({
|
||||
* prompt: "Analyze the following article: Lorem ipsum...",
|
||||
* schema: summarySchema,
|
||||
* system: "You are an expert content analyzer.",
|
||||
* temperature: 0.3
|
||||
* });
|
||||
*
|
||||
* console.log(result.object.title);
|
||||
* console.log(result.object.keyPoints);
|
||||
* ```
|
||||
*/
|
||||
export async function generateObject<T extends z.ZodSchema>(
|
||||
options: GenerateObjectOptions<T>,
|
||||
customConfig?: ProviderConfig
|
||||
): Promise<GenerateObjectResult<z.infer<T>>> {
|
||||
const { model } = getAIModel(customConfig);
|
||||
|
||||
try {
|
||||
const result = await aiGenerateObject({
|
||||
model,
|
||||
prompt: options.prompt,
|
||||
schema: options.schema,
|
||||
system: options.system,
|
||||
temperature: options.temperature,
|
||||
...(options.maxTokens && { maxTokens: options.maxTokens }),
|
||||
});
|
||||
|
||||
return {
|
||||
object: result.object,
|
||||
usage: result.usage
|
||||
? {
|
||||
inputTokens: result.usage.inputTokens,
|
||||
outputTokens: result.usage.outputTokens,
|
||||
totalTokens: result.usage.totalTokens,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to generate object: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function for text summarization
|
||||
*
|
||||
* @param text - Text to summarize
|
||||
* @param maxLength - Optional maximum length for the summary
|
||||
* @returns Promise resolving to the summary
|
||||
*/
|
||||
export async function summarizeText(
|
||||
text: string,
|
||||
maxLength?: number,
|
||||
customConfig?: ProviderConfig
|
||||
): Promise<string> {
|
||||
const prompt = `Summarize the following text${maxLength ? ` in approximately ${maxLength} characters` : ""}:\n\n${text}`;
|
||||
|
||||
const result = await generateText(
|
||||
{
|
||||
prompt,
|
||||
system:
|
||||
"You are a helpful assistant that creates clear, concise summaries. Focus on the key points and main ideas.",
|
||||
temperature: 0.3,
|
||||
maxTokens: maxLength ? Math.ceil(maxLength / 3) : undefined, // Rough token estimate
|
||||
},
|
||||
customConfig
|
||||
);
|
||||
|
||||
return result.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function for text translation
|
||||
*
|
||||
* @param text - Text to translate
|
||||
* @param targetLanguage - Target language for translation
|
||||
* @param sourceLanguage - Optional source language (auto-detected if not provided)
|
||||
* @returns Promise resolving to the translated text
|
||||
*/
|
||||
export async function translateText(
|
||||
text: string,
|
||||
targetLanguage: string,
|
||||
sourceLanguage?: string,
|
||||
customConfig?: ProviderConfig
|
||||
): Promise<string> {
|
||||
const sourceText = sourceLanguage ? `from ${sourceLanguage} ` : "";
|
||||
const prompt = `Translate the following text ${sourceText}to ${targetLanguage}:\n\n${text}`;
|
||||
|
||||
const result = await generateText(
|
||||
{
|
||||
prompt,
|
||||
system: `You are a professional translator. Provide only the translated text without any additional commentary or explanations. Maintain the original tone and style.`,
|
||||
temperature: 0.1, // Low temperature for consistency
|
||||
},
|
||||
customConfig
|
||||
);
|
||||
|
||||
return result.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the AI model instance (useful for testing or when configuration changes)
|
||||
*/
|
||||
export function resetAIModel(): void {
|
||||
aiModelInstance = null;
|
||||
}
|
||||
168
packages/ai/src/config.ts
Normal file
168
packages/ai/src/config.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import type { LanguageModelV1 } from "ai";
|
||||
import type {
|
||||
AIEnvironmentConfig,
|
||||
AIModelInstance,
|
||||
AIProvider,
|
||||
AnthropicConfig,
|
||||
OpenAIConfig,
|
||||
ProviderConfig,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Default models for each provider
|
||||
*/
|
||||
const DEFAULT_MODELS: Record<AIProvider, string> = {
|
||||
openai: "gpt-4",
|
||||
anthropic: "claude-3-sonnet-20240229",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get environment configuration from process.env
|
||||
*/
|
||||
function getEnvironmentConfig(): AIEnvironmentConfig {
|
||||
return {
|
||||
AI_PROVIDER: process.env.AI_PROVIDER,
|
||||
AI_MODEL: process.env.AI_MODEL,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
||||
AI_BASE_URL: process.env.AI_BASE_URL,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create provider configuration from environment variables
|
||||
*/
|
||||
function createProviderConfigFromEnv(): ProviderConfig {
|
||||
const env = getEnvironmentConfig();
|
||||
|
||||
// Determine provider (default to openai if not specified)
|
||||
const provider = (env.AI_PROVIDER as AIProvider) || "openai";
|
||||
|
||||
// Get model for the provider
|
||||
const model = env.AI_MODEL || DEFAULT_MODELS[provider];
|
||||
|
||||
// Create configuration based on provider
|
||||
switch (provider) {
|
||||
case "openai": {
|
||||
const config: OpenAIConfig = {
|
||||
provider: "openai",
|
||||
model,
|
||||
apiKey: env.OPENAI_API_KEY,
|
||||
baseURL: env.AI_BASE_URL,
|
||||
};
|
||||
return config;
|
||||
}
|
||||
case "anthropic": {
|
||||
const config: AnthropicConfig = {
|
||||
provider: "anthropic",
|
||||
model,
|
||||
apiKey: env.ANTHROPIC_API_KEY,
|
||||
baseURL: env.AI_BASE_URL,
|
||||
};
|
||||
return config;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported AI provider: ${provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a language model instance from provider configuration
|
||||
*/
|
||||
function createModelFromConfig(config: ProviderConfig): LanguageModelV1 {
|
||||
switch (config.provider) {
|
||||
case "openai": {
|
||||
const options: any = {};
|
||||
|
||||
if (config.apiKey) {
|
||||
options.apiKey = config.apiKey;
|
||||
}
|
||||
|
||||
if (config.baseURL) {
|
||||
options.baseURL = config.baseURL;
|
||||
}
|
||||
|
||||
return openai(config.model, options);
|
||||
}
|
||||
case "anthropic": {
|
||||
const options: any = {};
|
||||
|
||||
if (config.apiKey) {
|
||||
options.apiKey = config.apiKey;
|
||||
}
|
||||
|
||||
if (config.baseURL) {
|
||||
options.baseURL = config.baseURL;
|
||||
}
|
||||
|
||||
return anthropic(config.model, options);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${(config as ProviderConfig).provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that required API keys are present for the configured provider
|
||||
*/
|
||||
function validateConfiguration(config: ProviderConfig): void {
|
||||
switch (config.provider) {
|
||||
case "openai":
|
||||
if (!config.apiKey && !process.env.OPENAI_API_KEY) {
|
||||
throw new Error(
|
||||
"OpenAI API key is required. Set OPENAI_API_KEY environment variable or provide apiKey in configuration."
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "anthropic":
|
||||
if (!config.apiKey && !process.env.ANTHROPIC_API_KEY) {
|
||||
throw new Error(
|
||||
"Anthropic API key is required. Set ANTHROPIC_API_KEY environment variable or provide apiKey in configuration."
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${(config as ProviderConfig).provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and configure the AI model instance
|
||||
*/
|
||||
export function createAIModel(customConfig?: ProviderConfig): AIModelInstance {
|
||||
// Use custom config or create from environment
|
||||
const config = customConfig || createProviderConfigFromEnv();
|
||||
|
||||
// Validate the configuration
|
||||
validateConfiguration(config);
|
||||
|
||||
// Create the model instance
|
||||
const model = createModelFromConfig(config);
|
||||
|
||||
return {
|
||||
model,
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current provider configuration without creating a model
|
||||
*/
|
||||
export function getProviderConfig(): ProviderConfig {
|
||||
return createProviderConfigFromEnv();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if AI is properly configured
|
||||
*/
|
||||
export function isAIConfigured(): boolean {
|
||||
try {
|
||||
const config = createProviderConfigFromEnv();
|
||||
validateConfiguration(config);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
20
packages/ai/src/index.ts
Normal file
20
packages/ai/src/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Main AI functions
|
||||
export { generateText, generateObject, summarizeText, translateText, resetAIModel } from "./ai";
|
||||
|
||||
// Configuration functions
|
||||
export { createAIModel, getProviderConfig, isAIConfigured } from "./config";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
AIProvider,
|
||||
AIProviderConfig,
|
||||
OpenAIConfig,
|
||||
AnthropicConfig,
|
||||
ProviderConfig,
|
||||
AIEnvironmentConfig,
|
||||
GenerateTextOptions,
|
||||
GenerateObjectOptions,
|
||||
GenerateTextResult,
|
||||
GenerateObjectResult,
|
||||
AIModelInstance,
|
||||
} from "./types";
|
||||
102
packages/ai/src/types.ts
Normal file
102
packages/ai/src/types.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { LanguageModelV1 } from "ai";
|
||||
import type { z } from "zod";
|
||||
|
||||
/**
|
||||
* Supported AI providers
|
||||
*/
|
||||
export type AIProvider = "openai" | "anthropic";
|
||||
|
||||
/**
|
||||
* Configuration for different AI providers
|
||||
*/
|
||||
export interface AIProviderConfig {
|
||||
provider: AIProvider;
|
||||
model: string;
|
||||
apiKey?: string;
|
||||
baseURL?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI specific configuration
|
||||
*/
|
||||
export interface OpenAIConfig extends AIProviderConfig {
|
||||
provider: "openai";
|
||||
model: string; // e.g., "gpt-4", "gpt-3.5-turbo"
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic specific configuration
|
||||
*/
|
||||
export interface AnthropicConfig extends AIProviderConfig {
|
||||
provider: "anthropic";
|
||||
model: string; // e.g., "claude-3-sonnet-20240229", "claude-3-haiku-20240307"
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all provider configurations
|
||||
*/
|
||||
export type ProviderConfig = OpenAIConfig | AnthropicConfig;
|
||||
|
||||
/**
|
||||
* Environment variables for AI configuration
|
||||
*/
|
||||
export interface AIEnvironmentConfig {
|
||||
AI_PROVIDER?: string;
|
||||
AI_MODEL?: string;
|
||||
OPENAI_API_KEY?: string;
|
||||
ANTHROPIC_API_KEY?: string;
|
||||
AI_BASE_URL?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for text generation
|
||||
*/
|
||||
export interface GenerateTextOptions {
|
||||
prompt: string;
|
||||
system?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for object generation
|
||||
*/
|
||||
export interface GenerateObjectOptions<T extends z.ZodSchema> {
|
||||
prompt: string;
|
||||
schema: T;
|
||||
system?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from text generation
|
||||
*/
|
||||
export interface GenerateTextResult {
|
||||
text: string;
|
||||
usage?: {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from object generation
|
||||
*/
|
||||
export interface GenerateObjectResult<T> {
|
||||
object: T;
|
||||
usage?: {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal type for the language model instance
|
||||
*/
|
||||
export interface AIModelInstance {
|
||||
model: LanguageModelV1;
|
||||
config: ProviderConfig;
|
||||
}
|
||||
12
packages/ai/tsconfig.json
Normal file
12
packages/ai/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"],
|
||||
"extends": "@formbricks/config-typescript/js-library.json",
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
49
packages/ai/vite.config.ts
Normal file
49
packages/ai/vite.config.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { resolve } from "path";
|
||||
import { UserConfig, defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig((): UserConfig => {
|
||||
return {
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, "src/index.ts"),
|
||||
},
|
||||
output: [
|
||||
{
|
||||
format: "esm",
|
||||
entryFileNames: "[name].js",
|
||||
chunkFileNames: "[name].js",
|
||||
},
|
||||
{
|
||||
format: "cjs",
|
||||
entryFileNames: "[name].cjs",
|
||||
chunkFileNames: "[name].cjs",
|
||||
},
|
||||
],
|
||||
external: [
|
||||
// External dependencies that should not be bundled
|
||||
"@ai-sdk/anthropic",
|
||||
"@ai-sdk/openai",
|
||||
"ai",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
emptyOutDir: true,
|
||||
ssr: true, // Server-side rendering mode for Node.js
|
||||
},
|
||||
plugins: [
|
||||
dts({
|
||||
rollupTypes: false,
|
||||
include: ["src/**/*"],
|
||||
exclude: ["src/**/*.test.ts", "src/**/*.spec.ts"],
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
});
|
||||
@@ -136,11 +136,7 @@ export function WelcomeCard({
|
||||
<ScrollableContainer>
|
||||
<div>
|
||||
{fileUrl ? (
|
||||
<img
|
||||
src={fileUrl}
|
||||
className="fb-mb-8 fb-max-h-96 fb-w-1/4 fb-rounded-lg fb-object-contain"
|
||||
alt="Company Logo"
|
||||
/>
|
||||
<img src={fileUrl} className="fb-mb-8 fb-max-h-96 fb-w-1/4 fb-object-contain" alt="Company Logo" />
|
||||
) : null}
|
||||
|
||||
<Headline
|
||||
|
||||
222
pnpm-lock.yaml
generated
222
pnpm-lock.yaml
generated
@@ -442,8 +442,8 @@ importers:
|
||||
specifier: 5.99.8
|
||||
version: 5.99.8(esbuild@0.25.4)
|
||||
xlsx:
|
||||
specifier: 0.18.5
|
||||
version: 0.18.5
|
||||
specifier: file:vendor/xlsx-0.20.3.tgz
|
||||
version: file:apps/web/vendor/xlsx-0.20.3.tgz
|
||||
zod:
|
||||
specifier: 3.24.4
|
||||
version: 3.24.4
|
||||
@@ -536,6 +536,37 @@ importers:
|
||||
specifier: 3.1.0
|
||||
version: 3.1.0(typescript@5.8.3)(vitest@3.1.3(@types/node@22.15.18)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.0))
|
||||
|
||||
packages/ai:
|
||||
dependencies:
|
||||
'@ai-sdk/anthropic':
|
||||
specifier: ^1.0.6
|
||||
version: 1.2.12(zod@3.24.4)
|
||||
'@ai-sdk/openai':
|
||||
specifier: ^1.0.20
|
||||
version: 1.3.23(zod@3.24.4)
|
||||
ai:
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.2(zod@3.24.4)
|
||||
zod:
|
||||
specifier: 3.24.4
|
||||
version: 3.24.4
|
||||
devDependencies:
|
||||
'@formbricks/config-typescript':
|
||||
specifier: workspace:*
|
||||
version: link:../config-typescript
|
||||
'@formbricks/eslint-config':
|
||||
specifier: workspace:*
|
||||
version: link:../config-eslint
|
||||
typescript:
|
||||
specifier: 5.8.3
|
||||
version: 5.8.3
|
||||
vite:
|
||||
specifier: 6.3.5
|
||||
version: 6.3.5(@types/node@22.15.18)(jiti@2.4.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.0)
|
||||
vite-plugin-dts:
|
||||
specifier: 4.5.3
|
||||
version: 4.5.3(@types/node@22.15.18)(rollup@4.46.1)(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.18)(jiti@2.4.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.0))
|
||||
|
||||
packages/config-eslint:
|
||||
devDependencies:
|
||||
'@next/eslint-plugin-next':
|
||||
@@ -824,6 +855,44 @@ packages:
|
||||
'@adobe/css-tools@4.4.3':
|
||||
resolution: {integrity: sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==}
|
||||
|
||||
'@ai-sdk/anthropic@1.2.12':
|
||||
resolution: {integrity: sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.0.0
|
||||
|
||||
'@ai-sdk/gateway@1.0.0':
|
||||
resolution: {integrity: sha512-VEm87DyRx1yIPywbTy8ntoyh4jEDv1rJ88m+2I7zOm08jJI5BhFtAWh0OF6YzZu1Vu4NxhOWO4ssGdsqydDQ3A==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4
|
||||
|
||||
'@ai-sdk/openai@1.3.23':
|
||||
resolution: {integrity: sha512-86U7rFp8yacUAOE/Jz8WbGcwMCqWvjK33wk5DXkfnAOEn3mx2r7tNSJdjukQFZbAK97VMXGPPHxF+aEARDXRXQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.0.0
|
||||
|
||||
'@ai-sdk/provider-utils@2.2.8':
|
||||
resolution: {integrity: sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.23.8
|
||||
|
||||
'@ai-sdk/provider-utils@3.0.0':
|
||||
resolution: {integrity: sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4
|
||||
|
||||
'@ai-sdk/provider@1.1.3':
|
||||
resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@ai-sdk/provider@2.0.0':
|
||||
resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@alloc/quick-lru@5.2.0':
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3734,6 +3803,9 @@ packages:
|
||||
'@sqltools/formatter@1.2.5':
|
||||
resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==}
|
||||
|
||||
'@standard-schema/spec@1.0.0':
|
||||
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
||||
|
||||
'@standard-schema/utils@0.3.0':
|
||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||
|
||||
@@ -4661,10 +4733,6 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
adler-32@1.3.1:
|
||||
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
agent-base@6.0.2:
|
||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
@@ -4681,6 +4749,12 @@ packages:
|
||||
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ai@5.0.2:
|
||||
resolution: {integrity: sha512-Uk4lmwlr2b/4G9DUYCWYKcWz93xQ6p6AEeRZN+/AO9NbOyCm9axrDru26c83Ax8OB8IHUvoseA3CqaZkg9Z0Kg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4
|
||||
|
||||
ajv-draft-04@1.0.0:
|
||||
resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==}
|
||||
peerDependencies:
|
||||
@@ -5043,10 +5117,6 @@ packages:
|
||||
caniuse-lite@1.0.30001731:
|
||||
resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==}
|
||||
|
||||
cfb@1.2.2:
|
||||
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
chai@5.2.1:
|
||||
resolution: {integrity: sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5148,10 +5218,6 @@ packages:
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
codepage@1.15.0:
|
||||
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
color-convert@1.9.3:
|
||||
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
|
||||
|
||||
@@ -5254,11 +5320,6 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
crc-32@1.2.2:
|
||||
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
create-require@1.1.1:
|
||||
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
||||
|
||||
@@ -5904,6 +5965,10 @@ packages:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
|
||||
eventsource-parser@3.0.3:
|
||||
resolution: {integrity: sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
expand-template@2.0.3:
|
||||
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -6054,10 +6119,6 @@ packages:
|
||||
forwarded-parse@2.1.2:
|
||||
resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==}
|
||||
|
||||
frac@1.1.2:
|
||||
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
fraction.js@4.3.7:
|
||||
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
|
||||
|
||||
@@ -6744,6 +6805,9 @@ packages:
|
||||
json-schema-traverse@1.0.0:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
|
||||
json-schema@0.4.0:
|
||||
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1:
|
||||
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
||||
|
||||
@@ -8502,10 +8566,6 @@ packages:
|
||||
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
ssf@0.11.2:
|
||||
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
ssri@8.0.1:
|
||||
resolution: {integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -9369,18 +9429,10 @@ packages:
|
||||
wide-align@1.1.5:
|
||||
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
|
||||
|
||||
wmf@1.0.2:
|
||||
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
word-wrap@1.2.5:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
word@0.3.0:
|
||||
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -9416,8 +9468,9 @@ packages:
|
||||
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
xlsx@0.18.5:
|
||||
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
||||
xlsx@file:apps/web/vendor/xlsx-0.20.3.tgz:
|
||||
resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: file:apps/web/vendor/xlsx-0.20.3.tgz}
|
||||
version: 0.20.3
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
@@ -9522,6 +9575,11 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.21.4
|
||||
|
||||
zod-to-json-schema@3.24.6:
|
||||
resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==}
|
||||
peerDependencies:
|
||||
zod: ^3.24.1
|
||||
|
||||
zod@3.24.4:
|
||||
resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==}
|
||||
|
||||
@@ -9529,6 +9587,47 @@ snapshots:
|
||||
|
||||
'@adobe/css-tools@4.4.3': {}
|
||||
|
||||
'@ai-sdk/anthropic@1.2.12(zod@3.24.4)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 1.1.3
|
||||
'@ai-sdk/provider-utils': 2.2.8(zod@3.24.4)
|
||||
zod: 3.24.4
|
||||
|
||||
'@ai-sdk/gateway@1.0.0(zod@3.24.4)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 2.0.0
|
||||
'@ai-sdk/provider-utils': 3.0.0(zod@3.24.4)
|
||||
zod: 3.24.4
|
||||
|
||||
'@ai-sdk/openai@1.3.23(zod@3.24.4)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 1.1.3
|
||||
'@ai-sdk/provider-utils': 2.2.8(zod@3.24.4)
|
||||
zod: 3.24.4
|
||||
|
||||
'@ai-sdk/provider-utils@2.2.8(zod@3.24.4)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 1.1.3
|
||||
nanoid: 3.3.11
|
||||
secure-json-parse: 2.7.0
|
||||
zod: 3.24.4
|
||||
|
||||
'@ai-sdk/provider-utils@3.0.0(zod@3.24.4)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 2.0.0
|
||||
'@standard-schema/spec': 1.0.0
|
||||
eventsource-parser: 3.0.3
|
||||
zod: 3.24.4
|
||||
zod-to-json-schema: 3.24.6(zod@3.24.4)
|
||||
|
||||
'@ai-sdk/provider@1.1.3':
|
||||
dependencies:
|
||||
json-schema: 0.4.0
|
||||
|
||||
'@ai-sdk/provider@2.0.0':
|
||||
dependencies:
|
||||
json-schema: 0.4.0
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
@@ -13538,6 +13637,8 @@ snapshots:
|
||||
|
||||
'@sqltools/formatter@1.2.5': {}
|
||||
|
||||
'@standard-schema/spec@1.0.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
||||
'@storybook/addon-a11y@9.0.15(storybook@9.0.15(@testing-library/dom@8.20.1)(prettier@3.5.3))':
|
||||
@@ -14637,8 +14738,6 @@ snapshots:
|
||||
|
||||
acorn@8.15.0: {}
|
||||
|
||||
adler-32@1.3.1: {}
|
||||
|
||||
agent-base@6.0.2:
|
||||
dependencies:
|
||||
debug: 4.4.1
|
||||
@@ -14658,6 +14757,14 @@ snapshots:
|
||||
indent-string: 4.0.0
|
||||
optional: true
|
||||
|
||||
ai@5.0.2(zod@3.24.4):
|
||||
dependencies:
|
||||
'@ai-sdk/gateway': 1.0.0(zod@3.24.4)
|
||||
'@ai-sdk/provider': 2.0.0
|
||||
'@ai-sdk/provider-utils': 3.0.0(zod@3.24.4)
|
||||
'@opentelemetry/api': 1.9.0
|
||||
zod: 3.24.4
|
||||
|
||||
ajv-draft-04@1.0.0(ajv@8.13.0):
|
||||
optionalDependencies:
|
||||
ajv: 8.13.0
|
||||
@@ -15049,11 +15156,6 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001731: {}
|
||||
|
||||
cfb@1.2.2:
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
crc-32: 1.2.2
|
||||
|
||||
chai@5.2.1:
|
||||
dependencies:
|
||||
assertion-error: 2.0.1
|
||||
@@ -15156,8 +15258,6 @@ snapshots:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
codepage@1.15.0: {}
|
||||
|
||||
color-convert@1.9.3:
|
||||
dependencies:
|
||||
color-name: 1.1.3
|
||||
@@ -15249,8 +15349,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.8.3
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
create-require@1.1.1: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
@@ -16086,6 +16184,8 @@ snapshots:
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
eventsource-parser@3.0.3: {}
|
||||
|
||||
expand-template@2.0.3: {}
|
||||
|
||||
expect-type@1.2.2: {}
|
||||
@@ -16222,8 +16322,6 @@ snapshots:
|
||||
|
||||
forwarded-parse@2.1.2: {}
|
||||
|
||||
frac@1.1.2: {}
|
||||
|
||||
fraction.js@4.3.7: {}
|
||||
|
||||
framer-motion@12.10.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
@@ -16971,6 +17069,8 @@ snapshots:
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
json-schema@0.4.0: {}
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||
|
||||
json5@1.0.2:
|
||||
@@ -18850,10 +18950,6 @@ snapshots:
|
||||
|
||||
sqlstring@2.3.3: {}
|
||||
|
||||
ssf@0.11.2:
|
||||
dependencies:
|
||||
frac: 1.1.2
|
||||
|
||||
ssri@8.0.1:
|
||||
dependencies:
|
||||
minipass: 3.3.6
|
||||
@@ -19799,12 +19895,8 @@ snapshots:
|
||||
string-width: 4.2.3
|
||||
optional: true
|
||||
|
||||
wmf@1.0.2: {}
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
word@0.3.0: {}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@@ -19837,15 +19929,7 @@ snapshots:
|
||||
dependencies:
|
||||
is-wsl: 3.1.0
|
||||
|
||||
xlsx@0.18.5:
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
cfb: 1.2.2
|
||||
codepage: 1.15.0
|
||||
crc-32: 1.2.2
|
||||
ssf: 0.11.2
|
||||
wmf: 1.0.2
|
||||
word: 0.3.0
|
||||
xlsx@file:apps/web/vendor/xlsx-0.20.3.tgz: {}
|
||||
|
||||
xml-crypto@6.1.2:
|
||||
dependencies:
|
||||
@@ -19940,4 +20024,8 @@ snapshots:
|
||||
dependencies:
|
||||
zod: 3.24.4
|
||||
|
||||
zod-to-json-schema@3.24.6(zod@3.24.4):
|
||||
dependencies:
|
||||
zod: 3.24.4
|
||||
|
||||
zod@3.24.4: {}
|
||||
|
||||
Reference in New Issue
Block a user