Compare commits

..

5 Commits

Author SHA1 Message Date
Dhruwang bb8c35440d removed an used file 2025-11-25 16:28:23 +05:30
Dhruwang 57a5b40717 clean ups 2025-11-25 16:25:48 +05:30
Dhruwang ef601a5437 survey-core -> ui 2025-11-25 16:00:02 +05:30
Dhruwang c65ee80066 surveys-embed -> surveys package 2025-11-25 15:17:03 +05:30
Dhruwang ed70c5fb73 introduced survey-embed and survey-core packages 2025-11-25 13:01:10 +05:30
161 changed files with 2661 additions and 2034 deletions
+7 -2
View File
@@ -1,8 +1,13 @@
--- ---
description: > description: >
globs: schema.prisma This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
alwaysApply: false and data patterns. It should be used **only when the agent explicitly requests database schema-level
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
globs: []
alwaysApply: agent-requested
--- ---
# Formbricks Database Schema Reference # Formbricks Database Schema Reference
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly. This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
@@ -0,0 +1,5 @@
---
description:
globs:
alwaysApply: false
---
@@ -0,0 +1,5 @@
---
description:
globs:
alwaysApply: false
---
@@ -281,9 +281,15 @@ runs:
tags: ${{ inputs.registry_type == 'ecr' && steps.ecr-tags.outputs.tags || (inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.tags) || (inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'false' && steps.ghcr-extra-tags.outputs.tags) || (inputs.registry_type == 'ghcr' && format('ghcr.io/{0}:{1}', inputs.ghcr_image_name, steps.version.outputs.version)) || (inputs.registry_type == 'ecr' && format('{0}/{1}:{2}', inputs.ecr_registry, inputs.ecr_repository, steps.version.outputs.version)) }} tags: ${{ inputs.registry_type == 'ecr' && steps.ecr-tags.outputs.tags || (inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.tags) || (inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'false' && steps.ghcr-extra-tags.outputs.tags) || (inputs.registry_type == 'ghcr' && format('ghcr.io/{0}:{1}', inputs.ghcr_image_name, steps.version.outputs.version)) || (inputs.registry_type == 'ecr' && format('{0}/{1}:{2}', inputs.ecr_registry, inputs.ecr_repository, steps.version.outputs.version)) }}
labels: ${{ inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.labels || '' }} labels: ${{ inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.labels || '' }}
secrets: | secrets: |
database_url=${{ env.DUMMY_DATABASE_URL }}
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
redis_url=${{ env.DUMMY_REDIS_URL }}
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }} sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
env: env:
DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }} DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
- name: Sign GHCR image (GHCR only) - name: Sign GHCR image (GHCR only)
+3
View File
@@ -88,4 +88,7 @@ jobs:
make_latest: ${{ inputs.MAKE_LATEST }} make_latest: ${{ inputs.MAKE_LATEST }}
env: env:
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }} DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
@@ -70,7 +70,9 @@ jobs:
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
secrets: | secrets: |
sentry_auth_token= database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
redis_url=redis://localhost:6379
- name: Verify and Initialize PostgreSQL - name: Verify and Initialize PostgreSQL
run: | run: |
@@ -127,6 +129,7 @@ jobs:
shell: bash shell: bash
env: env:
GITHUB_SHA: ${{ github.sha }} GITHUB_SHA: ${{ github.sha }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
run: | run: |
echo "🧪 Testing if the Docker image starts correctly..." echo "🧪 Testing if the Docker image starts correctly..."
@@ -138,7 +141,7 @@ jobs:
$DOCKER_RUN_ARGS \ $DOCKER_RUN_ARGS \
-p 3000:3000 \ -p 3000:3000 \
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \ -e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="test-key-00000000000000000000000000000000000000000000000000" \ -e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
-e REDIS_URL="redis://host.docker.internal:6379" \ -e REDIS_URL="redis://host.docker.internal:6379" \
-d "formbricks-test:$GITHUB_SHA" -d "formbricks-test:$GITHUB_SHA"
+1
View File
@@ -17,6 +17,7 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
TELEMETRY_DISABLED: 1
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }} TURBO_TEAM: ${{ vars.TURBO_TEAM }}
@@ -44,4 +44,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }} DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
@@ -102,4 +102,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }} DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+5 -1
View File
@@ -13,7 +13,11 @@ function getAbsolutePath(value: string): any {
} }
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"], stories: [
"../src/**/*.mdx",
"../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)",
"../../../packages/ui/src/**/stories.@(js|jsx|mjs|ts|tsx)",
],
addons: [ addons: [
getAbsolutePath("@storybook/addon-onboarding"), getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-links"), getAbsolutePath("@storybook/addon-links"),
+3 -4
View File
@@ -1,12 +1,12 @@
import type { Preview } from "@storybook/react-vite"; import type { Preview } from "@storybook/react-vite";
import React from "react"; import React from "react";
import { I18nProvider } from "../../web/lingodotdev/client"; import { I18nProvider as WebI18nProvider } from "../../web/lingodotdev/client";
import "../../web/modules/ui/globals.css"; import "../../web/modules/ui/globals.css";
// Create a Storybook-specific Lingodot Dev decorator // Create a Storybook-specific Lingodot Dev decorator for web components
const withLingodotDev = (Story: any) => { const withLingodotDev = (Story: any) => {
return React.createElement( return React.createElement(
I18nProvider, WebI18nProvider,
{ {
language: "en-US", language: "en-US",
defaultLanguage: "en-US", defaultLanguage: "en-US",
@@ -14,7 +14,6 @@ const withLingodotDev = (Story: any) => {
React.createElement(Story) React.createElement(Story)
); );
}; };
const preview: Preview = { const preview: Preview = {
parameters: { parameters: {
controls: { controls: {
+1
View File
@@ -11,6 +11,7 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "../web"), "@": path.resolve(__dirname, "../web"),
"@ui": path.resolve(__dirname, "../../packages/ui/src"),
}, },
}, },
}); });
+11 -9
View File
@@ -25,6 +25,10 @@ RUN corepack prepare pnpm@9.15.9 --activate
# Install necessary build tools and compilers # Install necessary build tools and compilers
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3 RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
# Copy the secrets handling script
COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh
RUN chmod +x /tmp/read-secrets.sh
# Increase Node.js memory limit as a regular build argument # Increase Node.js memory limit as a regular build argument
ARG NODE_OPTIONS="--max_old_space_size=8192" ARG NODE_OPTIONS="--max_old_space_size=8192"
ENV NODE_OPTIONS=${NODE_OPTIONS} ENV NODE_OPTIONS=${NODE_OPTIONS}
@@ -33,10 +37,6 @@ ENV NODE_OPTIONS=${NODE_OPTIONS}
# but needs explicit declaration for some build systems (like Depot) # but needs explicit declaration for some build systems (like Depot)
ARG TARGETARCH ARG TARGETARCH
# Base path for the application (optional)
ARG BASE_PATH=""
ENV BASE_PATH=${BASE_PATH}
# Set the working directory # Set the working directory
WORKDIR /app WORKDIR /app
@@ -57,11 +57,13 @@ RUN pnpm install --ignore-scripts
# Build the database package first # Build the database package first
RUN pnpm build --filter=@formbricks/database RUN pnpm build --filter=@formbricks/database
# Build the project - only mount Sentry token for optional sourcemap uploads # Build the project using our secret reader script
# DATABASE_URL, REDIS_URL, ENCRYPTION_KEY defaults are provided by env.ts during build # This mounts the secrets only during this build step without storing them in layers
RUN --mount=type=secret,id=sentry_auth_token \ RUN --mount=type=secret,id=database_url \
SENTRY_AUTH_TOKEN=$(cat /run/secrets/sentry_auth_token 2>/dev/null || echo "") \ --mount=type=secret,id=encryption_key \
pnpm build --filter=@formbricks/web... --mount=type=secret,id=redis_url \
--mount=type=secret,id=sentry_auth_token \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
# Extract Prisma version # Extract Prisma version
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
@@ -1,6 +1,8 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AuthorizationError } from "@formbricks/types/errors"; import { AuthorizationError } from "@formbricks/types/errors";
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
import { IS_POSTHOG_CONFIGURED } from "@/lib/constants";
import { canUserAccessOrganization } from "@/lib/organization/auth"; import { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
@@ -38,6 +40,14 @@ const ProjectOnboardingLayout = async (props) => {
return ( return (
<div className="flex-1 bg-slate-50"> <div className="flex-1 bg-slate-50">
<PosthogIdentify
session={session}
user={user}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<ToasterClient /> <ToasterClient />
{children} {children}
</div> </div>
@@ -1,13 +1,14 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils"; import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
const SurveyEditorEnvironmentLayout = async (props) => { const SurveyEditorEnvironmentLayout = async (props) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId); const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
if (!session) { if (!session) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
@@ -24,9 +25,15 @@ const SurveyEditorEnvironmentLayout = async (props) => {
} }
return ( return (
<div className="flex h-screen flex-col"> <EnvironmentIdBaseLayout
<div className="h-full overflow-y-auto bg-slate-50">{children}</div> environmentId={params.environmentId}
</div> session={session}
user={user}
organization={organization}>
<div className="flex h-screen flex-col">
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
</EnvironmentIdBaseLayout>
); );
}; };
@@ -0,0 +1,61 @@
"use client";
import type { Session } from "next-auth";
import { usePostHog } from "posthog-js/react";
import { useEffect } from "react";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
interface PosthogIdentifyProps {
session: Session;
user: TUser;
environmentId?: string;
organizationId?: string;
organizationName?: string;
organizationBilling?: TOrganizationBilling;
isPosthogEnabled: boolean;
}
export const PosthogIdentify = ({
session,
user,
environmentId,
organizationId,
organizationName,
organizationBilling,
isPosthogEnabled,
}: PosthogIdentifyProps) => {
const posthog = usePostHog();
useEffect(() => {
if (isPosthogEnabled && session.user && posthog) {
posthog.identify(session.user.id, {
name: user.name,
email: user.email,
});
if (environmentId) {
posthog.group("environment", environmentId, { name: environmentId });
}
if (organizationId) {
posthog.group("organization", organizationId, {
name: organizationName,
plan: organizationBilling?.plan,
responseLimit: organizationBilling?.limits.monthly.responses,
miuLimit: organizationBilling?.limits.monthly.miu,
});
}
}
}, [
posthog,
session.user,
environmentId,
organizationId,
organizationName,
organizationBilling,
user.name,
user.email,
isPosthogEnabled,
]);
return null;
};
@@ -4,6 +4,7 @@ import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/comp
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context"; import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils"; import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler"; import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
const EnvLayout = async (props: { const EnvLayout = async (props: {
@@ -23,7 +24,11 @@ const EnvLayout = async (props: {
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id); const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
return ( return (
<> <EnvironmentIdBaseLayout
environmentId={params.environmentId}
session={layoutData.session}
user={layoutData.user}
organization={layoutData.organization}>
<EnvironmentStorageHandler environmentId={params.environmentId} /> <EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentContextWrapper <EnvironmentContextWrapper
environment={layoutData.environment} environment={layoutData.environment}
@@ -31,7 +36,7 @@ const EnvLayout = async (props: {
organization={layoutData.organization}> organization={layoutData.organization}>
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout> <EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
</EnvironmentContextWrapper> </EnvironmentContextWrapper>
</> </EnvironmentIdBaseLayout>
); );
}; };
@@ -1,6 +1,5 @@
import { Metadata } from "next"; import { Metadata } from "next";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -26,7 +25,7 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
}; };
const SurveyLayout = async ({ children }) => { const SurveyLayout = async ({ children }) => {
return <ResponseFilterProvider>{children}</ResponseFilterProvider>; return <>{children}</>;
}; };
export default SurveyLayout; export default SurveyLayout;
@@ -8,8 +8,8 @@ import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user"; import { TUser, TUserLocale } from "@formbricks/types/user";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"; import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView"; import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter"; import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys"; import { getFormattedFilters } from "@/app/lib/surveys/surveys";
@@ -8,7 +8,6 @@ import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { ArrayResponse } from "@/modules/ui/components/array-response"; import { ArrayResponse } from "@/modules/ui/components/array-response";
import { PersonAvatar } from "@/modules/ui/components/avatars"; import { PersonAvatar } from "@/modules/ui/components/avatars";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface AddressSummaryProps { interface AddressSummaryProps {
@@ -30,48 +29,42 @@ export const AddressSummary = ({ questionSummary, environmentId, survey, locale
<div className="px-4 md:px-6">{t("common.time")}</div> <div className="px-4 md:px-6">{t("common.time")}</div>
</div> </div>
<div className="max-h-[62vh] w-full overflow-y-auto"> <div className="max-h-[62vh] w-full overflow-y-auto">
{questionSummary.samples.length === 0 ? ( {questionSummary.samples.map((response) => {
<div className="p-8"> return (
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" /> <div
</div> key={response.id}
) : ( className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
questionSummary.samples.map((response) => { <div className="pl-4 md:pl-6">
return ( {response.contact ? (
<div <Link
key={response.id} className="ph-no-capture group flex items-center"
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base"> href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="pl-4 md:pl-6"> <div className="hidden md:flex">
{response.contact ? ( <PersonAvatar personId={response.contact.id} />
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div> </div>
)} <p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
</div> {getContactIdentifier(response.contact, response.contactAttributes)}
<div className="ph-no-capture col-span-2 pl-6 font-semibold"> </p>
<ArrayResponse value={response.value} /> </Link>
</div> ) : (
<div className="group flex items-center">
<div className="px-4 text-slate-500 md:px-6"> <div className="hidden md:flex">
{timeSince(new Date(response.updatedAt).toISOString(), locale)} <PersonAvatar personId="anonymous" />
</div> </div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</div> </div>
); <div className="ph-no-capture col-span-2 pl-6 font-semibold">
}) <ArrayResponse value={response.value} />
)} </div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>
);
})}
</div> </div>
</div> </div>
</div> </div>
@@ -8,7 +8,6 @@ import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { ArrayResponse } from "@/modules/ui/components/array-response"; import { ArrayResponse } from "@/modules/ui/components/array-response";
import { PersonAvatar } from "@/modules/ui/components/avatars"; import { PersonAvatar } from "@/modules/ui/components/avatars";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface ContactInfoSummaryProps { interface ContactInfoSummaryProps {
@@ -35,48 +34,42 @@ export const ContactInfoSummary = ({
<div className="px-4 md:px-6">{t("common.time")}</div> <div className="px-4 md:px-6">{t("common.time")}</div>
</div> </div>
<div className="max-h-[62vh] w-full overflow-y-auto"> <div className="max-h-[62vh] w-full overflow-y-auto">
{questionSummary.samples.length === 0 ? ( {questionSummary.samples.map((response) => {
<div className="p-8"> return (
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" /> <div
</div> key={response.id}
) : ( className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
questionSummary.samples.map((response) => { <div className="pl-4 md:pl-6">
return ( {response.contact ? (
<div <Link
key={response.id} className="ph-no-capture group flex items-center"
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base"> href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="pl-4 md:pl-6"> <div className="hidden md:flex">
{response.contact ? ( <PersonAvatar personId={response.contact.id} />
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div> </div>
)} <p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
</div> {getContactIdentifier(response.contact, response.contactAttributes)}
<div className="ph-no-capture col-span-2 pl-6 font-semibold"> </p>
<ArrayResponse value={response.value} /> </Link>
</div> ) : (
<div className="group flex items-center">
<div className="px-4 text-slate-500 md:px-6"> <div className="hidden md:flex">
{timeSince(new Date(response.updatedAt).toISOString(), locale)} <PersonAvatar personId="anonymous" />
</div> </div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</div> </div>
); <div className="ph-no-capture col-span-2 pl-6 font-semibold">
}) <ArrayResponse value={response.value} />
)} </div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>
);
})}
</div> </div>
</div> </div>
</div> </div>
@@ -10,7 +10,6 @@ import { getContactIdentifier } from "@/lib/utils/contact";
import { formatDateWithOrdinal } from "@/lib/utils/datetime"; import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { PersonAvatar } from "@/modules/ui/components/avatars"; import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface DateQuestionSummary { interface DateQuestionSummary {
@@ -56,47 +55,41 @@ export const DateQuestionSummary = ({
<div className="px-4 md:px-6">{t("common.time")}</div> <div className="px-4 md:px-6">{t("common.time")}</div>
</div> </div>
<div className="max-h-[62vh] w-full overflow-y-auto"> <div className="max-h-[62vh] w-full overflow-y-auto">
{questionSummary.samples.length === 0 ? ( {questionSummary.samples.slice(0, visibleResponses).map((response) => (
<div className="p-8"> <div
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" /> key={response.id}
</div> className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
) : ( <div className="pl-4 md:pl-6">
questionSummary.samples.slice(0, visibleResponses).map((response) => ( {response.contact ? (
<div <Link
key={response.id} className="ph-no-capture group flex items-center"
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base"> href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="pl-4 md:pl-6"> <div className="hidden md:flex">
{response.contact ? ( <PersonAvatar personId={response.contact.id} />
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div> </div>
)} <p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
</div> {getContactIdentifier(response.contact, response.contactAttributes)}
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold"> </p>
{renderResponseValue(response.value)} </Link>
</div> ) : (
<div className="px-4 text-slate-500 md:px-6"> <div className="group flex items-center">
{timeSince(new Date(response.updatedAt).toISOString(), locale)} <div className="hidden md:flex">
</div> <PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</div> </div>
)) <div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
)} {renderResponseValue(response.value)}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>
))}
</div> </div>
{questionSummary.samples.length > 0 && visibleResponses < questionSummary.samples.length && ( {visibleResponses < questionSummary.samples.length && (
<div className="flex justify-center py-4"> <div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm"> <Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")} {t("common.load_more")}
@@ -11,7 +11,6 @@ import { getContactIdentifier } from "@/lib/utils/contact";
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils"; import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
import { PersonAvatar } from "@/modules/ui/components/avatars"; import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface FileUploadSummaryProps { interface FileUploadSummaryProps {
@@ -46,77 +45,71 @@ export const FileUploadSummary = ({
<div className="px-4 md:px-6">{t("common.time")}</div> <div className="px-4 md:px-6">{t("common.time")}</div>
</div> </div>
<div className="max-h-[62vh] w-full overflow-y-auto"> <div className="max-h-[62vh] w-full overflow-y-auto">
{questionSummary.files.length === 0 ? ( {questionSummary.files.slice(0, visibleResponses).map((response) => (
<div className="p-8"> <div
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" /> key={response.id}
</div> className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
) : ( <div className="pl-4 md:pl-6">
questionSummary.files.slice(0, visibleResponses).map((response) => ( {response.contact ? (
<div <Link
key={response.id} className="ph-no-capture group flex items-center"
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base"> href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="pl-4 md:pl-6"> <div className="hidden md:flex">
{response.contact ? ( <PersonAvatar personId={response.contact.id} />
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div> </div>
)} <p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
</div> {getContactIdentifier(response.contact, response.contactAttributes)}
</p>
<div className="col-span-2 grid"> </Link>
{Array.isArray(response.value) && ) : (
(response.value.length > 0 ? ( <div className="group flex items-center">
response.value.map((fileUrl) => { <div className="hidden md:flex">
const fileName = getOriginalFileNameFromUrl(fileUrl); <PersonAvatar personId="anonymous" />
</div>
return ( <p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}> </div>
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer"> )}
<div className="absolute right-0 top-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<DownloadIcon className="h-6 text-slate-500" />
</div>
</div>
</a>
<div className="flex flex-col items-center justify-center p-2">
<FileIcon className="h-6 text-slate-500" />
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{fileName}</p>
</div>
</div>
);
})
) : (
<div className="flex w-full flex-col items-center justify-center p-2">
<p className="mt-2 text-sm font-semibold text-slate-500 dark:text-slate-400">
{t("common.skipped")}
</p>
</div>
))}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div> </div>
))
)} <div className="col-span-2 grid">
{Array.isArray(response.value) &&
(response.value.length > 0 ? (
response.value.map((fileUrl) => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
return (
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
<div className="absolute right-0 top-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<DownloadIcon className="h-6 text-slate-500" />
</div>
</div>
</a>
<div className="flex flex-col items-center justify-center p-2">
<FileIcon className="h-6 text-slate-500" />
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{fileName}</p>
</div>
</div>
);
})
) : (
<div className="flex w-full flex-col items-center justify-center p-2">
<p className="mt-2 text-sm font-semibold text-slate-500 dark:text-slate-400">
{t("common.skipped")}
</p>
</div>
))}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>
))}
</div> </div>
{questionSummary.files.length > 0 && visibleResponses < questionSummary.files.length && ( {visibleResponses < questionSummary.files.length && (
<div className="flex justify-center py-4"> <div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm"> <Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")} {t("common.load_more")}
@@ -10,7 +10,6 @@ import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { PersonAvatar } from "@/modules/ui/components/avatars"; import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
interface HiddenFieldsSummaryProps { interface HiddenFieldsSummaryProps {
environment: TEnvironment; environment: TEnvironment;
@@ -52,46 +51,40 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
<div className="col-span-2 pl-4 md:pl-6">{t("common.response")}</div> <div className="col-span-2 pl-4 md:pl-6">{t("common.response")}</div>
<div className="px-4 md:px-6">{t("common.time")}</div> <div className="px-4 md:px-6">{t("common.time")}</div>
</div> </div>
{questionSummary.samples.length === 0 ? ( {questionSummary.samples.slice(0, visibleResponses).map((response, idx) => (
<div className="p-8"> <div
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" /> key={`${response.value}-${idx}`}
</div> className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
) : ( <div className="pl-4 md:pl-6">
questionSummary.samples.slice(0, visibleResponses).map((response, idx) => ( {response.contact ? (
<div <Link
key={`${response.value}-${idx}`} className="ph-no-capture group flex items-center"
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base"> href={`/environments/${environment.id}/contacts/${response.contact.id}`}>
<div className="pl-4 md:pl-6"> <div className="hidden md:flex">
{response.contact ? ( <PersonAvatar personId={response.contact.id} />
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environment.id}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div> </div>
)} <p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
</div> {getContactIdentifier(response.contact, response.contactAttributes)}
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold"> </p>
{response.value} </Link>
</div> ) : (
<div className="px-4 text-slate-500 md:px-6"> <div className="group flex items-center">
{timeSince(new Date(response.updatedAt).toISOString(), locale)} <div className="hidden md:flex">
</div> <PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</div> </div>
)) <div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
)} {response.value}
{questionSummary.samples.length > 0 && visibleResponses < questionSummary.samples.length && ( </div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>
))}
{visibleResponses < questionSummary.samples.length && (
<div className="flex justify-center py-4"> <div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm"> <Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")} {t("common.load_more")}
@@ -85,98 +85,96 @@ export const MultipleChoiceSummary = ({
) : undefined ) : undefined
} }
/> />
<div className="px-4 pb-6 pt-4 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="space-y-5"> {results.map((result) => {
{results.map((result) => { const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
const choiceId = getChoiceIdByValue(result.value, questionSummary.question); return (
return ( <Fragment key={result.value}>
<Fragment key={result.value}> <button
<button type="button"
type="button" className="group w-full cursor-pointer"
className="group w-full cursor-pointer" onClick={() =>
onClick={() => setFilter(
setFilter( questionSummary.question.id,
questionSummary.question.id, questionSummary.question.headline,
questionSummary.question.headline, questionSummary.question.type,
questionSummary.question.type, questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value ? t("environments.surveys.summary.includes_either")
? t("environments.surveys.summary.includes_either") : t("environments.surveys.summary.includes_all"),
: t("environments.surveys.summary.includes_all"), [result.value]
[result.value] )
) }>
}> <div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row"> <div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal"> <p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline"> {result.value}
{result.value} </p>
</p> {choiceId && <IdBadge id={choiceId} />}
{choiceId && <IdBadge id={choiceId} />}
</div>
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div> </div>
<div className="group-hover:opacity-80"> <div className="flex w-full space-x-2">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} /> <p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div> </div>
</button> </div>
{result.others && result.others.length > 0 && ( <div className="group-hover:opacity-80">
<div className="mt-4 rounded-lg border border-slate-200"> <ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900"> </div>
<div className="col-span-1 pl-6"> </button>
{t("environments.surveys.summary.other_values_found")} {result.others && result.others.length > 0 && (
</div> <div className="mt-4 rounded-lg border border-slate-200">
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div> <div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">
{t("environments.surveys.summary.other_values_found")}
</div> </div>
{result.others <div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
.filter((otherValue) => otherValue.value !== "") </div>
.slice(0, visibleOtherResponses) {result.others
.map((otherValue, idx) => ( .filter((otherValue) => otherValue.value !== "")
<div key={`${idx}-${otherValue}`} dir="auto"> .slice(0, visibleOtherResponses)
{surveyType === "link" && ( .map((otherValue, idx) => (
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900"> <div key={`${idx}-${otherValue}`} dir="auto">
{surveyType === "link" && (
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
)}
{surveyType === "app" && otherValue.contact && (
<Link
href={
otherValue.contact.id
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
: { pathname: null }
}
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
<span>{otherValue.value}</span> <span>{otherValue.value}</span>
</div> </div>
)} <div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
{surveyType === "app" && otherValue.contact && ( {otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
<Link <span>
href={ {getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
otherValue.contact.id </span>
? `/environments/${environmentId}/contacts/${otherValue.contact.id}` </div>
: { pathname: null } </Link>
} )}
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
<span>
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
</span>
</div>
</Link>
)}
</div>
))}
{visibleOtherResponses < result.others.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div> </div>
)} ))}
</div> {visibleOtherResponses < result.others.length && (
)} <div className="flex justify-center py-4">
</Fragment> <Button onClick={handleLoadMore} variant="secondary" size="sm">
); {t("common.load_more")}
})} </Button>
</div> </div>
)}
</div>
)}
</Fragment>
);
})}
</div> </div>
</div> </div>
); );
@@ -106,38 +106,36 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
</div> </div>
<TabsContent value="aggregated" className="mt-4"> <TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="space-y-5 text-sm md:text-base"> {["promoters", "passives", "detractors", "dismissed"].map((group) => (
{["promoters", "passives", "detractors", "dismissed"].map((group) => ( <button
<button className="w-full cursor-pointer hover:opacity-80"
className="w-full cursor-pointer hover:opacity-80" key={group}
key={group} onClick={() => applyFilter(group)}>
onClick={() => applyFilter(group)}> <div
<div className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}> <div className="mr-8 flex space-x-1">
<div className="mr-8 flex space-x-1"> <p
<p className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}> {group}
{group}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary[group]?.count}{" "}
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
</p>
</div>
</div> </div>
<ProgressBar <p className="flex w-32 items-end justify-end text-slate-600">
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"} {questionSummary[group]?.count}{" "}
progress={questionSummary[group]?.percentage / 100} {questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
/> </p>
</button> </div>
))} <ProgressBar
</div> barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</button>
))}
</div> </div>
</TabsContent> </TabsContent>
@@ -10,7 +10,6 @@ import { getContactIdentifier } from "@/lib/utils/contact";
import { renderHyperlinkedContent } from "@/modules/analysis/utils"; import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { PersonAvatar } from "@/modules/ui/components/avatars"; import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
@@ -36,65 +35,59 @@ export const OpenTextSummary = ({ questionSummary, environmentId, survey, locale
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="border-t border-slate-200"></div> <div className="border-t border-slate-200"></div>
{questionSummary.samples.length === 0 ? ( <div className="max-h-[40vh] overflow-y-auto">
<div className="p-8"> <Table>
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" /> <TableHeader className="bg-slate-100">
</div> <TableRow>
) : ( <TableHead>{t("common.user")}</TableHead>
<div className="max-h-[40vh] overflow-y-auto"> <TableHead>{t("common.response")}</TableHead>
<Table> <TableHead>{t("common.time")}</TableHead>
<TableHeader className="bg-slate-100"> </TableRow>
<TableRow> </TableHeader>
<TableHead className="w-1/4">{t("common.user")}</TableHead> <TableBody>
<TableHead className="w-2/4">{t("common.response")}</TableHead> {questionSummary.samples.slice(0, visibleResponses).map((response) => (
<TableHead className="w-1/4">{t("common.time")}</TableHead> <TableRow key={response.id}>
</TableRow> <TableCell>
</TableHeader> {response.contact ? (
<TableBody> <Link
{questionSummary.samples.slice(0, visibleResponses).map((response) => ( className="ph-no-capture group flex items-center"
<TableRow key={response.id}> href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<TableCell className="w-1/4"> <div className="hidden md:flex">
{response.contact ? ( <PersonAvatar personId={response.contact.id} />
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-normal text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div> </div>
)} <p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
</TableCell> {getContactIdentifier(response.contact, response.contactAttributes)}
<TableCell className="w-2/4 font-medium"> </p>
{typeof response.value === "string" </Link>
? renderHyperlinkedContent(response.value) ) : (
: response.value} <div className="group flex items-center">
</TableCell> <div className="hidden md:flex">
<TableCell className="w-1/4"> <PersonAvatar personId="anonymous" />
{timeSince(new Date(response.updatedAt).toISOString(), locale)} </div>
</TableCell> <p className="break-normal text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</TableRow> </div>
))} )}
</TableBody> </TableCell>
</Table> <TableCell className="font-medium">
{visibleResponses < questionSummary.samples.length && ( {typeof response.value === "string"
<div className="flex justify-center py-4"> ? renderHyperlinkedContent(response.value)
<Button onClick={handleLoadMore} variant="secondary" size="sm"> : response.value}
{t("common.load_more")} </TableCell>
</Button> <TableCell width={120}>
</div> {timeSince(new Date(response.updatedAt).toISOString(), locale)}
)} </TableCell>
</div> </TableRow>
)} ))}
</TableBody>
</Table>
{visibleResponses < questionSummary.samples.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</div>
</div> </div>
); );
}; };
@@ -11,7 +11,6 @@ import {
TSurveyQuestionTypeEnum, TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types"; } from "@formbricks/types/surveys/types";
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils"; import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { ProgressBar } from "@/modules/ui/components/progress-bar"; import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { RatingResponse } from "@/modules/ui/components/rating-response"; import { RatingResponse } from "@/modules/ui/components/rating-response";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
@@ -85,7 +84,11 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
<div className="px-4 pb-6 pt-4 md:px-6"> <div className="px-4 pb-6 pt-4 md:px-6">
{questionSummary.responseCount === 0 ? ( {questionSummary.responseCount === 0 ? (
<> <>
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" /> <div className="rounded-lg border border-slate-200 bg-slate-50 p-8 text-center">
<p className="text-sm text-slate-500">
{t("environments.surveys.summary.no_responses_found")}
</p>
</div>
<RatingScaleLegend <RatingScaleLegend
scale={questionSummary.question.scale} scale={questionSummary.question.scale}
range={questionSummary.question.range} range={questionSummary.question.range}
@@ -12,11 +12,11 @@ import {
} from "@formbricks/types/surveys/types"; } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { EmptyAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
import { import {
SelectedFilterValue, SelectedFilterValue,
useResponseFilter, useResponseFilter,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context"; } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { EmptyAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
import { CTASummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary"; import { CTASummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary";
import { CalSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary"; import { CalSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary";
import { ConsentSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary"; import { ConsentSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary";
@@ -8,7 +8,6 @@ import { cn } from "@/modules/ui/lib/utils";
interface SummaryMetadataProps { interface SummaryMetadataProps {
surveySummary: TSurveySummary["meta"]; surveySummary: TSurveySummary["meta"];
quotasCount: number;
isLoading: boolean; isLoading: boolean;
tab: "dropOffs" | "quotas" | undefined; tab: "dropOffs" | "quotas" | undefined;
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>; setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
@@ -32,7 +31,6 @@ const formatTime = (ttc) => {
export const SummaryMetadata = ({ export const SummaryMetadata = ({
surveySummary, surveySummary,
quotasCount,
isLoading, isLoading,
tab, tab,
setTab, setTab,
@@ -63,7 +61,7 @@ export const SummaryMetadata = ({
<div <div
className={cn( className={cn(
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`, `grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6" isQuotasAllowed && "2xl:grid-cols-6"
)}> )}>
<StatCard <StatCard
label={t("environments.surveys.summary.impressions")} label={t("environments.surveys.summary.impressions")}
@@ -107,7 +105,7 @@ export const SummaryMetadata = ({
isLoading={isLoading} isLoading={isLoading}
/> />
{isQuotasAllowed && quotasCount > 0 && ( {isQuotasAllowed && (
<InteractiveCard <InteractiveCard
key="quotas" key="quotas"
tab="quotas" tab="quotas"
@@ -5,8 +5,8 @@ import { useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"; import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop"; import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs"; import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter"; import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
@@ -115,7 +115,6 @@ export const SummaryPage = ({
<> <>
<SummaryMetadata <SummaryMetadata
surveySummary={surveySummary.meta} surveySummary={surveySummary.meta}
quotasCount={surveySummary.quotas?.length ?? 0}
isLoading={isLoading} isLoading={isLoading}
tab={tab} tab={tab}
setTab={setTab} setTab={setTab}
@@ -25,7 +25,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { import {
DateRange, DateRange,
useResponseFilter, useResponseFilter,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context"; } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions"; import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils"; import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys"; import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys";
@@ -209,7 +209,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
{open && ( {open && (
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none"> <div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none">
<CommandList className="max-h-[600px]"> <CommandList>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty> <CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
{options?.map((data) => ( {options?.map((data) => (
<Fragment key={data.header}> <Fragment key={data.header}>
@@ -9,7 +9,7 @@ import {
SelectedFilterValue, SelectedFilterValue,
TResponseStatus, TResponseStatus,
useResponseFilter, useResponseFilter,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context"; } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions"; import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox"; import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys"; import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
+17 -3
View File
@@ -1,9 +1,12 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { Suspense } from "react";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper"; import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@/lib/constants";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout"; import { ClientLogout } from "@/modules/ui/components/client-logout";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay"; import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
import { ToasterClient } from "@/modules/ui/components/toaster-client"; import { ToasterClient } from "@/modules/ui/components/toaster-client";
const AppLayout = async ({ children }) => { const AppLayout = async ({ children }) => {
@@ -18,9 +21,20 @@ const AppLayout = async ({ children }) => {
return ( return (
<> <>
<NoMobileOverlay /> <NoMobileOverlay />
<IntercomClientWrapper user={user} /> <Suspense>
<ToasterClient /> <PostHogPageview
{children} posthogEnabled={IS_POSTHOG_CONFIGURED}
postHogApiHost={POSTHOG_API_HOST}
postHogApiKey={POSTHOG_API_KEY}
/>
</Suspense>
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
<>
<IntercomClientWrapper user={user} />
<ToasterClient />
{children}
</>
</PHProvider>
</> </>
); );
}; };
+34
View File
@@ -0,0 +1,34 @@
import { Organization } from "@prisma/client";
import { logger } from "@formbricks/logger";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
export const handleBillingLimitsCheck = async (
environmentId: string,
organizationId: string,
organizationBilling: Organization["billing"]
): Promise<void> => {
if (!IS_FORMBRICKS_CLOUD) return;
const responsesCount = await getMonthlyOrganizationResponseCount(organizationId);
const responsesLimit = organizationBilling.limits.monthly.responses;
if (responsesLimit && responsesCount >= responsesLimit) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organizationBilling.plan,
limits: {
projects: null,
monthly: {
responses: responsesLimit,
miu: null,
},
},
});
} catch (err) {
// Log error but do not throw
logger.error(err, "Error sending plan limits reached event to Posthog");
}
}
};
@@ -18,6 +18,10 @@ import {
getMonthlyOrganizationResponseCount, getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId, getOrganizationByEnvironmentId,
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { COLOR_DEFAULTS } from "@/lib/styling/constants"; import { COLOR_DEFAULTS } from "@/lib/styling/constants";
@@ -54,6 +58,20 @@ const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
const monthlyResponseLimit = organization.billing.limits.monthly.responses; const monthlyResponseLimit = organization.billing.limits.monthly.responses;
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit; const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
if (isLimitReached) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organization.billing.plan,
limits: {
projects: null,
monthly: { responses: monthlyResponseLimit, miu: null },
},
});
} catch (error) {
logger.error({ error }, `Error sending plan limits reached event to Posthog`);
}
}
return isLimitReached; return isLimitReached;
}; };
@@ -93,7 +111,10 @@ export const GET = withV1ApiWrapper({
} }
if (!environment.appSetupCompleted) { if (!environment.appSetupCompleted) {
await updateEnvironment(environment.id, { appSetupCompleted: true }); await Promise.all([
updateEnvironment(environment.id, { appSetupCompleted: true }),
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
]);
} }
// check organization subscriptions and response limits // check organization subscriptions and response limits
@@ -5,6 +5,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display"; import { createDisplay } from "./lib/display";
@@ -58,6 +59,7 @@ export const POST = withV1ApiWrapper({
try { try {
const response = await createDisplay(inputValidation.data); const response = await createDisplay(inputValidation.data);
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
return { return {
response: responses.successResponse(response, true), response: responses.successResponse(response, true),
}; };
@@ -8,11 +8,16 @@ import { TOrganization } from "@formbricks/types/organizations";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { cache } from "@/lib/cache"; import { cache } from "@/lib/cache";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service"; import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { EnvironmentStateData, getEnvironmentStateData } from "./data"; import { EnvironmentStateData, getEnvironmentStateData } from "./data";
import { getEnvironmentState } from "./environmentState"; import { getEnvironmentState } from "./environmentState";
// Mock dependencies // Mock dependencies
vi.mock("@/lib/organization/service"); vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/cache", () => ({ vi.mock("@/lib/cache", () => ({
cache: { cache: {
withCache: vi.fn(), withCache: vi.fn(),
@@ -38,6 +43,7 @@ vi.mock("@/lib/constants", () => ({
RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key", RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key",
IS_RECAPTCHA_CONFIGURED: true, IS_RECAPTCHA_CONFIGURED: true,
IS_PRODUCTION: true, IS_PRODUCTION: true,
IS_POSTHOG_CONFIGURED: false,
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key", ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
})); }));
@@ -182,7 +188,9 @@ describe("getEnvironmentState", () => {
expect(result.data).toEqual(expectedData); expect(result.data).toEqual(expectedData);
expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId); expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId);
expect(prisma.environment.update).not.toHaveBeenCalled(); expect(prisma.environment.update).not.toHaveBeenCalled();
expect(capturePosthogEnvironmentEvent).not.toHaveBeenCalled();
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id); expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
}); });
test("should throw ResourceNotFoundError if environment not found", async () => { test("should throw ResourceNotFoundError if environment not found", async () => {
@@ -218,6 +226,7 @@ describe("getEnvironmentState", () => {
where: { id: environmentId }, where: { id: environmentId },
data: { appSetupCompleted: true }, data: { appSetupCompleted: true },
}); });
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(environmentId, "app setup completed");
expect(result.data).toBeDefined(); expect(result.data).toBeDefined();
}); });
@@ -228,6 +237,16 @@ describe("getEnvironmentState", () => {
expect(result.data.surveys).toEqual([]); expect(result.data.surveys).toEqual([]);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id); expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
plan: mockOrganization.billing.plan,
limits: {
projects: null,
monthly: {
miu: null,
responses: mockOrganization.billing.limits.monthly.responses,
},
},
});
}); });
test("should return surveys if monthly response limit not reached (Cloud)", async () => { test("should return surveys if monthly response limit not reached (Cloud)", async () => {
@@ -237,6 +256,21 @@ describe("getEnvironmentState", () => {
expect(result.data.surveys).toEqual(mockSurveys); expect(result.data.surveys).toEqual(mockSurveys);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id); expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should handle error when sending Posthog limit reached event", async () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
const posthogError = new Error("Posthog failed");
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
const result = await getEnvironmentState(environmentId);
expect(result.data.surveys).toEqual([]);
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
}); });
test("should include recaptchaSiteKey if recaptcha variables are set", async () => { test("should include recaptchaSiteKey if recaptcha variables are set", async () => {
@@ -279,6 +313,7 @@ describe("getEnvironmentState", () => {
// Should return surveys even with high count since limit is null (unlimited) // Should return surveys even with high count since limit is null (unlimited)
expect(result.data.surveys).toEqual(mockSurveys); expect(result.data.surveys).toEqual(mockSurveys);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
}); });
test("should propagate database update errors", async () => { test("should propagate database update errors", async () => {
@@ -296,6 +331,21 @@ describe("getEnvironmentState", () => {
await expect(getEnvironmentState(environmentId)).rejects.toThrow("Database error"); await expect(getEnvironmentState(environmentId)).rejects.toThrow("Database error");
}); });
test("should propagate PostHog event capture errors", async () => {
const incompleteEnvironmentData = {
...mockEnvironmentStateData,
environment: {
...mockEnvironmentStateData.environment,
appSetupCompleted: false,
},
};
vi.mocked(getEnvironmentStateData).mockResolvedValue(incompleteEnvironmentData);
vi.mocked(capturePosthogEnvironmentEvent).mockRejectedValue(new Error("PostHog error"));
// Should throw error since Promise.all will fail if PostHog event capture fails
await expect(getEnvironmentState(environmentId)).rejects.toThrow("PostHog error");
});
test("should include recaptchaSiteKey when IS_RECAPTCHA_CONFIGURED is true", async () => { test("should include recaptchaSiteKey when IS_RECAPTCHA_CONFIGURED is true", async () => {
const result = await getEnvironmentState(environmentId); const result = await getEnvironmentState(environmentId);
@@ -1,10 +1,15 @@
import "server-only"; import "server-only";
import { createCacheKey } from "@formbricks/cache"; import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TJsEnvironmentState } from "@formbricks/types/js"; import { TJsEnvironmentState } from "@formbricks/types/js";
import { cache } from "@/lib/cache"; import { cache } from "@/lib/cache";
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service"; import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { getEnvironmentStateData } from "./data"; import { getEnvironmentStateData } from "./data";
/** /**
@@ -28,10 +33,13 @@ export const getEnvironmentState = async (
// Handle app setup completion update if needed // Handle app setup completion update if needed
// This is a one-time setup flag that can tolerate TTL-based cache expiration // This is a one-time setup flag that can tolerate TTL-based cache expiration
if (!environment.appSetupCompleted) { if (!environment.appSetupCompleted) {
await prisma.environment.update({ await Promise.all([
where: { id: environmentId }, prisma.environment.update({
data: { appSetupCompleted: true }, where: { id: environmentId },
}); data: { appSetupCompleted: true },
}),
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
]);
} }
// Check monthly response limits for Formbricks Cloud // Check monthly response limits for Formbricks Cloud
@@ -41,6 +49,24 @@ export const getEnvironmentState = async (
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id); const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
isMonthlyResponsesLimitReached = isMonthlyResponsesLimitReached =
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit; monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
// Send plan limits event if needed
if (isMonthlyResponsesLimitReached) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organization.billing.plan,
limits: {
projects: null,
monthly: {
miu: null,
responses: organization.billing.limits.monthly.responses,
},
},
});
} catch (err) {
logger.error(err, "Error sending plan limits reached event to Posthog");
}
}
} }
// Build the response data // Build the response data
@@ -1,10 +1,15 @@
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurveyQuota } from "@formbricks/types/quota"; import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseInput } from "@formbricks/types/responses"; import { TResponseInput } from "@formbricks/types/responses";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service"; import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { createResponse, createResponseWithQuotaEvaluation } from "./response"; import { createResponse, createResponseWithQuotaEvaluation } from "./response";
@@ -19,13 +24,22 @@ vi.mock("@/lib/constants", () => ({
})); }));
vi.mock("@/lib/organization/service", () => ({ vi.mock("@/lib/organization/service", () => ({
getMonthlyOrganizationResponseCount: vi.fn(),
getOrganizationByEnvironmentId: vi.fn(), getOrganizationByEnvironmentId: vi.fn(),
})); }));
vi.mock("@/lib/posthogServer", () => ({
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn(),
}));
vi.mock("@/lib/response/utils", () => ({ vi.mock("@/lib/response/utils", () => ({
calculateTtcTotal: vi.fn((ttc) => ttc), calculateTtcTotal: vi.fn((ttc) => ttc),
})); }));
vi.mock("@/lib/telemetry", () => ({
captureTelemetry: vi.fn(),
}));
vi.mock("@/lib/utils/validate", () => ({ vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(), validateInputs: vi.fn(),
})); }));
@@ -124,6 +138,35 @@ describe("createResponse", () => {
); );
}); });
test("should check response limits if IS_FORMBRICKS_CLOUD is true", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
await createResponse(mockResponseInput, prisma);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
await createResponse(mockResponseInput, prisma);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
plan: "free",
limits: {
projects: null,
monthly: {
responses: 100,
miu: null,
},
},
});
});
test("should throw ResourceNotFoundError if organization not found", async () => { test("should throw ResourceNotFoundError if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null); vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(ResourceNotFoundError); await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(ResourceNotFoundError);
@@ -143,6 +186,20 @@ describe("createResponse", () => {
vi.mocked(prisma.response.create).mockRejectedValue(genericError); vi.mocked(prisma.response.create).mockRejectedValue(genericError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError); await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
}); });
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
const posthogError = new Error("PostHog error");
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
await createResponse(mockResponseInput);
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
});
}); });
describe("createResponseWithQuotaEvaluation", () => { describe("createResponseWithQuotaEvaluation", () => {
@@ -6,9 +6,11 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota"; import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses"; import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils"; import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service"; import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContactByUserId } from "./contact"; import { getContactByUserId } from "./contact";
@@ -81,6 +83,7 @@ export const createResponse = async (
tx: Prisma.TransactionClient tx: Prisma.TransactionClient
): Promise<TResponse> => { ): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]); validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const { environmentId, userId, finished, ttc: initialTtc } = responseInput; const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
@@ -118,6 +121,8 @@ export const createResponse = async (
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
}; };
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
return response; return response;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -10,6 +10,7 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines"; import { sendToPipeline } from "@/app/lib/pipelines";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers"; import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
@@ -171,6 +172,11 @@ export const POST = withV1ApiWrapper({
}); });
} }
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
surveyId: responseData.surveyId,
surveyType: survey.type,
});
const quotaObj = createQuotaFullObject(quotaFull); const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = { const responseDataWithQuota = {
@@ -4,7 +4,11 @@ import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseInput } from "@formbricks/types/responses"; import { TResponse, TResponseInput } from "@formbricks/types/responses";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { getResponseContact } from "@/lib/response/service"; import { getResponseContact } from "@/lib/response/service";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
@@ -92,6 +96,9 @@ const mockTransformedResponses = [mockResponse, { ...mockResponse, id: "response
// Mock dependencies // Mock dependencies
vi.mock("@/lib/constants", () => ({ vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true, IS_FORMBRICKS_CLOUD: true,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key", ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id", GITHUB_ID: "mock-github-id",
@@ -111,8 +118,10 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
})); }));
vi.mock("@/lib/organization/service"); vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/response/service"); vi.mock("@/lib/response/service");
vi.mock("@/lib/response/utils"); vi.mock("@/lib/response/utils");
vi.mock("@/lib/telemetry");
vi.mock("@/lib/utils/validate"); vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({ vi.mock("@formbricks/database", () => ({
prisma: { prisma: {
@@ -153,6 +162,7 @@ describe("Response Lib Tests", () => {
vi.mocked(mockTx.response.create).mockResolvedValue({ vi.mocked(mockTx.response.create).mockResolvedValue({
...mockResponsePrisma, ...mockResponsePrisma,
}); });
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
const response = await createResponse(mockResponseInputWithUserId, mockTx); const response = await createResponse(mockResponseInputWithUserId, mockTx);
@@ -207,6 +217,68 @@ describe("Response Lib Tests", () => {
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError); await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
}); });
describe("Cloud specific tests", () => {
test("should check response limit and send event if limit reached", async () => {
// IS_FORMBRICKS_CLOUD is true by default from the top-level mock
const limit = 100;
const mockOrgWithBilling = {
...mockOrganization,
billing: { limits: { monthly: { responses: limit } } },
} as any;
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
});
test("should check response limit and not send event if limit not reached", async () => {
const limit = 100;
const mockOrgWithBilling = {
...mockOrganization,
billing: { limits: { monthly: { responses: limit } } },
} as any;
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit - 1); // Limit not reached
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should log error if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
const limit = 100;
const mockOrgWithBilling = {
...mockOrganization,
billing: { limits: { monthly: { responses: limit } } },
} as any;
const posthogError = new Error("Posthog error");
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
// Expecting successful response creation despite PostHog error
const response = await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
expect(response).toEqual(mockResponse); // Should still return the created response
});
});
}); });
describe("getResponsesByEnvironmentIds", () => { describe("getResponsesByEnvironmentIds", () => {
@@ -8,12 +8,14 @@ import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses"; import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils"; import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { RESPONSES_PER_PAGE } from "@/lib/constants"; import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseContact } from "@/lib/response/service"; import { getResponseContact } from "@/lib/response/service";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service"; import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContactByUserId } from "./contact"; import { getContactByUserId } from "./contact";
@@ -91,6 +93,7 @@ export const createResponse = async (
tx?: Prisma.TransactionClient tx?: Prisma.TransactionClient
): Promise<TResponse> => { ): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]); validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const { environmentId, userId, finished, ttc: initialTtc } = responseInput; const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
@@ -128,6 +131,8 @@ export const createResponse = async (
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
}; };
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
return response; return response;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -3,6 +3,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display"; import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display"; import { createDisplay } from "./lib/display";
@@ -48,6 +49,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
try { try {
const response = await createDisplay(inputValidation.data); const response = await createDisplay(inputValidation.data);
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
return responses.successResponse(response, true); return responses.successResponse(response, true);
} catch (error) { } catch (error) {
if (error instanceof ResourceNotFoundError) { if (error instanceof ResourceNotFoundError) {
@@ -8,8 +8,13 @@ import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses"; import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response"; import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service"; import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContact } from "./contact"; import { getContact } from "./contact";
@@ -44,7 +49,9 @@ vi.mock("@/lib/constants", () => ({
})); }));
vi.mock("@/lib/organization/service"); vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/response/utils"); vi.mock("@/lib/response/utils");
vi.mock("@/lib/telemetry");
vi.mock("@/lib/utils/validate"); vi.mock("@/lib/utils/validate");
vi.mock("@/modules/ee/quotas/lib/evaluation-service"); vi.mock("@/modules/ee/quotas/lib/evaluation-service");
vi.mock("@formbricks/database", () => ({ vi.mock("@formbricks/database", () => ({
@@ -159,6 +166,9 @@ describe("createResponse V2", () => {
...ttc, ...ttc,
_total: Object.values(ttc).reduce((a, b) => a + b, 0), _total: Object.values(ttc).reduce((a, b) => a + b, 0),
})); }));
vi.mocked(captureTelemetry).mockResolvedValue(undefined);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockResolvedValue(undefined);
vi.mocked(evaluateResponseQuotas).mockResolvedValue({ vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: false, shouldEndSurvey: false,
quotaFull: null, quotaFull: null,
@@ -169,6 +179,32 @@ describe("createResponse V2", () => {
mockIsFormbricksCloud = false; mockIsFormbricksCloud = false;
}); });
test("should check response limits if IS_FORMBRICKS_CLOUD is true", async () => {
mockIsFormbricksCloud = true;
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
plan: "free",
limits: {
projects: null,
monthly: {
responses: 100,
miu: null,
},
},
});
});
test("should throw ResourceNotFoundError if organization not found", async () => { test("should throw ResourceNotFoundError if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null); vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(ResourceNotFoundError); await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(ResourceNotFoundError);
@@ -189,6 +225,20 @@ describe("createResponse V2", () => {
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError); await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
}); });
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
const posthogError = new Error("PostHog error");
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
await createResponse(mockResponseInput, mockTx); // Should not throw
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
});
test("should correctly map prisma tags to response tags", async () => { test("should correctly map prisma tags to response tags", async () => {
const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId }; const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId };
const prismaResponseWithTags = { const prismaResponseWithTags = {
@@ -219,6 +269,7 @@ describe("createResponseWithQuotaEvaluation V2", () => {
...ttc, ...ttc,
_total: Object.values(ttc).reduce((a, b) => a + b, 0), _total: Object.values(ttc).reduce((a, b) => a + b, 0),
})); }));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
vi.mocked(evaluateResponseQuotas).mockResolvedValue({ vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: false, shouldEndSurvey: false,
quotaFull: null, quotaFull: null,
@@ -6,10 +6,12 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota"; import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses"; import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response"; import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response"; import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service"; import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContact } from "./contact"; import { getContact } from "./contact";
@@ -89,6 +91,7 @@ export const createResponse = async (
tx?: Prisma.TransactionClient tx?: Prisma.TransactionClient
): Promise<TResponse> => { ): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]); validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const { environmentId, contactId, finished, ttc: initialTtc } = responseInput; const { environmentId, contactId, finished, ttc: initialTtc } = responseInput;
@@ -126,6 +129,8 @@ export const createResponse = async (
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
}; };
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
return response; return response;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -8,6 +8,7 @@ import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/respons
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines"; import { sendToPipeline } from "@/app/lib/pipelines";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question"; import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -147,6 +148,11 @@ export const POST = async (request: Request, context: Context): Promise<Response
}); });
} }
await capturePosthogEnvironmentEvent(environmentId, "response created", {
surveyId: responseData.surveyId,
surveyType: survey.type,
});
const quotaObj = createQuotaFullObject(quotaFull); const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = { const responseDataWithQuota = {
+1 -128
View File
@@ -12,7 +12,7 @@ import { TTag } from "@formbricks/types/tags";
import { import {
DateRange, DateRange,
SelectedFilterValue, SelectedFilterValue,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context"; } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox"; import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { generateQuestionAndFilterOptions, getFormattedFilters, getTodayDate } from "./surveys"; import { generateQuestionAndFilterOptions, getFormattedFilters, getTodayDate } from "./surveys";
@@ -268,64 +268,6 @@ describe("surveys", () => {
expect(sourceFilterOption).toBeDefined(); expect(sourceFilterOption).toBeDefined();
expect(sourceFilterOption?.filterOptions).toEqual(["Equals", "Not equals"]); expect(sourceFilterOption?.filterOptions).toEqual(["Equals", "Not equals"]);
}); });
test("should include quota options in filter options when quotas are provided", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
} as unknown as TSurvey;
const quotas = [{ id: "quota1" }];
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}, quotas as any);
const quotaFilterOption = result.questionFilterOptions.find((o) => o.id === "quota1");
expect(quotaFilterOption).toBeDefined();
expect(quotaFilterOption?.type).toBe("Quotas");
expect(quotaFilterOption?.filterOptions).toEqual(["Status"]);
expect(quotaFilterOption?.filterComboBoxOptions).toEqual([
"Screened in",
"Screened out (overquota)",
"Not in quota",
]);
});
test("should include multiple quota options when multiple quotas are provided", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
} as unknown as TSurvey;
const quotas = [{ id: "quota1" }, { id: "quota2" }];
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}, quotas as any);
const quota1 = result.questionFilterOptions.find((o) => o.id === "quota1");
const quota2 = result.questionFilterOptions.find((o) => o.id === "quota2");
expect(quota1).toBeDefined();
expect(quota2).toBeDefined();
expect(quota1?.filterComboBoxOptions).toEqual([
"Screened in",
"Screened out (overquota)",
"Not in quota",
]);
expect(quota2?.filterComboBoxOptions).toEqual([
"Screened in",
"Screened out (overquota)",
"Not in quota",
]);
});
}); });
describe("getFormattedFilters", () => { describe("getFormattedFilters", () => {
@@ -925,75 +867,6 @@ describe("surveys", () => {
expect(result.meta?.url).toEqual({ op: "contains", value: "formbricks.com" }); expect(result.meta?.url).toEqual({ op: "contains", value: "formbricks.com" });
expect(result.meta?.source).toEqual({ op: "equals", value: "newsletter" }); expect(result.meta?.source).toEqual({ op: "equals", value: "newsletter" });
}); });
test("should filter by quota with screened in status", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
filter: [
{
questionType: { type: "Quotas", label: "Quota 1", id: "quota1" },
filterType: { filterComboBoxValue: "Screened in" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.quotas?.quota1).toEqual({ op: "screenedIn" });
});
test("should filter by quota with screened out status", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
filter: [
{
questionType: { type: "Quotas", label: "Quota 1", id: "quota1" },
filterType: { filterComboBoxValue: "Screened out (overquota)" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.quotas?.quota1).toEqual({ op: "screenedOut" });
});
test("should filter by quota with not in quota status", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
filter: [
{
questionType: { type: "Quotas", label: "Quota 1", id: "quota1" },
filterType: { filterComboBoxValue: "Not in quota" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.quotas?.quota1).toEqual({ op: "screenedOutNotInQuota" });
});
test("should filter by multiple quotas with different statuses", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
filter: [
{
questionType: { type: "Quotas", label: "Quota 1", id: "quota1" },
filterType: { filterComboBoxValue: "Screened in" },
},
{
questionType: { type: "Quotas", label: "Quota 2", id: "quota2" },
filterType: { filterComboBoxValue: "Not in quota" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.quotas?.quota1).toEqual({ op: "screenedIn" });
expect(result.quotas?.quota2).toEqual({ op: "screenedOutNotInQuota" });
});
}); });
describe("getTodayDate", () => { describe("getTodayDate", () => {
+3 -3
View File
@@ -12,7 +12,7 @@ import {
DateRange, DateRange,
FilterValue, FilterValue,
SelectedFilterValue, SelectedFilterValue,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context"; } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { import {
OptionsType, OptionsType,
QuestionOption, QuestionOption,
@@ -236,7 +236,7 @@ export const generateQuestionAndFilterOptions = (
questionFilterOptions.push({ questionFilterOptions.push({
type: "Quotas", type: "Quotas",
filterOptions: ["Status"], filterOptions: ["Status"],
filterComboBoxOptions: ["Screened in", "Screened out (overquota)", "Not in quota"], filterComboBoxOptions: ["Screened in", "Screened out (overquota)", "Screened out (not in quota)"],
id: quota.id, id: quota.id,
}); });
}); });
@@ -549,7 +549,7 @@ export const getFormattedFilters = (
const statusMap: Record<string, "screenedIn" | "screenedOut" | "screenedOutNotInQuota"> = { const statusMap: Record<string, "screenedIn" | "screenedOut" | "screenedOutNotInQuota"> = {
"Screened in": "screenedIn", "Screened in": "screenedIn",
"Screened out (overquota)": "screenedOut", "Screened out (overquota)": "screenedOut",
"Not in quota": "screenedOutNotInQuota", "Screened out (not in quota)": "screenedOutNotInQuota",
}; };
const op = statusMap[String(filterType.filterComboBoxValue)]; const op = statusMap[String(filterType.filterComboBoxValue)];
if (op) filters.quotas[quotaId] = { op }; if (op) filters.quotas[quotaId] = { op };
+2 -2
View File
@@ -2073,7 +2073,7 @@ const careerDevelopmentSurvey = (t: TFunction): TTemplate => {
return buildSurvey( return buildSurvey(
{ {
name: t("templates.career_development_survey_name"), name: t("templates.career_development_survey_name"),
role: "peopleManager", role: "productManager",
industries: ["saas", "eCommerce", "other"], industries: ["saas", "eCommerce", "other"],
channels: ["link"], channels: ["link"],
description: t("templates.career_development_survey_description"), description: t("templates.career_development_survey_description"),
@@ -2160,7 +2160,7 @@ const professionalDevelopmentSurvey = (t: TFunction): TTemplate => {
return buildSurvey( return buildSurvey(
{ {
name: t("templates.professional_development_survey_name"), name: t("templates.professional_development_survey_name"),
role: "peopleManager", role: "productManager",
industries: ["saas", "eCommerce", "other"], industries: ["saas", "eCommerce", "other"],
channels: ["link"], channels: ["link"],
description: t("templates.professional_development_survey_description"), description: t("templates.professional_development_survey_description"),
+2 -6
View File
@@ -304,7 +304,7 @@ checksums:
common/project_not_found: be3b516c02b05553acb4ae338511f645 common/project_not_found: be3b516c02b05553acb4ae338511f645
common/project_permission_not_found: ace6b03f06bd14e884e4295c5022d61b common/project_permission_not_found: ace6b03f06bd14e884e4295c5022d61b
common/projects: fe8af5cfb3c95cb35534872a325b225e common/projects: fe8af5cfb3c95cb35534872a325b225e
common/question: 2a47e06b62410b16003c4979dee0099f common/question: 0576462ce60d4263d7c482463fcc9547
common/question_id: d0c3672976c281411bdccf749faf5ffd common/question_id: d0c3672976c281411bdccf749faf5ffd
common/questions: 38d08215fd7a8026077c7b64eea6bb59 common/questions: 38d08215fd7a8026077c7b64eea6bb59
common/quota: edd33b180b463ee7a70a64a5c4ad7f02 common/quota: edd33b180b463ee7a70a64a5c4ad7f02
@@ -596,7 +596,6 @@ checksums:
environments/contacts/upload_contacts_modal_pick_different_file: e748a6e81a425ef9aa33f96ca4edc157 environments/contacts/upload_contacts_modal_pick_different_file: e748a6e81a425ef9aa33f96ca4edc157
environments/contacts/upload_contacts_modal_preview: c4406f8d9a54f131abfff4e9928228bb environments/contacts/upload_contacts_modal_preview: c4406f8d9a54f131abfff4e9928228bb
environments/contacts/upload_contacts_modal_upload_btn: 47b7f3bcf478a7d8dc258d2efc80af37 environments/contacts/upload_contacts_modal_upload_btn: 47b7f3bcf478a7d8dc258d2efc80af37
environments/contacts/upload_contacts_success: cd5d6b6d587586dd4f944868c92835bc
environments/formbricks_logo: b7ee57de32c8b13463cc8ca8643eddd4 environments/formbricks_logo: b7ee57de32c8b13463cc8ca8643eddd4
environments/integrations/activepieces_integration_description: 62a8fbf86762bab01c7d2db2ba60fff4 environments/integrations/activepieces_integration_description: 62a8fbf86762bab01c7d2db2ba60fff4
environments/integrations/additional_settings: 20936205a75745fba2c4047375a04db3 environments/integrations/additional_settings: 20936205a75745fba2c4047375a04db3
@@ -749,11 +748,8 @@ checksums:
environments/project/app-connection/how_to_setup_description: 2ae5cd9456a8acd3986e3d3678e70ed2 environments/project/app-connection/how_to_setup_description: 2ae5cd9456a8acd3986e3d3678e70ed2
environments/project/app-connection/receiving_data: 9f2a48c0b0278861add70b526061264c environments/project/app-connection/receiving_data: 9f2a48c0b0278861add70b526061264c
environments/project/app-connection/recheck: f95f2bbe6990a123d60255c87bdd59f7 environments/project/app-connection/recheck: f95f2bbe6990a123d60255c87bdd59f7
environments/project/app-connection/sdk_connection_details: 89f2c169fd1604c1df5a834517f1eae1
environments/project/app-connection/sdk_connection_details_description: d9b5d06776a139aef6fc8ed53d71bf0a
environments/project/app-connection/setup_alert_description: 6d676044d01dc2147731ffab7df6c259 environments/project/app-connection/setup_alert_description: 6d676044d01dc2147731ffab7df6c259
environments/project/app-connection/setup_alert_title: 9561cca2b391e0df81e8a982921ff2bb environments/project/app-connection/setup_alert_title: 9561cca2b391e0df81e8a982921ff2bb
environments/project/app-connection/webapp_url: d64d8cc3c4c4ecce780d94755f7e4de9
environments/project/general/cannot_delete_only_project: 24751701a42d8b4d2ba6112a5f642bad environments/project/general/cannot_delete_only_project: 24751701a42d8b4d2ba6112a5f642bad
environments/project/general/delete_project: e4a2a227105c4ec71e561ab1f140eb26 environments/project/general/delete_project: e4a2a227105c4ec71e561ab1f140eb26
environments/project/general/delete_project_confirmation: 54a4ee78867537e0244c7170453cdb3f environments/project/general/delete_project_confirmation: 54a4ee78867537e0244c7170453cdb3f
@@ -1271,7 +1267,7 @@ checksums:
environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7 environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113 environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113
environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413 environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413
environments/surveys/edit/external_urls_paywall_tooltip: a8860ff0a2ad5f283bc0becba374cd54 environments/surveys/edit/external_urls_paywall_tooltip: 0dbb62557e8a6fa817f0e74709eeb3d2
environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722 environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722
environments/surveys/edit/fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first: ad4afe2980e1dfeffb20aa78eb892350 environments/surveys/edit/fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first: ad4afe2980e1dfeffb20aa78eb892350
environments/surveys/edit/fieldId_is_used_in_quota_please_remove_it_from_quota_first: 374c563964fc805ab0b8974e781687d9 environments/surveys/edit/fieldId_is_used_in_quota_please_remove_it_from_quota_first: 374c563964fc805ab0b8974e781687d9
+4
View File
@@ -218,6 +218,10 @@ export const INTERCOM_SECRET_KEY = env.INTERCOM_SECRET_KEY;
export const INTERCOM_APP_ID = env.INTERCOM_APP_ID; export const INTERCOM_APP_ID = env.INTERCOM_APP_ID;
export const IS_INTERCOM_CONFIGURED = Boolean(env.INTERCOM_APP_ID && INTERCOM_SECRET_KEY); export const IS_INTERCOM_CONFIGURED = Boolean(env.INTERCOM_APP_ID && INTERCOM_SECRET_KEY);
export const POSTHOG_API_KEY = env.POSTHOG_API_KEY;
export const POSTHOG_API_HOST = env.POSTHOG_API_HOST;
export const IS_POSTHOG_CONFIGURED = Boolean(POSTHOG_API_KEY && POSTHOG_API_HOST);
export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY; export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY;
export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY; export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;
export const IS_TURNSTILE_CONFIGURED = Boolean(env.TURNSTILE_SITE_KEY && TURNSTILE_SECRET_KEY); export const IS_TURNSTILE_CONFIGURED = Boolean(env.TURNSTILE_SITE_KEY && TURNSTILE_SECRET_KEY);
+10 -16
View File
@@ -1,10 +1,6 @@
import { createEnv } from "@t3-oss/env-nextjs"; import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod"; import { z } from "zod";
// During build time, we only need valid-format URLs for Prisma to generate the client.
// Actual connectivity and secrets are validated at runtime by the startup script.
const isBuildTime = process.env.NEXT_PHASE === "phase-production-build";
export const env = createEnv({ export const env = createEnv({
/* /*
* Serverside Environment variables, not available on the client. * Serverside Environment variables, not available on the client.
@@ -18,21 +14,14 @@ export const env = createEnv({
CRON_SECRET: z.string().optional(), CRON_SECRET: z.string().optional(),
BREVO_API_KEY: z.string().optional(), BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(), BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: isBuildTime DATABASE_URL: z.string().url(),
? z
.string()
.optional()
.default("postgresql://formbricks:formbricks@localhost:5432/formbricks?schema=public")
: z.string().url(),
DEBUG: z.enum(["1", "0"]).optional(), DEBUG: z.enum(["1", "0"]).optional(),
AUTH_DEFAULT_TEAM_ID: z.string().optional(), AUTH_DEFAULT_TEAM_ID: z.string().optional(),
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(), AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
E2E_TESTING: z.enum(["1", "0"]).optional(), E2E_TESTING: z.enum(["1", "0"]).optional(),
EMAIL_AUTH_DISABLED: z.enum(["1", "0"]).optional(), EMAIL_AUTH_DISABLED: z.enum(["1", "0"]).optional(),
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(), EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
ENCRYPTION_KEY: isBuildTime ENCRYPTION_KEY: z.string(),
? z.string().optional().default("0000000000000000000000000000000000000000000000000000000000000000")
: z.string(),
ENTERPRISE_LICENSE_KEY: z.string().optional(), ENTERPRISE_LICENSE_KEY: z.string().optional(),
GITHUB_ID: z.string().optional(), GITHUB_ID: z.string().optional(),
GITHUB_SECRET: z.string().optional(), GITHUB_SECRET: z.string().optional(),
@@ -65,12 +54,13 @@ export const env = createEnv({
OIDC_ISSUER: z.string().optional(), OIDC_ISSUER: z.string().optional(),
OIDC_SIGNING_ALGORITHM: z.string().optional(), OIDC_SIGNING_ALGORITHM: z.string().optional(),
OPENTELEMETRY_LISTENER_URL: z.string().optional(), OPENTELEMETRY_LISTENER_URL: z.string().optional(),
REDIS_URL: isBuildTime REDIS_URL:
? z.string().optional().default("redis://localhost:6379") process.env.NODE_ENV === "test"
: process.env.NODE_ENV === "test"
? z.string().optional() ? z.string().optional()
: z.string().url("REDIS_URL is required for caching, rate limiting, and audit logging"), : z.string().url("REDIS_URL is required for caching, rate limiting, and audit logging"),
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(), PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
POSTHOG_API_HOST: z.string().optional(),
POSTHOG_API_KEY: z.string().optional(),
PRIVACY_URL: z PRIVACY_URL: z
.string() .string()
.url() .url()
@@ -113,6 +103,7 @@ export const env = createEnv({
} }
) )
.optional(), .optional(),
TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(),
TERMS_URL: z TERMS_URL: z
.string() .string()
.url() .url()
@@ -181,6 +172,8 @@ export const env = createEnv({
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME, MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
SENTRY_DSN: process.env.SENTRY_DSN, SENTRY_DSN: process.env.SENTRY_DSN,
POSTHOG_API_KEY: process.env.POSTHOG_API_KEY,
POSTHOG_API_HOST: process.env.POSTHOG_API_HOST,
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL, OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
INTERCOM_APP_ID: process.env.INTERCOM_APP_ID, INTERCOM_APP_ID: process.env.INTERCOM_APP_ID,
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID, NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
@@ -213,6 +206,7 @@ export const env = createEnv({
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
PUBLIC_URL: process.env.PUBLIC_URL, PUBLIC_URL: process.env.PUBLIC_URL,
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY, TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY, TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY, RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY,
+5
View File
@@ -17,6 +17,7 @@ import {
} from "@formbricks/types/environment"; } from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors"; import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { getOrganizationsByUserId } from "../organization/service"; import { getOrganizationsByUserId } from "../organization/service";
import { capturePosthogEnvironmentEvent } from "../posthogServer";
import { getUserProjects } from "../project/service"; import { getUserProjects } from "../project/service";
import { validateInputs } from "../utils/validate"; import { validateInputs } from "../utils/validate";
@@ -172,6 +173,10 @@ export const createEnvironment = async (
}, },
}); });
await capturePosthogEnvironmentEvent(environment.id, "environment created", {
environmentType: environment.type,
});
return environment; return environment;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
+56
View File
@@ -0,0 +1,56 @@
import { PostHog } from "posthog-node";
import { createCacheKey } from "@formbricks/cache";
import { logger } from "@formbricks/logger";
import { TOrganizationBillingPlan, TOrganizationBillingPlanLimits } from "@formbricks/types/organizations";
import { cache } from "@/lib/cache";
import { IS_POSTHOG_CONFIGURED, IS_PRODUCTION, POSTHOG_API_HOST, POSTHOG_API_KEY } from "./constants";
const enabled = IS_PRODUCTION && IS_POSTHOG_CONFIGURED;
export const capturePosthogEnvironmentEvent = async (
environmentId: string,
eventName: string,
properties: any = {}
) => {
if (!enabled || typeof POSTHOG_API_HOST !== "string" || typeof POSTHOG_API_KEY !== "string") {
return;
}
try {
const client = new PostHog(POSTHOG_API_KEY, {
host: POSTHOG_API_HOST,
});
client.capture({
// workaround with a static string as exaplained in PostHog docs: https://posthog.com/docs/product-analytics/group-analytics
distinctId: "environmentEvents",
event: eventName,
groups: { environment: environmentId },
properties,
});
await client.shutdown();
} catch (error) {
logger.error(error, "error sending posthog event");
}
};
export const sendPlanLimitsReachedEventToPosthogWeekly = async (
environmentId: string,
billing: {
plan: TOrganizationBillingPlan;
limits: TOrganizationBillingPlanLimits;
}
) =>
await cache.withCache(
async () => {
try {
await capturePosthogEnvironmentEvent(environmentId, "plan limit reached", {
...billing,
});
return "success";
} catch (error) {
logger.error(error, "error sending plan limits reached event to posthog weekly");
throw error;
}
},
createCacheKey.custom("analytics", environmentId, `plan_limits_${billing.plan}`),
60 * 60 * 24 * 7 * 1000 // 7 days in milliseconds
);
+7
View File
@@ -13,6 +13,7 @@ import {
getOrganizationByEnvironmentId, getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses, subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { evaluateLogic } from "@/lib/surveyLogic/utils"; import { evaluateLogic } from "@/lib/surveyLogic/utils";
import { import {
mockActionClass, mockActionClass,
@@ -43,6 +44,11 @@ vi.mock("@/lib/organization/service", () => ({
subscribeOrganizationMembersToSurveyResponses: vi.fn(), subscribeOrganizationMembersToSurveyResponses: vi.fn(),
})); }));
// Mock posthogServer
vi.mock("@/lib/posthogServer", () => ({
capturePosthogEnvironmentEvent: vi.fn(),
}));
// Mock actionClass service // Mock actionClass service
vi.mock("@/lib/actionClass/service", () => ({ vi.mock("@/lib/actionClass/service", () => ({
getActionClasses: vi.fn(), getActionClasses: vi.fn(),
@@ -640,6 +646,7 @@ describe("Tests for createSurvey", () => {
expect(prisma.survey.create).toHaveBeenCalled(); expect(prisma.survey.create).toHaveBeenCalled();
expect(result.name).toEqual(mockSurveyOutput.name); expect(result.name).toEqual(mockSurveyOutput.name);
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalled(); expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalled();
expect(capturePosthogEnvironmentEvent).toHaveBeenCalled();
}); });
test("creates a private segment for app surveys", async () => { test("creates a private segment for app surveys", async () => {
+6
View File
@@ -13,6 +13,7 @@ import {
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { getActionClasses } from "../actionClass/service"; import { getActionClasses } from "../actionClass/service";
import { ITEMS_PER_PAGE } from "../constants"; import { ITEMS_PER_PAGE } from "../constants";
import { capturePosthogEnvironmentEvent } from "../posthogServer";
import { validateInputs } from "../utils/validate"; import { validateInputs } from "../utils/validate";
import { checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils"; import { checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils";
@@ -672,6 +673,11 @@ export const createSurvey = async (
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id); await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
} }
await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", {
surveyId: survey.id,
surveyType: survey.type,
});
return transformedSurvey; return transformedSurvey;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
+38
View File
@@ -0,0 +1,38 @@
/* We use this telemetry service to better understand how Formbricks is being used
and how we can improve it. All data including the IP address is collected anonymously
and we cannot trace anything back to you or your customers. If you still want to
disable telemetry, set the environment variable TELEMETRY_DISABLED=1 */
import { logger } from "@formbricks/logger";
import { IS_PRODUCTION } from "./constants";
import { env } from "./env";
const crypto = require("crypto");
// We are using the hashed CRON_SECRET as the distinct identifier for the instance for telemetry.
// The hash cannot be traced back to the original value or the instance itself.
// This is to ensure that the telemetry data is anonymous but still unique to the instance.
const getTelemetryId = (): string => {
return crypto.createHash("sha256").update(env.CRON_SECRET).digest("hex");
};
export const captureTelemetry = async (eventName: string, properties = {}) => {
if (env.TELEMETRY_DISABLED !== "1" && IS_PRODUCTION) {
try {
await fetch("https://telemetry.formbricks.com/capture/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
api_key: "phc_SoIFUJ8b9ufDm0YOnoOxJf6PXyuHpO7N6RztxFdZTy", // NOSONAR // This is a public API key for telemetry and not a secret
event: eventName,
properties: {
distinct_id: getTelemetryId(),
...properties,
},
timestamp: new Date().toISOString(),
}),
});
} catch (error) {
logger.error(error, "error sending telemetry");
}
}
};
+3 -7
View File
@@ -631,8 +631,7 @@
"upload_contacts_modal_duplicates_update_title": "Aktualisieren", "upload_contacts_modal_duplicates_update_title": "Aktualisieren",
"upload_contacts_modal_pick_different_file": "Wähle eine andere Datei", "upload_contacts_modal_pick_different_file": "Wähle eine andere Datei",
"upload_contacts_modal_preview": "Hier ist eine Vorschau deiner Daten.", "upload_contacts_modal_preview": "Hier ist eine Vorschau deiner Daten.",
"upload_contacts_modal_upload_btn": "Kontakte hochladen", "upload_contacts_modal_upload_btn": "Kontakte hochladen"
"upload_contacts_success": "Kontakte erfolgreich hochgeladen"
}, },
"formbricks_logo": "Formbricks-Logo", "formbricks_logo": "Formbricks-Logo",
"integrations": { "integrations": {
@@ -802,11 +801,8 @@
"how_to_setup_description": "Befolge diese Schritte, um das Formbricks Widget in deiner App einzurichten.", "how_to_setup_description": "Befolge diese Schritte, um das Formbricks Widget in deiner App einzurichten.",
"receiving_data": "Daten werden empfangen 💃🕺", "receiving_data": "Daten werden empfangen 💃🕺",
"recheck": "Erneut prüfen", "recheck": "Erneut prüfen",
"sdk_connection_details": "SDK-Verbindungsdetails",
"sdk_connection_details_description": "Deine eindeutige Umgebungs-ID und SDK-Verbindungs-URL zur Integration von Formbricks mit deiner Anwendung.",
"setup_alert_description": "Befolge dieses Schritt-für-Schritt-Tutorial, um deine App oder Website in weniger als 5 Minuten zu verbinden.", "setup_alert_description": "Befolge dieses Schritt-für-Schritt-Tutorial, um deine App oder Website in weniger als 5 Minuten zu verbinden.",
"setup_alert_title": "Wie man verbindet", "setup_alert_title": "Wie man verbindet"
"webapp_url": "SDK-Verbindungs-URL"
}, },
"general": { "general": {
"cannot_delete_only_project": "Dies ist dein einziges Projekt, es kann nicht gelöscht werden. Erstelle zuerst ein neues Projekt.", "cannot_delete_only_project": "Dies ist dein einziges Projekt, es kann nicht gelöscht werden. Erstelle zuerst ein neues Projekt.",
@@ -1356,7 +1352,7 @@
"error_saving_changes": "Fehler beim Speichern der Änderungen", "error_saving_changes": "Fehler beim Speichern der Änderungen",
"even_after_they_submitted_a_response_e_g_feedback_box": "Mehrfachantworten erlauben; weiterhin anzeigen, auch nach einer Antwort (z.B. Feedback-Box).", "even_after_they_submitted_a_response_e_g_feedback_box": "Mehrfachantworten erlauben; weiterhin anzeigen, auch nach einer Antwort (z.B. Feedback-Box).",
"everyone": "Jeder", "everyone": "Jeder",
"external_urls_paywall_tooltip": "Bitte führen sie ein upgrade auf den Startup-plan durch, um externe URLs anzupassen. Dies hilft uns, phishing zu verhindern.", "external_urls_paywall_tooltip": "Bitte aktualisieren, um die externe URL anzupassen. Phishing-Prävention.",
"fallback_missing": "Fehlender Fallback", "fallback_missing": "Fehlender Fallback",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Verstecktes Feld \"{fieldId}\" wird in der \"{quotaName}\" Quote verwendet", "fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Verstecktes Feld \"{fieldId}\" wird in der \"{quotaName}\" Quote verwendet",
+4 -8
View File
@@ -331,7 +331,7 @@
"project_not_found": "Project not found", "project_not_found": "Project not found",
"project_permission_not_found": "Project permission not found", "project_permission_not_found": "Project permission not found",
"projects": "Projects", "projects": "Projects",
"question": "question", "question": "Question",
"question_id": "Question ID", "question_id": "Question ID",
"questions": "Questions", "questions": "Questions",
"quota": "Quota", "quota": "Quota",
@@ -631,8 +631,7 @@
"upload_contacts_modal_duplicates_update_title": "Update", "upload_contacts_modal_duplicates_update_title": "Update",
"upload_contacts_modal_pick_different_file": "Pick a different file", "upload_contacts_modal_pick_different_file": "Pick a different file",
"upload_contacts_modal_preview": "Here's a preview of your data.", "upload_contacts_modal_preview": "Here's a preview of your data.",
"upload_contacts_modal_upload_btn": "Upload contacts", "upload_contacts_modal_upload_btn": "Upload contacts"
"upload_contacts_success": "Contacts uploaded successfully"
}, },
"formbricks_logo": "Formbricks Logo", "formbricks_logo": "Formbricks Logo",
"integrations": { "integrations": {
@@ -802,11 +801,8 @@
"how_to_setup_description": "Follow these steps to setup the Formbricks widget within your app.", "how_to_setup_description": "Follow these steps to setup the Formbricks widget within your app.",
"receiving_data": "Receiving data \uD83D\uDC83\uD83D\uDD7A", "receiving_data": "Receiving data \uD83D\uDC83\uD83D\uDD7A",
"recheck": "Re-check", "recheck": "Re-check",
"sdk_connection_details": "SDK Connection Details",
"sdk_connection_details_description": "Your unique environment ID and SDK connection URL for integrating Formbricks with your application.",
"setup_alert_description": "Follow this step-by-step tutorial to connect your app or website in under 5 minutes.", "setup_alert_description": "Follow this step-by-step tutorial to connect your app or website in under 5 minutes.",
"setup_alert_title": "How to connect", "setup_alert_title": "How to connect"
"webapp_url": "SDK Connection URL"
}, },
"general": { "general": {
"cannot_delete_only_project": "This is your only project, it cannot be deleted. Create a new project first.", "cannot_delete_only_project": "This is your only project, it cannot be deleted. Create a new project first.",
@@ -1356,7 +1352,7 @@
"error_saving_changes": "Error saving changes", "error_saving_changes": "Error saving changes",
"even_after_they_submitted_a_response_e_g_feedback_box": "Allow multiple responses; continue showing even after a response (e.g., Feedback Box).", "even_after_they_submitted_a_response_e_g_feedback_box": "Allow multiple responses; continue showing even after a response (e.g., Feedback Box).",
"everyone": "Everyone", "everyone": "Everyone",
"external_urls_paywall_tooltip": "Please upgrade to Startup plan to customize external URLs. This helps us prevent phishing.", "external_urls_paywall_tooltip": "Please upgrade to customize external URL. Phishing prevention.",
"fallback_missing": "Fallback missing", "fallback_missing": "Fallback missing",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Hidden field \"{fieldId}\" is being used in \"{quotaName}\" quota", "fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Hidden field \"{fieldId}\" is being used in \"{quotaName}\" quota",
+4 -8
View File
@@ -331,7 +331,7 @@
"project_not_found": "Proyecto no encontrado", "project_not_found": "Proyecto no encontrado",
"project_permission_not_found": "Permiso de proyecto no encontrado", "project_permission_not_found": "Permiso de proyecto no encontrado",
"projects": "Proyectos", "projects": "Proyectos",
"question": "pregunta", "question": "Pregunta",
"question_id": "ID de pregunta", "question_id": "ID de pregunta",
"questions": "Preguntas", "questions": "Preguntas",
"quota": "Cuota", "quota": "Cuota",
@@ -631,8 +631,7 @@
"upload_contacts_modal_duplicates_update_title": "Actualizar", "upload_contacts_modal_duplicates_update_title": "Actualizar",
"upload_contacts_modal_pick_different_file": "Selecciona un archivo diferente", "upload_contacts_modal_pick_different_file": "Selecciona un archivo diferente",
"upload_contacts_modal_preview": "Aquí tienes una vista previa de tus datos.", "upload_contacts_modal_preview": "Aquí tienes una vista previa de tus datos.",
"upload_contacts_modal_upload_btn": "Subir contactos", "upload_contacts_modal_upload_btn": "Subir contactos"
"upload_contacts_success": "Contactos subidos correctamente"
}, },
"formbricks_logo": "Logo de Formbricks", "formbricks_logo": "Logo de Formbricks",
"integrations": { "integrations": {
@@ -802,11 +801,8 @@
"how_to_setup_description": "Sigue estos pasos para configurar el widget de Formbricks en tu aplicación.", "how_to_setup_description": "Sigue estos pasos para configurar el widget de Formbricks en tu aplicación.",
"receiving_data": "Recibiendo datos 💃🕺", "receiving_data": "Recibiendo datos 💃🕺",
"recheck": "Volver a comprobar", "recheck": "Volver a comprobar",
"sdk_connection_details": "Detalles de conexión del SDK",
"sdk_connection_details_description": "Tu ID de entorno único y URL de conexión del SDK para integrar Formbricks con tu aplicación.",
"setup_alert_description": "Sigue este tutorial paso a paso para conectar tu aplicación o sitio web en menos de 5 minutos.", "setup_alert_description": "Sigue este tutorial paso a paso para conectar tu aplicación o sitio web en menos de 5 minutos.",
"setup_alert_title": "Cómo conectar", "setup_alert_title": "Cómo conectar"
"webapp_url": "URL de conexión del SDK"
}, },
"general": { "general": {
"cannot_delete_only_project": "Este es tu único proyecto, no se puede eliminar. Crea un proyecto nuevo primero.", "cannot_delete_only_project": "Este es tu único proyecto, no se puede eliminar. Crea un proyecto nuevo primero.",
@@ -1356,7 +1352,7 @@
"error_saving_changes": "Error al guardar los cambios", "error_saving_changes": "Error al guardar los cambios",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir respuestas múltiples; seguir mostrando incluso después de una respuesta (p. ej., cuadro de comentarios).", "even_after_they_submitted_a_response_e_g_feedback_box": "Permitir respuestas múltiples; seguir mostrando incluso después de una respuesta (p. ej., cuadro de comentarios).",
"everyone": "Todos", "everyone": "Todos",
"external_urls_paywall_tooltip": "Por favor, actualiza al plan Startup para personalizar URLs externos. Esto nos ayuda a prevenir el phishing.", "external_urls_paywall_tooltip": "Por favor, actualiza para personalizar la URL externa. Prevención de phishing.",
"fallback_missing": "Falta respaldo", "fallback_missing": "Falta respaldo",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínalo primero de la lógica.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínalo primero de la lógica.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "El campo oculto \"{fieldId}\" se está utilizando en la cuota \"{quotaName}\"", "fieldId_is_used_in_quota_please_remove_it_from_quota_first": "El campo oculto \"{fieldId}\" se está utilizando en la cuota \"{quotaName}\"",
+4 -8
View File
@@ -331,7 +331,7 @@
"project_not_found": "Projet non trouvé", "project_not_found": "Projet non trouvé",
"project_permission_not_found": "Autorisation de projet non trouvée", "project_permission_not_found": "Autorisation de projet non trouvée",
"projects": "Projets", "projects": "Projets",
"question": "question", "question": "Question",
"question_id": "ID de la question", "question_id": "ID de la question",
"questions": "Questions", "questions": "Questions",
"quota": "Quota", "quota": "Quota",
@@ -631,8 +631,7 @@
"upload_contacts_modal_duplicates_update_title": "Mettre à jour", "upload_contacts_modal_duplicates_update_title": "Mettre à jour",
"upload_contacts_modal_pick_different_file": "Choisissez un fichier différent", "upload_contacts_modal_pick_different_file": "Choisissez un fichier différent",
"upload_contacts_modal_preview": "Voici un aperçu de vos données.", "upload_contacts_modal_preview": "Voici un aperçu de vos données.",
"upload_contacts_modal_upload_btn": "Importer des contacts", "upload_contacts_modal_upload_btn": "Importer des contacts"
"upload_contacts_success": "Contacts téléchargés avec succès"
}, },
"formbricks_logo": "Logo Formbricks", "formbricks_logo": "Logo Formbricks",
"integrations": { "integrations": {
@@ -802,11 +801,8 @@
"how_to_setup_description": "Suivez ces étapes pour configurer le widget Formbricks dans votre application.", "how_to_setup_description": "Suivez ces étapes pour configurer le widget Formbricks dans votre application.",
"receiving_data": "Réception des données 💃🕺", "receiving_data": "Réception des données 💃🕺",
"recheck": "Réessayer", "recheck": "Réessayer",
"sdk_connection_details": "Détails de connexion SDK",
"sdk_connection_details_description": "Votre ID d'environnement unique et votre URL de connexion SDK pour intégrer Formbricks à votre application.",
"setup_alert_description": "Suivez les indications de ce tutoriel pour connecter votre application ou votre site Web en moins de cinq minutes.", "setup_alert_description": "Suivez les indications de ce tutoriel pour connecter votre application ou votre site Web en moins de cinq minutes.",
"setup_alert_title": "Connexion", "setup_alert_title": "Connexion"
"webapp_url": "URL de connexion SDK"
}, },
"general": { "general": {
"cannot_delete_only_project": "Comme il s'agit de votre seul projet, il ne peut pas être supprimé. Créez d'abord un nouveau projet.", "cannot_delete_only_project": "Comme il s'agit de votre seul projet, il ne peut pas être supprimé. Créez d'abord un nouveau projet.",
@@ -1356,7 +1352,7 @@
"error_saving_changes": "Erreur lors de l'enregistrement des modifications", "error_saving_changes": "Erreur lors de l'enregistrement des modifications",
"even_after_they_submitted_a_response_e_g_feedback_box": "Autoriser plusieurs réponses; continuer à afficher même après une réponse (par exemple, boîte de commentaires).", "even_after_they_submitted_a_response_e_g_feedback_box": "Autoriser plusieurs réponses; continuer à afficher même après une réponse (par exemple, boîte de commentaires).",
"everyone": "Tout le monde", "everyone": "Tout le monde",
"external_urls_paywall_tooltip": "Veuillez passer au forfait Startup pour personnaliser les URL externes. Cela nous aide à prévenir le phishing.", "external_urls_paywall_tooltip": "Veuillez passer à la version supérieure pour personnaliser l'URL externe. Prévention contre l'hameçonnage.",
"fallback_missing": "Fallback manquant", "fallback_missing": "Fallback manquant",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Le champ masqué \"{fieldId}\" est utilisé dans le quota \"{quotaName}\"", "fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Le champ masqué \"{fieldId}\" est utilisé dans le quota \"{quotaName}\"",
+3 -7
View File
@@ -631,8 +631,7 @@
"upload_contacts_modal_duplicates_update_title": "更新", "upload_contacts_modal_duplicates_update_title": "更新",
"upload_contacts_modal_pick_different_file": "別のファイルを選択", "upload_contacts_modal_pick_different_file": "別のファイルを選択",
"upload_contacts_modal_preview": "データのプレビューです。", "upload_contacts_modal_preview": "データのプレビューです。",
"upload_contacts_modal_upload_btn": "連絡先をアップロード", "upload_contacts_modal_upload_btn": "連絡先をアップロード"
"upload_contacts_success": "連絡先のアップロードに成功しました"
}, },
"formbricks_logo": "Formbricksのロゴ", "formbricks_logo": "Formbricksのロゴ",
"integrations": { "integrations": {
@@ -802,11 +801,8 @@
"how_to_setup_description": "アプリ内でFormbricksウィジェットを設定する手順に従ってください。", "how_to_setup_description": "アプリ内でFormbricksウィジェットを設定する手順に従ってください。",
"receiving_data": "データ受信中 💃🕺", "receiving_data": "データ受信中 💃🕺",
"recheck": "再チェック", "recheck": "再チェック",
"sdk_connection_details": "SDK接続詳細",
"sdk_connection_details_description": "FormbricksをアプリケーションとAPI統合するためのEnvironmentIdとSDK接続URL。",
"setup_alert_description": "5 分以内でアプリまたはウェブサイト を 接続する手順をステップバイステップ の チュートリアルに従ってください。", "setup_alert_description": "5 分以内でアプリまたはウェブサイト を 接続する手順をステップバイステップ の チュートリアルに従ってください。",
"setup_alert_title": "接続方法", "setup_alert_title": "接続方法"
"webapp_url": "SDK接続URL"
}, },
"general": { "general": {
"cannot_delete_only_project": "これは唯一のプロジェクトのため削除できません。まず新しいプロジェクトを作成してください。", "cannot_delete_only_project": "これは唯一のプロジェクトのため削除できません。まず新しいプロジェクトを作成してください。",
@@ -1356,7 +1352,7 @@
"error_saving_changes": "変更の保存中にエラーが発生しました", "error_saving_changes": "変更の保存中にエラーが発生しました",
"even_after_they_submitted_a_response_e_g_feedback_box": "複数の回答を許可;回答後も表示を継続(例:フィードボックス)。", "even_after_they_submitted_a_response_e_g_feedback_box": "複数の回答を許可;回答後も表示を継続(例:フィードボックス)。",
"everyone": "全員", "everyone": "全員",
"external_urls_paywall_tooltip": "外部URLをカスタマイズするには、スタートアッププランへのアップグレードが必要です。これによりフィッシング詐欺を防止することができます。", "external_urls_paywall_tooltip": "外部 URL をカスタマイズするにはアップグレードしてください 。 フィッシング防止 。",
"fallback_missing": "フォールバックがありません", "fallback_missing": "フォールバックがありません",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隠しフィールド \"{fieldId}\" は \"{quotaName}\" クォータ で使用されています", "fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隠しフィールド \"{fieldId}\" は \"{quotaName}\" クォータ で使用されています",
+4 -8
View File
@@ -331,7 +331,7 @@
"project_not_found": "Project niet gevonden", "project_not_found": "Project niet gevonden",
"project_permission_not_found": "Projecttoestemming niet gevonden", "project_permission_not_found": "Projecttoestemming niet gevonden",
"projects": "Projecten", "projects": "Projecten",
"question": "vraag", "question": "Vraag",
"question_id": "Vraag-ID", "question_id": "Vraag-ID",
"questions": "Vragen", "questions": "Vragen",
"quota": "Quotum", "quota": "Quotum",
@@ -631,8 +631,7 @@
"upload_contacts_modal_duplicates_update_title": "Update", "upload_contacts_modal_duplicates_update_title": "Update",
"upload_contacts_modal_pick_different_file": "Kies een ander bestand", "upload_contacts_modal_pick_different_file": "Kies een ander bestand",
"upload_contacts_modal_preview": "Hier ziet u een voorbeeld van uw gegevens.", "upload_contacts_modal_preview": "Hier ziet u een voorbeeld van uw gegevens.",
"upload_contacts_modal_upload_btn": "Contacten uploaden", "upload_contacts_modal_upload_btn": "Contacten uploaden"
"upload_contacts_success": "Contacten succesvol geüpload"
}, },
"formbricks_logo": "Formbricks-logo", "formbricks_logo": "Formbricks-logo",
"integrations": { "integrations": {
@@ -802,11 +801,8 @@
"how_to_setup_description": "Volg deze stappen om de Formbricks-widget in uw app in te stellen.", "how_to_setup_description": "Volg deze stappen om de Formbricks-widget in uw app in te stellen.",
"receiving_data": "Gegevens ontvangen 💃🕺", "receiving_data": "Gegevens ontvangen 💃🕺",
"recheck": "Controleer opnieuw", "recheck": "Controleer opnieuw",
"sdk_connection_details": "SDK-verbindingsdetails",
"sdk_connection_details_description": "Uw unieke Environment ID en SDK-verbindings-URL voor integratie van Formbricks met uw applicatie.",
"setup_alert_description": "Volg deze stapsgewijze handleiding om uw app of website in minder dan 5 minuten te verbinden.", "setup_alert_description": "Volg deze stapsgewijze handleiding om uw app of website in minder dan 5 minuten te verbinden.",
"setup_alert_title": "Hoe te verbinden", "setup_alert_title": "Hoe te verbinden"
"webapp_url": "SDK-verbindings-URL"
}, },
"general": { "general": {
"cannot_delete_only_project": "Dit is uw enige project. Het kan niet worden verwijderd. Maak eerst een nieuw project aan.", "cannot_delete_only_project": "Dit is uw enige project. Het kan niet worden verwijderd. Maak eerst een nieuw project aan.",
@@ -1356,7 +1352,7 @@
"error_saving_changes": "Fout bij het opslaan van wijzigingen", "error_saving_changes": "Fout bij het opslaan van wijzigingen",
"even_after_they_submitted_a_response_e_g_feedback_box": "Meerdere reacties toestaan; blijf tonen, zelfs na een reactie (bijv. feedbackbox).", "even_after_they_submitted_a_response_e_g_feedback_box": "Meerdere reacties toestaan; blijf tonen, zelfs na een reactie (bijv. feedbackbox).",
"everyone": "Iedereen", "everyone": "Iedereen",
"external_urls_paywall_tooltip": "Upgrade naar het Startup-abonnement om externe URL's aan te passen. Dit helpt ons phishing te voorkomen.", "external_urls_paywall_tooltip": "Upgrade om de externe URL aan te passen. Phishing-preventie.",
"fallback_missing": "Terugval ontbreekt", "fallback_missing": "Terugval ontbreekt",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Verborgen veld \"{fieldId}\" wordt gebruikt in het \"{quotaName}\" quotum", "fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Verborgen veld \"{fieldId}\" wordt gebruikt in het \"{quotaName}\" quotum",
+4 -8
View File
@@ -331,7 +331,7 @@
"project_not_found": "Projeto não encontrado", "project_not_found": "Projeto não encontrado",
"project_permission_not_found": "Permissão do projeto não encontrada", "project_permission_not_found": "Permissão do projeto não encontrada",
"projects": "Projetos", "projects": "Projetos",
"question": "pergunta", "question": "Pergunta",
"question_id": "ID da Pergunta", "question_id": "ID da Pergunta",
"questions": "Perguntas", "questions": "Perguntas",
"quota": "Cota", "quota": "Cota",
@@ -631,8 +631,7 @@
"upload_contacts_modal_duplicates_update_title": "Atualizar", "upload_contacts_modal_duplicates_update_title": "Atualizar",
"upload_contacts_modal_pick_different_file": "Escolha um arquivo diferente", "upload_contacts_modal_pick_different_file": "Escolha um arquivo diferente",
"upload_contacts_modal_preview": "Aqui está uma prévia dos seus dados.", "upload_contacts_modal_preview": "Aqui está uma prévia dos seus dados.",
"upload_contacts_modal_upload_btn": "Fazer upload de contatos", "upload_contacts_modal_upload_btn": "Fazer upload de contatos"
"upload_contacts_success": "Contatos carregados com sucesso"
}, },
"formbricks_logo": "Logo da Formbricks", "formbricks_logo": "Logo da Formbricks",
"integrations": { "integrations": {
@@ -802,11 +801,8 @@
"how_to_setup_description": "Siga esses passos para configurar o widget do Formbricks no seu app.", "how_to_setup_description": "Siga esses passos para configurar o widget do Formbricks no seu app.",
"receiving_data": "Recebendo dados 💃🕺", "receiving_data": "Recebendo dados 💃🕺",
"recheck": "Verificar novamente", "recheck": "Verificar novamente",
"sdk_connection_details": "Detalhes de Conexão do SDK",
"sdk_connection_details_description": "Seu ID de ambiente único e URL de conexão do SDK para integrar o Formbricks com seu aplicativo.",
"setup_alert_description": "Siga este tutorial passo a passo para conectar seu app ou site em menos de 5 minutos.", "setup_alert_description": "Siga este tutorial passo a passo para conectar seu app ou site em menos de 5 minutos.",
"setup_alert_title": "Como conectar", "setup_alert_title": "Como conectar"
"webapp_url": "URL de conexão do SDK"
}, },
"general": { "general": {
"cannot_delete_only_project": "Esse é seu único projeto, não pode ser deletado. Crie um novo projeto primeiro.", "cannot_delete_only_project": "Esse é seu único projeto, não pode ser deletado. Crie um novo projeto primeiro.",
@@ -1356,7 +1352,7 @@
"error_saving_changes": "Erro ao salvar alterações", "error_saving_changes": "Erro ao salvar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar mostrando mesmo após uma resposta (ex.: caixa de feedback).", "even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar mostrando mesmo após uma resposta (ex.: caixa de feedback).",
"everyone": "Todo mundo", "everyone": "Todo mundo",
"external_urls_paywall_tooltip": "Por favor, faça upgrade para o plano Startup para personalizar URLs externos. Isso nos ajuda a prevenir phishing.", "external_urls_paywall_tooltip": "Por favor, faça upgrade para personalizar o URL externo. Prevenção de phishing.",
"fallback_missing": "Faltando alternativa", "fallback_missing": "Faltando alternativa",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Campo oculto \"{fieldId}\" está sendo usado na cota \"{quotaName}\"", "fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Campo oculto \"{fieldId}\" está sendo usado na cota \"{quotaName}\"",
+4 -8
View File
@@ -331,7 +331,7 @@
"project_not_found": "Projeto não encontrado", "project_not_found": "Projeto não encontrado",
"project_permission_not_found": "Permissão do projeto não encontrada", "project_permission_not_found": "Permissão do projeto não encontrada",
"projects": "Projetos", "projects": "Projetos",
"question": "pergunta", "question": "Pergunta",
"question_id": "ID da pergunta", "question_id": "ID da pergunta",
"questions": "Perguntas", "questions": "Perguntas",
"quota": "Quota", "quota": "Quota",
@@ -631,8 +631,7 @@
"upload_contacts_modal_duplicates_update_title": "Atualizar", "upload_contacts_modal_duplicates_update_title": "Atualizar",
"upload_contacts_modal_pick_different_file": "Escolher um ficheiro diferente", "upload_contacts_modal_pick_different_file": "Escolher um ficheiro diferente",
"upload_contacts_modal_preview": "Aqui está uma pré-visualização dos seus dados.", "upload_contacts_modal_preview": "Aqui está uma pré-visualização dos seus dados.",
"upload_contacts_modal_upload_btn": "Carregar contactos", "upload_contacts_modal_upload_btn": "Carregar contactos"
"upload_contacts_success": "Contactos carregados com sucesso"
}, },
"formbricks_logo": "Logotipo do Formbricks", "formbricks_logo": "Logotipo do Formbricks",
"integrations": { "integrations": {
@@ -802,11 +801,8 @@
"how_to_setup_description": "Siga estes passos para configurar o widget Formbricks na sua aplicação.", "how_to_setup_description": "Siga estes passos para configurar o widget Formbricks na sua aplicação.",
"receiving_data": "A receber dados 💃🕺", "receiving_data": "A receber dados 💃🕺",
"recheck": "Verificar novamente", "recheck": "Verificar novamente",
"sdk_connection_details": "Detalhes de Conexão SDK",
"sdk_connection_details_description": "O seu ID de ambiente único e URL de conexão SDK para integrar o Formbricks com a sua aplicação.",
"setup_alert_description": "Siga este tutorial passo a passo para conectar a sua app ou site em menos de 5 minutos", "setup_alert_description": "Siga este tutorial passo a passo para conectar a sua app ou site em menos de 5 minutos",
"setup_alert_title": "Como conectar", "setup_alert_title": "Como conectar"
"webapp_url": "URL de ligação do SDK"
}, },
"general": { "general": {
"cannot_delete_only_project": "Este é o seu único projeto, não pode ser eliminado. Crie um novo projeto primeiro.", "cannot_delete_only_project": "Este é o seu único projeto, não pode ser eliminado. Crie um novo projeto primeiro.",
@@ -1356,7 +1352,7 @@
"error_saving_changes": "Erro ao guardar alterações", "error_saving_changes": "Erro ao guardar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar a mostrar mesmo após uma resposta (por exemplo, Caixa de Feedback).", "even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar a mostrar mesmo após uma resposta (por exemplo, Caixa de Feedback).",
"everyone": "Todos", "everyone": "Todos",
"external_urls_paywall_tooltip": "Por favor, atualize para o plano Startup para personalizar URLs externos. Isto ajuda-nos a prevenir o phishing.", "external_urls_paywall_tooltip": "Por favor, atualize para personalizar o URL externo. Prevenção contra phishing.",
"fallback_missing": "Substituição em falta", "fallback_missing": "Substituição em falta",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Campo oculto \"{fieldId}\" está a ser usado na quota \"{quotaName}\"", "fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Campo oculto \"{fieldId}\" está a ser usado na quota \"{quotaName}\"",
+4 -8
View File
@@ -331,7 +331,7 @@
"project_not_found": "Proiectul nu a fost găsit", "project_not_found": "Proiectul nu a fost găsit",
"project_permission_not_found": "Permisiunea proiectului nu a fost găsită", "project_permission_not_found": "Permisiunea proiectului nu a fost găsită",
"projects": "Proiecte", "projects": "Proiecte",
"question": "întrebare", "question": "Întrebare",
"question_id": "ID întrebare", "question_id": "ID întrebare",
"questions": "Întrebări", "questions": "Întrebări",
"quota": "Cotă", "quota": "Cotă",
@@ -631,8 +631,7 @@
"upload_contacts_modal_duplicates_update_title": "Actualizare", "upload_contacts_modal_duplicates_update_title": "Actualizare",
"upload_contacts_modal_pick_different_file": "Selectați un alt fișier", "upload_contacts_modal_pick_different_file": "Selectați un alt fișier",
"upload_contacts_modal_preview": "Iată o previzualizare a datelor tale.", "upload_contacts_modal_preview": "Iată o previzualizare a datelor tale.",
"upload_contacts_modal_upload_btn": "Încărcați contacte", "upload_contacts_modal_upload_btn": "Încărcați contacte"
"upload_contacts_success": "Contactele au fost încărcate cu succes"
}, },
"formbricks_logo": "Logo Formbricks", "formbricks_logo": "Logo Formbricks",
"integrations": { "integrations": {
@@ -802,11 +801,8 @@
"how_to_setup_description": "Urmează acești pași pentru a configura widget-ul Formbricks în aplicația ta.", "how_to_setup_description": "Urmează acești pași pentru a configura widget-ul Formbricks în aplicația ta.",
"receiving_data": "Recepționare date 💃🕺", "receiving_data": "Recepționare date 💃🕺",
"recheck": "Re-verifică", "recheck": "Re-verifică",
"sdk_connection_details": "Detalii de conexiune SDK",
"sdk_connection_details_description": "ID-ul mediului tău unic și URL-ul de conexiune SDK pentru a integra Formbricks cu aplicația ta.",
"setup_alert_description": "Urmează acest tutorial pas cu pas pentru a-ți conecta aplicația sau site-ul în mai puțin de 5 minute.", "setup_alert_description": "Urmează acest tutorial pas cu pas pentru a-ți conecta aplicația sau site-ul în mai puțin de 5 minute.",
"setup_alert_title": "Cum să conectezi", "setup_alert_title": "Cum să conectezi"
"webapp_url": "URL de conexiune SDK"
}, },
"general": { "general": {
"cannot_delete_only_project": "Acesta este singurul tău proiect, nu poate fi șters. Creează mai întâi un proiect nou.", "cannot_delete_only_project": "Acesta este singurul tău proiect, nu poate fi șters. Creează mai întâi un proiect nou.",
@@ -1356,7 +1352,7 @@
"error_saving_changes": "Eroare la salvarea modificărilor", "error_saving_changes": "Eroare la salvarea modificărilor",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permite răspunsuri multiple; continuă afișarea chiar și după un răspuns (de exemplu, Caseta de Feedback).", "even_after_they_submitted_a_response_e_g_feedback_box": "Permite răspunsuri multiple; continuă afișarea chiar și după un răspuns (de exemplu, Caseta de Feedback).",
"everyone": "Toată lumea", "everyone": "Toată lumea",
"external_urls_paywall_tooltip": "Vă rugăm să treceți la planul Startup pentru a personaliza URL-urile externe. Acest lucru ne ajută să prevenim phishing-ul.", "external_urls_paywall_tooltip": "Vă rugăm să faceți upgrade pentru a personaliza URL-ul extern. Prevenire phishing.",
"fallback_missing": "Rezerva lipsă", "fallback_missing": "Rezerva lipsă",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} este folosit în logică întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} este folosit în logică întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Câmpul ascuns \"{fieldId}\" este folosit în cota \"{quotaName}\"", "fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Câmpul ascuns \"{fieldId}\" este folosit în cota \"{quotaName}\"",
+3 -7
View File
@@ -631,8 +631,7 @@
"upload_contacts_modal_duplicates_update_title": "更新", "upload_contacts_modal_duplicates_update_title": "更新",
"upload_contacts_modal_pick_different_file": "选择不同的文件", "upload_contacts_modal_pick_different_file": "选择不同的文件",
"upload_contacts_modal_preview": "这是 你 的 数据 预览。", "upload_contacts_modal_preview": "这是 你 的 数据 预览。",
"upload_contacts_modal_upload_btn": "上传 联系人", "upload_contacts_modal_upload_btn": "上传 联系人"
"upload_contacts_success": "联系人上传成功"
}, },
"formbricks_logo": "Formbricks Logo", "formbricks_logo": "Formbricks Logo",
"integrations": { "integrations": {
@@ -802,11 +801,8 @@
"how_to_setup_description": "遵循这些步骤在你的应用中设置 Formbricks 小部件。", "how_to_setup_description": "遵循这些步骤在你的应用中设置 Formbricks 小部件。",
"receiving_data": "接收 数据 💃🕺", "receiving_data": "接收 数据 💃🕺",
"recheck": "重新检查", "recheck": "重新检查",
"sdk_connection_details": "SDK 连接详情",
"sdk_connection_details_description": "您唯一的环境 ID 和 SDK 连接 URL,用于将 Formbricks 与您的应用程序集成。",
"setup_alert_description": "按照 此 步骤教程 在 5 分钟 以内 连接 你的 应用 或 网站。", "setup_alert_description": "按照 此 步骤教程 在 5 分钟 以内 连接 你的 应用 或 网站。",
"setup_alert_title": "如何 连接", "setup_alert_title": "如何 连接"
"webapp_url": "SDK连接URL"
}, },
"general": { "general": {
"cannot_delete_only_project": "这是 您 唯一的 项目,不可 删除。请 先 创建一个新的 项目。", "cannot_delete_only_project": "这是 您 唯一的 项目,不可 删除。请 先 创建一个新的 项目。",
@@ -1356,7 +1352,7 @@
"error_saving_changes": "保存 更改 时 出错", "error_saving_changes": "保存 更改 时 出错",
"even_after_they_submitted_a_response_e_g_feedback_box": "允许多次回应;即使已提交回应,仍会继续显示(例如,反馈框)。", "even_after_they_submitted_a_response_e_g_feedback_box": "允许多次回应;即使已提交回应,仍会继续显示(例如,反馈框)。",
"everyone": "所有 人", "everyone": "所有 人",
"external_urls_paywall_tooltip": "请升级到 Startup 计划以自定义外部 URL。这有助于我们防止网络钓鱼。", "external_urls_paywall_tooltip": "请升级 以自定义 外部 URL 。 网络钓鱼 预防 。",
"fallback_missing": "备用 缺失", "fallback_missing": "备用 缺失",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{fieldId} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{fieldId} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隐藏 字段 \"{fieldId}\" 正在 被 \"{quotaName}\" 配额 使用", "fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隐藏 字段 \"{fieldId}\" 正在 被 \"{quotaName}\" 配额 使用",
+3 -7
View File
@@ -631,8 +631,7 @@
"upload_contacts_modal_duplicates_update_title": "更新", "upload_contacts_modal_duplicates_update_title": "更新",
"upload_contacts_modal_pick_different_file": "選取不同的檔案", "upload_contacts_modal_pick_different_file": "選取不同的檔案",
"upload_contacts_modal_preview": "這是您的資料預覽。", "upload_contacts_modal_preview": "這是您的資料預覽。",
"upload_contacts_modal_upload_btn": "上傳聯絡人", "upload_contacts_modal_upload_btn": "上傳聯絡人"
"upload_contacts_success": "聯絡人已成功上傳"
}, },
"formbricks_logo": "Formbricks 標誌", "formbricks_logo": "Formbricks 標誌",
"integrations": { "integrations": {
@@ -802,11 +801,8 @@
"how_to_setup_description": "請按照這些步驟在您的應用程式中設定 Formbricks 小工具。", "how_to_setup_description": "請按照這些步驟在您的應用程式中設定 Formbricks 小工具。",
"receiving_data": "正在接收資料 💃🕺", "receiving_data": "正在接收資料 💃🕺",
"recheck": "重新檢查", "recheck": "重新檢查",
"sdk_connection_details": "SDK 連線詳細資訊",
"sdk_connection_details_description": "您的唯一環境 ID 和 SDK 連線 URL,用於將 Formbricks 與您的應用程式整合。",
"setup_alert_description": "遵循 此 分步 教程 ,在 5 分鐘 內 將您的應用程式 或 網站 連線 。", "setup_alert_description": "遵循 此 分步 教程 ,在 5 分鐘 內 將您的應用程式 或 網站 連線 。",
"setup_alert_title": "如何 連線", "setup_alert_title": "如何 連線"
"webapp_url": "SDK 連接 URL"
}, },
"general": { "general": {
"cannot_delete_only_project": "這是您唯一的專案,無法刪除。請先建立新專案。", "cannot_delete_only_project": "這是您唯一的專案,無法刪除。請先建立新專案。",
@@ -1356,7 +1352,7 @@
"error_saving_changes": "儲存變更時發生錯誤", "error_saving_changes": "儲存變更時發生錯誤",
"even_after_they_submitted_a_response_e_g_feedback_box": "允許多次回應;即使已提交回應仍繼續顯示(例如:意見回饋框)。", "even_after_they_submitted_a_response_e_g_feedback_box": "允許多次回應;即使已提交回應仍繼續顯示(例如:意見回饋框)。",
"everyone": "所有人", "everyone": "所有人",
"external_urls_paywall_tooltip": "請升級至 Startup 計劃以自訂外部 URL。這有助於防止網路釣魚攻擊。", "external_urls_paywall_tooltip": "請升級以自訂 external URL 。 Phishing 預防。",
"fallback_missing": "遺失的回退", "fallback_missing": "遺失的回退",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隱藏欄位 \"{fieldId}\" 正被使用於 \"{quotaName}\" 配額中", "fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隱藏欄位 \"{fieldId}\" 正被使用於 \"{quotaName}\" 配額中",
@@ -1,10 +1,13 @@
import "server-only"; import "server-only";
import { Prisma, Response } from "@prisma/client"; import { Prisma, Response } from "@prisma/client";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TContactAttributes } from "@formbricks/types/contact-attribute"; import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { Result, err, ok } from "@formbricks/types/error-handlers"; import { Result, err, ok } from "@formbricks/types/error-handlers";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { getContactByUserId } from "@/modules/api/v2/management/responses/lib/contact"; import { getContactByUserId } from "@/modules/api/v2/management/responses/lib/contact";
import { import {
getMonthlyOrganizationResponseCount, getMonthlyOrganizationResponseCount,
@@ -48,6 +51,8 @@ export const createResponse = async (
responseInput: TResponseInput, responseInput: TResponseInput,
tx?: Prisma.TransactionClient tx?: Prisma.TransactionClient
): Promise<Result<Response, ApiErrorResponseV2>> => { ): Promise<Result<Response, ApiErrorResponseV2>> => {
captureTelemetry("response created");
const { const {
surveyId, surveyId,
displayId, displayId,
@@ -121,6 +126,7 @@ export const createResponse = async (
if (!billing.ok) { if (!billing.ok) {
return err(billing.error as ApiErrorResponseV2); return err(billing.error as ApiErrorResponseV2);
} }
const billingData = billing.data;
const prismaClient = tx ?? prisma; const prismaClient = tx ?? prisma;
@@ -134,7 +140,26 @@ export const createResponse = async (
return err(responsesCountResult.error as ApiErrorResponseV2); return err(responsesCountResult.error as ApiErrorResponseV2);
} }
// Limit check completed const responsesCount = responsesCountResult.data;
const responsesLimit = billingData.limits?.monthly.responses;
if (responsesLimit && responsesCount >= responsesLimit) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: billingData.plan,
limits: {
projects: null,
monthly: {
responses: responsesLimit,
miu: null,
},
},
});
} catch (err) {
// Log error but do not throw it
logger.error(err, "Error sending plan limits reached event to Posthog");
}
}
} }
return ok(response); return ok(response);
@@ -12,6 +12,7 @@ import {
import { beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { err, ok } from "@formbricks/types/error-handlers"; import { err, ok } from "@formbricks/types/error-handlers";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { import {
getMonthlyOrganizationResponseCount, getMonthlyOrganizationResponseCount,
getOrganizationBilling, getOrganizationBilling,
@@ -19,6 +20,10 @@ import {
} from "@/modules/api/v2/management/responses/lib/organization"; } from "@/modules/api/v2/management/responses/lib/organization";
import { createResponse, getResponses } from "../response"; import { createResponse, getResponses } from "../response";
vi.mock("@/lib/posthogServer", () => ({
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/api/v2/management/responses/lib/organization", () => ({ vi.mock("@/modules/api/v2/management/responses/lib/organization", () => ({
getOrganizationIdFromEnvironmentId: vi.fn(), getOrganizationIdFromEnvironmentId: vi.fn(),
getOrganizationBilling: vi.fn(), getOrganizationBilling: vi.fn(),
@@ -145,8 +150,11 @@ describe("Response Lib", () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100)); vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockImplementation(() => Promise.resolve(""));
const result = await createResponse(environmentId, responseInput); const result = await createResponse(environmentId, responseInput);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
if (result.ok) { if (result.ok) {
expect(result.data).toEqual(response); expect(result.data).toEqual(response);
@@ -183,6 +191,10 @@ describe("Response Lib", () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100)); vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(
new Error("Error sending plan limits")
);
const result = await createResponse(environmentId, responseInput); const result = await createResponse(environmentId, responseInput);
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
if (result.ok) { if (result.ok) {
@@ -1,6 +1,7 @@
import { WebhookSource } from "@prisma/client"; import { WebhookSource } from "@prisma/client";
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { captureTelemetry } from "@/lib/telemetry";
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks"; import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { createWebhook, getWebhooks } from "../webhook"; import { createWebhook, getWebhooks } from "../webhook";
@@ -15,6 +16,10 @@ vi.mock("@formbricks/database", () => ({
}, },
})); }));
vi.mock("@/lib/telemetry", () => ({
captureTelemetry: vi.fn(),
}));
describe("getWebhooks", () => { describe("getWebhooks", () => {
const environmentId = "env1"; const environmentId = "env1";
const params = { const params = {
@@ -81,6 +86,7 @@ describe("createWebhook", () => {
vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook); vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook);
const result = await createWebhook(inputWebhook); const result = await createWebhook(inputWebhook);
expect(captureTelemetry).toHaveBeenCalledWith("webhook_created");
expect(prisma.webhook.create).toHaveBeenCalled(); expect(prisma.webhook.create).toHaveBeenCalled();
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
@@ -1,6 +1,7 @@
import { Prisma, Webhook } from "@prisma/client"; import { Prisma, Webhook } from "@prisma/client";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers"; import { Result, err, ok } from "@formbricks/types/error-handlers";
import { captureTelemetry } from "@/lib/telemetry";
import { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils"; import { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils";
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks"; import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
@@ -46,6 +47,8 @@ export const getWebhooks = async (
}; };
export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webhook, ApiErrorResponseV2>> => { export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webhook, ApiErrorResponseV2>> => {
captureTelemetry("webhook_created");
const { environmentId, name, url, source, triggers, surveyIds } = webhook; const { environmentId, name, url, source, triggers, surveyIds } = webhook;
try { try {
@@ -2,6 +2,7 @@ import { ProjectTeam } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers"; import { Result, err, ok } from "@formbricks/types/error-handlers";
import { captureTelemetry } from "@/lib/telemetry";
import { getProjectTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils"; import { getProjectTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils";
import { import {
TGetProjectTeamsFilter, TGetProjectTeamsFilter,
@@ -43,6 +44,8 @@ export const getProjectTeams = async (
export const createProjectTeam = async ( export const createProjectTeam = async (
teamInput: TProjectTeamInput teamInput: TProjectTeamInput
): Promise<Result<ProjectTeam, ApiErrorResponseV2>> => { ): Promise<Result<ProjectTeam, ApiErrorResponseV2>> => {
captureTelemetry("project team created");
const { teamId, projectId, permission } = teamInput; const { teamId, projectId, permission } = teamInput;
try { try {
@@ -2,6 +2,7 @@ import "server-only";
import { Team } from "@prisma/client"; import { Team } from "@prisma/client";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers"; import { Result, err, ok } from "@formbricks/types/error-handlers";
import { captureTelemetry } from "@/lib/telemetry";
import { getTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/teams/lib/utils"; import { getTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/teams/lib/utils";
import { import {
TGetTeamsFilter, TGetTeamsFilter,
@@ -14,6 +15,8 @@ export const createTeam = async (
teamInput: TTeamInput, teamInput: TTeamInput,
organizationId: string organizationId: string
): Promise<Result<Team, ApiErrorResponseV2>> => { ): Promise<Result<Team, ApiErrorResponseV2>> => {
captureTelemetry("team created");
const { name } = teamInput; const { name } = teamInput;
try { try {
@@ -2,6 +2,7 @@ import { OrganizationRole, Prisma, TeamUserRole } from "@prisma/client";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { TUser } from "@formbricks/database/zod/users"; import { TUser } from "@formbricks/database/zod/users";
import { Result, err, ok } from "@formbricks/types/error-handlers"; import { Result, err, ok } from "@formbricks/types/error-handlers";
import { captureTelemetry } from "@/lib/telemetry";
import { getUsersQuery } from "@/modules/api/v2/organizations/[organizationId]/users/lib/utils"; import { getUsersQuery } from "@/modules/api/v2/organizations/[organizationId]/users/lib/utils";
import { import {
TGetUsersFilter, TGetUsersFilter,
@@ -72,6 +73,8 @@ export const createUser = async (
userInput: TUserInput, userInput: TUserInput,
organizationId organizationId
): Promise<Result<TUser, ApiErrorResponseV2>> => { ): Promise<Result<TUser, ApiErrorResponseV2>> => {
captureTelemetry("user created");
const { name, email, role, teams, isActive } = userInput; const { name, email, role, teams, isActive } = userInput;
try { try {
@@ -147,6 +150,8 @@ export const updateUser = async (
userInput: TUserInputPatch, userInput: TUserInputPatch,
organizationId: string organizationId: string
): Promise<Result<TUser, ApiErrorResponseV2>> => { ): Promise<Result<TUser, ApiErrorResponseV2>> => {
captureTelemetry("user updated");
const { name, email, role, teams, isActive } = userInput; const { name, email, role, teams, isActive } = userInput;
let existingTeams: string[] = []; let existingTeams: string[] = [];
let newTeams; let newTeams;
+9 -3
View File
@@ -13,7 +13,7 @@ import { ActionClientCtx } from "@/lib/utils/action-client/types/context";
import { createUser, updateUser } from "@/modules/auth/lib/user"; import { createUser, updateUser } from "@/modules/auth/lib/user";
import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite"; import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite";
import { createTeamMembership } from "@/modules/auth/signup/lib/team"; import { createTeamMembership } from "@/modules/auth/signup/lib/team";
import { verifyTurnstileToken } from "@/modules/auth/signup/lib/utils"; import { captureFailedSignup, verifyTurnstileToken } from "@/modules/auth/signup/lib/utils";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers"; import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
@@ -46,15 +46,21 @@ const ZCreateUserAction = z.object({
), ),
}); });
async function verifyTurnstileIfConfigured(turnstileToken: string | undefined): Promise<void> { async function verifyTurnstileIfConfigured(
turnstileToken: string | undefined,
email: string,
name: string
): Promise<void> {
if (!IS_TURNSTILE_CONFIGURED) return; if (!IS_TURNSTILE_CONFIGURED) return;
if (!turnstileToken || !TURNSTILE_SECRET_KEY) { if (!turnstileToken || !TURNSTILE_SECRET_KEY) {
captureFailedSignup(email, name);
throw new UnknownError("Server configuration error"); throw new UnknownError("Server configuration error");
} }
const isHuman = await verifyTurnstileToken(TURNSTILE_SECRET_KEY, turnstileToken); const isHuman = await verifyTurnstileToken(TURNSTILE_SECRET_KEY, turnstileToken);
if (!isHuman) { if (!isHuman) {
captureFailedSignup(email, name);
throw new UnknownError("reCAPTCHA verification failed"); throw new UnknownError("reCAPTCHA verification failed");
} }
} }
@@ -174,7 +180,7 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(
"user", "user",
async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => { async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => {
await applyIPRateLimit(rateLimitConfigs.auth.signup); await applyIPRateLimit(rateLimitConfigs.auth.signup);
await verifyTurnstileIfConfigured(parsedInput.turnstileToken); await verifyTurnstileIfConfigured(parsedInput.turnstileToken, parsedInput.email, parsedInput.name);
const hashedPassword = await hashPassword(parsedInput.password); const hashedPassword = await hashPassword(parsedInput.password);
const { user, userAlreadyExisted } = await createUserSafely( const { user, userAlreadyExisted } = await createUserSafely(
@@ -13,6 +13,7 @@ import { TUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createUserAction } from "@/modules/auth/signup/actions"; import { createUserAction } from "@/modules/auth/signup/actions";
import { TermsPrivacyLinks } from "@/modules/auth/signup/components/terms-privacy-links"; import { TermsPrivacyLinks } from "@/modules/auth/signup/components/terms-privacy-links";
import { captureFailedSignup } from "@/modules/auth/signup/lib/utils";
import { SSOOptions } from "@/modules/ee/sso/components/sso-options"; import { SSOOptions } from "@/modules/ee/sso/components/sso-options";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form"; import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
@@ -235,6 +236,7 @@ export const SignupForm = ({
onError={() => { onError={() => {
setTurnstileToken(undefined); setTurnstileToken(undefined);
toast.error(t("auth.signup.captcha_failed")); toast.error(t("auth.signup.captcha_failed"));
captureFailedSignup(form.getValues("email"), form.getValues("name"));
}} }}
/> />
)} )}
+17 -1
View File
@@ -1,5 +1,6 @@
import posthog from "posthog-js";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { verifyTurnstileToken } from "./utils"; import { captureFailedSignup, verifyTurnstileToken } from "./utils";
beforeEach(() => { beforeEach(() => {
global.fetch = vi.fn(); global.fetch = vi.fn();
@@ -61,3 +62,18 @@ describe("verifyTurnstileToken", () => {
expect(result).toBe(false); expect(result).toBe(false);
}); });
}); });
describe("captureFailedSignup", () => {
test("should capture TELEMETRY_FAILED_SIGNUP event with email and name", () => {
const captureSpy = vi.spyOn(posthog, "capture");
const email = "test@example.com";
const name = "Test User";
captureFailedSignup(email, name);
expect(captureSpy).toHaveBeenCalledWith("TELEMETRY_FAILED_SIGNUP", {
email,
name,
});
});
});
@@ -1,3 +1,5 @@
import posthog from "posthog-js";
export const verifyTurnstileToken = async (secretKey: string, token: string): Promise<boolean> => { export const verifyTurnstileToken = async (secretKey: string, token: string): Promise<boolean> => {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); const timeoutId = setTimeout(() => controller.abort(), 5000);
@@ -27,3 +29,10 @@ export const verifyTurnstileToken = async (secretKey: string, token: string): Pr
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
}; };
export const captureFailedSignup = (email: string, name: string) => {
posthog.capture("TELEMETRY_FAILED_SIGNUP", {
email,
name,
});
};
@@ -19,7 +19,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-lg font-bold text-slate-700">{t("common.attributes")}</h2> <h2 className="text-lg font-bold text-slate-700">{t("common.attributes")}</h2>
<div> <div>
<dt className="text-sm font-medium text-slate-500">email</dt> <dt className="text-sm font-medium text-slate-500">{t("common.email")}</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900"> <dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.email ? ( {attributes.email ? (
<span>{attributes.email}</span> <span>{attributes.email}</span>
@@ -29,7 +29,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-slate-500">language</dt> <dt className="text-sm font-medium text-slate-500">{t("common.language")}</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900"> <dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.language ? ( {attributes.language ? (
<span>{attributes.language}</span> <span>{attributes.language}</span>
@@ -39,7 +39,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-slate-500">userId</dt> <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"> <dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.userId ? ( {attributes.userId ? (
<IdBadge id={attributes.userId} /> <IdBadge id={attributes.userId} />
@@ -49,7 +49,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-slate-500">contactId</dt> <dt className="text-sm font-medium text-slate-500">ID</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{contact.id}</dd> <dd className="ph-no-capture mt-1 text-sm text-slate-900">{contact.id}</dd>
</div> </div>
@@ -58,7 +58,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
.map(([key, attributeData]) => { .map(([key, attributeData]) => {
return ( return (
<div key={key}> <div key={key}>
<dt className="text-sm font-medium text-slate-500">{key}</dt> <dt className="text-sm font-medium text-slate-500">{key.toString()}</dt>
<dd className="mt-1 text-sm text-slate-900">{attributeData}</dd> <dd className="mt-1 text-sm text-slate-900">{attributeData}</dd>
</div> </div>
); );
@@ -8,7 +8,6 @@ import { getPublishedLinkSurveys } from "@/modules/ee/contacts/lib/surveys";
import { getContactIdentifier } from "@/modules/ee/contacts/lib/utils"; import { getContactIdentifier } from "@/modules/ee/contacts/lib/utils";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { ResponseSection } from "./components/response-section"; import { ResponseSection } from "./components/response-section";
@@ -48,7 +47,6 @@ export const SingleContactPage = async (props: {
return ( return (
<PageContentWrapper> <PageContentWrapper>
<GoBackButton url={`/environments/${params.environmentId}/contacts`} />
<PageHeader pageTitle={getContactIdentifier(contactAttributes)} cta={getContactControlBar()} /> <PageHeader pageTitle={getContactIdentifier(contactAttributes)} cta={getContactControlBar()} />
<section className="pb-24 pt-6"> <section className="pb-24 pt-6">
<div className="grid grid-cols-4 gap-x-8"> <div className="grid grid-cols-4 gap-x-8">
@@ -274,7 +274,7 @@ export const ContactsTable = ({
}} }}
style={cell.column.id === "select" ? getCommonPinningStyles(cell.column) : {}} style={cell.column.id === "select" ? getCommonPinningStyles(cell.column) : {}}
className={cn( className={cn(
"px-4 py-2 border-slate-200 bg-white shadow-none group-hover:bg-slate-100", "border-slate-200 bg-white shadow-none group-hover:bg-slate-100",
row.getIsSelected() && "bg-slate-100", row.getIsSelected() && "bg-slate-100",
{ {
"border-r": !cell.column.getIsLastColumn(), "border-r": !cell.column.getIsLastColumn(),
@@ -282,7 +282,7 @@ export const ContactsTable = ({
} }
)}> )}>
<div <div
className={cn("flex flex-1 items-center truncate", isExpanded ? "h-10" : "h-full")}> className={cn("flex flex-1 items-center truncate", isExpanded ? "h-full" : "h-10")}>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</div> </div>
</TableCell> </TableCell>
@@ -12,14 +12,14 @@ export const CsvTable = ({ data }: CsvTableProps) => {
const columns = Object.keys(data[0]); const columns = Object.keys(data[0]);
return ( return (
<div className="w-full overflow-x-auto rounded-md"> <div className="w-full rounded-md hover:overflow-auto">
<div <div
className="sticky top-0 z-10 grid gap-2 border-b-2 border-slate-100 bg-slate-100 px-3 py-2 text-left" className="grid gap-4 border-b-2 border-slate-100 bg-slate-100 p-4 text-left"
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(100px, 1fr))` }}> style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))` }}>
{columns.map((header, index) => ( {columns.map((header, index) => (
<span <span
key={index} key={index}
className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-semibold capitalize leading-tight"> className="overflow-hidden text-ellipsis whitespace-nowrap text-sm font-semibold capitalize">
{header.replace(/_/g, " ")} {header.replace(/_/g, " ")}
</span> </span>
))} ))}
@@ -28,10 +28,10 @@ export const CsvTable = ({ data }: CsvTableProps) => {
{data.map((row, rowIndex) => ( {data.map((row, rowIndex) => (
<div <div
key={rowIndex} key={rowIndex}
className="grid gap-2 border-b border-gray-200 bg-white px-3 py-2 text-left leading-tight last:border-b-0" className="grid gap-4 border-b border-gray-200 bg-white p-4 text-left last:border-b-0"
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(100px, 1fr))` }}> style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))` }}>
{columns.map((header, colIndex) => ( {columns.map((header, colIndex) => (
<span key={colIndex} className="overflow-hidden text-ellipsis whitespace-nowrap text-xs"> <span key={colIndex} className="overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{row[header]} {row[header]}
</span> </span>
))} ))}
@@ -1,6 +1,5 @@
"use client"; "use client";
import { ChevronDownIcon } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -51,26 +50,16 @@ export const UploadContactsAttributeCombobox = ({
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
{currentKey ? ( {currentKey ? (
<Button <Button variant="ghost" size="sm" className="border border-slate-300" aria-expanded={open}>
variant="ghost"
size="sm"
className="justify-between border border-slate-300"
aria-expanded={open}>
{currentKey.label} {currentKey.label}
<ChevronDownIcon className="h-4 w-4 opacity-50" />
</Button> </Button>
) : ( ) : (
<Button <Button variant="ghost" size="sm" className="border border-slate-300" aria-expanded={open}>
variant="ghost"
size="sm"
className="justify-between border border-slate-300"
aria-expanded={open}>
{t("environments.contacts.select_attribute")} {t("environments.contacts.select_attribute")}
<ChevronDownIcon className="h-4 w-4 opacity-50" />
</Button> </Button>
)} )}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="h-full w-[200px] p-0"> <PopoverContent className="h-full w-[200px] overflow-y-auto p-0">
<Command <Command
filter={(value, search) => { filter={(value, search) => {
if (value === "_create") { if (value === "_create") {
@@ -103,7 +92,7 @@ export const UploadContactsAttributeCombobox = ({
}} }}
/> />
</div> </div>
<CommandList className="max-h-[300px] overflow-y-auto border-0"> <CommandList className="border-0">
<CommandGroup> <CommandGroup>
{keys.map((tag) => { {keys.map((tag) => {
return ( return (
@@ -4,7 +4,6 @@ import { parse } from "csv-parse/sync";
import { ArrowUpFromLineIcon, FileUpIcon, PlusIcon } from "lucide-react"; import { ArrowUpFromLineIcon, FileUpIcon, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
@@ -45,7 +44,7 @@ export const UploadContactsCSVButton = ({
); );
const [csvResponse, setCSVResponse] = useState<TContactCSVUploadResponse>([]); const [csvResponse, setCSVResponse] = useState<TContactCSVUploadResponse>([]);
const [attributeMap, setAttributeMap] = useState<Record<string, string>>({}); const [attributeMap, setAttributeMap] = useState<Record<string, string>>({});
const [error, setError] = useState(""); const [error, setErrror] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const processCSVFile = async (file: File) => { const processCSVFile = async (file: File) => {
@@ -53,25 +52,25 @@ export const UploadContactsCSVButton = ({
// Check file type // Check file type
if (!file.type && !file.name.endsWith(".csv")) { if (!file.type && !file.name.endsWith(".csv")) {
setError("Please upload a CSV file"); setErrror("Please upload a CSV file");
return; return;
} }
if (file.type && file.type !== "text/csv" && !file.type.includes("csv")) { if (file.type && file.type !== "text/csv" && !file.type.includes("csv")) {
setError("Please upload a CSV file"); setErrror("Please upload a CSV file");
return; return;
} }
// Max file size check (800KB) // Max file size check (800KB)
const maxSizeInBytes = 800 * 1024; const maxSizeInBytes = 800 * 1024;
if (file.size > maxSizeInBytes) { if (file.size > maxSizeInBytes) {
setError("File size exceeds the maximum limit of 800KB"); setErrror("File size exceeds the maximum limit of 800KB");
return; return;
} }
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async (e) => { reader.onload = async (e) => {
setError(""); setErrror("");
const csv = e.target?.result as string; const csv = e.target?.result as string;
try { try {
@@ -83,12 +82,12 @@ export const UploadContactsCSVButton = ({
const parsedRecords = ZContactCSVUploadResponse.safeParse(records); const parsedRecords = ZContactCSVUploadResponse.safeParse(records);
if (!parsedRecords.success) { if (!parsedRecords.success) {
console.error("Error parsing CSV:", parsedRecords.error); console.error("Error parsing CSV:", parsedRecords.error);
setError(parsedRecords.error.errors[0].message); setErrror(parsedRecords.error.errors[0].message);
return; return;
} }
if (!parsedRecords.data.length) { if (!parsedRecords.data.length) {
setError( setErrror(
"The uploaded CSV file does not contain any valid contacts, please see the sample CSV file for the correct format." "The uploaded CSV file does not contain any valid contacts, please see the sample CSV file for the correct format."
); );
return; return;
@@ -124,7 +123,7 @@ export const UploadContactsCSVButton = ({
const resetState = (closeModal?: boolean) => { const resetState = (closeModal?: boolean) => {
setCSVResponse([]); setCSVResponse([]);
setDuplicateContactsAction("skip"); setDuplicateContactsAction("skip");
setError(""); setErrror("");
setAttributeMap({}); setAttributeMap({});
setLoading(false); setLoading(false);
@@ -139,7 +138,7 @@ export const UploadContactsCSVButton = ({
} }
setLoading(true); setLoading(true);
setError(""); setErrror("");
const values = Object.values(attributeMap); const values = Object.values(attributeMap);
@@ -157,7 +156,9 @@ export const UploadContactsCSVButton = ({
.filter(([_, value]) => duplicateValues.includes(value)) .filter(([_, value]) => duplicateValues.includes(value))
.map(([key, _]) => key); .map(([key, _]) => key);
setError(`Duplicate mappings found for the following attributes: ${duplicateAttributeKeys.join(", ")}`); setErrror(
`Duplicate mappings found for the following attributes: ${duplicateAttributeKeys.join(", ")}`
);
errorContainerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); errorContainerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
setLoading(false); setLoading(false);
return; return;
@@ -192,8 +193,7 @@ export const UploadContactsCSVButton = ({
}); });
if (result?.data) { if (result?.data) {
setError(""); setErrror("");
toast.success(t("environments.contacts.upload_contacts_success"));
resetState(true); resetState(true);
router.refresh(); router.refresh();
@@ -201,7 +201,7 @@ export const UploadContactsCSVButton = ({
} }
if (result?.serverError) { if (result?.serverError) {
setError(result.serverError); setErrror(result.serverError);
} }
if (result?.validationErrors) { if (result?.validationErrors) {
@@ -210,9 +210,9 @@ export const UploadContactsCSVButton = ({
: result.validationErrors.csvData?._errors?.[0]; : result.validationErrors.csvData?._errors?.[0];
if (csvDataErrors) { if (csvDataErrors) {
setError(csvDataErrors); setErrror(csvDataErrors);
} else { } else {
setError("An error occurred while uploading the contacts. Please try again later."); setErrror("An error occurred while uploading the contacts. Please try again later.");
} }
} }
@@ -295,18 +295,8 @@ export const UploadContactsCSVButton = ({
{t("common.upload")} CSV {t("common.upload")} CSV
<PlusIcon /> <PlusIcon />
</Button> </Button>
<Dialog <Dialog open={open} onOpenChange={setOpen}>
open={open} <DialogContent disableCloseOnOutsideClick={true} className="overflow-auto">
onOpenChange={(newOpen) => {
setOpen(newOpen);
if (!newOpen) {
setError("");
}
}}>
<DialogContent
disableCloseOnOutsideClick={true}
unconstrained={true}
style={{ scrollbarGutter: "stable" }}>
<DialogHeader> <DialogHeader>
<FileUpIcon /> <FileUpIcon />
<DialogTitle>{t("common.upload")} CSV</DialogTitle> <DialogTitle>{t("common.upload")} CSV</DialogTitle>
@@ -315,8 +305,8 @@ export const UploadContactsCSVButton = ({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogBody unconstrained={false}> <DialogBody>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-6">
{error ? ( {error ? (
<Alert variant="error" size="small"> <Alert variant="error" size="small">
{error} {error}
@@ -350,11 +340,11 @@ export const UploadContactsCSVButton = ({
</label> </label>
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-8">
<h3 className="font-medium text-slate-500"> <h3 className="font-medium text-slate-500">
{t("environments.contacts.upload_contacts_modal_preview")} {t("environments.contacts.upload_contacts_modal_preview")}
</h3> </h3>
<div className="max-h-[300px] w-full overflow-auto rounded-md border border-slate-300"> <div className="h-[300px] w-full overflow-auto rounded-md border border-slate-300">
<CsvTable data={[...csvResponse.slice(0, 11)]} /> <CsvTable data={[...csvResponse.slice(0, 11)]} />
</div> </div>
</div> </div>
@@ -394,11 +384,11 @@ export const UploadContactsCSVButton = ({
</div> </div>
) : null} ) : null}
<div className="flex flex-col gap-2"> <div className="flex flex-col">
<h3 className="font-medium text-slate-900"> <h3 className="font-medium text-slate-900">
{t("environments.contacts.upload_contacts_modal_duplicates_title")} {t("environments.contacts.upload_contacts_modal_duplicates_title")}
</h3> </h3>
<p className="text-slate-500"> <p className="mb-2 text-slate-500">
{t("environments.contacts.upload_contacts_modal_duplicates_description")} {t("environments.contacts.upload_contacts_modal_duplicates_description")}
</p> </p>
<StylingTabs <StylingTabs
@@ -423,8 +413,8 @@ export const UploadContactsCSVButton = ({
tabsContainerClassName="p-1 rounded-lg" tabsContainerClassName="p-1 rounded-lg"
/> />
<div> <div className="mt-1">
<p className="text-xs font-medium text-slate-500"> <p className="text-sm font-medium text-slate-500">
{duplicateContactsAction === "skip" && {duplicateContactsAction === "skip" &&
t("environments.contacts.upload_contacts_modal_duplicates_skip_description")} t("environments.contacts.upload_contacts_modal_duplicates_skip_description")}
{duplicateContactsAction === "update" && {duplicateContactsAction === "update" &&
File diff suppressed because it is too large Load Diff
+10 -16
View File
@@ -291,10 +291,10 @@ export const createContactsFromCSV = async (
attributeKeyMap.set(attrKey.key, attrKey.id); attributeKeyMap.set(attrKey.key, attrKey.id);
}); });
// Identify missing attribute keys (normalize keys to lowercase) // Identify missing attribute keys
const csvKeys = new Set<string>(); const csvKeys = new Set<string>();
csvData.forEach((record) => { csvData.forEach((record) => {
Object.keys(record).forEach((key) => csvKeys.add(key.toLowerCase())); Object.keys(record).forEach((key) => csvKeys.add(key));
}); });
const missingKeys = Array.from(csvKeys).filter((key) => !attributeKeyMap.has(key)); const missingKeys = Array.from(csvKeys).filter((key) => !attributeKeyMap.has(key));
@@ -328,18 +328,12 @@ export const createContactsFromCSV = async (
// Process contacts in parallel // Process contacts in parallel
const contactPromises = csvData.map(async (record) => { const contactPromises = csvData.map(async (record) => {
// Normalize record keys to lowercase
const normalizedRecord: Record<string, string> = {};
Object.entries(record).forEach(([key, value]) => {
normalizedRecord[key.toLowerCase()] = value;
});
// Skip records without email // Skip records without email
if (!normalizedRecord.email) { if (!record.email) {
throw new ValidationError("Email is required for all contacts"); throw new ValidationError("Email is required for all contacts");
} }
const existingContact = emailToContactMap.get(normalizedRecord.email); const existingContact = emailToContactMap.get(record.email);
if (existingContact) { if (existingContact) {
// Handle duplicates based on duplicateContactsAction // Handle duplicates based on duplicateContactsAction
@@ -350,11 +344,11 @@ export const createContactsFromCSV = async (
case "update": { case "update": {
// if the record has a userId, check if it already exists // if the record has a userId, check if it already exists
const existingUserId = existingUserIds.find( const existingUserId = existingUserIds.find(
(attr) => attr.value === normalizedRecord.userid && attr.contactId !== existingContact.id (attr) => attr.value === record.userId && attr.contactId !== existingContact.id
); );
let recordToProcess = { ...normalizedRecord }; let recordToProcess = { ...record };
if (existingUserId) { if (existingUserId) {
const { userid, ...rest } = recordToProcess; const { userId, ...rest } = recordToProcess;
const existingContactUserId = existingContact.attributes.find( const existingContactUserId = existingContact.attributes.find(
(attr) => attr.attributeKey.key === "userId" (attr) => attr.attributeKey.key === "userId"
@@ -407,11 +401,11 @@ export const createContactsFromCSV = async (
case "overwrite": { case "overwrite": {
// if the record has a userId, check if it already exists // if the record has a userId, check if it already exists
const existingUserId = existingUserIds.find( const existingUserId = existingUserIds.find(
(attr) => attr.value === normalizedRecord.userid && attr.contactId !== existingContact.id (attr) => attr.value === record.userId && attr.contactId !== existingContact.id
); );
let recordToProcess = { ...normalizedRecord }; let recordToProcess = { ...record };
if (existingUserId) { if (existingUserId) {
const { userid, ...rest } = recordToProcess; const { userId, ...rest } = recordToProcess;
const existingContactUserId = existingContact.attributes.find( const existingContactUserId = existingContact.attributes.find(
(attr) => attr.attributeKey.key === "userId" (attr) => attr.attributeKey.key === "userId"
)?.value; )?.value;
@@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import type { TBaseFilter, TSegment } from "@formbricks/types/segment"; import type { TBaseFilter, TSegment } from "@formbricks/types/segment";
import { ZSegmentFilters } from "@formbricks/types/segment"; import { ZSegmentFilters } from "@formbricks/types/segment";
@@ -101,14 +100,13 @@ export function CreateSegmentModal({
toast.error(errorMessage); toast.error(errorMessage);
setIsCreatingSegment(false); setIsCreatingSegment(false);
} }
} catch (error_) { } catch (err: any) {
logger.error("Error creating segment:", error_);
// parse the segment filters to check if they are valid // parse the segment filters to check if they are valid
const parsedFilters = ZSegmentFilters.safeParse(segment.filters); const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (parsedFilters.success) { if (!parsedFilters.success) {
toast.error(t("common.something_went_wrong_please_try_again"));
} else {
toast.error(t("environments.segments.invalid_segment_filters")); toast.error(t("environments.segments.invalid_segment_filters"));
} else {
toast.error(t("common.something_went_wrong_please_try_again"));
} }
setIsCreatingSegment(false); setIsCreatingSegment(false);
return; return;
@@ -117,12 +115,8 @@ export function CreateSegmentModal({
const isSaveDisabled = useMemo(() => { const isSaveDisabled = useMemo(() => {
// check if title is empty // check if title is empty
if (!segment.title || segment.title.trim() === "") {
return true;
}
// check if filters are empty if (!segment.title || segment.title.trim() === "") {
if (segment.filters.length === 0) {
return true; return true;
} }
@@ -37,6 +37,7 @@ import {
} from "@formbricks/types/segment"; } from "@formbricks/types/segment";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { structuredClone } from "@/lib/pollyfills/structuredClone"; import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { isCapitalized } from "@/lib/utils/strings";
import { import {
convertOperatorToText, convertOperatorToText,
convertOperatorToTitle, convertOperatorToTitle,
@@ -148,10 +149,8 @@ function SegmentFilterItemContextMenu({
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild disabled={viewOnly}> <DropdownMenuTrigger disabled={viewOnly}>
<Button variant="outline" size="icon"> <MoreVertical className="h-4 w-4" />
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
@@ -186,13 +185,13 @@ function SegmentFilterItemContextMenu({
</DropdownMenu> </DropdownMenu>
<Button <Button
size="icon" className="mr-4 p-0"
disabled={viewOnly} disabled={viewOnly}
onClick={() => { onClick={() => {
if (viewOnly) return; if (viewOnly) return;
onDeleteFilter(filterId); onDeleteFilter(filterId);
}} }}
variant="outline"> variant="ghost">
<Trash2 className={cn("h-4 w-4 cursor-pointer", viewOnly && "cursor-not-allowed")} /> <Trash2 className={cn("h-4 w-4 cursor-pointer", viewOnly && "cursor-not-allowed")} />
</Button> </Button>
</div> </div>
@@ -318,7 +317,7 @@ function AttributeSegmentFilter({
className="flex w-auto items-center justify-center whitespace-nowrap bg-white" className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
hideArrow> hideArrow>
<SelectValue> <SelectValue>
<div className="flex items-center gap-2"> <div className={cn("flex items-center gap-2", !isCapitalized(attrKeyValue ?? "") && "lowercase")}>
<TagIcon className="h-4 w-4 text-sm" /> <TagIcon className="h-4 w-4 text-sm" />
<p>{attrKeyValue}</p> <p>{attrKeyValue}</p>
</div> </div>
@@ -358,7 +357,7 @@ function AttributeSegmentFilter({
{!["isSet", "isNotSet"].includes(resource.qualifier.operator) && ( {!["isSet", "isNotSet"].includes(resource.qualifier.operator) && (
<div className="relative flex flex-col gap-1"> <div className="relative flex flex-col gap-1">
<Input <Input
className={cn("h-9 w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")} className={cn("w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")}
disabled={viewOnly} disabled={viewOnly}
onChange={(e) => { onChange={(e) => {
if (viewOnly) return; if (viewOnly) return;
@@ -538,7 +537,7 @@ function PersonSegmentFilter({
{!["isSet", "isNotSet"].includes(resource.qualifier.operator) && ( {!["isSet", "isNotSet"].includes(resource.qualifier.operator) && (
<div className="relative flex flex-col gap-1"> <div className="relative flex flex-col gap-1">
<Input <Input
className={cn("h-8 w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")} className={cn("w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")}
disabled={viewOnly} disabled={viewOnly}
onChange={(e) => { onChange={(e) => {
if (viewOnly) return; if (viewOnly) return;
@@ -18,7 +18,6 @@ interface QuotaConditionBuilderProps {
conditions: TSurveyQuotaLogic; conditions: TSurveyQuotaLogic;
onChange: (conditions: TSurveyQuotaLogic) => void; onChange: (conditions: TSurveyQuotaLogic) => void;
quotaErrors?: FieldErrors<TSurveyQuotaInput>; quotaErrors?: FieldErrors<TSurveyQuotaInput>;
isSubmitted?: boolean;
} }
export const QuotaConditionBuilder = ({ export const QuotaConditionBuilder = ({
@@ -26,7 +25,6 @@ export const QuotaConditionBuilder = ({
conditions, conditions,
onChange, onChange,
quotaErrors, quotaErrors,
isSubmitted,
}: QuotaConditionBuilderProps) => { }: QuotaConditionBuilderProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -68,7 +66,6 @@ export const QuotaConditionBuilder = ({
config={config} config={config}
callbacks={callbacks} callbacks={callbacks}
quotaErrors={quotaErrors} quotaErrors={quotaErrors}
isSubmitted={isSubmitted}
/> />
</div> </div>
); );
@@ -18,13 +18,9 @@ import {
} from "@formbricks/types/quota"; } from "@formbricks/types/quota";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { createQuotaAction, updateQuotaAction } from "@/modules/ee/quotas/actions"; import { createQuotaAction, updateQuotaAction } from "@/modules/ee/quotas/actions";
import { EndingCardSelector } from "@/modules/ee/quotas/components/ending-card-selector"; import { EndingCardSelector } from "@/modules/ee/quotas/components/ending-card-selector";
import { import { getDefaultOperatorForQuestion } from "@/modules/survey/editor/lib/utils";
getDefaultOperatorForQuestion,
replaceEndingCardHeadlineRecall,
} from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal"; import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { import {
@@ -84,15 +80,6 @@ export const QuotaModal = ({
const { t } = useTranslation(); const { t } = useTranslation();
const [openConfirmationModal, setOpenConfirmationModal] = useState(false); const [openConfirmationModal, setOpenConfirmationModal] = useState(false);
const [openConfirmChangesInInclusionCriteria, setOpenConfirmChangesInInclusionCriteria] = useState(false); const [openConfirmChangesInInclusionCriteria, setOpenConfirmChangesInInclusionCriteria] = useState(false);
// Transform survey to replace recall: with actual question headlines
const transformedSurvey = useMemo(() => {
let modifiedSurvey = replaceHeadlineRecall(survey, "default");
modifiedSurvey = replaceEndingCardHeadlineRecall(modifiedSurvey, "default");
return modifiedSurvey;
}, [survey]);
const defaultValues = useMemo(() => { const defaultValues = useMemo(() => {
return { return {
name: quota?.name || "", name: quota?.name || "",
@@ -137,7 +124,7 @@ export const QuotaModal = ({
reset, reset,
watch, watch,
control, control,
formState: { isSubmitting, isDirty, errors, isValid, isSubmitted }, formState: { isSubmitting, isDirty, errors, isValid },
} = form; } = form;
// Watch form values for conditional logic // Watch form values for conditional logic
@@ -325,17 +312,14 @@ export const QuotaModal = ({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className="space-y-4 rounded-lg bg-slate-50 p-3"> <div className="space-y-4 rounded-lg bg-slate-50 p-3">
<label className="text-sm font-medium text-slate-800"> <FormLabel>{t("environments.surveys.edit.quotas.inclusion_criteria")}</FormLabel>
{t("environments.surveys.edit.quotas.inclusion_criteria")}
</label>
<FormControl> <FormControl>
{field.value && ( {field.value && (
<QuotaConditionBuilder <QuotaConditionBuilder
survey={transformedSurvey} survey={survey}
conditions={field.value} conditions={field.value}
onChange={handleConditionsChange} onChange={handleConditionsChange}
quotaErrors={errors} quotaErrors={errors}
isSubmitted={isSubmitted}
/> />
)} )}
</FormControl> </FormControl>
@@ -4,7 +4,6 @@ import Link from "next/link";
import { WidgetStatusIndicator } from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator"; import { WidgetStatusIndicator } from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { getActionClasses } from "@/lib/actionClass/service"; import { getActionClasses } from "@/lib/actionClass/service";
import { WEBAPP_URL } from "@/lib/constants";
import { getEnvironments } from "@/lib/environment/service"; import { getEnvironments } from "@/lib/environment/service";
import { findMatchingLocale } from "@/lib/utils/locale"; import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
@@ -41,12 +40,9 @@ export const AppConnectionPage = async ({ params }: { params: Promise<{ environm
<div className="space-y-4"> <div className="space-y-4">
<EnvironmentNotice environmentId={environmentId} subPageUrl="/project/app-connection" /> <EnvironmentNotice environmentId={environmentId} subPageUrl="/project/app-connection" />
<SettingsCard <SettingsCard
title={t("environments.project.app-connection.sdk_connection_details")} title={t("environments.project.app-connection.environment_id")}
description={t("environments.project.app-connection.sdk_connection_details_description")}> description={t("environments.project.app-connection.environment_id_description")}>
<div className="space-y-3"> <IdBadge id={environmentId} />
<IdBadge id={environmentId} label={t("environments.project.app-connection.environment_id")} />
<IdBadge id={WEBAPP_URL} label={t("environments.project.app-connection.webapp_url")} />
</div>
</SettingsCard> </SettingsCard>
<SettingsCard <SettingsCard
title={t("environments.project.app-connection.app_connection")} title={t("environments.project.app-connection.app_connection")}
@@ -9,11 +9,16 @@ import {
getOrganizationByEnvironmentId, getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses, subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getActionClasses } from "@/modules/survey/lib/action-class"; import { getActionClasses } from "@/modules/survey/lib/action-class";
import { selectSurvey } from "@/modules/survey/lib/survey"; import { selectSurvey } from "@/modules/survey/lib/survey";
import { createSurvey, handleTriggerUpdates } from "./survey"; import { createSurvey, handleTriggerUpdates } from "./survey";
// Mock dependencies // Mock dependencies
vi.mock("@/lib/posthogServer", () => ({
capturePosthogEnvironmentEvent: vi.fn(),
}));
vi.mock("@/lib/survey/utils", () => ({ vi.mock("@/lib/survey/utils", () => ({
checkForInvalidImagesInQuestions: vi.fn(), checkForInvalidImagesInQuestions: vi.fn(),
})); }));
@@ -116,6 +121,11 @@ describe("survey module", () => {
"user-123", "user-123",
"org-123" "org-123"
); );
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(
environmentId,
"survey created",
expect.objectContaining({ surveyId: "survey-123" })
);
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(result.id).toBe("survey-123"); expect(result.id).toBe("survey-123");
}); });
@@ -7,6 +7,7 @@ import {
getOrganizationByEnvironmentId, getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses, subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils"; import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger"; import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class"; import { getActionClasses } from "@/modules/survey/lib/action-class";
@@ -121,6 +122,11 @@ export const createSurvey = async (
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id); await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
} }
await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", {
surveyId: survey.id,
surveyType: survey.type,
});
return transformedSurvey; return transformedSurvey;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -225,7 +225,7 @@ export const SurveyVariablesCardItem = ({
form.setValue("value", value === "number" ? 0 : ""); form.setValue("value", value === "number" ? 0 : "");
field.onChange(value); field.onChange(value);
}}> }}>
<SelectTrigger className="h-10 w-24"> <SelectTrigger className="w-24">
<SelectValue placeholder={t("environments.surveys.edit.select_type")} /> <SelectValue placeholder={t("environments.surveys.edit.select_type")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -74,7 +74,7 @@ export const UpdateQuestionId = ({
disabled={localSurvey.status !== "draft" && !question.isDraft} disabled={localSurvey.status !== "draft" && !question.isDraft}
className={`h-10 ${isInputInvalid ? "border-red-300 focus:border-red-300" : ""}`} className={`h-10 ${isInputInvalid ? "border-red-300 focus:border-red-300" : ""}`}
/> />
<Button size="sm" onClick={saveAction} disabled={isButtonDisabled()} className="h-10"> <Button size="sm" onClick={saveAction} disabled={isButtonDisabled()}>
{t("common.save")} {t("common.save")}
</Button> </Button>
</div> </div>

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