mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-15 03:31:26 -05:00
Compare commits
71 Commits
fix-select
...
feat/unify
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f356e86729 | ||
|
|
1a02f91afd | ||
|
|
cc22ccb22d | ||
|
|
12763f0ef6 | ||
|
|
d39e3ee638 | ||
|
|
d85242a86b | ||
|
|
ef53065abc | ||
|
|
805c1c6874 | ||
|
|
01687e8907 | ||
|
|
31d455002d | ||
|
|
d96304d86d | ||
|
|
1064f68435 | ||
|
|
3d16e859c6 | ||
|
|
af198c5632 | ||
|
|
a43ed2b25c | ||
|
|
87bcad2b20 | ||
|
|
b5eaa4c7fd | ||
|
|
995c03bc01 | ||
|
|
b4395a48c5 | ||
|
|
461e3893fe | ||
|
|
735a9f84ec | ||
|
|
8cb8d734cf | ||
|
|
44d5530b48 | ||
|
|
a314eb391e | ||
|
|
6c34c316d0 | ||
|
|
4f26278f16 | ||
|
|
b975e7fa2e | ||
|
|
6c3052f9e4 | ||
|
|
5bb8119ebf | ||
|
|
02411277d4 | ||
|
|
4cfb8c6d7b | ||
|
|
e74a51a5ff | ||
|
|
29cc6a10fe | ||
|
|
01f765e969 | ||
|
|
9366960f18 | ||
|
|
697dc9cc99 | ||
|
|
83bc272ed2 | ||
|
|
59cc9c564e | ||
|
|
20dc147682 | ||
|
|
2bb7a6f277 | ||
|
|
deb062dd03 | ||
|
|
474be86d33 | ||
|
|
e7ca66ed77 | ||
|
|
2b49dbecd3 | ||
|
|
6da4c6f352 | ||
|
|
659b240fca | ||
|
|
19c0b1d14d | ||
|
|
b4472f48e9 | ||
|
|
d197271771 | ||
|
|
37f652c70e | ||
|
|
645f0ab0d1 | ||
|
|
389a7d9e7b | ||
|
|
c4cf468c7e | ||
|
|
cbc3e923e4 | ||
|
|
a96ba8b1e7 | ||
|
|
e830871361 | ||
|
|
998e5c0819 | ||
|
|
13a56b0237 | ||
|
|
0b5418a03a | ||
|
|
0d8a338965 | ||
|
|
d3250736a9 | ||
|
|
e6ee6a6b0d | ||
|
|
c0b097f929 | ||
|
|
78d336f8c7 | ||
|
|
95a7a265b9 | ||
|
|
136e59da68 | ||
|
|
eb0a87cf80 | ||
|
|
0dcb98ac29 | ||
|
|
540f7aaae7 | ||
|
|
2d4614a0bd | ||
|
|
633bf18204 |
9
.codex/environments/environment.toml
Normal file
9
.codex/environments/environment.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "formbricks"
|
||||
|
||||
[setup]
|
||||
script = '''
|
||||
pnpm install
|
||||
pnpm dev:setup
|
||||
'''
|
||||
41
.env.example
41
.env.example
@@ -94,6 +94,12 @@ EMAIL_VERIFICATION_DISABLED=1
|
||||
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
||||
PASSWORD_RESET_DISABLED=1
|
||||
|
||||
# Password reset token lifetime in minutes. Must be between 5 and 120 if set.
|
||||
# PASSWORD_RESET_TOKEN_LIFETIME_MINUTES=30
|
||||
|
||||
# Development-only helper: log the password reset link to the server console instead of sending reset emails.
|
||||
# DEBUG_SHOW_RESET_LINK=1
|
||||
|
||||
# Email login. Disable the ability for users to login with email.
|
||||
# EMAIL_AUTH_DISABLED=1
|
||||
|
||||
@@ -132,6 +138,31 @@ AZUREAD_CLIENT_ID=
|
||||
AZUREAD_CLIENT_SECRET=
|
||||
AZUREAD_TENANT_ID=
|
||||
|
||||
# Configure Formbricks AI at the instance level
|
||||
# Set the provider used for AI features on this instance.
|
||||
# Accepted values for AI_PROVIDER: aws, gcp, azure
|
||||
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching credentials below.
|
||||
# AI_PROVIDER=gcp
|
||||
# AI_MODEL=gemini-2.5-flash
|
||||
|
||||
# Google Vertex AI credentials
|
||||
# AI_GCP_PROJECT=
|
||||
# AI_GCP_LOCATION=
|
||||
# AI_GCP_CREDENTIALS_JSON=
|
||||
# AI_GCP_APPLICATION_CREDENTIALS=
|
||||
|
||||
# Amazon Bedrock credentials
|
||||
# AI_AWS_REGION=
|
||||
# AI_AWS_ACCESS_KEY_ID=
|
||||
# AI_AWS_SECRET_ACCESS_KEY=
|
||||
# AI_AWS_SESSION_TOKEN=
|
||||
|
||||
# Azure AI / Microsoft Foundry credentials
|
||||
# AI_AZURE_BASE_URL=
|
||||
# AI_AZURE_RESOURCE_NAME=
|
||||
# AI_AZURE_API_KEY=
|
||||
# AI_AZURE_API_VERSION=v1
|
||||
|
||||
# OpenID Connect (OIDC) configuration
|
||||
# OIDC_CLIENT_ID=
|
||||
# OIDC_CLIENT_SECRET=
|
||||
@@ -185,6 +216,14 @@ ENTERPRISE_LICENSE_KEY=
|
||||
# Ignore Rate Limiting across the Formbricks app
|
||||
# RATE_LIMITING_DISABLED=1
|
||||
|
||||
# Disable telemetry reporting (usage stats sent to Formbricks). Ignored when an EE license is active.
|
||||
# TELEMETRY_DISABLED=1
|
||||
|
||||
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
|
||||
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
|
||||
# that need to send webhooks to internal services.
|
||||
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
|
||||
|
||||
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||
@@ -231,4 +270,4 @@ REDIS_URL=redis://localhost:6379
|
||||
|
||||
|
||||
# Lingo.dev API key for translation generation
|
||||
LINGODOTDEV_API_KEY=your_api_key_here
|
||||
LINGO_API_KEY=your_api_key_here
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -45,7 +45,7 @@ yarn-error.log*
|
||||
.direnv
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
**/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
@@ -52,6 +52,14 @@ We are using SonarQube to identify code smells and security hotspots.
|
||||
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
|
||||
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
|
||||
|
||||
## Date and Time Rendering
|
||||
|
||||
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
|
||||
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
|
||||
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
|
||||
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
|
||||
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
|
||||
|
||||
## Database & Prisma Performance
|
||||
|
||||
- Multi-tenancy: All data must be scoped by Organization or Environment.
|
||||
|
||||
26
README.md
26
README.md
@@ -127,34 +127,10 @@ Formbricks has a hosted cloud offering with a generous free plan to get you up a
|
||||
|
||||
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
|
||||
|
||||
If you opt for self-hosting Formbricks, here are a few options to consider:
|
||||
|
||||
#### Docker
|
||||
|
||||
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
|
||||
|
||||
#### Community-managed One Click Hosting
|
||||
|
||||
##### Railway
|
||||
|
||||
You can deploy Formbricks on [Railway](https://railway.app) using the button below.
|
||||
|
||||
[](https://railway.app/new/template/PPDzCd)
|
||||
|
||||
##### RepoCloud
|
||||
|
||||
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
|
||||
|
||||
[](https://repocloud.io/details/?app_id=254)
|
||||
|
||||
##### Zeabur
|
||||
|
||||
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
|
||||
|
||||
[](https://zeabur.com/templates/G4TUJL)
|
||||
|
||||
<a id="development"></a>
|
||||
|
||||
## 👨💻 Development
|
||||
|
||||
### Prerequisites
|
||||
@@ -247,4 +223,4 @@ We currently do not offer Formbricks white-labeled. That means that we don't sel
|
||||
|
||||
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
|
||||
|
||||
<p align="right"><a href="#top">🔼 Back to top</a></p>
|
||||
<a id="readme-de"></a>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
@@ -20,12 +21,12 @@ const Page = async (props: ConnectPageProps) => {
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||
}
|
||||
|
||||
const project = await getProjectByEnvironmentId(environment.id);
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const channel = project.config.channel || null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import Link from "next/link";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
|
||||
@@ -23,22 +24,22 @@ const Page = async (props: XMTemplatePageProps) => {
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
const t = await getTranslate();
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
|
||||
const project = await getProjectByEnvironmentId(environment.id);
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const projects = await getUserProjects(session.user.id, organizationId);
|
||||
|
||||
@@ -26,7 +26,8 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
const { isMember } = getAccessFlags(membership?.role);
|
||||
const { isBilling } = getAccessFlags(membership?.role);
|
||||
const isMembershipPending = membership?.role === undefined;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-row">
|
||||
@@ -44,7 +45,8 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
||||
isLicenseActive={false}
|
||||
isOwnerOrManager={false}
|
||||
isAccessControlAllowed={false}
|
||||
isMember={isMember}
|
||||
isBilling={isBilling}
|
||||
isMembershipPending={isMembershipPending}
|
||||
environments={[]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
@@ -25,7 +25,7 @@ const ProjectOnboardingLayout = async (props: {
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
|
||||
@@ -36,7 +36,7 @@ const ProjectOnboardingLayout = async (props: {
|
||||
|
||||
const organization = await getOrganization(params.organizationId);
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
@@ -28,7 +29,7 @@ const OnboardingLayout = async (props: {
|
||||
|
||||
const organization = await getOrganization(params.organizationId);
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
|
||||
}
|
||||
|
||||
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
|
||||
@@ -45,7 +46,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
|
||||
|
||||
if (!organizationTeams) {
|
||||
throw new Error(t("common.organization_teams_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.team"), null);
|
||||
}
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
|
||||
@@ -17,13 +18,13 @@ const SurveyEditorEnvironmentLayout = async (props: {
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
||||
|
||||
const MainNavLayout = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
const { children } = props;
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
||||
|
||||
return <EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>;
|
||||
};
|
||||
|
||||
export default MainNavLayout;
|
||||
@@ -29,6 +29,7 @@ import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surv
|
||||
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
||||
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -96,8 +97,8 @@ export const ResponseTable = ({
|
||||
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
||||
// Generate columns
|
||||
const columns = useMemo(
|
||||
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
|
||||
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
|
||||
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn),
|
||||
[survey, isExpanded, isReadOnly, locale, t, showQuotasColumn]
|
||||
);
|
||||
|
||||
// Save settings to localStorage when they change
|
||||
@@ -201,7 +202,13 @@ export const ResponseTable = ({
|
||||
};
|
||||
|
||||
const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => {
|
||||
await deleteResponseAction({ responseId, decrementQuotas: params?.decrementQuotas ?? false });
|
||||
const result = await deleteResponseAction({
|
||||
responseId,
|
||||
decrementQuotas: params?.decrementQuotas ?? false,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
throw new Error(getFormattedErrorMessage(result));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle downloading selected responses
|
||||
@@ -300,7 +307,6 @@ export const ResponseTable = ({
|
||||
<DataTableSettingsModal
|
||||
open={isTableSettingsModalOpen}
|
||||
setOpen={setIsTableSettingsModalOpen}
|
||||
survey={survey}
|
||||
table={table}
|
||||
columnOrder={columnOrder}
|
||||
handleDragEnd={handleDragEnd}
|
||||
@@ -8,10 +8,11 @@ import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
@@ -34,6 +35,7 @@ const getElementColumnsData = (
|
||||
element: TSurveyElement,
|
||||
survey: TSurvey,
|
||||
isExpanded: boolean,
|
||||
locale: TUserLocale,
|
||||
t: TFunction
|
||||
): ColumnDef<TResponseTableData>[] => {
|
||||
const ELEMENTS_ICON_MAP = getElementIconMap(t);
|
||||
@@ -167,6 +169,7 @@ const getElementColumnsData = (
|
||||
survey={survey}
|
||||
responseData={responseValue}
|
||||
language={language}
|
||||
locale={locale}
|
||||
isExpanded={isExpanded}
|
||||
showId={false}
|
||||
/>
|
||||
@@ -218,6 +221,7 @@ const getElementColumnsData = (
|
||||
survey={survey}
|
||||
responseData={responseValue}
|
||||
language={language}
|
||||
locale={locale}
|
||||
isExpanded={isExpanded}
|
||||
showId={false}
|
||||
/>
|
||||
@@ -259,11 +263,14 @@ export const generateResponseTableColumns = (
|
||||
survey: TSurvey,
|
||||
isExpanded: boolean,
|
||||
isReadOnly: boolean,
|
||||
locale: TUserLocale,
|
||||
t: TFunction,
|
||||
showQuotasColumn: boolean
|
||||
): ColumnDef<TResponseTableData>[] => {
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t));
|
||||
const elementColumns = elements.flatMap((element) =>
|
||||
getElementColumnsData(element, survey, isExpanded, locale, t)
|
||||
);
|
||||
|
||||
const dateColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "createdAt",
|
||||
@@ -271,7 +278,7 @@ export const generateResponseTableColumns = (
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.original.createdAt);
|
||||
return <p className="text-slate-900">{getFormattedDateTimeString(date)}</p>;
|
||||
return <p className="text-slate-900">{formatDateTimeForDisplay(date, locale)}</p>;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
@@ -7,7 +8,6 @@ import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -23,25 +23,24 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
||||
|
||||
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
|
||||
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([
|
||||
getSurvey(params.surveyId),
|
||||
getUser(session.user.id),
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getIsContactsEnabled(organization.id),
|
||||
getResponseCountBySurveyId(params.surveyId),
|
||||
findMatchingLocale(),
|
||||
]);
|
||||
|
||||
if (!survey) {
|
||||
throw new Error(t("common.survey_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||
}
|
||||
|
||||
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
||||
@@ -50,7 +49,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
||||
|
||||
const organizationBilling = await getOrganizationBilling(organization.id);
|
||||
if (!organizationBilling) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), organization.id);
|
||||
}
|
||||
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
|
||||
@@ -86,7 +85,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
||||
environmentTags={tags}
|
||||
user={user}
|
||||
responsesPerPage={RESPONSES_PER_PAGE}
|
||||
locale={locale}
|
||||
locale={user.locale}
|
||||
isReadOnly={isReadOnly}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
quotas={quotas}
|
||||
@@ -7,7 +7,7 @@ import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/ty
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
||||
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
@@ -32,13 +32,14 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
||||
};
|
||||
|
||||
const renderResponseValue = (value: string) => {
|
||||
const parsedDate = new Date(value);
|
||||
const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale);
|
||||
|
||||
const formattedDate = isNaN(parsedDate.getTime())
|
||||
? `${t("common.invalid_date")}(${value})`
|
||||
: formatDateWithOrdinal(parsedDate);
|
||||
|
||||
return formattedDate;
|
||||
return (
|
||||
formattedDate ??
|
||||
t("common.invalid_date_with_value", {
|
||||
value,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -59,7 +60,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
||||
elementSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<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">
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
@@ -84,7 +85,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
||||
<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">
|
||||
<div className="px-4 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -163,6 +163,7 @@ export const PersonalLinksTab = ({
|
||||
<UpgradePrompt
|
||||
title={t("environments.surveys.share.personal_links.upgrade_prompt_title")}
|
||||
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
|
||||
feature="personal_links"
|
||||
buttons={[
|
||||
{
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -9,11 +10,11 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
|
||||
const t = await getTranslate();
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
throw new Error("Survey not found");
|
||||
throw new ResourceNotFoundError(t("common.survey"), surveyId);
|
||||
}
|
||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||
if (!project) {
|
||||
throw new Error("Workspace not found");
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const styling = getStyling(project, survey);
|
||||
@@ -11,8 +11,7 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import {
|
||||
getElementSummary,
|
||||
getResponsesForSummary,
|
||||
@@ -44,7 +43,7 @@ vi.mock("@/lib/survey/service", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/surveyLogic/utils", () => ({
|
||||
evaluateLogic: vi.fn(),
|
||||
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredQuestionIds: [], calculations: {} })),
|
||||
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredElementIds: [], calculations: {} })),
|
||||
}));
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
@@ -229,12 +228,6 @@ describe("getSurveySummaryDropOff", () => {
|
||||
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
|
||||
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
|
||||
);
|
||||
vi.mocked(evaluateLogic).mockReturnValue(false); // Default: no logic triggers
|
||||
vi.mocked(performActions).mockReturnValue({
|
||||
jumpTarget: undefined,
|
||||
requiredElementIds: [],
|
||||
calculations: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("calculates dropOff correctly with welcome card disabled", () => {
|
||||
@@ -246,7 +239,7 @@ describe("getSurveySummaryDropOff", () => {
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: "en",
|
||||
ttc: { q1: 10 },
|
||||
ttc: { q1: 10, q2: 5 }, // Saw q2 but didn't answer it
|
||||
finished: false,
|
||||
}, // Dropped at q2
|
||||
{
|
||||
@@ -269,22 +262,55 @@ describe("getSurveySummaryDropOff", () => {
|
||||
);
|
||||
|
||||
expect(dropOff.length).toBe(2);
|
||||
// Q1
|
||||
// Q1: welcome card disabled so impressions = displayCount
|
||||
expect(dropOff[0].elementId).toBe("q1");
|
||||
expect(dropOff[0].impressions).toBe(displayCount); // Welcome card disabled, so first question impressions = displayCount
|
||||
expect(dropOff[0].impressions).toBe(displayCount);
|
||||
expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1
|
||||
expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100
|
||||
expect(dropOff[0].ttc).toBe(10);
|
||||
|
||||
// Q2
|
||||
// Q2: both responses saw q2 (r1 has ttc for q2, r2 answered q2)
|
||||
expect(dropOff[1].elementId).toBe("q2");
|
||||
expect(dropOff[1].impressions).toBe(responses.length); // 2 responses reached q1, so 2 impressions for q2
|
||||
expect(dropOff[1].dropOffCount).toBe(1); // 1 response dropped at q2
|
||||
expect(dropOff[1].impressions).toBe(2);
|
||||
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
|
||||
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
|
||||
expect(dropOff[1].ttc).toBe(10);
|
||||
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms)
|
||||
});
|
||||
|
||||
test("handles logic jumps", () => {
|
||||
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
|
||||
// Welcome card enabled so first element drop-off is NOT overridden by displayCount
|
||||
const surveyWithWelcome: TSurvey = {
|
||||
...surveyWithBlocks,
|
||||
welcomeCard: { enabled: true, headline: { default: "Welcome" } } as unknown as TSurvey["welcomeCard"],
|
||||
};
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { q1: "a" },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: "en",
|
||||
ttc: { q1: 10 }, // Only saw q1, never reached q2
|
||||
finished: false,
|
||||
},
|
||||
] as any;
|
||||
const displayCount = 1;
|
||||
const dropOff = getSurveySummaryDropOff(
|
||||
surveyWithWelcome,
|
||||
getElementsFromBlocks(surveyWithWelcome.blocks),
|
||||
responses,
|
||||
displayCount
|
||||
);
|
||||
|
||||
expect(dropOff[0].impressions).toBe(1); // Saw q1
|
||||
expect(dropOff[0].dropOffCount).toBe(1); // Dropped at q1 (last seen element)
|
||||
expect(dropOff[1].impressions).toBe(0); // Never saw q2
|
||||
expect(dropOff[1].dropOffCount).toBe(0);
|
||||
});
|
||||
|
||||
test("handles logic jumps — impressions based on actual ttc/data, not logic replay", () => {
|
||||
// Survey with 4 questions across 4 blocks, logic on block2 jumps q2->q4 (skipping q3)
|
||||
const surveyWithLogic: TSurvey = {
|
||||
...mockBaseSurvey,
|
||||
blocks: [
|
||||
@@ -315,36 +341,6 @@ describe("getSurveySummaryDropOff", () => {
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
] as TSurveyElement[],
|
||||
logic: [
|
||||
{
|
||||
id: "logic1",
|
||||
conditions: {
|
||||
id: "condition1",
|
||||
connector: "and" as const,
|
||||
conditions: [
|
||||
{
|
||||
id: "c1",
|
||||
leftOperand: {
|
||||
type: "element" as const,
|
||||
value: "q2",
|
||||
},
|
||||
operator: "equals" as const,
|
||||
rightOperand: {
|
||||
type: "static" as const,
|
||||
value: "b",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: "action1",
|
||||
objective: "jumpToBlock" as const,
|
||||
target: "q4",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "block3",
|
||||
@@ -377,28 +373,21 @@ describe("getSurveySummaryDropOff", () => {
|
||||
],
|
||||
questions: [],
|
||||
};
|
||||
|
||||
// Response where user answered q1, q2, then logic jumped to q4 (skipping q3).
|
||||
// The ttc/data reflects exactly what elements were shown — no logic replay needed.
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { q1: "a", q2: "b" },
|
||||
data: { q1: "a", q2: "b", q4: "d" },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: "en",
|
||||
ttc: { q1: 10, q2: 10 },
|
||||
ttc: { q1: 10, q2: 10, q4: 10 }, // q3 has no ttc entry — was skipped by logic
|
||||
finished: false,
|
||||
}, // Jumps from q2 to q4, drops at q4
|
||||
},
|
||||
];
|
||||
vi.mocked(evaluateLogic).mockImplementation((_s, data, _v, _, _l) => {
|
||||
// Simulate logic on q2 triggering
|
||||
return data.q2 === "b";
|
||||
});
|
||||
vi.mocked(performActions).mockImplementation((_s, actions, _d, _v) => {
|
||||
if (actions[0] && "objective" in actions[0] && actions[0].objective === "jumpToBlock") {
|
||||
return { jumpTarget: actions[0].target, requiredElementIds: [], calculations: {} };
|
||||
}
|
||||
return { jumpTarget: undefined, requiredElementIds: [], calculations: {} };
|
||||
});
|
||||
|
||||
const dropOff = getSurveySummaryDropOff(
|
||||
surveyWithLogic,
|
||||
@@ -407,11 +396,11 @@ describe("getSurveySummaryDropOff", () => {
|
||||
1
|
||||
);
|
||||
|
||||
expect(dropOff[0].impressions).toBe(1); // q1
|
||||
expect(dropOff[1].impressions).toBe(1); // q2
|
||||
expect(dropOff[2].impressions).toBe(0); // q3 (skipped)
|
||||
expect(dropOff[3].impressions).toBe(1); // q4 (jumped to)
|
||||
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4
|
||||
expect(dropOff[0].impressions).toBe(1); // q1: seen
|
||||
expect(dropOff[1].impressions).toBe(1); // q2: seen
|
||||
expect(dropOff[2].impressions).toBe(0); // q3: skipped by logic (no ttc, no data)
|
||||
expect(dropOff[3].impressions).toBe(1); // q4: jumped to, seen
|
||||
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4 (last seen element, not finished)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
TResponseData,
|
||||
TResponseFilterCriteria,
|
||||
TResponseTtc,
|
||||
TResponseVariables,
|
||||
ZResponseFilterCriteria,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
@@ -37,8 +36,7 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { buildWhereClause } from "@/lib/response/utils";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { findElementLocation, getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { convertFloatTo2Decimal } from "./utils";
|
||||
|
||||
@@ -93,63 +91,13 @@ export const getSurveySummaryMeta = (
|
||||
};
|
||||
};
|
||||
|
||||
const evaluateLogicAndGetNextElementId = (
|
||||
localSurvey: TSurvey,
|
||||
elements: TSurveyElement[],
|
||||
data: TResponseData,
|
||||
localVariables: TResponseVariables,
|
||||
currentElementIndex: number,
|
||||
currElementTemp: TSurveyElement,
|
||||
selectedLanguage: string | null
|
||||
): {
|
||||
nextElementId: string | undefined;
|
||||
updatedSurvey: TSurvey;
|
||||
updatedVariables: TResponseVariables;
|
||||
} => {
|
||||
let updatedSurvey = { ...localSurvey };
|
||||
let updatedVariables = { ...localVariables };
|
||||
|
||||
let firstJumpTarget: string | undefined;
|
||||
|
||||
const { block: currentBlock } = findElementLocation(localSurvey, currElementTemp.id);
|
||||
|
||||
if (currentBlock?.logic && currentBlock.logic.length > 0) {
|
||||
for (const logic of currentBlock.logic) {
|
||||
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
|
||||
const { jumpTarget, requiredElementIds, calculations } = performActions(
|
||||
updatedSurvey,
|
||||
logic.actions,
|
||||
data,
|
||||
updatedVariables
|
||||
);
|
||||
|
||||
if (requiredElementIds.length > 0) {
|
||||
// Update blocks to mark elements as required
|
||||
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
|
||||
...block,
|
||||
elements: block.elements.map((e) =>
|
||||
requiredElementIds.includes(e.id) ? { ...e, required: true } : e
|
||||
),
|
||||
}));
|
||||
}
|
||||
updatedVariables = { ...updatedVariables, ...calculations };
|
||||
|
||||
if (jumpTarget && !firstJumpTarget) {
|
||||
firstJumpTarget = jumpTarget;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no jump target was set, check for a fallback logic
|
||||
if (!firstJumpTarget && currentBlock?.logicFallback) {
|
||||
firstJumpTarget = currentBlock.logicFallback;
|
||||
}
|
||||
|
||||
// Return the first jump target if found, otherwise go to the next element
|
||||
const nextElementId = firstJumpTarget || elements[currentElementIndex + 1]?.id || undefined;
|
||||
|
||||
return { nextElementId, updatedSurvey, updatedVariables };
|
||||
// Determine whether a response interacted with a given element.
|
||||
// An element was "seen" if the respondent has a ttc entry for it OR provided an answer.
|
||||
// This is more reliable than replaying survey logic, which can misattribute impressions
|
||||
// when branching logic skips elements or when partial response data is insufficient
|
||||
// to evaluate conditions correctly.
|
||||
const wasElementSeen = (response: TSurveySummaryResponse, elementId: string): boolean => {
|
||||
return (response.ttc != null && response.ttc[elementId] > 0) || response.data[elementId] !== undefined;
|
||||
};
|
||||
|
||||
export const getSurveySummaryDropOff = (
|
||||
@@ -170,16 +118,8 @@ export const getSurveySummaryDropOff = (
|
||||
let impressionsArr = new Array(elements.length).fill(0) as number[];
|
||||
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
|
||||
|
||||
const surveyVariablesData = survey.variables?.reduce(
|
||||
(acc, variable) => {
|
||||
acc[variable.id] = variable.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string | number>
|
||||
);
|
||||
|
||||
responses.forEach((response) => {
|
||||
// Calculate total time-to-completion
|
||||
// Calculate total time-to-completion per element
|
||||
Object.keys(totalTtc).forEach((elementId) => {
|
||||
if (response.ttc && response.ttc[elementId]) {
|
||||
totalTtc[elementId] += response.ttc[elementId];
|
||||
@@ -187,51 +127,21 @@ export const getSurveySummaryDropOff = (
|
||||
}
|
||||
});
|
||||
|
||||
let localSurvey = structuredClone(survey);
|
||||
let localResponseData: TResponseData = { ...response.data };
|
||||
let localVariables: TResponseVariables = {
|
||||
...surveyVariablesData,
|
||||
};
|
||||
// Count impressions based on actual interaction data (ttc + response data)
|
||||
// instead of replaying survey logic which is unreliable with branching
|
||||
let lastSeenIdx = -1;
|
||||
|
||||
let currQuesIdx = 0;
|
||||
|
||||
while (currQuesIdx < elements.length) {
|
||||
const currQues = elements[currQuesIdx];
|
||||
if (!currQues) break;
|
||||
|
||||
// element is not answered and required
|
||||
if (response.data[currQues.id] === undefined && currQues.required) {
|
||||
dropOffArr[currQuesIdx]++;
|
||||
impressionsArr[currQuesIdx]++;
|
||||
break;
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i];
|
||||
if (wasElementSeen(response, element.id)) {
|
||||
impressionsArr[i]++;
|
||||
lastSeenIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
impressionsArr[currQuesIdx]++;
|
||||
|
||||
const { nextElementId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextElementId(
|
||||
localSurvey,
|
||||
elements,
|
||||
localResponseData,
|
||||
localVariables,
|
||||
currQuesIdx,
|
||||
currQues,
|
||||
response.language
|
||||
);
|
||||
|
||||
localSurvey = updatedSurvey;
|
||||
localVariables = updatedVariables;
|
||||
|
||||
if (nextElementId) {
|
||||
const nextQuesIdx = elements.findIndex((q) => q.id === nextElementId);
|
||||
if (!response.data[nextElementId] && !response.finished) {
|
||||
dropOffArr[nextQuesIdx]++;
|
||||
impressionsArr[nextQuesIdx]++;
|
||||
break;
|
||||
}
|
||||
currQuesIdx = nextQuesIdx;
|
||||
} else {
|
||||
currQuesIdx++;
|
||||
}
|
||||
// Attribute drop-off to the last element the respondent interacted with
|
||||
if (!response.finished && lastSeenIdx >= 0) {
|
||||
dropOffArr[lastSeenIdx]++;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -240,6 +150,8 @@ export const getSurveySummaryDropOff = (
|
||||
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0;
|
||||
});
|
||||
|
||||
// When the welcome card is disabled, the first element's impressions should equal displayCount
|
||||
// because every survey display is an impression of the first element
|
||||
if (!survey.welcomeCard.enabled) {
|
||||
dropOffArr[0] = displayCount - impressionsArr[0];
|
||||
if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0;
|
||||
@@ -251,7 +163,7 @@ export const getSurveySummaryDropOff = (
|
||||
|
||||
impressionsArr[0] = displayCount;
|
||||
} else {
|
||||
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
|
||||
dropOffPercentageArr[0] = impressionsArr[0] > 0 ? (dropOffArr[0] / impressionsArr[0]) * 100 : 0;
|
||||
}
|
||||
|
||||
for (let i = 1; i < elements.length; i++) {
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
@@ -32,13 +33,13 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
const survey = await getSurvey(params.surveyId);
|
||||
|
||||
if (!survey) {
|
||||
throw new Error(t("common.survey_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
|
||||
@@ -46,11 +47,11 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
|
||||
|
||||
if (!organizationId) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||
}
|
||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||
if (!organizationBilling) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), organizationId);
|
||||
}
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
|
||||
|
||||
@@ -2,21 +2,17 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
||||
import { ZSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
|
||||
const ZGetResponsesDownloadUrlAction = z.object({
|
||||
@@ -28,9 +24,11 @@ const ZGetResponsesDownloadUrlAction = z.object({
|
||||
export const getResponsesDownloadUrlAction = authenticatedActionClient
|
||||
.inputSchema(ZGetResponsesDownloadUrlAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
@@ -44,11 +42,20 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
|
||||
],
|
||||
});
|
||||
|
||||
return await getResponseDownloadFile(
|
||||
const result = await getResponseDownloadFile(
|
||||
parsedInput.surveyId,
|
||||
parsedInput.format,
|
||||
parsedInput.filterCriteria
|
||||
);
|
||||
|
||||
capturePostHogEvent(ctx.user.id, "responses_exported", {
|
||||
survey_id: parsedInput.surveyId,
|
||||
format: parsedInput.format,
|
||||
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
|
||||
organization_id: organizationId,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const ZGetSurveyFilterDataAction = z.object({
|
||||
@@ -97,68 +104,3 @@ export const getSurveyFilterDataAction = authenticatedActionClient
|
||||
|
||||
return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
|
||||
});
|
||||
|
||||
/**
|
||||
* Checks if survey follow-ups are enabled for the given organization.
|
||||
*
|
||||
* @param {string} organizationId The ID of the organization to check.
|
||||
* @returns {Promise<void>} A promise that resolves if the permission is granted.
|
||||
* @throws {ResourceNotFoundError} If the organization is not found.
|
||||
* @throws {OperationNotAllowedError} If survey follow-ups are not enabled for the organization.
|
||||
*/
|
||||
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("Organization not found", organizationId);
|
||||
}
|
||||
|
||||
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
|
||||
if (!isSurveyFollowUpsEnabled) {
|
||||
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
|
||||
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user?.id ?? "",
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.id),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { followUps } = parsedInput;
|
||||
|
||||
const oldSurvey = await getSurvey(parsedInput.id);
|
||||
|
||||
if (parsedInput.recaptcha?.enabled) {
|
||||
await checkSpamProtectionPermission(organizationId);
|
||||
}
|
||||
|
||||
if (followUps?.length) {
|
||||
await checkSurveyFollowUpsPermission(organizationId);
|
||||
}
|
||||
|
||||
// Context for audit log
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.id;
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.oldObject = oldSurvey;
|
||||
|
||||
const newSurvey = await updateSurvey(parsedInput);
|
||||
|
||||
ctx.auditLoggingCtx.newObject = newSurvey;
|
||||
|
||||
return newSurvey;
|
||||
})
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
AirplayIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
@@ -54,6 +55,25 @@ export enum OptionsType {
|
||||
QUOTAS = "Quotas",
|
||||
}
|
||||
|
||||
const getOptionsTypeTranslationKey = (type: OptionsType, t: TFunction): string => {
|
||||
switch (type) {
|
||||
case OptionsType.ELEMENTS:
|
||||
return t("common.elements");
|
||||
case OptionsType.TAGS:
|
||||
return t("common.tags");
|
||||
case OptionsType.ATTRIBUTES:
|
||||
return t("common.attributes");
|
||||
case OptionsType.OTHERS:
|
||||
return t("common.other_filters");
|
||||
case OptionsType.META:
|
||||
return t("common.meta");
|
||||
case OptionsType.HIDDEN_FIELDS:
|
||||
return t("common.hidden_fields");
|
||||
case OptionsType.QUOTAS:
|
||||
return t("common.quotas");
|
||||
}
|
||||
};
|
||||
|
||||
export type ElementOption = {
|
||||
label: string;
|
||||
elementType?: TSurveyElementTypeEnum;
|
||||
@@ -218,7 +238,12 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
||||
{options?.map((data) => (
|
||||
<Fragment key={data.header}>
|
||||
{data?.option.length > 0 && (
|
||||
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
|
||||
<CommandGroup
|
||||
heading={
|
||||
<p className="text-sm font-medium text-slate-600">
|
||||
{getOptionsTypeTranslationKey(data.header, t)}
|
||||
</p>
|
||||
}>
|
||||
{data?.option?.map((o) => (
|
||||
<CommandItem
|
||||
key={o.id}
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
|
||||
import { updateSurveyAction } from "../actions";
|
||||
|
||||
interface SurveyStatusDropdownProps {
|
||||
environment: TEnvironment;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user