Compare commits

...

33 Commits

Author SHA1 Message Date
TheodorTomas
cb8c39f007 Merge remote-tracking branch 'origin/epic/dashboards' into feat/crud-charts-endpoint 2026-02-20 19:24:12 +07:00
Theodór Tómas
62aa186a81 chore: merge main into dashboard epic (#7321)
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Chowdhury Tafsir Ahmed Siddiki <ctafsiras@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: neila <40727091+neila@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-20 12:22:54 +00:00
TheodorTomas
d819359216 fix: addressing code review issues 2026-02-20 19:19:08 +07:00
TheodorTomas
3007e75d5b chore: adding test coverage for dashboards 2026-02-20 15:55:56 +07:00
TheodorTomas
2c68a007a0 chore: adding test coverage for dashboards/charts CRUD actions 2026-02-20 15:44:17 +07:00
TheodorTomas
07a131dfd3 fix: applying fixes after doing self review 2026-02-20 15:19:11 +07:00
TheodorTomas
b52cd51771 Merge remote-tracking branch 'origin/epic/dashboards' into feat/crud-charts-endpoint 2026-02-20 14:41:17 +07:00
TheodorTomas
b02dcbb25f fix: applying sonarqube suggestions and PR feedback and doing self review 2026-02-20 14:39:05 +07:00
Dhruwang
bb257ed3d2 relocated cube setup files 2026-02-20 11:10:03 +05:30
Theodór Tómas
f35e54f21d feat: (dashboards) adding analysis tab to sidebar along with placeholder pages (#7311) 2026-02-20 09:58:26 +05:30
TheodorTomas
d2038d9770 chore: adding seed data for chards, dashboards and dashboar widgets 2026-02-19 21:05:49 +07:00
TheodorTomas
3e7fc6610a fix: update HasFindMany type 2026-02-19 20:57:35 +07:00
TheodorTomas
d01dc80712 fix: changing charts api routes to server actions 2026-02-19 19:33:40 +07:00
TheodorTomas
d32437b4a6 feat: adding CRUD operations for charts 2026-02-19 17:51:13 +07:00
Theodór Tómas
f49f40610b feat: add Cube.js dev setup and analytics client (#7287) 2026-02-18 21:10:52 +07:00
Theodór Tómas
9e754bad9c feat: add Chart, Dashboard, DashboardWidget schema and migration (#7286) 2026-02-18 21:10:36 +07:00
Dhruwang
4dcf6fda40 fix: code rabbit feedback 2026-02-18 18:44:24 +05:30
Dhruwang
1b8ccd7199 feat: add JSON type definitions for Chart and Dashboard fields
Add Zod schemas and TypeScript types for ChartQuery, ChartConfig,
WidgetLayout. ChartQuery mirrors Cube.js REST API query format.
Register types with prisma-json-types-generator.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 18:17:44 +05:30
Dhruwang
4f9088559f feat: add Cube.js dev setup and analytics client
- Add Cube container to docker-compose.dev.yml (pinned v1.3.21)
- Add Cube server config (cube/cube.js) and FeedbackRecords schema
- Add @cubejs-client/core dependency and singleton client in EE module
- Add CUBEJS_API_URL and CUBEJS_API_TOKEN to .env.example

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 18:05:47 +05:30
Dhruwang
18550f1d11 feat: link Chart and Dashboard createdBy to User
- Add creator relation on Chart and Dashboard to User
- Add createdBy foreign key constraints in migration (ON DELETE SET NULL)
- Mirror Survey pattern for createdBy user tracking

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:26:42 +05:30
Dhruwang
881cd31f74 feat: add Chart, Dashboard, DashboardWidget schema and migration
- Add Prisma models for Chart, Dashboard, DashboardWidget
- ChartType: area, bar, line, pie, big_number only
- Remove DashboardStatus and WidgetType (widgets are always charts)
- DashboardWidget requires chartId, remove content/type fields

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:24:48 +05:30
Dhruwang
e00405dca2 feat: add Chart, Dashboard, and DashboardWidget schema and migration
- Add Prisma models for Chart, Dashboard, DashboardWidget
- Add ChartType, DashboardStatus, WidgetType enums
- Add migration 20260128111722 for charts and dashboards tables

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:21:34 +05:30
Theodór Tómas
33542d0c54 fix: default preview colors (#7277)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-17 11:28:58 +00:00
Matti Nannt
f37d22f13d docs: align rate limiting docs with current code enforcement (#7267)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-02-17 07:42:53 +00:00
Anshuman Pandey
202ae903ac chore: makes rate limit config const (#7274) 2026-02-17 06:49:56 +00:00
Dhruwang Jariwala
6ab5cc367c fix: reduced default height of input (#7259) 2026-02-17 05:11:29 +00:00
Theodór Tómas
21559045ba fix: input placeholder color (#7265) 2026-02-17 05:11:01 +00:00
Theodór Tómas
d7c57a7a48 fix: disabling cache in dev (#7269) 2026-02-17 04:44:22 +00:00
Chowdhury Tafsir Ahmed Siddiki
11b2ef4788 docs: remove stale 'coming soon' placeholders (#7254) 2026-02-16 13:21:12 +00:00
Theodór Tómas
6fefd51cce fix: suggest colors has better succes copy (#7258) 2026-02-16 13:18:46 +00:00
Theodór Tómas
65af826222 fix: matrix table preview (#7257)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-16 13:18:17 +00:00
Anshuman Pandey
12eb54c653 fix: fixes number being passed into string attribute (#7255) 2026-02-16 11:18:59 +00:00
Dhruwang Jariwala
5aa1427e64 fix: input combobx height (#7256) 2026-02-16 10:03:23 +00:00
109 changed files with 4612 additions and 439 deletions

View File

@@ -229,5 +229,14 @@ REDIS_URL=redis://localhost:6379
# AUDIT_LOG_GET_USER_IP=0
# Cube.js Analytics (optional — only needed for the analytics/dashboard feature)
# Required when running the Cube service (docker-compose.dev.yml). Generate with: openssl rand -hex 32
# Use the same value for CUBEJS_API_TOKEN so the client can authenticate.
# CUBEJS_API_SECRET=
# URL where the Cube.js instance is running
# CUBEJS_API_URL=http://localhost:4000
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
# CUBEJS_API_TOKEN=
# Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here

View File

@@ -6,19 +6,9 @@ permissions:
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
push:
branches:
- main
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
jobs:
validate-translations:
@@ -33,30 +23,38 @@ jobs:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check for relevant changes
id: changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
filters: |
translations:
- 'apps/web/**/*.ts'
- 'apps/web/**/*.tsx'
- 'apps/web/locales/**/*.json'
- 'packages/surveys/src/**/*.{ts,tsx}'
- 'packages/surveys/locales/**/*.json'
- 'packages/email/**/*.{ts,tsx}'
- name: Setup Node.js 22.x
if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 22.x
- name: Install pnpm
if: steps.changes.outputs.translations == 'true'
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
if: steps.changes.outputs.translations == 'true'
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Validate translation keys
run: |
echo ""
echo "🔍 Validating translation keys..."
echo ""
pnpm run scan-translations
if: steps.changes.outputs.translations == 'true'
run: pnpm run scan-translations
- name: Summary
if: success()
run: |
echo ""
echo "✅ Translation validation completed successfully!"
echo ""
- name: Skip (no translation-related changes)
if: steps.changes.outputs.translations != 'true'
run: echo "No translation-related files changed — skipping validation."

View File

@@ -1,40 +1 @@
# Load environment variables from .env files
if [ -f .env ]; then
set -a
. .env
set +a
fi
pnpm lint-staged
# Run Lingo.dev i18n workflow if LINGODOTDEV_API_KEY is set
if [ -n "$LINGODOTDEV_API_KEY" ]; then
echo ""
echo "🌍 Running Lingo.dev translation workflow..."
echo ""
# Run translation generation and validation
if pnpm run i18n; then
echo ""
echo "✅ Translation validation passed"
echo ""
# Add updated locale files to git
git add apps/web/locales/*.json
else
echo ""
echo "❌ Translation validation failed!"
echo ""
echo "Please fix the translation issues above before committing:"
echo " • Add missing translation keys to your locale files"
echo " • Remove unused translation keys"
echo ""
echo "Or run 'pnpm i18n' to see the detailed report"
echo ""
exit 1
fi
else
echo ""
echo "⚠️ Skipping translation validation: LINGODOTDEV_API_KEY is not set"
echo " (This is expected for community contributors)"
echo ""
fi
pnpm lint-staged

View File

@@ -32,6 +32,7 @@ The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the b
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
We are using SonarQube to identify code smells and security hotspots.
Always mark React component props as `Readonly<>` (e.g., `({ children }: Readonly<MyProps>)`).
## Architecture & Patterns

View File

@@ -0,0 +1,9 @@
const ChartsPage = () => {
return (
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
Charts will appear here.
</div>
);
};
export default ChartsPage;

View File

@@ -0,0 +1,8 @@
import type { ReactNode } from "react";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
const DashboardDetailLayout = ({ children }: Readonly<{ children: ReactNode }>) => {
return <PageContentWrapper>{children}</PageContentWrapper>;
};
export default DashboardDetailLayout;

View File

@@ -0,0 +1,11 @@
const DashboardDetailPage = async (props: { params: Promise<{ dashboardId: string }> }) => {
const { dashboardId } = await props.params;
return (
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
Dashboard detail for {dashboardId} will appear here.
</div>
);
};
export default DashboardDetailPage;

View File

@@ -0,0 +1,9 @@
const DashboardsPage = () => {
return (
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
Dashboards will appear here.
</div>
);
};
export default DashboardsPage;

View File

@@ -0,0 +1,16 @@
import type { ReactNode } from "react";
import { getTranslate } from "@/lingodotdev/server";
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
const AnalysisLayout = async (props: { children: ReactNode; params: Promise<{ environmentId: string }> }) => {
const { environmentId } = await props.params;
const t = await getTranslate();
return (
<AnalysisPageLayout pageTitle={t("common.analysis")} environmentId={environmentId}>
{props.children}
</AnalysisPageLayout>
);
};
export default AnalysisLayout;

View File

@@ -0,0 +1,8 @@
import { redirect } from "next/navigation";
const AnalysisPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const { environmentId } = await props.params;
return redirect(`/environments/${environmentId}/analysis/dashboards`);
};
export default AnalysisPage;

View File

@@ -2,6 +2,7 @@
import {
ArrowUpRightIcon,
ChartBar,
ChevronRightIcon,
Cog,
LogOutIcon,
@@ -114,6 +115,13 @@ export const MainNavigation = ({
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
},
{
name: t("common.analysis"),
href: `/environments/${environment.id}/analysis`,
icon: ChartBar,
isActive: pathname?.includes("/analysis"),
isHidden: false,
},
{
name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`,
@@ -188,7 +196,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />

View File

@@ -30,7 +30,7 @@ export const NotificationSwitch = ({
const isChecked =
notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
: notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true;
: notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true;
const handleSwitchChange = async () => {
setIsLoading(true);
@@ -49,8 +49,11 @@ export const NotificationSwitch = ({
];
}
} else {
updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId] =
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
updatedNotificationSettings[notificationType] = {
...updatedNotificationSettings[notificationType],
[surveyOrProjectOrOrganizationId]:
!updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
};
}
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
@@ -78,7 +81,7 @@ export const NotificationSwitch = ({
) {
switch (notificationType) {
case "alert":
if (notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true) {
if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) {
handleSwitchChange();
toast.success(
t(

View File

@@ -1,12 +1,49 @@
"use server";
import { z } from "zod";
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
import { ZId } from "@formbricks/types/common";
import {
TIntegrationGoogleSheets,
ZIntegrationGoogleSheets,
} from "@formbricks/types/integration/google-sheet";
import { getSpreadsheetNameById, validateGoogleSheetsConnection } from "@/lib/googleSheet/service";
import { getIntegrationByType } from "@/lib/integration/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
const ZValidateGoogleSheetsConnectionAction = z.object({
environmentId: ZId,
});
export const validateGoogleSheetsConnectionAction = authenticatedActionClient
.schema(ZValidateGoogleSheetsConnectionAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],
});
const integration = await getIntegrationByType(parsedInput.environmentId, "googleSheets");
if (!integration) {
return { data: false };
}
await validateGoogleSheetsConnection(integration as TIntegrationGoogleSheets);
return { data: true };
});
const ZGetSpreadsheetNameByIdAction = z.object({
googleSheetIntegration: ZIntegrationGoogleSheets,
environmentId: z.string(),

View File

@@ -20,6 +20,10 @@ import {
isValidGoogleSheetsUrl,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
import {
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
GOOGLE_SHEET_INTEGRATION_INVALID_GRANT,
} from "@/lib/googleSheet/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -118,6 +122,17 @@ export const AddIntegrationModal = ({
resetForm();
}, [selectedIntegration, surveys]);
const showErrorMessageToast = (response: Awaited<ReturnType<typeof getSpreadsheetNameByIdAction>>) => {
const errorMessage = getFormattedErrorMessage(response);
if (errorMessage === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
toast.error(t("environments.integrations.google_sheets.token_expired_error"));
} else if (errorMessage === GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION) {
toast.error(t("environments.integrations.google_sheets.spreadsheet_permission_error"));
} else {
toast.error(errorMessage);
}
};
const linkSheet = async () => {
try {
if (!isValidGoogleSheetsUrl(spreadsheetUrl)) {
@@ -129,6 +144,7 @@ export const AddIntegrationModal = ({
if (selectedElements.length === 0) {
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
}
setIsLinkingSheet(true);
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
googleSheetIntegration,
@@ -137,13 +153,11 @@ export const AddIntegrationModal = ({
});
if (!spreadsheetNameResponse?.data) {
const errorMessage = getFormattedErrorMessage(spreadsheetNameResponse);
throw new Error(errorMessage);
showErrorMessageToast(spreadsheetNameResponse);
return;
}
const spreadsheetName = spreadsheetNameResponse.data;
setIsLinkingSheet(true);
integrationData.spreadsheetId = spreadsheetId;
integrationData.spreadsheetName = spreadsheetName;
integrationData.surveyId = selectedSurvey.id;
@@ -280,7 +294,7 @@ export const AddIntegrationModal = ({
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{surveyElements.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
@@ -8,9 +8,11 @@ import {
} from "@formbricks/types/integration/google-sheet";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
import googleSheetLogo from "@/images/googleSheetsLogo.png";
import { GOOGLE_SHEET_INTEGRATION_INVALID_GRANT } from "@/lib/googleSheet/constants";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { AddIntegrationModal } from "./AddIntegrationModal";
@@ -35,10 +37,23 @@ export const GoogleSheetWrapper = ({
googleSheetIntegration ? googleSheetIntegration.config?.key : false
);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [showReconnectButton, setShowReconnectButton] = useState<boolean>(false);
const [selectedIntegration, setSelectedIntegration] = useState<
(TIntegrationGoogleSheetsConfigData & { index: number }) | null
>(null);
const validateConnection = useCallback(async () => {
if (!isConnected || !googleSheetIntegration) return;
const response = await validateGoogleSheetsConnectionAction({ environmentId: environment.id });
if (response?.serverError === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
setShowReconnectButton(true);
}
}, [environment.id, isConnected, googleSheetIntegration]);
useEffect(() => {
validateConnection();
}, [validateConnection]);
const handleGoogleAuthorization = async () => {
authorize(environment.id, webAppUrl).then((url: string) => {
if (url) {
@@ -64,6 +79,8 @@ export const GoogleSheetWrapper = ({
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
showReconnectButton={showReconnectButton}
handleGoogleAuthorization={handleGoogleAuthorization}
locale={locale}
/>
</>

View File

@@ -1,6 +1,6 @@
"use client";
import { Trash2Icon } from "lucide-react";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -12,15 +12,19 @@ import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface ManageIntegrationProps {
googleSheetIntegration: TIntegrationGoogleSheets;
setOpenAddIntegrationModal: (v: boolean) => void;
setIsConnected: (v: boolean) => void;
setSelectedIntegration: (v: (TIntegrationGoogleSheetsConfigData & { index: number }) | null) => void;
showReconnectButton: boolean;
handleGoogleAuthorization: () => void;
locale: TUserLocale;
}
@@ -29,6 +33,8 @@ export const ManageIntegration = ({
setOpenAddIntegrationModal,
setIsConnected,
setSelectedIntegration,
showReconnectButton,
handleGoogleAuthorization,
locale,
}: ManageIntegrationProps) => {
const { t } = useTranslation();
@@ -68,7 +74,17 @@ export const ManageIntegration = ({
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
<div className="flex w-full justify-end">
{showReconnectButton && (
<Alert variant="warning" size="small" className="mb-4 w-full">
<AlertDescription>
{t("environments.integrations.google_sheets.reconnect_button_description")}
</AlertDescription>
<AlertButton onClick={handleGoogleAuthorization}>
{t("environments.integrations.google_sheets.reconnect_button")}
</AlertButton>
</Alert>
)}
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
@@ -77,6 +93,19 @@ export const ManageIntegration = ({
})}
</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleGoogleAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.google_sheets.reconnect_button")}
</Button>
</TooltipTrigger>
<TooltipContent>
{t("environments.integrations.google_sheets.reconnect_button_tooltip")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
onClick={() => {
setSelectedIntegration(null);

View File

@@ -21,6 +21,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { parseRecallInfo } from "@/lib/utils/recall";
import { truncateText } from "@/lib/utils/strings";
import { resolveStorageUrlAuto } from "@/modules/storage/utils";
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
let result: string[] = [];
@@ -256,10 +257,16 @@ const processElementResponse = (
const selectedChoiceIds = responseValue as string[];
return element.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => choice.imageUrl)
.map((choice) => resolveStorageUrlAuto(choice.imageUrl))
.join("\n");
}
if (element.type === TSurveyElementTypeEnum.FileUpload && Array.isArray(responseValue)) {
return responseValue
.map((url) => (typeof url === "string" ? resolveStorageUrlAuto(url) : url))
.join("; ");
}
return processResponseData(responseValue);
};
@@ -368,7 +375,7 @@ const buildNotionPayloadProperties = (
responses[resp] = (pictureElement as any)?.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => choice.imageUrl);
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
}
});

View File

@@ -18,6 +18,7 @@ import { convertDatesInObject } from "@/lib/time";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { sendResponseFinishedEmail } from "@/modules/email";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { handleIntegrations } from "./lib/handleIntegrations";
@@ -95,12 +96,15 @@ export const POST = async (request: Request) => {
]);
};
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
const webhookPromises = webhooks.map((webhook) => {
const body = JSON.stringify({
webhookId: webhook.id,
event,
data: {
...response,
data: resolvedResponseData,
survey: {
title: survey.name,
type: survey.type,

View File

@@ -1,5 +1,6 @@
import { google } from "googleapis";
import { getServerSession } from "next-auth";
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
import { responses } from "@/app/lib/api/response";
import {
GOOGLE_SHEETS_CLIENT_ID,
@@ -8,7 +9,7 @@ import {
WEBAPP_URL,
} from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (req: Request) => {
@@ -42,33 +43,39 @@ export const GET = async (req: Request) => {
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
let key;
let userEmail;
if (code) {
const token = await oAuth2Client.getToken(code);
key = token.res?.data;
// Set credentials using the provided token
oAuth2Client.setCredentials({
access_token: key.access_token,
});
// Fetch user's email
const oauth2 = google.oauth2({
auth: oAuth2Client,
version: "v2",
});
const userInfo = await oauth2.userinfo.get();
userEmail = userInfo.data.email;
if (!code) {
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
);
}
const token = await oAuth2Client.getToken(code);
const key = token.res?.data;
if (!key) {
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
);
}
oAuth2Client.setCredentials({ access_token: key.access_token });
const oauth2 = google.oauth2({ auth: oAuth2Client, version: "v2" });
const userInfo = await oauth2.userinfo.get();
const userEmail = userInfo.data.email;
if (!userEmail) {
return responses.internalServerErrorResponse("Failed to get user email");
}
const integrationType = "googleSheets" as const;
const existingIntegration = await getIntegrationByType(environmentId, integrationType);
const existingConfig = existingIntegration?.config as TIntegrationGoogleSheetsConfig;
const googleSheetIntegration = {
type: "googleSheets" as "googleSheets",
type: integrationType,
environment: environmentId,
config: {
key,
data: [],
data: existingConfig?.data ?? [],
email: userEmail,
},
};

View File

@@ -10,6 +10,7 @@ import {
TJsEnvironmentStateSurvey,
} from "@formbricks/types/js";
import { validateInputs } from "@/lib/utils/validate";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
/**
@@ -177,14 +178,14 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
overlay: environmentData.project.overlay,
placement: environmentData.project.placement,
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
styling: environmentData.project.styling,
styling: resolveStorageUrlsInObject(environmentData.project.styling),
},
},
organization: {
id: environmentData.project.organization.id,
billing: environmentData.project.organization.billing,
},
surveys: transformedSurveys,
surveys: resolveStorageUrlsInObject(transformedSurveys),
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
};
} catch (error) {

View File

@@ -44,13 +44,10 @@ const validateResponse = (
...responseUpdateInput.data,
};
const isFinished = responseUpdateInput.finished ?? false;
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
isFinished,
survey.questions
);

View File

@@ -41,7 +41,6 @@ const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) =>
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);

View File

@@ -10,7 +10,7 @@ import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
async function fetchAndAuthorizeResponse(
@@ -57,7 +57,10 @@ export const GET = withV1ApiWrapper({
}
return {
response: responses.successResponse(result.response),
response: responses.successResponse({
...result.response,
data: resolveStorageUrlsInObject(result.response.data),
}),
};
} catch (error) {
return {
@@ -146,7 +149,6 @@ export const PUT = withV1ApiWrapper({
result.survey.blocks,
responseUpdate.data,
responseUpdate.language ?? "en",
responseUpdate.finished,
result.survey.questions
);
@@ -190,7 +192,7 @@ export const PUT = withV1ApiWrapper({
}
return {
response: responses.successResponse(updated),
response: responses.successResponse({ ...updated, data: resolveStorageUrlsInObject(updated.data) }),
};
} catch (error) {
return {

View File

@@ -9,7 +9,7 @@ import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import {
createResponseWithQuotaEvaluation,
getResponses,
@@ -54,7 +54,9 @@ export const GET = withV1ApiWrapper({
allResponses.push(...environmentResponses);
}
return {
response: responses.successResponse(allResponses),
response: responses.successResponse(
allResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }))
),
};
} catch (error) {
if (error instanceof DatabaseError) {
@@ -155,7 +157,6 @@ export const POST = withV1ApiWrapper({
surveyResult.survey.blocks,
responseInput.data,
responseInput.language ?? "en",
responseInput.finished,
surveyResult.survey.questions
);

View File

@@ -16,6 +16,7 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
const fetchAndAuthorizeSurvey = async (
surveyId: string,
@@ -58,16 +59,18 @@ export const GET = withV1ApiWrapper({
if (shouldTransformToQuestions) {
return {
response: responses.successResponse({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
}),
response: responses.successResponse(
resolveStorageUrlsInObject({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
})
),
};
}
return {
response: responses.successResponse(result.survey),
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
};
} catch (error) {
return {
@@ -202,12 +205,12 @@ export const PUT = withV1ApiWrapper({
};
return {
response: responses.successResponse(surveyWithQuestions),
response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)),
};
}
return {
response: responses.successResponse(updatedSurvey),
response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)),
};
} catch (error) {
return {

View File

@@ -14,6 +14,7 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getSurveys } from "./lib/surveys";
export const GET = withV1ApiWrapper({
@@ -55,7 +56,7 @@ export const GET = withV1ApiWrapper({
});
return {
response: responses.successResponse(surveysWithQuestions),
response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)),
};
} catch (error) {
if (error instanceof DatabaseError) {

View File

@@ -112,7 +112,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);

View File

@@ -257,6 +257,7 @@ describe("endpoint-validator", () => {
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
expect(isAuthProtectedRoute("/")).toBe(false);
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
expect(isAuthProtectedRoute("/p/pretty-url")).toBe(false);
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
expect(isAuthProtectedRoute("/health")).toBe(false);
});
@@ -312,6 +313,19 @@ describe("endpoint-validator", () => {
expect(isPublicDomainRoute("/c")).toBe(false);
expect(isPublicDomainRoute("/contact/token")).toBe(false);
});
test("should return true for pretty URL survey routes", () => {
expect(isPublicDomainRoute("/p/pretty123")).toBe(true);
expect(isPublicDomainRoute("/p/pretty-name-with-dashes")).toBe(true);
expect(isPublicDomainRoute("/p/survey_id_with_underscores")).toBe(true);
expect(isPublicDomainRoute("/p/abc123def456")).toBe(true);
});
test("should return false for malformed pretty URL survey routes", () => {
expect(isPublicDomainRoute("/p/")).toBe(false);
expect(isPublicDomainRoute("/p")).toBe(false);
expect(isPublicDomainRoute("/pretty/123")).toBe(false);
});
test("should return true for client API routes", () => {
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
@@ -375,6 +389,8 @@ describe("endpoint-validator", () => {
expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false);
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).toBe(false);
expect(isAdminDomainRoute("/p/pretty123")).toBe(false);
expect(isAdminDomainRoute("/p/pretty-name-with-dashes")).toBe(false);
expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false);
expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false);
});
@@ -390,6 +406,7 @@ describe("endpoint-validator", () => {
test("should allow public routes on public domain", () => {
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
expect(isRouteAllowedForDomain("/p/pretty123", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
@@ -426,6 +443,8 @@ describe("endpoint-validator", () => {
expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", false)).toBe(false);
expect(isRouteAllowedForDomain("/p/pretty123", false)).toBe(false);
expect(isRouteAllowedForDomain("/p/pretty-name-with-dashes", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false);
});
@@ -440,6 +459,8 @@ describe("endpoint-validator", () => {
test("should handle paths with query parameters and fragments", () => {
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
expect(isRouteAllowedForDomain("/s/survey123#section", true)).toBe(true);
expect(isRouteAllowedForDomain("/p/pretty123?param=value", true)).toBe(true);
expect(isRouteAllowedForDomain("/p/pretty123#section", true)).toBe(true);
expect(isRouteAllowedForDomain("/environments/123?tab=settings", true)).toBe(false);
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
});
@@ -450,6 +471,7 @@ describe("endpoint-validator", () => {
describe("URL parsing edge cases", () => {
test("should handle paths with query parameters", () => {
expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123?param=value&other=test")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/test?query=data")).toBe(true);
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
@@ -458,12 +480,14 @@ describe("endpoint-validator", () => {
test("should handle paths with fragments", () => {
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
expect(isPublicDomainRoute("/c/jwt-token#top")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123#section")).toBe(true);
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
});
test("should handle trailing slashes", () => {
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
isManagementApi: true,
@@ -478,6 +502,9 @@ describe("endpoint-validator", () => {
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/thank-you")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/preview")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/embed")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/thank-you")).toBe(true);
});
test("should handle nested client API routes", () => {
@@ -529,6 +556,7 @@ describe("endpoint-validator", () => {
test("should handle special characters in survey IDs", () => {
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
expect(isPublicDomainRoute("/p/pretty-123_test.v2")).toBe(true);
});
});
@@ -536,6 +564,7 @@ describe("endpoint-validator", () => {
test("should properly validate malicious or injection-like URLs", () => {
// SQL injection-like attempts
expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format
expect(isPublicDomainRoute("/p/'; DROP TABLE users; --")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
@@ -543,10 +572,12 @@ describe("endpoint-validator", () => {
// Path traversal attempts
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
expect(isPublicDomainRoute("/p/../../../etc/passwd")).toBe(true);
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
// XSS-like attempts
expect(isPublicDomainRoute("/s/<script>alert('xss')</script>")).toBe(true);
expect(isPublicDomainRoute("/p/<script>alert('xss')</script>")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/<script>alert('xss')</script>")).toEqual({
isClientSideApi: true,
isRateLimited: true,
@@ -556,6 +587,7 @@ describe("endpoint-validator", () => {
test("should handle URL encoding", () => {
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
expect(isPublicDomainRoute("/p/pretty%20123")).toBe(true);
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
isManagementApi: true,
@@ -591,6 +623,7 @@ describe("endpoint-validator", () => {
// These should not match due to case sensitivity
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
expect(isPublicDomainRoute("/P/pretty123")).toBe(false);
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
isClientSideApi: false,
isRateLimited: true,

View File

@@ -7,6 +7,7 @@ const PUBLIC_ROUTES = {
SURVEY_ROUTES: [
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
/^\/p\/[^/]+/, // /p/[prettyUrl] - pretty URL pages
],
// API routes accessible from public domain

View File

@@ -106,6 +106,7 @@ checksums:
common/allow: 3e39cc5940255e6bff0fea95c817dd43
common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56
common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2
common/analysis: 409bac6215382c47e59f5039cc4cdcdd
common/and: dc75b95c804b16dc617a5f16f7393bca
common/and_response_limit_of: 05be41a1d7e8dafa4aa012dcba77f5d4
common/anonymous: 77b5222e710cc1dae073dae32309f8ed
@@ -122,6 +123,7 @@ checksums:
common/bottom_right: aaef9a70ef795affc806c6d1853d8373
common/cancel: 2e2a849c2223911717de8caa2c71bade
common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507
common/charts: 1da4564d89264c89de4ed28d7451b43e
common/choices: 8a7a77a71ec6eebc363c5dc0f8490a4d
common/choose_environment: 5762cd499529815fc3e6a7feea39f90b
common/choose_organization: a8f5db68012323bfbb1a0ad0fb194603
@@ -160,6 +162,7 @@ checksums:
common/created_by: 6775c2fa7d495fea48f1ad816daea93b
common/customer_success: 2b0c99a5f57e1d16cf0a998f9bb116c4
common/dark_overlay: 173e84b526414dbc70dbf9737e443b60
common/dashboards: 4bc47e48559a6b688684dcb7ac4babc9
common/date: 56f41c5d30a76295bb087b20b7bee4c3
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
@@ -711,7 +714,12 @@ checksums:
environments/integrations/google_sheets/link_google_sheet: fa78146ae26ce5b1d2aaf2678f628943
environments/integrations/google_sheets/link_new_sheet: 8ad2ea8708f50ed184c00b84577b325e
environments/integrations/google_sheets/no_integrations_yet: ea46f7747937baf48a47a4c1b1776aee
environments/integrations/google_sheets/reconnect_button: 8992a0f250278c116cb26be448b68ba2
environments/integrations/google_sheets/reconnect_button_description: 851fd2fda57211293090f371d5b2c734
environments/integrations/google_sheets/reconnect_button_tooltip: 210dd97470fde8264d2c076db3c98fde
environments/integrations/google_sheets/spreadsheet_permission_error: 94f0007a187d3b9a7ab8200fe26aad20
environments/integrations/google_sheets/spreadsheet_url: b1665f96e6ecce23ea2d9196f4a3e5dd
environments/integrations/google_sheets/token_expired_error: 555d34c18c554ec8ac66614f21bd44fc
environments/integrations/include_created_at: 8011355b13e28e638d74e6f3d68a2bbf
environments/integrations/include_hidden_fields: 25f0ea5ca1c6ead2cd121f8754cb8d72
environments/integrations/include_metadata: 750091d965d7cc8d02468b5239816dc5
@@ -2036,12 +2044,12 @@ checksums:
environments/workspace/look/advanced_styling_field_headline_size_description: 13debc3855e4edae992c7a1ebff599c3
environments/workspace/look/advanced_styling_field_headline_weight: 0c8b8262945c61f8e2978502362e0a42
environments/workspace/look/advanced_styling_field_headline_weight_description: 1a9c40bd76ff5098b1e48b1d3893171b
environments/workspace/look/advanced_styling_field_height: f4da6d7ecd26e3fa75cfea03abb60c00
environments/workspace/look/advanced_styling_field_height: 40ca2224bb2936ad1329091b35a9ffe2
environments/workspace/look/advanced_styling_field_indicator_bg: 00febda2901af0f1b0c17e44f9917c38
environments/workspace/look/advanced_styling_field_indicator_bg_description: 7eb3b54a8b331354ec95c0dc1545c620
environments/workspace/look/advanced_styling_field_input_border_radius_description: 0007f1bb572b35d9a3720daeb7a55617
environments/workspace/look/advanced_styling_field_input_font_size_description: 5311f95dcbd083623e35c98ea5374c3b
environments/workspace/look/advanced_styling_field_input_height_description: b704fc67e805223992c811d6f86a9c00
environments/workspace/look/advanced_styling_field_input_height_description: e19ec0dc432478def0fd1199ad765e38
environments/workspace/look/advanced_styling_field_input_padding_x_description: 10e14296468321c13fda77fd1ba58dfd
environments/workspace/look/advanced_styling_field_input_padding_y_description: 98b4aeff2940516d05ea61bdc1211d0d
environments/workspace/look/advanced_styling_field_input_placeholder_opacity_description: f55a6700884d24014404e58876121ddf
@@ -2104,6 +2112,7 @@ checksums:
environments/workspace/look/show_powered_by_formbricks: a0e96edadec8ef326423feccc9d06be7
environments/workspace/look/styling_updated_successfully: b8b74b50dde95abcd498633e9d0c891f
environments/workspace/look/suggest_colors: ddc4543b416ab774007b10a3434343cd
environments/workspace/look/suggested_colors_applied_please_save: 226fa70af5efc8ffa0a3755909c8163e
environments/workspace/look/theme: 21fe00b7a518089576fb83c08631107a
environments/workspace/look/theme_settings_description: 9fc45322818c3774ab4a44ea14d7836e
environments/workspace/tags/add: 87c4a663507f2bcbbf79934af8164e13

View File

@@ -0,0 +1,6 @@
/**
* Error codes returned by Google Sheets integration.
* Use these constants when comparing error responses to avoid typos and enable reuse.
*/
export const GOOGLE_SHEET_INTEGRATION_INVALID_GRANT = "invalid_grant";
export const GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION = "insufficient_permission";

View File

@@ -2,7 +2,12 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { ZString } from "@formbricks/types/common";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import {
AuthenticationError,
DatabaseError,
OperationNotAllowedError,
UnknownError,
} from "@formbricks/types/errors";
import {
TIntegrationGoogleSheets,
ZIntegrationGoogleSheets,
@@ -11,8 +16,12 @@ import {
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
GOOGLE_SHEET_MESSAGE_LIMIT,
} from "@/lib/constants";
import { GOOGLE_SHEET_MESSAGE_LIMIT } from "@/lib/constants";
import {
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
GOOGLE_SHEET_INTEGRATION_INVALID_GRANT,
} from "@/lib/googleSheet/constants";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { truncateText } from "../utils/strings";
import { validateInputs } from "../utils/validate";
@@ -81,6 +90,17 @@ export const writeData = async (
}
};
export const validateGoogleSheetsConnection = async (
googleSheetIntegrationData: TIntegrationGoogleSheets
): Promise<void> => {
validateInputs([googleSheetIntegrationData, ZIntegrationGoogleSheets]);
const integrationData = structuredClone(googleSheetIntegrationData);
integrationData.config.data.forEach((data) => {
data.createdAt = new Date(data.createdAt);
});
await authorize(integrationData);
};
export const getSpreadsheetNameById = async (
googleSheetIntegrationData: TIntegrationGoogleSheets,
spreadsheetId: string
@@ -94,7 +114,17 @@ export const getSpreadsheetNameById = async (
return new Promise((resolve, reject) => {
sheets.spreadsheets.get({ spreadsheetId }, (err, response) => {
if (err) {
reject(new UnknownError(`Error while fetching spreadsheet data: ${err.message}`));
const msg = err.message?.toLowerCase() ?? "";
const isPermissionError =
msg.includes("permission") ||
msg.includes("caller does not have") ||
msg.includes("insufficient permission") ||
msg.includes("access denied");
if (isPermissionError) {
reject(new OperationNotAllowedError(GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION));
} else {
reject(new UnknownError(`Error while fetching spreadsheet data: ${err.message}`));
}
return;
}
const spreadsheetTitle = response.data.properties.title;
@@ -109,26 +139,70 @@ export const getSpreadsheetNameById = async (
}
};
const isInvalidGrantError = (error: unknown): boolean => {
const err = error as { message?: string; response?: { data?: { error?: string } } };
return (
typeof err?.message === "string" &&
err.message.toLowerCase().includes(GOOGLE_SHEET_INTEGRATION_INVALID_GRANT)
);
};
/** Buffer in ms before expiry_date to consider token near-expired (5 minutes). */
const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
const GOOGLE_TOKENINFO_URL = "https://www.googleapis.com/oauth2/v1/tokeninfo";
/**
* Verifies that the access token is still valid and not revoked (e.g. user removed app access).
* Returns true if token is valid, false if invalid/revoked.
*/
const isAccessTokenValid = async (accessToken: string): Promise<boolean> => {
try {
const res = await fetch(`${GOOGLE_TOKENINFO_URL}?access_token=${encodeURIComponent(accessToken)}`);
return res.ok;
} catch {
return false;
}
};
const authorize = async (googleSheetIntegrationData: TIntegrationGoogleSheets) => {
const client_id = GOOGLE_SHEETS_CLIENT_ID;
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const refresh_token = googleSheetIntegrationData.config.key.refresh_token;
oAuth2Client.setCredentials({
refresh_token,
});
const { credentials } = await oAuth2Client.refreshAccessToken();
await createOrUpdateIntegration(googleSheetIntegrationData.environmentId, {
type: "googleSheets",
config: {
data: googleSheetIntegrationData.config?.data ?? [],
email: googleSheetIntegrationData.config?.email ?? "",
key: credentials,
},
});
const key = googleSheetIntegrationData.config.key;
oAuth2Client.setCredentials(credentials);
const hasStoredCredentials =
key.access_token && key.expiry_date && key.expiry_date > Date.now() + TOKEN_EXPIRY_BUFFER_MS;
return oAuth2Client;
if (hasStoredCredentials && (await isAccessTokenValid(key.access_token))) {
oAuth2Client.setCredentials(key);
return oAuth2Client;
}
oAuth2Client.setCredentials({ refresh_token: key.refresh_token });
try {
const { credentials } = await oAuth2Client.refreshAccessToken();
const mergedCredentials = {
...credentials,
refresh_token: credentials.refresh_token ?? key.refresh_token,
};
await createOrUpdateIntegration(googleSheetIntegrationData.environmentId, {
type: "googleSheets",
config: {
data: googleSheetIntegrationData.config?.data ?? [],
email: googleSheetIntegrationData.config?.email ?? "",
key: mergedCredentials,
},
});
oAuth2Client.setCredentials(mergedCredentials);
return oAuth2Client;
} catch (error) {
if (isInvalidGrantError(error)) {
throw new AuthenticationError(GOOGLE_SHEET_INTEGRATION_INVALID_GRANT);
}
throw error;
}
};

View File

@@ -22,6 +22,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
import { deleteFile } from "@/modules/storage/service";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { ITEMS_PER_PAGE } from "../constants";
@@ -408,9 +409,10 @@ export const getResponseDownloadFile = async (
if (survey.isVerifyEmailEnabled) {
headers.push("Verified Email");
}
const resolvedResponses = responses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }));
const jsonData = getResponsesJson(
survey,
responses,
resolvedResponses,
elements,
userAttributes,
hiddenFields,

View File

@@ -118,10 +118,10 @@ export const STYLE_DEFAULTS: TProjectStyling = {
// Inputs
inputTextColor: { light: _colors["inputTextColor.light"] },
inputBorderRadius: 8,
inputHeight: 40,
inputHeight: 20,
inputFontSize: 14,
inputPaddingX: 16,
inputPaddingY: 16,
inputPaddingX: 8,
inputPaddingY: 8,
inputPlaceholderOpacity: 0.5,
inputShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
@@ -149,6 +149,42 @@ export const STYLE_DEFAULTS: TProjectStyling = {
progressIndicatorBgColor: { light: _colors["progressIndicatorBgColor.light"] },
};
/**
* Fills in new v4.7 color fields from legacy v4.6 fields when they are missing.
*
* v4.6 stored: brandColor, questionColor, inputColor, inputBorderColor.
* v4.7 adds: elementHeadlineColor, buttonBgColor, optionBgColor, etc.
*
* When loading v4.6 data the new fields are absent. Without this helper the
* form would fall back to STYLE_DEFAULTS (derived from the *default* brand
* colour), causing a visible mismatch. This function derives the new fields
* from the actually-saved legacy fields so the preview and form stay coherent.
*
* Only sets a field when the legacy source exists AND the new field is absent.
*/
export const deriveNewFieldsFromLegacy = (saved: Record<string, unknown>): Record<string, unknown> => {
const light = (key: string): string | undefined =>
(saved[key] as { light?: string } | null | undefined)?.light;
const q = light("questionColor");
const b = light("brandColor");
const i = light("inputColor");
return {
...(q && !saved.elementHeadlineColor && { elementHeadlineColor: { light: q } }),
...(q && !saved.elementDescriptionColor && { elementDescriptionColor: { light: q } }),
...(q && !saved.elementUpperLabelColor && { elementUpperLabelColor: { light: q } }),
...(q && !saved.inputTextColor && { inputTextColor: { light: q } }),
...(q && !saved.optionLabelColor && { optionLabelColor: { light: q } }),
...(b && !saved.buttonBgColor && { buttonBgColor: { light: b } }),
...(b && !saved.buttonTextColor && { buttonTextColor: { light: isLight(b) ? "#0f172a" : "#ffffff" } }),
...(i && !saved.optionBgColor && { optionBgColor: { light: i } }),
...(b && !saved.progressIndicatorBgColor && { progressIndicatorBgColor: { light: b } }),
...(b &&
!saved.progressTrackBgColor && { progressTrackBgColor: { light: mixColor(b, "#ffffff", 0.8) } }),
};
};
/**
* Builds a complete TProjectStyling object from a single brand color.
*

View File

@@ -22,6 +22,9 @@ export type AuditLoggingCtx = {
quotaId?: string;
teamId?: string;
integrationId?: string;
chartId?: string;
dashboardId?: string;
dashboardWidgetId?: string;
};
export type ActionClientCtx = {

View File

@@ -12,11 +12,18 @@ export function validateInputs<T extends ValidationPair<any>[]>(
for (const [value, schema] of pairs) {
const inputValidation = schema.safeParse(value);
if (!inputValidation.success) {
const zodDetails = inputValidation.error.issues
.map((issue) => {
const path = issue?.path?.join(".") ?? "";
return `${path}${issue.message}`;
})
.join("; ");
logger.error(
inputValidation.error,
`Validation failed for ${JSON.stringify(value).substring(0, 100)} and ${JSON.stringify(schema)}`
);
throw new ValidationError("Validation failed");
throw new ValidationError(`Validation failed: ${zodDetails}`);
}
parsedData.push(inputValidation.data);
}

View File

@@ -133,6 +133,7 @@
"allow": "erlauben",
"allow_users_to_exit_by_clicking_outside_the_survey": "Erlaube Nutzern, die Umfrage zu verlassen, indem sie außerhalb klicken",
"an_unknown_error_occurred_while_deleting_table_items": "Beim Löschen von {type}s ist ein unbekannter Fehler aufgetreten",
"analysis": "Analyse",
"and": "und",
"and_response_limit_of": "und Antwortlimit von",
"anonymous": "Anonym",
@@ -149,6 +150,7 @@
"bottom_right": "Unten rechts",
"cancel": "Abbrechen",
"centered_modal": "Zentriertes Modalfenster",
"charts": "Diagramme",
"choices": "Entscheidungen",
"choose_environment": "Umgebung auswählen",
"choose_organization": "Organisation auswählen",
@@ -187,6 +189,7 @@
"created_by": "Erstellt von",
"customer_success": "Kundenerfolg",
"dark_overlay": "Dunkle Überlagerung",
"dashboards": "Dashboards",
"date": "Datum",
"days": "Tage",
"default": "Standard",
@@ -752,7 +755,12 @@
"link_google_sheet": "Tabelle verlinken",
"link_new_sheet": "Neues Blatt verknüpfen",
"no_integrations_yet": "Deine verknüpften Tabellen werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
"spreadsheet_url": "Tabellen-URL"
"reconnect_button": "Erneut verbinden",
"reconnect_button_description": "Deine Google Sheets-Verbindung ist abgelaufen. Bitte verbinde dich erneut, um weiterhin Antworten zu synchronisieren. Deine bestehenden Tabellen-Links und Daten bleiben erhalten.",
"reconnect_button_tooltip": "Verbinde die Integration erneut, um deinen Zugriff zu aktualisieren. Deine bestehenden Tabellen-Links und Daten bleiben erhalten.",
"spreadsheet_permission_error": "Du hast keine Berechtigung, auf diese Tabelle zuzugreifen. Bitte stelle sicher, dass die Tabelle mit deinem Google-Konto geteilt ist und du Schreibzugriff auf die Tabelle hast.",
"spreadsheet_url": "Tabellen-URL",
"token_expired_error": "Das Google Sheets-Aktualisierungstoken ist abgelaufen oder wurde widerrufen. Bitte verbinde die Integration erneut."
},
"include_created_at": "Erstellungsdatum einbeziehen",
"include_hidden_fields": "Versteckte Felder (hidden fields) einbeziehen",
@@ -2153,12 +2161,12 @@
"advanced_styling_field_headline_size_description": "Skaliert den Überschriftentext.",
"advanced_styling_field_headline_weight": "Schriftstärke der Überschrift",
"advanced_styling_field_headline_weight_description": "Macht den Überschriftentext heller oder fetter.",
"advanced_styling_field_height": "Höhe",
"advanced_styling_field_height": "Mindesthöhe",
"advanced_styling_field_indicator_bg": "Indikator-Hintergrund",
"advanced_styling_field_indicator_bg_description": "Färbt den gefüllten Teil des Balkens.",
"advanced_styling_field_input_border_radius_description": "Rundet die Eingabeecken ab.",
"advanced_styling_field_input_font_size_description": "Skaliert den eingegebenen Text in Eingabefeldern.",
"advanced_styling_field_input_height_description": "Steuert die Höhe des Eingabefelds.",
"advanced_styling_field_input_height_description": "Legt die Mindesthöhe des Eingabefelds fest.",
"advanced_styling_field_input_padding_x_description": "Fügt links und rechts Abstand hinzu.",
"advanced_styling_field_input_padding_y_description": "Fügt oben und unten Abstand hinzu.",
"advanced_styling_field_input_placeholder_opacity_description": "Blendet den Platzhaltertext aus.",
@@ -2221,6 +2229,7 @@
"show_powered_by_formbricks": "\"Powered by Formbricks\"-Signatur anzeigen",
"styling_updated_successfully": "Styling erfolgreich aktualisiert",
"suggest_colors": "Farben vorschlagen",
"suggested_colors_applied_please_save": "Vorgeschlagene Farben erfolgreich generiert. Drücke \"Speichern\", um die Änderungen zu übernehmen.",
"theme": "Theme",
"theme_settings_description": "Erstelle ein Style-Theme für alle Umfragen. Du kannst für jede Umfrage individuelles Styling aktivieren."
},

View File

@@ -133,6 +133,7 @@
"allow": "Allow",
"allow_users_to_exit_by_clicking_outside_the_survey": "Allow users to exit by clicking outside the survey",
"an_unknown_error_occurred_while_deleting_table_items": "An unknown error occurred while deleting {type}s",
"analysis": "Analysis",
"and": "And",
"and_response_limit_of": "and response limit of",
"anonymous": "Anonymous",
@@ -149,6 +150,7 @@
"bottom_right": "Bottom Right",
"cancel": "Cancel",
"centered_modal": "Centered Modal",
"charts": "Charts",
"choices": "Choices",
"choose_environment": "Choose environment",
"choose_organization": "Choose organization",
@@ -187,6 +189,7 @@
"created_by": "Created by",
"customer_success": "Customer Success",
"dark_overlay": "Dark overlay",
"dashboards": "Dashboards",
"date": "Date",
"days": "days",
"default": "Default",
@@ -752,7 +755,12 @@
"link_google_sheet": "Link Google Sheet",
"link_new_sheet": "Link new Sheet",
"no_integrations_yet": "Your google sheet integrations will appear here as soon as you add them. ⏲️",
"spreadsheet_url": "Spreadsheet URL"
"reconnect_button": "Reconnect",
"reconnect_button_description": "Your Google Sheets connection has expired. Please reconnect to continue syncing responses. Your existing spreadsheet links and data will be preserved.",
"reconnect_button_tooltip": "Reconnect the integration to refresh your access. Your existing spreadsheet links and data will be preserved.",
"spreadsheet_permission_error": "You don't have permission to access this spreadsheet. Please ensure the spreadsheet is shared with your Google account and you have write access to the spreadsheet.",
"spreadsheet_url": "Spreadsheet URL",
"token_expired_error": "Google Sheets refresh token has expired or been revoked. Please reconnect the integration."
},
"include_created_at": "Include Created At",
"include_hidden_fields": "Include Hidden Fields",
@@ -2153,12 +2161,12 @@
"advanced_styling_field_headline_size_description": "Scales the headline text.",
"advanced_styling_field_headline_weight": "Headline Font Weight",
"advanced_styling_field_headline_weight_description": "Makes headline text lighter or bolder.",
"advanced_styling_field_height": "Height",
"advanced_styling_field_height": "Minimum Height",
"advanced_styling_field_indicator_bg": "Indicator Background",
"advanced_styling_field_indicator_bg_description": "Colors the filled portion of the bar.",
"advanced_styling_field_input_border_radius_description": "Rounds the input corners.",
"advanced_styling_field_input_font_size_description": "Scales the typed text in inputs.",
"advanced_styling_field_input_height_description": "Controls the input field height.",
"advanced_styling_field_input_height_description": "Controls the minimum height of the input field.",
"advanced_styling_field_input_padding_x_description": "Adds space on the left and right.",
"advanced_styling_field_input_padding_y_description": "Adds space on the top and bottom.",
"advanced_styling_field_input_placeholder_opacity_description": "Fades the placeholder hint text.",
@@ -2221,6 +2229,7 @@
"show_powered_by_formbricks": "Show “Powered by Formbricks” Signature",
"styling_updated_successfully": "Styling updated successfully",
"suggest_colors": "Suggest colors",
"suggested_colors_applied_please_save": "Suggested colors generated successfully. Press \"Save\" to persist the changes.",
"theme": "Theme",
"theme_settings_description": "Create a style theme for all surveys. You can enable custom styling for each survey."
},

View File

@@ -133,6 +133,7 @@
"allow": "Permitir",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir a los usuarios salir haciendo clic fuera de la encuesta",
"an_unknown_error_occurred_while_deleting_table_items": "Se ha producido un error desconocido al eliminar {type}s",
"analysis": "Análisis",
"and": "Y",
"and_response_limit_of": "y límite de respuesta de",
"anonymous": "Anónimo",
@@ -149,6 +150,7 @@
"bottom_right": "Inferior derecha",
"cancel": "Cancelar",
"centered_modal": "Modal centrado",
"charts": "Gráficos",
"choices": "Opciones",
"choose_environment": "Elegir entorno",
"choose_organization": "Elegir organización",
@@ -187,6 +189,7 @@
"created_by": "Creado por",
"customer_success": "Éxito del cliente",
"dark_overlay": "Superposición oscura",
"dashboards": "Paneles",
"date": "Fecha",
"days": "días",
"default": "Predeterminado",
@@ -752,7 +755,12 @@
"link_google_sheet": "Vincular Google Sheet",
"link_new_sheet": "Vincular nueva hoja",
"no_integrations_yet": "Tus integraciones de Google Sheet aparecerán aquí tan pronto como las añadas. ⏲️",
"spreadsheet_url": "URL de la hoja de cálculo"
"reconnect_button": "Reconectar",
"reconnect_button_description": "Tu conexión con Google Sheets ha caducado. Reconecta para continuar sincronizando respuestas. Tus enlaces de hojas de cálculo y datos existentes se conservarán.",
"reconnect_button_tooltip": "Reconecta la integración para actualizar tu acceso. Tus enlaces de hojas de cálculo y datos existentes se conservarán.",
"spreadsheet_permission_error": "No tienes permiso para acceder a esta hoja de cálculo. Asegúrate de que la hoja de cálculo esté compartida con tu cuenta de Google y de que tengas acceso de escritura a la hoja de cálculo.",
"spreadsheet_url": "URL de la hoja de cálculo",
"token_expired_error": "El token de actualización de Google Sheets ha caducado o ha sido revocado. Reconecta la integración."
},
"include_created_at": "Incluir fecha de creación",
"include_hidden_fields": "Incluir campos ocultos",
@@ -2153,12 +2161,12 @@
"advanced_styling_field_headline_size_description": "Escala el texto del titular.",
"advanced_styling_field_headline_weight": "Grosor de fuente del titular",
"advanced_styling_field_headline_weight_description": "Hace el texto del titular más ligero o más grueso.",
"advanced_styling_field_height": "Altura",
"advanced_styling_field_height": "Altura mínima",
"advanced_styling_field_indicator_bg": "Fondo del indicador",
"advanced_styling_field_indicator_bg_description": "Colorea la porción rellena de la barra.",
"advanced_styling_field_input_border_radius_description": "Redondea las esquinas del campo.",
"advanced_styling_field_input_font_size_description": "Escala el texto escrito en los campos.",
"advanced_styling_field_input_height_description": "Controla la altura del campo de entrada.",
"advanced_styling_field_input_height_description": "Controla la altura mínima del campo de entrada.",
"advanced_styling_field_input_padding_x_description": "Añade espacio a la izquierda y a la derecha.",
"advanced_styling_field_input_padding_y_description": "Añade espacio en la parte superior e inferior.",
"advanced_styling_field_input_placeholder_opacity_description": "Atenúa el texto de sugerencia del marcador de posición.",
@@ -2221,6 +2229,7 @@
"show_powered_by_formbricks": "Mostrar firma 'Powered by Formbricks'",
"styling_updated_successfully": "Estilo actualizado correctamente",
"suggest_colors": "Sugerir colores",
"suggested_colors_applied_please_save": "Colores sugeridos generados correctamente. Pulsa \"Guardar\" para conservar los cambios.",
"theme": "Tema",
"theme_settings_description": "Crea un tema de estilo para todas las encuestas. Puedes activar el estilo personalizado para cada encuesta."
},

View File

@@ -133,6 +133,7 @@
"allow": "Autoriser",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permettre aux utilisateurs de quitter en cliquant hors de l'enquête",
"an_unknown_error_occurred_while_deleting_table_items": "Une erreur inconnue est survenue lors de la suppression des {type}s",
"analysis": "Analyse",
"and": "Et",
"and_response_limit_of": "et limite de réponse de",
"anonymous": "Anonyme",
@@ -149,6 +150,7 @@
"bottom_right": "En bas à droite",
"cancel": "Annuler",
"centered_modal": "Au centre",
"charts": "Graphiques",
"choices": "Choix",
"choose_environment": "Choisir l'environnement",
"choose_organization": "Choisir l'organisation",
@@ -187,6 +189,7 @@
"created_by": "Créé par",
"customer_success": "Succès Client",
"dark_overlay": "Foncée",
"dashboards": "Tableaux de bord",
"date": "Date",
"days": "jours",
"default": "Par défaut",
@@ -752,7 +755,12 @@
"link_google_sheet": "Lien Google Sheet",
"link_new_sheet": "Lier une nouvelle feuille",
"no_integrations_yet": "Vos intégrations Google Sheets apparaîtront ici dès que vous les ajouterez. ⏲️",
"spreadsheet_url": "URL de la feuille de calcul"
"reconnect_button": "Reconnecter",
"reconnect_button_description": "Votre connexion Google Sheets a expiré. Veuillez vous reconnecter pour continuer à synchroniser les réponses. Vos liens de feuilles de calcul et données existants seront préservés.",
"reconnect_button_tooltip": "Reconnectez l'intégration pour actualiser votre accès. Vos liens de feuilles de calcul et données existants seront préservés.",
"spreadsheet_permission_error": "Vous n'avez pas la permission d'accéder à cette feuille de calcul. Veuillez vous assurer que la feuille de calcul est partagée avec votre compte Google et que vous disposez d'un accès en écriture.",
"spreadsheet_url": "URL de la feuille de calcul",
"token_expired_error": "Le jeton d'actualisation Google Sheets a expiré ou a été révoqué. Veuillez reconnecter l'intégration."
},
"include_created_at": "Inclure la date de création",
"include_hidden_fields": "Inclure les champs cachés",
@@ -2153,12 +2161,12 @@
"advanced_styling_field_headline_size_description": "Ajuste la taille du texte du titre.",
"advanced_styling_field_headline_weight": "Graisse de police du titre",
"advanced_styling_field_headline_weight_description": "Rend le texte du titre plus léger ou plus gras.",
"advanced_styling_field_height": "Hauteur",
"advanced_styling_field_height": "Hauteur minimale",
"advanced_styling_field_indicator_bg": "Arrière-plan de l'indicateur",
"advanced_styling_field_indicator_bg_description": "Colore la partie remplie de la barre.",
"advanced_styling_field_input_border_radius_description": "Arrondit les coins du champ de saisie.",
"advanced_styling_field_input_font_size_description": "Ajuste la taille du texte saisi dans les champs.",
"advanced_styling_field_input_height_description": "Contrôle la hauteur du champ de saisie.",
"advanced_styling_field_input_height_description": "Contrôle la hauteur minimale du champ de saisie.",
"advanced_styling_field_input_padding_x_description": "Ajoute de l'espace à gauche et à droite.",
"advanced_styling_field_input_padding_y_description": "Ajoute de l'espace en haut et en bas.",
"advanced_styling_field_input_placeholder_opacity_description": "Atténue le texte d'indication du placeholder.",
@@ -2221,6 +2229,7 @@
"show_powered_by_formbricks": "Afficher la signature «Propulsé par Formbricks»",
"styling_updated_successfully": "Style mis à jour avec succès",
"suggest_colors": "Suggérer des couleurs",
"suggested_colors_applied_please_save": "Couleurs suggérées générées avec succès. Appuyez sur «Enregistrer» pour conserver les modifications.",
"theme": "Thème",
"theme_settings_description": "Créez un thème de style pour toutes les enquêtes. Vous pouvez activer un style personnalisé pour chaque enquête."
},

View File

@@ -133,6 +133,7 @@
"allow": "Engedélyezés",
"allow_users_to_exit_by_clicking_outside_the_survey": "Lehetővé tétel a felhasználók számára, hogy a kérdőíven kívülre kattintva kilépjenek",
"an_unknown_error_occurred_while_deleting_table_items": "{type} típusok törlésekor ismeretlen hiba történt",
"analysis": "Elemzés",
"and": "És",
"and_response_limit_of": "és kérdéskorlátja ennek:",
"anonymous": "Névtelen",
@@ -149,6 +150,7 @@
"bottom_right": "Jobbra lent",
"cancel": "Mégse",
"centered_modal": "Középre helyezett kizárólagos",
"charts": "Diagramok",
"choices": "Választási lehetőségek",
"choose_environment": "Környezet kiválasztása",
"choose_organization": "Szervezet kiválasztása",
@@ -187,6 +189,7 @@
"created_by": "Létrehozta",
"customer_success": "Ügyfélsiker",
"dark_overlay": "Sötét rávetítés",
"dashboards": "Irányítópultok",
"date": "Dátum",
"days": "napok",
"default": "Alapértelmezett",
@@ -752,7 +755,12 @@
"link_google_sheet": "Google Táblázatok összekapcsolása",
"link_new_sheet": "Új táblázat összekapcsolása",
"no_integrations_yet": "A Google Táblázatok integrációi itt fognak megjelenni, amint hozzáadja azokat. ⏲️",
"spreadsheet_url": "Táblázat URL-e"
"reconnect_button": "Újrakapcsolódás",
"reconnect_button_description": "A Google Táblázatok kapcsolata lejárt. Kérjük, csatlakozzon újra a válaszok szinkronizálásának folytatásához. A meglévő táblázathivatkozások és adatok megmaradnak.",
"reconnect_button_tooltip": "Csatlakoztassa újra az integrációt a hozzáférés frissítéséhez. A meglévő táblázathivatkozások és adatok megmaradnak.",
"spreadsheet_permission_error": "Nincs jogosultsága a táblázat eléréséhez. Kérjük, győződjön meg arról, hogy a táblázat meg van osztva a Google-fiókjával, és írási jogosultsággal rendelkezik a táblázathoz.",
"spreadsheet_url": "Táblázat URL-e",
"token_expired_error": "A Google Táblázatok frissítési tokenje lejárt vagy visszavonásra került. Kérjük, csatlakoztassa újra az integrációt."
},
"include_created_at": "Létrehozva felvétele",
"include_hidden_fields": "Rejtett mezők felvétele",
@@ -2153,12 +2161,12 @@
"advanced_styling_field_headline_size_description": "Átméretezi a címsor szövegét.",
"advanced_styling_field_headline_weight": "Címsor betűvastagsága",
"advanced_styling_field_headline_weight_description": "Vékonyabbá vagy vastagabbá teszi a címsor szövegét.",
"advanced_styling_field_height": "Magasság",
"advanced_styling_field_height": "Minimális magasság",
"advanced_styling_field_indicator_bg": "Jelző háttere",
"advanced_styling_field_indicator_bg_description": "Kiszínezi a sáv kitöltött részét.",
"advanced_styling_field_input_border_radius_description": "Lekerekíti a beviteli mező sarkait.",
"advanced_styling_field_input_font_size_description": "Átméretezi a beviteli mezőkbe beírt szöveget.",
"advanced_styling_field_input_height_description": "A beviteli mező magasságát vezérli.",
"advanced_styling_field_input_height_description": "A beviteli mező minimális magasságát szabályozza.",
"advanced_styling_field_input_padding_x_description": "Térközt ad hozzá balra és jobbra.",
"advanced_styling_field_input_padding_y_description": "Térközt ad hozzá fent és lent.",
"advanced_styling_field_input_placeholder_opacity_description": "Elhalványítja a helykitöltő súgószöveget.",
@@ -2221,6 +2229,7 @@
"show_powered_by_formbricks": "Az „A gépházban: Formbricks” aláírás megjelenítése",
"styling_updated_successfully": "A stílus sikeresen frissítve",
"suggest_colors": "Színek ajánlása",
"suggested_colors_applied_please_save": "A javasolt színek sikeresen generálva. Nyomd meg a \"Mentés\" gombot a változtatások véglegesítéséhez.",
"theme": "Téma",
"theme_settings_description": "Stílustéma létrehozása az összes kérdőívhez. Egyéni stílust engedélyezhet minden egyes kérdőívhez."
},

View File

@@ -133,6 +133,7 @@
"allow": "許可",
"allow_users_to_exit_by_clicking_outside_the_survey": "フォームの外側をクリックしてユーザーが終了できるようにする",
"an_unknown_error_occurred_while_deleting_table_items": "{type}の削除中に不明なエラーが発生しました",
"analysis": "分析",
"and": "および",
"and_response_limit_of": "と回答数の上限",
"anonymous": "匿名",
@@ -149,6 +150,7 @@
"bottom_right": "右下",
"cancel": "キャンセル",
"centered_modal": "中央モーダル",
"charts": "チャート",
"choices": "選択肢",
"choose_environment": "環境を選択",
"choose_organization": "組織を選択",
@@ -187,6 +189,7 @@
"created_by": "作成者",
"customer_success": "カスタマーサクセス",
"dark_overlay": "暗いオーバーレイ",
"dashboards": "ダッシュボード",
"date": "日付",
"days": "日",
"default": "デフォルト",
@@ -752,7 +755,12 @@
"link_google_sheet": "スプレッドシートをリンク",
"link_new_sheet": "新しいシートをリンク",
"no_integrations_yet": "Google スプレッドシート連携は、追加するとここに表示されます。⏲️",
"spreadsheet_url": "スプレッドシートURL"
"reconnect_button": "再接続",
"reconnect_button_description": "Google Sheetsの接続が期限切れになりました。回答の同期を続けるには再接続してください。既存のスプレッドシートリンクとデータは保持されます。",
"reconnect_button_tooltip": "統合を再接続してアクセスを更新します。既存のスプレッドシートリンクとデータは保持されます。",
"spreadsheet_permission_error": "このスプレッドシートにアクセスする権限がありません。スプレッドシートがGoogleアカウントと共有されており、書き込みアクセス権があることを確認してください。",
"spreadsheet_url": "スプレッドシートURL",
"token_expired_error": "Google Sheetsのリフレッシュトークンが期限切れになったか、取り消されました。統合を再接続してください。"
},
"include_created_at": "作成日時を含める",
"include_hidden_fields": "非表示フィールドを含める",
@@ -2153,12 +2161,12 @@
"advanced_styling_field_headline_size_description": "見出しテキストのサイズを調整します。",
"advanced_styling_field_headline_weight": "見出しのフォントの太さ",
"advanced_styling_field_headline_weight_description": "見出しテキストを細くまたは太くします。",
"advanced_styling_field_height": "高さ",
"advanced_styling_field_height": "最小の高さ",
"advanced_styling_field_indicator_bg": "インジケーターの背景",
"advanced_styling_field_indicator_bg_description": "バーの塗りつぶし部分に色を付けます。",
"advanced_styling_field_input_border_radius_description": "入力フィールドの角を丸めます。",
"advanced_styling_field_input_font_size_description": "入力フィールド内の入力テキストのサイズを調整します。",
"advanced_styling_field_input_height_description": "入力フィールドの高さを調整します。",
"advanced_styling_field_input_height_description": "入力フィールドの最小の高さを制御します。",
"advanced_styling_field_input_padding_x_description": "左右にスペースを追加します。",
"advanced_styling_field_input_padding_y_description": "上下にスペースを追加します。",
"advanced_styling_field_input_placeholder_opacity_description": "プレースホルダーのヒントテキストを薄くします。",
@@ -2221,6 +2229,7 @@
"show_powered_by_formbricks": "「Powered by Formbricks」署名を表示",
"styling_updated_successfully": "スタイルを正常に更新しました",
"suggest_colors": "カラーを提案",
"suggested_colors_applied_please_save": "推奨カラーが正常に生成されました。変更を保存するには「保存」を押してください。",
"theme": "テーマ",
"theme_settings_description": "すべてのアンケート用のスタイルテーマを作成します。各アンケートでカスタムスタイルを有効にできます。"
},

View File

@@ -133,6 +133,7 @@
"allow": "Toestaan",
"allow_users_to_exit_by_clicking_outside_the_survey": "Laat gebruikers afsluiten door buiten de enquête te klikken",
"an_unknown_error_occurred_while_deleting_table_items": "Er is een onbekende fout opgetreden bij het verwijderen van {type}s",
"analysis": "Analyse",
"and": "En",
"and_response_limit_of": "en responslimiet van",
"anonymous": "Anoniem",
@@ -149,6 +150,7 @@
"bottom_right": "Rechtsonder",
"cancel": "Annuleren",
"centered_modal": "Gecentreerd modaal",
"charts": "Grafieken",
"choices": "Keuzes",
"choose_environment": "Kies omgeving",
"choose_organization": "Kies organisatie",
@@ -187,6 +189,7 @@
"created_by": "Gemaakt door",
"customer_success": "Klant succes",
"dark_overlay": "Donkere overlay",
"dashboards": "Dashboards",
"date": "Datum",
"days": "dagen",
"default": "Standaard",
@@ -752,7 +755,12 @@
"link_google_sheet": "Link Google Spreadsheet",
"link_new_sheet": "Nieuw blad koppelen",
"no_integrations_yet": "Uw Google Spreadsheet-integraties verschijnen hier zodra u ze toevoegt. ⏲️",
"spreadsheet_url": "Spreadsheet-URL"
"reconnect_button": "Maak opnieuw verbinding",
"reconnect_button_description": "Je Google Sheets-verbinding is verlopen. Maak opnieuw verbinding om door te gaan met het synchroniseren van antwoorden. Je bestaande spreadsheetlinks en gegevens blijven behouden.",
"reconnect_button_tooltip": "Maak opnieuw verbinding met de integratie om je toegang te vernieuwen. Je bestaande spreadsheetlinks en gegevens blijven behouden.",
"spreadsheet_permission_error": "Je hebt geen toestemming om deze spreadsheet te openen. Zorg ervoor dat de spreadsheet is gedeeld met je Google-account en dat je schrijftoegang hebt tot de spreadsheet.",
"spreadsheet_url": "Spreadsheet-URL",
"token_expired_error": "Het vernieuwingstoken van Google Sheets is verlopen of ingetrokken. Maak opnieuw verbinding met de integratie."
},
"include_created_at": "Inclusief gemaakt op",
"include_hidden_fields": "Inclusief verborgen velden",
@@ -2153,12 +2161,12 @@
"advanced_styling_field_headline_size_description": "Schaalt de koptekst.",
"advanced_styling_field_headline_weight": "Letterdikte kop",
"advanced_styling_field_headline_weight_description": "Maakt koptekst lichter of vetter.",
"advanced_styling_field_height": "Hoogte",
"advanced_styling_field_height": "Minimale hoogte",
"advanced_styling_field_indicator_bg": "Indicatorachtergrond",
"advanced_styling_field_indicator_bg_description": "Kleurt het gevulde deel van de balk.",
"advanced_styling_field_input_border_radius_description": "Rondt de invoerhoeken af.",
"advanced_styling_field_input_font_size_description": "Schaalt de getypte tekst in invoervelden.",
"advanced_styling_field_input_height_description": "Bepaalt de hoogte van het invoerveld.",
"advanced_styling_field_input_height_description": "Bepaalt de minimale hoogte van het invoerveld.",
"advanced_styling_field_input_padding_x_description": "Voegt ruimte toe aan de linker- en rechterkant.",
"advanced_styling_field_input_padding_y_description": "Voegt ruimte toe aan de boven- en onderkant.",
"advanced_styling_field_input_placeholder_opacity_description": "Vervaagt de tijdelijke aanwijzingstekst.",
@@ -2221,6 +2229,7 @@
"show_powered_by_formbricks": "Toon 'Powered by Formbricks' handtekening",
"styling_updated_successfully": "Styling succesvol bijgewerkt",
"suggest_colors": "Kleuren voorstellen",
"suggested_colors_applied_please_save": "Voorgestelde kleuren succesvol gegenereerd. Druk op \"Opslaan\" om de wijzigingen te behouden.",
"theme": "Thema",
"theme_settings_description": "Maak een stijlthema voor alle enquêtes. Je kunt aangepaste styling inschakelen voor elke enquête."
},

View File

@@ -133,6 +133,7 @@
"allow": "permitir",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os usuários saiam clicando fora da pesquisa",
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao deletar {type}s",
"analysis": "Análise",
"and": "E",
"and_response_limit_of": "e limite de resposta de",
"anonymous": "Anônimo",
@@ -149,6 +150,7 @@
"bottom_right": "Canto Inferior Direito",
"cancel": "Cancelar",
"centered_modal": "Modal Centralizado",
"charts": "Gráficos",
"choices": "Escolhas",
"choose_environment": "Escolher ambiente",
"choose_organization": "Escolher organização",
@@ -187,6 +189,7 @@
"created_by": "Criado por",
"customer_success": "Sucesso do Cliente",
"dark_overlay": "sobreposição escura",
"dashboards": "Painéis",
"date": "Encontro",
"days": "dias",
"default": "Padrão",
@@ -752,7 +755,12 @@
"link_google_sheet": "Link da Planilha do Google",
"link_new_sheet": "Vincular nova planilha",
"no_integrations_yet": "Suas integrações do Google Sheets vão aparecer aqui assim que você adicioná-las. ⏲️",
"spreadsheet_url": "URL da planilha"
"reconnect_button": "Reconectar",
"reconnect_button_description": "Sua conexão com o Google Sheets expirou. Reconecte para continuar sincronizando respostas. Seus links de planilhas e dados existentes serão preservados.",
"reconnect_button_tooltip": "Reconecte a integração para atualizar seu acesso. Seus links de planilhas e dados existentes serão preservados.",
"spreadsheet_permission_error": "Você não tem permissão para acessar esta planilha. Certifique-se de que a planilha está compartilhada com sua conta do Google e que você tem acesso de escrita à planilha.",
"spreadsheet_url": "URL da planilha",
"token_expired_error": "O token de atualização do Google Sheets expirou ou foi revogado. Reconecte a integração."
},
"include_created_at": "Incluir Data de Criação",
"include_hidden_fields": "Incluir Campos Ocultos",
@@ -2153,12 +2161,12 @@
"advanced_styling_field_headline_size_description": "Ajusta o tamanho do texto do título.",
"advanced_styling_field_headline_weight": "Peso da fonte do título",
"advanced_styling_field_headline_weight_description": "Torna o texto do título mais leve ou mais negrito.",
"advanced_styling_field_height": "Altura",
"advanced_styling_field_height": "Altura mínima",
"advanced_styling_field_indicator_bg": "Fundo do indicador",
"advanced_styling_field_indicator_bg_description": "Colore a porção preenchida da barra.",
"advanced_styling_field_input_border_radius_description": "Arredonda os cantos do campo.",
"advanced_styling_field_input_font_size_description": "Ajusta o tamanho do texto digitado nos campos.",
"advanced_styling_field_input_height_description": "Controla a altura do campo de entrada.",
"advanced_styling_field_input_height_description": "Controla a altura mínima do campo de entrada.",
"advanced_styling_field_input_padding_x_description": "Adiciona espaço à esquerda e à direita.",
"advanced_styling_field_input_padding_y_description": "Adiciona espaço na parte superior e inferior.",
"advanced_styling_field_input_placeholder_opacity_description": "Esmaece o texto de dica do placeholder.",
@@ -2221,6 +2229,7 @@
"show_powered_by_formbricks": "Mostrar assinatura 'Powered by Formbricks'",
"styling_updated_successfully": "Estilo atualizado com sucesso",
"suggest_colors": "Sugerir cores",
"suggested_colors_applied_please_save": "Cores sugeridas geradas com sucesso. Pressione \"Salvar\" para manter as alterações.",
"theme": "Tema",
"theme_settings_description": "Crie um tema de estilo para todas as pesquisas. Você pode ativar estilo personalizado para cada pesquisa."
},

View File

@@ -133,6 +133,7 @@
"allow": "Permitir",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os utilizadores saiam se clicarem 'sair do questionário'",
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao eliminar {type}s",
"analysis": "Análise",
"and": "E",
"and_response_limit_of": "e limite de resposta de",
"anonymous": "Anónimo",
@@ -149,6 +150,7 @@
"bottom_right": "Inferior Direito",
"cancel": "Cancelar",
"centered_modal": "Modal Centralizado",
"charts": "Gráficos",
"choices": "Escolhas",
"choose_environment": "Escolha o ambiente",
"choose_organization": "Escolher organização",
@@ -187,6 +189,7 @@
"created_by": "Criado por",
"customer_success": "Sucesso do Cliente",
"dark_overlay": "Sobreposição escura",
"dashboards": "Dashboards",
"date": "Data",
"days": "dias",
"default": "Padrão",
@@ -752,7 +755,12 @@
"link_google_sheet": "Ligar Folha do Google",
"link_new_sheet": "Ligar nova Folha",
"no_integrations_yet": "As suas integrações com o Google Sheets aparecerão aqui assim que as adicionar. ⏲️",
"spreadsheet_url": "URL da folha de cálculo"
"reconnect_button": "Reconectar",
"reconnect_button_description": "A tua ligação ao Google Sheets expirou. Por favor, reconecta para continuar a sincronizar respostas. As tuas ligações de folhas de cálculo e dados existentes serão preservados.",
"reconnect_button_tooltip": "Reconecta a integração para atualizar o teu acesso. As tuas ligações de folhas de cálculo e dados existentes serão preservados.",
"spreadsheet_permission_error": "Não tens permissão para aceder a esta folha de cálculo. Por favor, certifica-te de que a folha de cálculo está partilhada com a tua conta Google e que tens acesso de escrita à folha de cálculo.",
"spreadsheet_url": "URL da folha de cálculo",
"token_expired_error": "O token de atualização do Google Sheets expirou ou foi revogado. Por favor, reconecta a integração."
},
"include_created_at": "Incluir Criado Em",
"include_hidden_fields": "Incluir Campos Ocultos",
@@ -2153,12 +2161,12 @@
"advanced_styling_field_headline_size_description": "Ajusta o tamanho do texto do título.",
"advanced_styling_field_headline_weight": "Peso da fonte do título",
"advanced_styling_field_headline_weight_description": "Torna o texto do título mais leve ou mais negrito.",
"advanced_styling_field_height": "Altura",
"advanced_styling_field_height": "Altura mínima",
"advanced_styling_field_indicator_bg": "Fundo do indicador",
"advanced_styling_field_indicator_bg_description": "Colore a porção preenchida da barra.",
"advanced_styling_field_input_border_radius_description": "Arredonda os cantos do campo.",
"advanced_styling_field_input_font_size_description": "Ajusta o tamanho do texto digitado nos campos.",
"advanced_styling_field_input_height_description": "Controla a altura do campo de entrada.",
"advanced_styling_field_input_height_description": "Controla a altura mínima do campo de entrada.",
"advanced_styling_field_input_padding_x_description": "Adiciona espaço à esquerda e à direita.",
"advanced_styling_field_input_padding_y_description": "Adiciona espaço no topo e na base.",
"advanced_styling_field_input_placeholder_opacity_description": "Atenua o texto de sugestão do placeholder.",
@@ -2221,6 +2229,7 @@
"show_powered_by_formbricks": "Mostrar assinatura 'Powered by Formbricks'",
"styling_updated_successfully": "Estilo atualizado com sucesso",
"suggest_colors": "Sugerir cores",
"suggested_colors_applied_please_save": "Cores sugeridas geradas com sucesso. Pressiona \"Guardar\" para manter as alterações.",
"theme": "Tema",
"theme_settings_description": "Crie um tema de estilo para todos os inquéritos. Pode ativar estilos personalizados para cada inquérito."
},

View File

@@ -133,6 +133,7 @@
"allow": "Permite",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permite utilizatorilor să iasă făcând clic în afara sondajului",
"an_unknown_error_occurred_while_deleting_table_items": "A apărut o eroare necunoscută la ștergerea elementelor de tipul {type}",
"analysis": "Analiză",
"and": "Și",
"and_response_limit_of": "și limită răspuns",
"anonymous": "Anonim",
@@ -149,6 +150,7 @@
"bottom_right": "Dreapta Jos",
"cancel": "Anulare",
"centered_modal": "Modală centralizată",
"charts": "Grafice",
"choices": "Alegeri",
"choose_environment": "Alege mediul",
"choose_organization": "Alege organizația",
@@ -187,6 +189,7 @@
"created_by": "Creat de",
"customer_success": "Succesul Clientului",
"dark_overlay": "Suprapunere întunecată",
"dashboards": "Tablouri de bord",
"date": "Dată",
"days": "zile",
"default": "Implicit",
@@ -752,7 +755,12 @@
"link_google_sheet": "Leagă Google Sheet",
"link_new_sheet": "Leagă un nou Sheet",
"no_integrations_yet": "Integrațiile tale Google Sheet vor apărea aici de îndată ce le vei adăuga. ⏲️",
"spreadsheet_url": "URL foaie de calcul"
"reconnect_button": "Reconectează",
"reconnect_button_description": "Conexiunea ta cu Google Sheets a expirat. Te rugăm să te reconectezi pentru a continua sincronizarea răspunsurilor. Linkurile și datele existente din foile de calcul vor fi păstrate.",
"reconnect_button_tooltip": "Reconectează integrarea pentru a-ți reîmprospăta accesul. Linkurile și datele existente din foile de calcul vor fi păstrate.",
"spreadsheet_permission_error": "Nu ai permisiunea de a accesa această foaie de calcul. Asigură-te că foaia de calcul este partajată cu contul tău Google și că ai acces de scriere la aceasta.",
"spreadsheet_url": "URL foaie de calcul",
"token_expired_error": "Tokenul de reîmprospătare Google Sheets a expirat sau a fost revocat. Te rugăm să reconectezi integrarea."
},
"include_created_at": "Include data creării",
"include_hidden_fields": "Include câmpuri ascunse",
@@ -2153,12 +2161,12 @@
"advanced_styling_field_headline_size_description": "Scalează textul titlului.",
"advanced_styling_field_headline_weight": "Grosime font titlu",
"advanced_styling_field_headline_weight_description": "Face textul titlului mai subțire sau mai îngroșat.",
"advanced_styling_field_height": "Înălțime",
"advanced_styling_field_height": "Înălțime minimă",
"advanced_styling_field_indicator_bg": "Fundal indicator",
"advanced_styling_field_indicator_bg_description": "Colorează partea umplută a barei.",
"advanced_styling_field_input_border_radius_description": "Rotunjește colțurile câmpurilor de introducere.",
"advanced_styling_field_input_font_size_description": "Scalează textul introdus în câmpuri.",
"advanced_styling_field_input_height_description": "Controlează înălțimea câmpului de introducere.",
"advanced_styling_field_input_height_description": "Controlează înălțimea minimă a câmpului de introducere.",
"advanced_styling_field_input_padding_x_description": "Adaugă spațiu la stânga și la dreapta.",
"advanced_styling_field_input_padding_y_description": "Adaugă spațiu deasupra și dedesubt.",
"advanced_styling_field_input_placeholder_opacity_description": "Estompează textul de sugestie din placeholder.",
@@ -2221,6 +2229,7 @@
"show_powered_by_formbricks": "Afișează semnătura „Powered by Formbricks”",
"styling_updated_successfully": "Stilizarea a fost actualizată cu succes",
"suggest_colors": "Sugerează culori",
"suggested_colors_applied_please_save": "Culorile sugerate au fost generate cu succes. Apasă pe „Salvează” pentru a păstra modificările.",
"theme": "Temă",
"theme_settings_description": "Creează o temă de stil pentru toate sondajele. Poți activa stilizare personalizată pentru fiecare sondaj."
},

View File

@@ -133,6 +133,7 @@
"allow": "Разрешить",
"allow_users_to_exit_by_clicking_outside_the_survey": "Разрешить пользователям выходить, кликнув вне опроса",
"an_unknown_error_occurred_while_deleting_table_items": "Произошла неизвестная ошибка при удалении {type}ов",
"analysis": "Аналитика",
"and": "и",
"and_response_limit_of": "и лимит ответов",
"anonymous": "Аноним",
@@ -149,6 +150,7 @@
"bottom_right": "Внизу справа",
"cancel": "Отмена",
"centered_modal": "Центрированное модальное окно",
"charts": "Графики",
"choices": "Варианты",
"choose_environment": "Выберите среду",
"choose_organization": "Выберите организацию",
@@ -187,6 +189,7 @@
"created_by": "Создано пользователем",
"customer_success": "Customer Success",
"dark_overlay": "Тёмный оверлей",
"dashboards": "Дашборды",
"date": "Дата",
"days": "дни",
"default": "По умолчанию",
@@ -752,7 +755,12 @@
"link_google_sheet": "Связать с Google Sheet",
"link_new_sheet": "Связать с новой таблицей",
"no_integrations_yet": "Ваши интеграции с Google Sheet появятся здесь, как только вы их добавите. ⏲️",
"spreadsheet_url": "URL таблицы"
"reconnect_button": "Переподключить",
"reconnect_button_description": "Срок действия подключения к Google Sheets истёк. Пожалуйста, переподключись, чтобы продолжить синхронизацию ответов. Все существующие ссылки на таблицы и данные будут сохранены.",
"reconnect_button_tooltip": "Переподключи интеграцию, чтобы обновить доступ. Все существующие ссылки на таблицы и данные будут сохранены.",
"spreadsheet_permission_error": "У тебя нет доступа к этой таблице. Убедись, что таблица открыта для твоего Google-аккаунта и у тебя есть права на запись.",
"spreadsheet_url": "URL таблицы",
"token_expired_error": "Срок действия токена обновления Google Sheets истёк или он был отозван. Пожалуйста, переподключи интеграцию."
},
"include_created_at": "Включить дату создания",
"include_hidden_fields": "Включить скрытые поля",
@@ -2153,12 +2161,12 @@
"advanced_styling_field_headline_size_description": "Масштабирует текст заголовка.",
"advanced_styling_field_headline_weight": "Толщина шрифта заголовка",
"advanced_styling_field_headline_weight_description": "Делает текст заголовка тоньше или жирнее.",
"advanced_styling_field_height": "Высота",
"advanced_styling_field_height": "Минимальная высота",
"advanced_styling_field_indicator_bg": "Фон индикатора",
"advanced_styling_field_indicator_bg_description": "Задаёт цвет заполненной части полосы.",
"advanced_styling_field_input_border_radius_description": "Скругляет углы полей ввода.",
"advanced_styling_field_input_font_size_description": "Масштабирует введённый текст в полях ввода.",
"advanced_styling_field_input_height_description": "Определяет высоту поля ввода.",
"advanced_styling_field_input_height_description": "Определяет минимальную высоту поля ввода.",
"advanced_styling_field_input_padding_x_description": "Добавляет отступы слева и справа.",
"advanced_styling_field_input_padding_y_description": "Добавляет пространство сверху и снизу.",
"advanced_styling_field_input_placeholder_opacity_description": "Делает текст подсказки менее заметным.",
@@ -2221,6 +2229,7 @@
"show_powered_by_formbricks": "Показывать подпись «Работает на Formbricks»",
"styling_updated_successfully": "Стили успешно обновлены",
"suggest_colors": "Предложить цвета",
"suggested_colors_applied_please_save": "Рекомендованные цвета успешно сгенерированы. Нажми «Сохранить», чтобы применить изменения.",
"theme": "Тема",
"theme_settings_description": "Создайте стиль для всех опросов. Вы можете включить индивидуальное оформление для каждого опроса."
},

View File

@@ -133,6 +133,7 @@
"allow": "Tillåt",
"allow_users_to_exit_by_clicking_outside_the_survey": "Tillåt användare att avsluta genom att klicka utanför enkäten",
"an_unknown_error_occurred_while_deleting_table_items": "Ett okänt fel uppstod vid borttagning av {type}",
"analysis": "Analys",
"and": "Och",
"and_response_limit_of": "och svarsgräns på",
"anonymous": "Anonym",
@@ -149,6 +150,7 @@
"bottom_right": "Nedre höger",
"cancel": "Avbryt",
"centered_modal": "Centrerad modal",
"charts": "Diagram",
"choices": "Val",
"choose_environment": "Välj miljö",
"choose_organization": "Välj organisation",
@@ -187,6 +189,7 @@
"created_by": "Skapad av",
"customer_success": "Kundframgång",
"dark_overlay": "Mörkt överlägg",
"dashboards": "Instrumentpaneler",
"date": "Datum",
"days": "dagar",
"default": "Standard",
@@ -752,7 +755,12 @@
"link_google_sheet": "Länka Google Kalkylark",
"link_new_sheet": "Länka nytt kalkylark",
"no_integrations_yet": "Dina Google Kalkylark-integrationer visas här så snart du lägger till dem. ⏲️",
"spreadsheet_url": "Kalkylblads-URL"
"reconnect_button": "Återanslut",
"reconnect_button_description": "Din Google Sheets-anslutning har gått ut. Återanslut för att fortsätta synkronisera svar. Dina befintliga kalkylarkslänkar och data kommer att sparas.",
"reconnect_button_tooltip": "Återanslut integrationen för att uppdatera din åtkomst. Dina befintliga kalkylarkslänkar och data kommer att sparas.",
"spreadsheet_permission_error": "Du har inte behörighet att komma åt det här kalkylarket. Kontrollera att kalkylarket är delat med ditt Google-konto och att du har skrivrättigheter till kalkylarket.",
"spreadsheet_url": "Kalkylblads-URL",
"token_expired_error": "Google Sheets refresh token har gått ut eller återkallats. Återanslut integrationen."
},
"include_created_at": "Inkludera Skapad vid",
"include_hidden_fields": "Inkludera dolda fält",
@@ -2153,12 +2161,12 @@
"advanced_styling_field_headline_size_description": "Ändrar storleken på rubriken.",
"advanced_styling_field_headline_weight": "Rubrikens teckentjocklek",
"advanced_styling_field_headline_weight_description": "Gör rubriktexten tunnare eller fetare.",
"advanced_styling_field_height": "Höjd",
"advanced_styling_field_height": "Minsta höjd",
"advanced_styling_field_indicator_bg": "Indikatorns bakgrund",
"advanced_styling_field_indicator_bg_description": "Färglägger den fyllda delen av stapeln.",
"advanced_styling_field_input_border_radius_description": "Rundar av hörnen på inmatningsfält.",
"advanced_styling_field_input_font_size_description": "Ändrar storleken på texten i inmatningsfält.",
"advanced_styling_field_input_height_description": "Styr höjden på inmatningsfältet.",
"advanced_styling_field_input_height_description": "Styr den minsta höjden på inmatningsfältet.",
"advanced_styling_field_input_padding_x_description": "Lägger till utrymme till vänster och höger.",
"advanced_styling_field_input_padding_y_description": "Lägger till utrymme upptill och nedtill.",
"advanced_styling_field_input_placeholder_opacity_description": "Tonar ut platshållartexten.",
@@ -2221,6 +2229,7 @@
"show_powered_by_formbricks": "Visa 'Powered by Formbricks'-signatur",
"styling_updated_successfully": "Stiluppdatering lyckades",
"suggest_colors": "Föreslå färger",
"suggested_colors_applied_please_save": "Föreslagna färger har skapats. Tryck på \"Spara\" för att spara ändringarna.",
"theme": "Tema",
"theme_settings_description": "Skapa ett stilmall för alla undersökningar. Du kan aktivera anpassad stil för varje undersökning."
},

View File

@@ -133,6 +133,7 @@
"allow": "允许",
"allow_users_to_exit_by_clicking_outside_the_survey": "允许 用户 通过 点击 调查 外部 退出",
"an_unknown_error_occurred_while_deleting_table_items": "删除 {type} 时发生未知错误",
"analysis": "分析",
"and": "和",
"and_response_limit_of": "和 响应限制",
"anonymous": "匿名",
@@ -149,6 +150,7 @@
"bottom_right": "右下",
"cancel": "取消",
"centered_modal": "居中 模态",
"charts": "图表",
"choices": "选项",
"choose_environment": "选择 环境",
"choose_organization": "选择 组织",
@@ -187,6 +189,7 @@
"created_by": "由 创建",
"customer_success": "客户成功",
"dark_overlay": "深色遮罩层",
"dashboards": "仪表盘",
"date": "日期",
"days": "天",
"default": "默认",
@@ -752,7 +755,12 @@
"link_google_sheet": "链接 Google 表格",
"link_new_sheet": "链接 新 表格",
"no_integrations_yet": "您的 Google Sheet 集成会在您 添加 后 出现在这里。 ⏲️",
"spreadsheet_url": "电子表格 URL"
"reconnect_button": "重新连接",
"reconnect_button_description": "你的 Google Sheets 连接已过期。请重新连接以继续同步回复。你现有的表格链接和数据会被保留。",
"reconnect_button_tooltip": "重新连接集成以刷新你的访问权限。你现有的表格链接和数据会被保留。",
"spreadsheet_permission_error": "你没有权限访问此表格。请确保该表格已与你的 Google 账号共享,并且你拥有该表格的编辑权限。",
"spreadsheet_url": "电子表格 URL",
"token_expired_error": "Google Sheets 的刷新令牌已过期或被撤销。请重新连接集成。"
},
"include_created_at": "包括 创建 于",
"include_hidden_fields": "包括 隐藏 字段",
@@ -2153,12 +2161,12 @@
"advanced_styling_field_headline_size_description": "调整主标题文字大小。",
"advanced_styling_field_headline_weight": "标题字体粗细",
"advanced_styling_field_headline_weight_description": "设置主标题文字的粗细。",
"advanced_styling_field_height": "高度",
"advanced_styling_field_height": "最小高度",
"advanced_styling_field_indicator_bg": "指示器背景",
"advanced_styling_field_indicator_bg_description": "设置进度条已填充部分的颜色。",
"advanced_styling_field_input_border_radius_description": "设置输入框圆角。",
"advanced_styling_field_input_font_size_description": "调整输入框内文字大小。",
"advanced_styling_field_input_height_description": "控制输入框高度。",
"advanced_styling_field_input_height_description": "设置输入框的最小高度。",
"advanced_styling_field_input_padding_x_description": "增加输入框左右间距。",
"advanced_styling_field_input_padding_y_description": "为输入框上下添加间距。",
"advanced_styling_field_input_placeholder_opacity_description": "调整占位提示文字的透明度。",
@@ -2221,6 +2229,7 @@
"show_powered_by_formbricks": "显示“Powered by Formbricks”标识",
"styling_updated_successfully": "样式更新成功",
"suggest_colors": "推荐颜色",
"suggested_colors_applied_please_save": "已成功生成推荐配色。请点击“保存”以保留更改。",
"theme": "主题",
"theme_settings_description": "为所有问卷创建一个样式主题。你可以为每个问卷启用自定义样式。"
},

View File

@@ -133,6 +133,7 @@
"allow": "允許",
"allow_users_to_exit_by_clicking_outside_the_survey": "允許使用者點擊問卷外退出",
"an_unknown_error_occurred_while_deleting_table_items": "刪除 '{'type'}' 時發生未知錯誤",
"analysis": "分析",
"and": "且",
"and_response_limit_of": "且回應上限為",
"anonymous": "匿名",
@@ -149,6 +150,7 @@
"bottom_right": "右下",
"cancel": "取消",
"centered_modal": "置中彈窗",
"charts": "圖表",
"choices": "選項",
"choose_environment": "選擇環境",
"choose_organization": "選擇 組織",
@@ -187,6 +189,7 @@
"created_by": "建立者",
"customer_success": "客戶成功",
"dark_overlay": "深色覆蓋",
"dashboards": "儀表板",
"date": "日期",
"days": "天",
"default": "預設",
@@ -752,7 +755,12 @@
"link_google_sheet": "連結 Google 試算表",
"link_new_sheet": "連結新試算表",
"no_integrations_yet": "您的 Google 試算表整合將在您新增後立即顯示在此處。⏲️",
"spreadsheet_url": "試算表網址"
"reconnect_button": "重新連線",
"reconnect_button_description": "你的 Google Sheets 連線已過期。請重新連線以繼續同步回應。你現有的試算表連結和資料都會被保留。",
"reconnect_button_tooltip": "重新連線整合以刷新存取權限。你現有的試算表連結和資料都會被保留。",
"spreadsheet_permission_error": "你沒有權限存取這個試算表。請確認該試算表已與你的 Google 帳戶分享,且你擁有寫入權限。",
"spreadsheet_url": "試算表網址",
"token_expired_error": "Google Sheets 的刷新權杖已過期或被撤銷。請重新連線整合。"
},
"include_created_at": "包含建立於",
"include_hidden_fields": "包含隱藏欄位",
@@ -2153,12 +2161,12 @@
"advanced_styling_field_headline_size_description": "調整標題文字的大小。",
"advanced_styling_field_headline_weight": "標題字體粗細",
"advanced_styling_field_headline_weight_description": "讓標題文字變細或變粗。",
"advanced_styling_field_height": "高度",
"advanced_styling_field_height": "最小高度",
"advanced_styling_field_indicator_bg": "指示器背景",
"advanced_styling_field_indicator_bg_description": "設定進度條已填滿部分的顏色。",
"advanced_styling_field_input_border_radius_description": "調整輸入框的圓角。",
"advanced_styling_field_input_font_size_description": "調整輸入框內輸入文字的大小。",
"advanced_styling_field_input_height_description": "調整輸入欄位的高度。",
"advanced_styling_field_input_height_description": "設定輸入欄位的最小高度。",
"advanced_styling_field_input_padding_x_description": "在左右兩側增加間距。",
"advanced_styling_field_input_padding_y_description": "在上方和下方增加間距。",
"advanced_styling_field_input_placeholder_opacity_description": "讓提示文字變得更淡。",
@@ -2221,6 +2229,7 @@
"show_powered_by_formbricks": "顯示「Powered by Formbricks」標記",
"styling_updated_successfully": "樣式已成功更新",
"suggest_colors": "建議顏色",
"suggested_colors_applied_please_save": "已成功產生建議色彩。請按「儲存」以保存變更。",
"theme": "主題",
"theme_settings_description": "為所有調查建立樣式主題。您可以為每個調查啟用自訂樣式。"
},

View File

@@ -95,7 +95,7 @@ describe("validateResponseData", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData([], mockResponseData, "en", true, mockQuestions);
validateResponseData([], mockResponseData, "en", mockQuestions);
expect(mockTransformQuestionsToBlocks).toHaveBeenCalledWith(mockQuestions, []);
expect(mockGetElementsFromBlocks).toHaveBeenCalledWith(transformedBlocks);
@@ -105,15 +105,15 @@ describe("validateResponseData", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, mockResponseData, "en", true, mockQuestions);
validateResponseData(mockBlocks, mockResponseData, "en", mockQuestions);
expect(mockTransformQuestionsToBlocks).not.toHaveBeenCalled();
});
test("should return null when both blocks and questions are empty", () => {
expect(validateResponseData([], mockResponseData, "en", true, [])).toBeNull();
expect(validateResponseData(null, mockResponseData, "en", true, [])).toBeNull();
expect(validateResponseData(undefined, mockResponseData, "en", true, null)).toBeNull();
expect(validateResponseData([], mockResponseData, "en", [])).toBeNull();
expect(validateResponseData(null, mockResponseData, "en", [])).toBeNull();
expect(validateResponseData(undefined, mockResponseData, "en", null)).toBeNull();
});
test("should use default language code", () => {
@@ -125,25 +125,58 @@ describe("validateResponseData", () => {
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, mockResponseData, "en");
});
test("should validate only present fields when finished is false", () => {
test("should validate only fields present in responseData", () => {
const partialResponseData: TResponseData = { element1: "test" };
const partialElements = [mockElements[0]];
const elementsToValidate = [mockElements[0]];
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, partialResponseData, "en", false);
validateResponseData(mockBlocks, partialResponseData, "en");
expect(mockValidateBlockResponses).toHaveBeenCalledWith(partialElements, partialResponseData, "en");
expect(mockValidateBlockResponses).toHaveBeenCalledWith(elementsToValidate, partialResponseData, "en");
});
test("should validate all fields when finished is true", () => {
const partialResponseData: TResponseData = { element1: "test" };
mockGetElementsFromBlocks.mockReturnValue(mockElements);
test("should never validate elements not in responseData", () => {
const blocksWithTwoElements: TSurveyBlock[] = [
...mockBlocks,
{
id: "block2",
name: "Block 2",
elements: [
{
id: "element2",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q2" },
required: true,
inputType: "text",
charLimit: { enabled: false },
},
],
},
];
const allElements = [
...mockElements,
{
id: "element2",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q2" },
required: true,
inputType: "text",
charLimit: { enabled: false },
},
];
const responseDataWithOnlyElement1: TResponseData = { element1: "test" };
mockGetElementsFromBlocks.mockReturnValue(allElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, partialResponseData, "en", true);
validateResponseData(blocksWithTwoElements, responseDataWithOnlyElement1, "en");
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, partialResponseData, "en");
// Only element1 should be validated, not element2 (even though it's required)
expect(mockValidateBlockResponses).toHaveBeenCalledWith(
[allElements[0]],
responseDataWithOnlyElement1,
"en"
);
});
});

View File

@@ -9,13 +9,13 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { ApiErrorDetails } from "@/modules/api/v2/types/api-error";
/**
* Validates response data against survey validation rules
* Handles partial responses (in-progress) by only validating present fields when finished is false
* Validates response data against survey validation rules.
* Only validates elements that have data in responseData - never validates
* all survey elements regardless of completion status.
*
* @param blocks - Survey blocks containing elements with validation rules (preferred)
* @param responseData - Response data to validate (keyed by element ID)
* @param languageCode - Language code for error messages (defaults to "en")
* @param finished - Whether the response is finished (defaults to true for management APIs)
* @param questions - Survey questions (legacy format, used as fallback if blocks are empty)
* @returns Validation error map keyed by element ID, or null if validation passes
*/
@@ -23,7 +23,6 @@ export const validateResponseData = (
blocks: TSurveyBlock[] | undefined | null,
responseData: TResponseData,
languageCode: string = "en",
finished: boolean = true,
questions?: TSurveyQuestion[] | undefined | null
): TValidationErrorMap | null => {
// Use blocks if available, otherwise transform questions to blocks
@@ -42,11 +41,8 @@ export const validateResponseData = (
// Extract elements from blocks
const allElements = getElementsFromBlocks(blocksToUse);
// If response is not finished, only validate elements that are present in the response data
// This prevents "required" errors for fields the user hasn't reached yet
const elementsToValidate = finished
? allElements
: allElements.filter((element) => Object.keys(responseData).includes(element.id));
// Always validate only elements that are present in responseData
const elementsToValidate = allElements.filter((element) => Object.keys(responseData).includes(element.id));
// Validate selected elements
const errorMap = validateBlockResponses(elementsToValidate, responseData, languageCode);

View File

@@ -12,7 +12,9 @@ type HasFindMany =
| Prisma.TeamFindManyArgs
| Prisma.ProjectTeamFindManyArgs
| Prisma.UserFindManyArgs
| Prisma.ContactAttributeKeyFindManyArgs;
| Prisma.ContactAttributeKeyFindManyArgs
| Prisma.ChartFindManyArgs
| Prisma.DashboardFindManyArgs;
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
const { limit, skip, sortBy, order, startDate, endDate, filterDateField = "createdAt" } = params || {};

View File

@@ -15,7 +15,7 @@ import {
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
@@ -51,7 +51,10 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI
return handleApiError(request, response.error as ApiErrorResponseV2);
}
return responses.successResponse(response);
return responses.successResponse({
...response,
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
});
},
});
@@ -198,7 +201,6 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
questionsResponse.data.blocks,
body.data,
body.language ?? "en",
body.finished,
questionsResponse.data.questions
);
@@ -244,7 +246,10 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
auditLog.newObject = response.data;
}
return responses.successResponse(response);
return responses.successResponse({
...response,
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
});
},
action: "updated",
targetType: "response",

View File

@@ -12,7 +12,7 @@ import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[respo
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
export const GET = async (request: NextRequest) =>
@@ -44,7 +44,9 @@ export const GET = async (request: NextRequest) =>
environmentResponses.push(...res.data.data);
return responses.successResponse({ data: environmentResponses });
return responses.successResponse({
data: environmentResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) })),
});
},
});
@@ -134,7 +136,6 @@ export const POST = async (request: Request) =>
surveyQuestions.data.blocks,
body.data,
body.language ?? "en",
body.finished,
surveyQuestions.data.questions
);

View File

@@ -30,4 +30,4 @@ export const rateLimitConfigs = {
upload: { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" }, // 5 per minute
delete: { interval: 60, allowedPerInterval: 5, namespace: "storage:delete" }, // 5 per minute
},
};
} as const;

View File

@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mockLoad = vi.fn();
const mockTablePivot = vi.fn();
vi.mock("@cubejs-client/core", () => ({
default: vi.fn(() => ({
load: mockLoad,
})),
}));
describe("executeQuery", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
const resultSet = { tablePivot: mockTablePivot };
mockLoad.mockResolvedValue(resultSet);
mockTablePivot.mockReturnValue([{ id: "1", count: 42 }]);
});
test("loads query and returns tablePivot result", async () => {
const { executeQuery } = await import("./cube-client");
const query = { measures: ["FeedbackRecords.count"] };
const result = await executeQuery(query);
expect(mockLoad).toHaveBeenCalledWith(query);
expect(mockTablePivot).toHaveBeenCalled();
expect(result).toEqual([{ id: "1", count: 42 }]);
});
test("preserves API URL when it already contains /cubejs-api/v1", async () => {
const fullUrl = "https://cube.example.com/cubejs-api/v1";
vi.stubEnv("CUBEJS_API_URL", fullUrl);
const { executeQuery } = await import("./cube-client");
await executeQuery({ measures: ["FeedbackRecords.count"] });
// eslint-disable-next-line @typescript-eslint/no-require-imports
const cubejs = ((await vi.importMock("@cubejs-client/core")) as any).default;
expect(cubejs).toHaveBeenCalledWith(expect.any(String), { apiUrl: fullUrl });
vi.unstubAllEnvs();
});
});

View File

@@ -0,0 +1,26 @@
import cubejs, { type CubeApi, type Query } from "@cubejs-client/core";
const getApiUrl = (): string => {
const baseUrl = process.env.CUBEJS_API_URL || "http://localhost:4000";
if (baseUrl.includes("/cubejs-api/v1")) {
return baseUrl;
}
return `${baseUrl.replace(/\/$/, "")}/cubejs-api/v1`;
};
let cubeClient: CubeApi | null = null;
function getCubeClient(): CubeApi {
if (!cubeClient) {
// TODO: This will fail silently if the token is not set. We need to fix this before going to production.
const token = process.env.CUBEJS_API_TOKEN ?? "";
cubeClient = cubejs(token, { apiUrl: getApiUrl() });
}
return cubeClient;
}
export async function executeQuery(query: Query) {
const client = getCubeClient();
const resultSet = await client.load(query);
return resultSet.tablePivot();
}

View File

@@ -0,0 +1,209 @@
"use server";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZChartConfig, ZChartQuery } from "@formbricks/types/dashboard";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import {
createChart,
deleteChart,
duplicateChart,
getChart,
getCharts,
updateChart,
} from "@/modules/ee/analysis/charts/lib/charts";
import { checkProjectAccess } from "@/modules/ee/analysis/lib/access";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { ZChartType, ZChartUpdateInput } from "../types/analysis";
const ZCreateChartAction = z.object({
environmentId: ZId,
name: z.string().min(1),
type: ZChartType,
query: ZChartQuery,
config: ZChartConfig.optional().default({}),
});
export const createChartAction = authenticatedActionClient.schema(ZCreateChartAction).action(
withAuditLogging(
"created",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const chart = await createChart({
projectId,
name: parsedInput.name,
type: parsedInput.type,
query: parsedInput.query,
config: parsedInput.config,
createdBy: ctx.user.id,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.chartId = chart.id;
ctx.auditLoggingCtx.newObject = chart;
return chart;
}
)
);
const ZUpdateChartAction = z
.object({
environmentId: ZId,
chartId: ZId,
})
.merge(ZChartUpdateInput);
export const updateChartAction = authenticatedActionClient.schema(ZUpdateChartAction).action(
withAuditLogging(
"updated",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const { chart, updatedChart } = await updateChart(parsedInput.chartId, projectId, {
name: parsedInput.name,
type: parsedInput.type,
query: parsedInput.query,
config: parsedInput.config,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.chartId = parsedInput.chartId;
ctx.auditLoggingCtx.oldObject = chart;
ctx.auditLoggingCtx.newObject = updatedChart;
return updatedChart;
}
)
);
const ZDuplicateChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const duplicateChartAction = authenticatedActionClient.schema(ZDuplicateChartAction).action(
withAuditLogging(
"created",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDuplicateChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const duplicatedChart = await duplicateChart(parsedInput.chartId, projectId, ctx.user.id);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.chartId = duplicatedChart.id;
ctx.auditLoggingCtx.newObject = duplicatedChart;
return duplicatedChart;
}
)
);
const ZDeleteChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const deleteChartAction = authenticatedActionClient.schema(ZDeleteChartAction).action(
withAuditLogging(
"deleted",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDeleteChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const chart = await deleteChart(parsedInput.chartId, projectId);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.chartId = parsedInput.chartId;
ctx.auditLoggingCtx.oldObject = chart;
return { success: true };
}
)
);
const ZGetChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const getChartAction = authenticatedActionClient
.schema(ZGetChartAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetChartAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
return getChart(parsedInput.chartId, projectId);
}
);
const ZGetChartsAction = z.object({
environmentId: ZId,
});
export const getChartsAction = authenticatedActionClient
.schema(ZGetChartsAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetChartsAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
return getCharts(projectId);
}
);

View File

@@ -0,0 +1,383 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
vi.mock("server-only", () => ({}));
var mockTxChart: {
// NOSONAR / test code
findFirst: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
vi.mock("@formbricks/database", () => {
const tx = {
findFirst: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
mockTxChart = tx;
return {
prisma: {
chart: {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
},
$transaction: vi.fn((cb: any) => cb({ chart: tx })),
},
};
});
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
const mockChartId = "chart-abc-123";
const mockProjectId = "project-abc-123";
const mockUserId = "user-abc-123";
const selectChart = {
id: true,
name: true,
type: true,
query: true,
config: true,
createdAt: true,
updatedAt: true,
};
const mockChart = {
id: mockChartId,
name: "Test Chart",
type: "bar",
query: { measures: ["Responses.count"] },
config: { showLegend: true },
createdAt: new Date("2025-01-01"),
updatedAt: new Date("2025-01-01"),
};
const makePrismaError = (code: string) =>
new Prisma.PrismaClientKnownRequestError("mock error", { code, clientVersion: "5.0.0" });
describe("Chart Service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("createChart", () => {
test("creates a chart successfully", async () => {
vi.mocked(prisma.chart.create).mockResolvedValue(mockChart as any);
const { createChart } = await import("./charts");
const result = await createChart({
projectId: mockProjectId,
name: "Test Chart",
type: "bar",
query: { measures: ["Responses.count"] },
config: { showLegend: true },
createdBy: mockUserId,
});
expect(result).toEqual(mockChart);
expect(prisma.chart.create).toHaveBeenCalledWith({
data: {
name: "Test Chart",
type: "bar",
projectId: mockProjectId,
query: { measures: ["Responses.count"] },
config: { showLegend: true },
createdBy: mockUserId,
},
select: selectChart,
});
});
test("throws InvalidInputError on unique constraint violation", async () => {
vi.mocked(prisma.chart.create).mockRejectedValue(
makePrismaError(PrismaErrorType.UniqueConstraintViolation)
);
const { createChart } = await import("./charts");
await expect(
createChart({
projectId: mockProjectId,
name: "Duplicate",
type: "bar",
query: {},
config: {},
createdBy: mockUserId,
})
).rejects.toMatchObject({
name: "InvalidInputError",
});
});
test("throws DatabaseError on other Prisma errors", async () => {
vi.mocked(prisma.chart.create).mockRejectedValue(makePrismaError("P9999"));
const { createChart } = await import("./charts");
await expect(
createChart({
projectId: mockProjectId,
name: "Test",
type: "bar",
query: {},
config: {},
createdBy: mockUserId,
})
).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("updateChart", () => {
test("updates a chart successfully", async () => {
const updatedChart = { ...mockChart, name: "Updated Chart" };
mockTxChart.findFirst.mockResolvedValue(mockChart);
mockTxChart.update.mockResolvedValue(updatedChart);
const { updateChart } = await import("./charts");
const result = await updateChart(mockChartId, mockProjectId, { name: "Updated Chart" });
expect(result).toEqual({ chart: mockChart, updatedChart });
expect(mockTxChart.findFirst).toHaveBeenCalledWith({
where: { id: mockChartId, projectId: mockProjectId },
select: selectChart,
});
expect(mockTxChart.update).toHaveBeenCalledWith({
where: { id: mockChartId },
data: { name: "Updated Chart", type: undefined, query: undefined, config: undefined },
select: selectChart,
});
});
test("throws ResourceNotFoundError when chart does not exist", async () => {
mockTxChart.findFirst.mockResolvedValue(null);
const { updateChart } = await import("./charts");
await expect(updateChart(mockChartId, mockProjectId, { name: "Updated" })).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Chart",
resourceId: mockChartId,
});
expect(mockTxChart.update).not.toHaveBeenCalled();
});
test("throws InvalidInputError on unique constraint violation", async () => {
mockTxChart.findFirst.mockResolvedValue(mockChart);
mockTxChart.update.mockRejectedValue(makePrismaError(PrismaErrorType.UniqueConstraintViolation));
vi.mocked(prisma.$transaction).mockImplementation((cb: any) => cb({ chart: mockTxChart }));
const { updateChart } = await import("./charts");
await expect(updateChart(mockChartId, mockProjectId, { name: "Taken Name" })).rejects.toMatchObject({
name: "InvalidInputError",
});
});
});
describe("duplicateChart", () => {
test("duplicates a chart with '(copy)' suffix", async () => {
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
vi.mocked(prisma.chart.findMany).mockResolvedValue([]);
vi.mocked(prisma.chart.create).mockResolvedValue({ ...mockChart, name: "Test Chart (copy)" } as any);
const { duplicateChart } = await import("./charts");
await duplicateChart(mockChartId, mockProjectId, mockUserId);
expect(prisma.chart.findFirst).toHaveBeenCalledWith({
where: { id: mockChartId, projectId: mockProjectId },
select: selectChart,
});
expect(prisma.chart.create).toHaveBeenCalledWith({
data: expect.objectContaining({ name: "Test Chart (copy)" }),
select: selectChart,
});
});
test("increments copy number when '(copy)' already exists", async () => {
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
vi.mocked(prisma.chart.findMany).mockResolvedValue([{ name: "Test Chart (copy)" }] as any);
vi.mocked(prisma.chart.create).mockResolvedValue({
...mockChart,
name: "Test Chart (copy 2)",
} as any);
const { duplicateChart } = await import("./charts");
await duplicateChart(mockChartId, mockProjectId, mockUserId);
expect(prisma.chart.create).toHaveBeenCalledWith({
data: expect.objectContaining({ name: "Test Chart (copy 2)" }),
select: selectChart,
});
});
test("finds next available copy number", async () => {
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
vi.mocked(prisma.chart.findMany).mockResolvedValue([
{ name: "Test Chart (copy)" },
{ name: "Test Chart (copy 2)" },
] as any);
vi.mocked(prisma.chart.create).mockResolvedValue({
...mockChart,
name: "Test Chart (copy 3)",
} as any);
const { duplicateChart } = await import("./charts");
await duplicateChart(mockChartId, mockProjectId, mockUserId);
expect(prisma.chart.create).toHaveBeenCalledWith({
data: expect.objectContaining({ name: "Test Chart (copy 3)" }),
select: selectChart,
});
});
test("strips existing copy suffix before generating new name", async () => {
const chartWithCopy = { ...mockChart, name: "Test Chart (copy)" };
vi.mocked(prisma.chart.findFirst).mockResolvedValue(chartWithCopy as any);
vi.mocked(prisma.chart.findMany).mockResolvedValue([{ name: "Test Chart (copy)" }] as any);
vi.mocked(prisma.chart.create).mockResolvedValue({
...mockChart,
name: "Test Chart (copy 2)",
} as any);
const { duplicateChart } = await import("./charts");
await duplicateChart(mockChartId, mockProjectId, mockUserId);
expect(prisma.chart.findMany).toHaveBeenCalledWith({
where: { projectId: mockProjectId, name: { startsWith: "Test Chart (copy" } },
select: { name: true },
});
});
test("throws ResourceNotFoundError when source chart does not exist", async () => {
vi.mocked(prisma.chart.findFirst).mockResolvedValue(null);
const { duplicateChart } = await import("./charts");
await expect(duplicateChart(mockChartId, mockProjectId, mockUserId)).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Chart",
resourceId: mockChartId,
});
});
});
describe("deleteChart", () => {
test("deletes a chart successfully", async () => {
mockTxChart.findFirst.mockResolvedValue(mockChart);
mockTxChart.delete.mockResolvedValue(undefined);
const { deleteChart } = await import("./charts");
const result = await deleteChart(mockChartId, mockProjectId);
expect(result).toEqual(mockChart);
expect(mockTxChart.findFirst).toHaveBeenCalledWith({
where: { id: mockChartId, projectId: mockProjectId },
select: selectChart,
});
expect(mockTxChart.delete).toHaveBeenCalledWith({ where: { id: mockChartId } });
});
test("throws ResourceNotFoundError when chart does not exist", async () => {
mockTxChart.findFirst.mockResolvedValue(null);
const { deleteChart } = await import("./charts");
await expect(deleteChart(mockChartId, mockProjectId)).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Chart",
resourceId: mockChartId,
});
expect(mockTxChart.delete).not.toHaveBeenCalled();
});
test("throws DatabaseError on Prisma errors", async () => {
mockTxChart.findFirst.mockRejectedValue(makePrismaError("P9999"));
vi.mocked(prisma.$transaction).mockImplementation((cb: any) => cb({ chart: mockTxChart }));
const { deleteChart } = await import("./charts");
await expect(deleteChart(mockChartId, mockProjectId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("getChart", () => {
test("returns a chart successfully", async () => {
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
const { getChart } = await import("./charts");
const result = await getChart(mockChartId, mockProjectId);
expect(result).toEqual(mockChart);
expect(prisma.chart.findFirst).toHaveBeenCalledWith({
where: { id: mockChartId, projectId: mockProjectId },
select: selectChart,
});
});
test("throws ResourceNotFoundError when chart does not exist", async () => {
vi.mocked(prisma.chart.findFirst).mockResolvedValue(null);
const { getChart } = await import("./charts");
await expect(getChart(mockChartId, mockProjectId)).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Chart",
resourceId: mockChartId,
});
});
test("throws DatabaseError on Prisma errors", async () => {
vi.mocked(prisma.chart.findFirst).mockRejectedValue(makePrismaError("P9999"));
const { getChart } = await import("./charts");
await expect(getChart(mockChartId, mockProjectId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("getCharts", () => {
test("returns all charts for a project", async () => {
const charts = [
{ ...mockChart, widgets: [{ dashboardId: "dash-1" }] },
{ ...mockChart, id: "chart-2", name: "Chart 2", widgets: [] },
];
vi.mocked(prisma.chart.findMany).mockResolvedValue(charts as any);
const { getCharts } = await import("./charts");
const result = await getCharts(mockProjectId);
expect(result).toEqual(charts);
expect(prisma.chart.findMany).toHaveBeenCalledWith({
where: { projectId: mockProjectId },
orderBy: { createdAt: "desc" },
select: expect.objectContaining({
id: true,
name: true,
widgets: { select: { dashboardId: true } },
}),
});
});
test("returns empty array when no charts exist", async () => {
vi.mocked(prisma.chart.findMany).mockResolvedValue([]);
const { getCharts } = await import("./charts");
const result = await getCharts(mockProjectId);
expect(result).toEqual([]);
});
test("throws DatabaseError on Prisma errors", async () => {
vi.mocked(prisma.chart.findMany).mockRejectedValue(makePrismaError("P9999"));
const { getCharts } = await import("./charts");
await expect(getCharts(mockProjectId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
});

View File

@@ -0,0 +1,246 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId } from "@formbricks/types/common";
import { ZChartConfig, ZChartQuery } from "@formbricks/types/dashboard";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import {
TChart,
TChartCreateInput,
TChartUpdateInput,
TChartWithWidgets,
ZChartCreateInput,
ZChartType,
ZChartUpdateInput,
} from "../../types/analysis";
export const selectChart = {
id: true,
name: true,
type: true,
query: true,
config: true,
createdAt: true,
updatedAt: true,
} as const;
export const createChart = async (data: TChartCreateInput): Promise<TChart> => {
validateInputs([data, ZChartCreateInput]);
try {
return await prisma.chart.create({
data: {
name: data.name,
type: data.type,
projectId: data.projectId,
query: data.query,
config: data.config,
createdBy: data.createdBy,
},
select: selectChart,
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError("A chart with this name already exists");
}
throw new DatabaseError(error.message);
}
throw error;
}
};
export const updateChart = async (
chartId: string,
projectId: string,
data: TChartUpdateInput
): Promise<{ chart: TChart; updatedChart: TChart }> => {
validateInputs([chartId, ZId], [projectId, ZId], [data, ZChartUpdateInput]);
try {
return await prisma.$transaction(async (tx) => {
const chart = await tx.chart.findFirst({
where: { id: chartId, projectId },
select: selectChart,
});
if (!chart) {
throw new ResourceNotFoundError("Chart", chartId);
}
const updatedChart = await tx.chart.update({
where: { id: chartId },
data: {
name: data.name,
type: data.type,
query: data.query,
config: data.config,
},
select: selectChart,
});
return { chart, updatedChart };
});
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError("A chart with this name already exists");
}
throw new DatabaseError(error.message);
}
throw error;
}
};
const getUniqueCopyName = async (baseName: string, projectId: string): Promise<string> => {
const stripped = baseName.replace(/ \(copy(?: \d+)?\)$/, "");
try {
const existing = await prisma.chart.findMany({
where: {
projectId,
name: { startsWith: `${stripped} (copy` },
},
select: { name: true },
});
const existingNames = new Set(existing.map((c) => c.name));
const firstCandidate = `${stripped} (copy)`;
if (!existingNames.has(firstCandidate)) {
return firstCandidate;
}
let n = 2;
while (existingNames.has(`${stripped} (copy ${n})`)) {
n++;
}
return `${stripped} (copy ${n})`;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const duplicateChart = async (
chartId: string,
projectId: string,
createdBy: string
): Promise<TChart> => {
validateInputs([chartId, ZId], [projectId, ZId], [createdBy, ZId]);
try {
const sourceChart = await prisma.chart.findFirst({
where: { id: chartId, projectId },
select: selectChart,
});
if (!sourceChart) {
throw new ResourceNotFoundError("Chart", chartId);
}
const uniqueName = await getUniqueCopyName(sourceChart.name, projectId);
return await createChart({
projectId,
name: uniqueName,
type: ZChartType.parse(sourceChart.type),
query: ZChartQuery.parse(sourceChart.query),
config: ZChartConfig.parse(sourceChart.config ?? {}),
createdBy,
});
} catch (error) {
if (error instanceof ResourceNotFoundError || error instanceof InvalidInputError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const deleteChart = async (chartId: string, projectId: string): Promise<TChart> => {
validateInputs([chartId, ZId], [projectId, ZId]);
try {
return await prisma.$transaction(async (tx) => {
const chart = await tx.chart.findFirst({
where: { id: chartId, projectId },
select: selectChart,
});
if (!chart) {
throw new ResourceNotFoundError("Chart", chartId);
}
await tx.chart.delete({
where: { id: chartId },
});
return chart;
});
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getChart = async (chartId: string, projectId: string): Promise<TChart> => {
validateInputs([chartId, ZId], [projectId, ZId]);
try {
const chart = await prisma.chart.findFirst({
where: { id: chartId, projectId },
select: selectChart,
});
if (!chart) {
throw new ResourceNotFoundError("Chart", chartId);
}
return chart;
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getCharts = async (projectId: string): Promise<TChartWithWidgets[]> => {
validateInputs([projectId, ZId]);
try {
return await prisma.chart.findMany({
where: { projectId },
orderBy: { createdAt: "desc" },
select: {
...selectChart,
widgets: {
select: { dashboardId: true },
},
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -0,0 +1,27 @@
import { ReactNode } from "react";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { AnalysisSecondaryNavigation } from "./analysis-secondary-navigation";
interface AnalysisPageLayoutProps {
pageTitle: string;
environmentId: string;
cta?: ReactNode;
children: ReactNode;
}
export function AnalysisPageLayout({
pageTitle,
environmentId,
cta,
children,
}: Readonly<AnalysisPageLayoutProps>) {
return (
<PageContentWrapper>
<PageHeader pageTitle={pageTitle} cta={cta}>
<AnalysisSecondaryNavigation environmentId={environmentId} />
</PageHeader>
{children}
</PageContentWrapper>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface AnalysisSecondaryNavigationProps {
environmentId: string;
}
export function AnalysisSecondaryNavigation({ environmentId }: Readonly<AnalysisSecondaryNavigationProps>) {
const { t } = useTranslation();
const pathname = usePathname();
const activeId = pathname?.includes("/charts") ? "charts" : "dashboards";
const navigation = [
{
id: "dashboards",
label: t("common.dashboards"),
href: `/environments/${environmentId}/analysis/dashboards`,
},
{
id: "charts",
label: t("common.charts"),
href: `/environments/${environmentId}/analysis/charts`,
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} />;
}

View File

@@ -0,0 +1,212 @@
"use server";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZWidgetLayout } from "@formbricks/types/dashboard";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { checkProjectAccess } from "@/modules/ee/analysis/lib/access";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { ZDashboardUpdateInput } from "../types/analysis";
import {
addChartToDashboard,
createDashboard,
deleteDashboard,
getDashboard,
getDashboards,
updateDashboard,
} from "./lib/dashboards";
const ZCreateDashboardAction = z.object({
environmentId: ZId,
name: z.string().min(1),
description: z.string().optional(),
});
export const createDashboardAction = authenticatedActionClient.schema(ZCreateDashboardAction).action(
withAuditLogging(
"created",
"dashboard",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const dashboard = await createDashboard({
projectId,
name: parsedInput.name,
description: parsedInput.description,
createdBy: ctx.user.id,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.dashboardId = dashboard.id;
ctx.auditLoggingCtx.newObject = dashboard;
return dashboard;
}
)
);
const ZUpdateDashboardAction = z
.object({
environmentId: ZId,
dashboardId: ZId,
})
.merge(ZDashboardUpdateInput);
export const updateDashboardAction = authenticatedActionClient.schema(ZUpdateDashboardAction).action(
withAuditLogging(
"updated",
"dashboard",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const { dashboard, updatedDashboard } = await updateDashboard(parsedInput.dashboardId, projectId, {
name: parsedInput.name,
description: parsedInput.description,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.dashboardId = parsedInput.dashboardId;
ctx.auditLoggingCtx.oldObject = dashboard;
ctx.auditLoggingCtx.newObject = updatedDashboard;
return updatedDashboard;
}
)
);
const ZDeleteDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
});
export const deleteDashboardAction = authenticatedActionClient.schema(ZDeleteDashboardAction).action(
withAuditLogging(
"deleted",
"dashboard",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDeleteDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const dashboard = await deleteDashboard(parsedInput.dashboardId, projectId);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.dashboardId = parsedInput.dashboardId;
ctx.auditLoggingCtx.oldObject = dashboard;
return { success: true };
}
)
);
const ZGetDashboardsAction = z.object({
environmentId: ZId,
});
export const getDashboardsAction = authenticatedActionClient
.schema(ZGetDashboardsAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetDashboardsAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
return getDashboards(projectId);
}
);
const ZGetDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
});
export const getDashboardAction = authenticatedActionClient
.schema(ZGetDashboardAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetDashboardAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
return getDashboard(parsedInput.dashboardId, projectId);
}
);
const ZAddChartToDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
chartId: ZId,
title: z.string().optional(),
layout: ZWidgetLayout.optional().default({ x: 0, y: 0, w: 4, h: 3 }),
});
export const addChartToDashboardAction = authenticatedActionClient.schema(ZAddChartToDashboardAction).action(
withAuditLogging(
"created",
"dashboardWidget",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZAddChartToDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const widget = await addChartToDashboard({
dashboardId: parsedInput.dashboardId,
chartId: parsedInput.chartId,
projectId,
title: parsedInput.title,
layout: parsedInput.layout,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.dashboardWidgetId = widget.id;
ctx.auditLoggingCtx.newObject = widget;
return widget;
}
)
);

View File

@@ -0,0 +1,474 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
vi.mock("server-only", () => ({}));
var mockTxDashboard: {
// NOSONAR / test code
findFirst: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
var mockTxChart: { findFirst: ReturnType<typeof vi.fn> }; // NOSONAR / test code
var mockTxWidget: {
// NOSONAR / test code
aggregate: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
};
vi.mock("@formbricks/database", () => {
const txDash = { findFirst: vi.fn(), update: vi.fn(), delete: vi.fn() };
const txChart = { findFirst: vi.fn() };
const txWidget = { aggregate: vi.fn(), create: vi.fn() };
mockTxDashboard = txDash;
mockTxChart = txChart;
mockTxWidget = txWidget;
return {
prisma: {
dashboard: {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
},
$transaction: vi.fn((cb: any) => cb({ dashboard: txDash, chart: txChart, dashboardWidget: txWidget })),
},
};
});
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@/modules/ee/analysis/charts/lib/charts", () => ({
selectChart: {
id: true,
name: true,
type: true,
query: true,
config: true,
createdAt: true,
updatedAt: true,
},
}));
const mockDashboardId = "dashboard-abc-123";
const mockProjectId = "project-abc-123";
const mockUserId = "user-abc-123";
const mockChartId = "chart-abc-123";
const selectDashboard = {
id: true,
name: true,
description: true,
createdAt: true,
updatedAt: true,
};
const mockDashboard = {
id: mockDashboardId,
name: "Test Dashboard",
description: "A test dashboard",
createdAt: new Date("2025-01-01"),
updatedAt: new Date("2025-01-01"),
};
const makePrismaError = (code: string) =>
new Prisma.PrismaClientKnownRequestError("mock error", { code, clientVersion: "5.0.0" });
describe("Dashboard Service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("createDashboard", () => {
test("creates a dashboard successfully", async () => {
vi.mocked(prisma.dashboard.create).mockResolvedValue(mockDashboard as any);
const { createDashboard } = await import("./dashboards");
const result = await createDashboard({
projectId: mockProjectId,
name: "Test Dashboard",
description: "A test dashboard",
createdBy: mockUserId,
});
expect(result).toEqual(mockDashboard);
expect(prisma.dashboard.create).toHaveBeenCalledWith({
data: {
name: "Test Dashboard",
description: "A test dashboard",
projectId: mockProjectId,
createdBy: mockUserId,
},
select: selectDashboard,
});
});
test("creates a dashboard without description", async () => {
const dashboardNoDesc = { ...mockDashboard, description: undefined };
vi.mocked(prisma.dashboard.create).mockResolvedValue(dashboardNoDesc as any);
const { createDashboard } = await import("./dashboards");
const result = await createDashboard({
projectId: mockProjectId,
name: "Test Dashboard",
createdBy: mockUserId,
});
expect(result).toEqual(dashboardNoDesc);
expect(prisma.dashboard.create).toHaveBeenCalledWith({
data: {
name: "Test Dashboard",
description: undefined,
projectId: mockProjectId,
createdBy: mockUserId,
},
select: selectDashboard,
});
});
test("throws InvalidInputError on unique constraint violation", async () => {
vi.mocked(prisma.dashboard.create).mockRejectedValue(
makePrismaError(PrismaErrorType.UniqueConstraintViolation)
);
const { createDashboard } = await import("./dashboards");
await expect(
createDashboard({
projectId: mockProjectId,
name: "Duplicate",
createdBy: mockUserId,
})
).rejects.toMatchObject({
name: "InvalidInputError",
});
});
test("throws DatabaseError on other Prisma errors", async () => {
vi.mocked(prisma.dashboard.create).mockRejectedValue(makePrismaError("P9999"));
const { createDashboard } = await import("./dashboards");
await expect(
createDashboard({
projectId: mockProjectId,
name: "Test",
createdBy: mockUserId,
})
).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("updateDashboard", () => {
test("updates a dashboard successfully", async () => {
const updatedDashboard = { ...mockDashboard, name: "Updated Dashboard" };
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
mockTxDashboard.update.mockResolvedValue(updatedDashboard);
const { updateDashboard } = await import("./dashboards");
const result = await updateDashboard(mockDashboardId, mockProjectId, { name: "Updated Dashboard" });
expect(result).toEqual({ dashboard: mockDashboard, updatedDashboard });
expect(mockTxDashboard.findFirst).toHaveBeenCalledWith({
where: { id: mockDashboardId, projectId: mockProjectId },
select: selectDashboard,
});
expect(mockTxDashboard.update).toHaveBeenCalledWith({
where: { id: mockDashboardId },
data: { name: "Updated Dashboard", description: undefined },
select: selectDashboard,
});
});
test("throws ResourceNotFoundError when dashboard does not exist", async () => {
mockTxDashboard.findFirst.mockResolvedValue(null);
const { updateDashboard } = await import("./dashboards");
await expect(
updateDashboard(mockDashboardId, mockProjectId, { name: "Updated" })
).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Dashboard",
resourceId: mockDashboardId,
});
expect(mockTxDashboard.update).not.toHaveBeenCalled();
});
test("throws InvalidInputError on unique constraint violation", async () => {
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
mockTxDashboard.update.mockRejectedValue(makePrismaError(PrismaErrorType.UniqueConstraintViolation));
vi.mocked(prisma.$transaction).mockImplementation((cb: any) =>
cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget })
);
const { updateDashboard } = await import("./dashboards");
await expect(
updateDashboard(mockDashboardId, mockProjectId, { name: "Taken Name" })
).rejects.toMatchObject({
name: "InvalidInputError",
});
});
});
describe("deleteDashboard", () => {
test("deletes a dashboard successfully", async () => {
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
mockTxDashboard.delete.mockResolvedValue(undefined);
const { deleteDashboard } = await import("./dashboards");
const result = await deleteDashboard(mockDashboardId, mockProjectId);
expect(result).toEqual(mockDashboard);
expect(mockTxDashboard.findFirst).toHaveBeenCalledWith({
where: { id: mockDashboardId, projectId: mockProjectId },
select: selectDashboard,
});
expect(mockTxDashboard.delete).toHaveBeenCalledWith({ where: { id: mockDashboardId } });
});
test("throws ResourceNotFoundError when dashboard does not exist", async () => {
mockTxDashboard.findFirst.mockResolvedValue(null);
const { deleteDashboard } = await import("./dashboards");
await expect(deleteDashboard(mockDashboardId, mockProjectId)).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Dashboard",
resourceId: mockDashboardId,
});
expect(mockTxDashboard.delete).not.toHaveBeenCalled();
});
test("throws DatabaseError on Prisma errors", async () => {
mockTxDashboard.findFirst.mockRejectedValue(makePrismaError("P9999"));
vi.mocked(prisma.$transaction).mockImplementation((cb: any) =>
cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget })
);
const { deleteDashboard } = await import("./dashboards");
await expect(deleteDashboard(mockDashboardId, mockProjectId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("getDashboard", () => {
test("returns a dashboard with widgets", async () => {
const dashboardWithWidgets = {
...mockDashboard,
widgets: [
{
id: "widget-1",
order: 0,
chart: { id: mockChartId, name: "Chart 1", type: "bar" },
},
],
};
vi.mocked(prisma.dashboard.findFirst).mockResolvedValue(dashboardWithWidgets as any);
const { getDashboard } = await import("./dashboards");
const result = await getDashboard(mockDashboardId, mockProjectId);
expect(result).toEqual(dashboardWithWidgets);
expect(prisma.dashboard.findFirst).toHaveBeenCalledWith({
where: { id: mockDashboardId, projectId: mockProjectId },
include: {
widgets: {
orderBy: { order: "asc" },
include: {
chart: {
select: expect.objectContaining({ id: true, name: true, type: true }),
},
},
},
},
});
});
test("throws ResourceNotFoundError when dashboard does not exist", async () => {
vi.mocked(prisma.dashboard.findFirst).mockResolvedValue(null);
const { getDashboard } = await import("./dashboards");
await expect(getDashboard(mockDashboardId, mockProjectId)).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Dashboard",
resourceId: mockDashboardId,
});
});
test("throws DatabaseError on Prisma errors", async () => {
vi.mocked(prisma.dashboard.findFirst).mockRejectedValue(makePrismaError("P9999"));
const { getDashboard } = await import("./dashboards");
await expect(getDashboard(mockDashboardId, mockProjectId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("getDashboards", () => {
test("returns all dashboards for a project", async () => {
const dashboards = [
{ ...mockDashboard, _count: { widgets: 3 } },
{ ...mockDashboard, id: "dash-2", name: "Dashboard 2", _count: { widgets: 0 } },
];
vi.mocked(prisma.dashboard.findMany).mockResolvedValue(dashboards as any);
const { getDashboards } = await import("./dashboards");
const result = await getDashboards(mockProjectId);
expect(result).toEqual(dashboards);
expect(prisma.dashboard.findMany).toHaveBeenCalledWith({
where: { projectId: mockProjectId },
orderBy: { createdAt: "desc" },
select: expect.objectContaining({
id: true,
name: true,
_count: { select: { widgets: true } },
}),
});
});
test("returns empty array when no dashboards exist", async () => {
vi.mocked(prisma.dashboard.findMany).mockResolvedValue([]);
const { getDashboards } = await import("./dashboards");
const result = await getDashboards(mockProjectId);
expect(result).toEqual([]);
});
test("throws DatabaseError on Prisma errors", async () => {
vi.mocked(prisma.dashboard.findMany).mockRejectedValue(makePrismaError("P9999"));
const { getDashboards } = await import("./dashboards");
await expect(getDashboards(mockProjectId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("addChartToDashboard", () => {
const mockLayout = { x: 0, y: 0, w: 4, h: 3 };
const mockWidget = {
id: "widget-abc-123",
dashboardId: mockDashboardId,
chartId: mockChartId,
title: "My Widget",
layout: mockLayout,
order: 0,
};
test("adds a chart to a dashboard as the first widget", async () => {
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
mockTxWidget.aggregate.mockResolvedValue({ _max: { order: null } });
mockTxWidget.create.mockResolvedValue(mockWidget);
const { addChartToDashboard } = await import("./dashboards");
const result = await addChartToDashboard({
dashboardId: mockDashboardId,
chartId: mockChartId,
projectId: mockProjectId,
title: "My Widget",
layout: mockLayout,
});
expect(result).toEqual(mockWidget);
expect(mockTxWidget.create).toHaveBeenCalledWith({
data: {
dashboardId: mockDashboardId,
chartId: mockChartId,
title: "My Widget",
layout: mockLayout,
order: 0,
},
});
});
test("appends widget after existing widgets", async () => {
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
mockTxWidget.aggregate.mockResolvedValue({ _max: { order: 2 } });
mockTxWidget.create.mockResolvedValue({ ...mockWidget, order: 3 });
const { addChartToDashboard } = await import("./dashboards");
await addChartToDashboard({
dashboardId: mockDashboardId,
chartId: mockChartId,
projectId: mockProjectId,
layout: mockLayout,
});
expect(mockTxWidget.create).toHaveBeenCalledWith({
data: expect.objectContaining({ order: 3 }),
});
});
test("throws ResourceNotFoundError when chart does not exist", async () => {
mockTxChart.findFirst.mockResolvedValue(null);
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
const { addChartToDashboard } = await import("./dashboards");
await expect(
addChartToDashboard({
dashboardId: mockDashboardId,
chartId: mockChartId,
projectId: mockProjectId,
layout: mockLayout,
})
).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Chart",
resourceId: mockChartId,
});
expect(mockTxWidget.create).not.toHaveBeenCalled();
});
test("throws ResourceNotFoundError when dashboard does not exist", async () => {
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
mockTxDashboard.findFirst.mockResolvedValue(null);
const { addChartToDashboard } = await import("./dashboards");
await expect(
addChartToDashboard({
dashboardId: mockDashboardId,
chartId: mockChartId,
projectId: mockProjectId,
layout: mockLayout,
})
).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Dashboard",
resourceId: mockDashboardId,
});
expect(mockTxWidget.create).not.toHaveBeenCalled();
});
test("throws InvalidInputError on unique constraint violation", async () => {
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
mockTxWidget.aggregate.mockResolvedValue({ _max: { order: null } });
mockTxWidget.create.mockRejectedValue(makePrismaError(PrismaErrorType.UniqueConstraintViolation));
vi.mocked(prisma.$transaction).mockImplementation((cb: any) =>
cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget })
);
const { addChartToDashboard } = await import("./dashboards");
await expect(
addChartToDashboard({
dashboardId: mockDashboardId,
chartId: mockChartId,
projectId: mockProjectId,
layout: mockLayout,
})
).rejects.toMatchObject({
name: "InvalidInputError",
});
});
});
});

View File

@@ -0,0 +1,227 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { selectChart } from "@/modules/ee/analysis/charts/lib/charts";
import {
TAddWidgetInput,
TDashboard,
TDashboardCreateInput,
TDashboardUpdateInput,
TDashboardWithCount,
ZAddWidgetInput,
ZDashboardCreateInput,
ZDashboardUpdateInput,
} from "../../types/analysis";
const selectDashboard = {
id: true,
name: true,
description: true,
createdAt: true,
updatedAt: true,
} as const;
export const createDashboard = async (data: TDashboardCreateInput): Promise<TDashboard> => {
validateInputs([data, ZDashboardCreateInput]);
try {
return await prisma.dashboard.create({
data: {
name: data.name,
description: data.description,
projectId: data.projectId,
createdBy: data.createdBy,
},
select: selectDashboard,
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError("A dashboard with this name already exists");
}
throw new DatabaseError(error.message);
}
throw error;
}
};
export const updateDashboard = async (
dashboardId: string,
projectId: string,
data: TDashboardUpdateInput
): Promise<{ dashboard: TDashboard; updatedDashboard: TDashboard }> => {
validateInputs([dashboardId, ZId], [projectId, ZId], [data, ZDashboardUpdateInput]);
try {
return await prisma.$transaction(async (tx) => {
const dashboard = await tx.dashboard.findFirst({
where: { id: dashboardId, projectId },
select: selectDashboard,
});
if (!dashboard) {
throw new ResourceNotFoundError("Dashboard", dashboardId);
}
const updatedDashboard = await tx.dashboard.update({
where: { id: dashboardId },
data: {
name: data.name,
description: data.description,
},
select: selectDashboard,
});
return { dashboard, updatedDashboard };
});
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError("A dashboard with this name already exists");
}
throw new DatabaseError(error.message);
}
throw error;
}
};
export const deleteDashboard = async (dashboardId: string, projectId: string): Promise<TDashboard> => {
validateInputs([dashboardId, ZId], [projectId, ZId]);
try {
return await prisma.$transaction(async (tx) => {
const dashboard = await tx.dashboard.findFirst({
where: { id: dashboardId, projectId },
select: selectDashboard,
});
if (!dashboard) {
throw new ResourceNotFoundError("Dashboard", dashboardId);
}
await tx.dashboard.delete({
where: { id: dashboardId },
});
return dashboard;
});
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getDashboard = async (dashboardId: string, projectId: string) => {
validateInputs([dashboardId, ZId], [projectId, ZId]);
try {
const dashboard = await prisma.dashboard.findFirst({
where: { id: dashboardId, projectId },
include: {
widgets: {
orderBy: { order: "asc" },
include: {
chart: {
select: selectChart,
},
},
},
},
});
if (!dashboard) {
throw new ResourceNotFoundError("Dashboard", dashboardId);
}
return dashboard;
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getDashboards = async (projectId: string): Promise<TDashboardWithCount[]> => {
validateInputs([projectId, ZId]);
try {
return await prisma.dashboard.findMany({
where: { projectId },
orderBy: { createdAt: "desc" },
select: {
...selectDashboard,
_count: { select: { widgets: true } },
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const addChartToDashboard = async (data: TAddWidgetInput) => {
validateInputs([data, ZAddWidgetInput]);
try {
return await prisma.$transaction(
async (tx) => {
const [chart, dashboard] = await Promise.all([
tx.chart.findFirst({ where: { id: data.chartId, projectId: data.projectId } }),
tx.dashboard.findFirst({ where: { id: data.dashboardId, projectId: data.projectId } }),
]);
if (!chart) {
throw new ResourceNotFoundError("Chart", data.chartId);
}
if (!dashboard) {
throw new ResourceNotFoundError("Dashboard", data.dashboardId);
}
const maxOrder = await tx.dashboardWidget.aggregate({
where: { dashboardId: data.dashboardId },
_max: { order: true },
});
return tx.dashboardWidget.create({
data: {
dashboardId: data.dashboardId,
chartId: data.chartId,
title: data.title,
layout: data.layout,
order: (maxOrder._max.order ?? -1) + 1,
},
});
},
{ isolationLevel: "Serializable" }
);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError("This chart is already on the dashboard");
}
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("server-only", () => ({}));
const mockGetEnvironment = vi.fn();
const mockGetOrganizationIdFromProjectId = vi.fn();
const mockCheckAuthorizationUpdated = vi.fn();
vi.mock("@/lib/environment/service", () => ({
getEnvironment: (...args: any[]) => mockGetEnvironment(...args),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromProjectId: (...args: any[]) => mockGetOrganizationIdFromProjectId(...args),
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: (...args: any[]) => mockCheckAuthorizationUpdated(...args),
}));
const mockUserId = "user-abc-123";
const mockEnvironmentId = "env-abc-123";
const mockProjectId = "project-abc-123";
const mockOrganizationId = "org-abc-123";
describe("checkProjectAccess", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns organizationId and projectId on successful access check", async () => {
mockGetEnvironment.mockResolvedValue({ projectId: mockProjectId });
mockGetOrganizationIdFromProjectId.mockResolvedValue(mockOrganizationId);
mockCheckAuthorizationUpdated.mockResolvedValue(undefined);
const { checkProjectAccess } = await import("./access");
const result = await checkProjectAccess(mockUserId, mockEnvironmentId, "readWrite");
expect(result).toEqual({ organizationId: mockOrganizationId, projectId: mockProjectId });
expect(mockGetEnvironment).toHaveBeenCalledWith(mockEnvironmentId);
expect(mockGetOrganizationIdFromProjectId).toHaveBeenCalledWith(mockProjectId);
expect(mockCheckAuthorizationUpdated).toHaveBeenCalledWith({
userId: mockUserId,
organizationId: mockOrganizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", minPermission: "readWrite", projectId: mockProjectId },
],
});
});
test("throws ResourceNotFoundError when environment is not found", async () => {
mockGetEnvironment.mockResolvedValue(null);
const { checkProjectAccess } = await import("./access");
await expect(checkProjectAccess(mockUserId, mockEnvironmentId, "read")).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "environment",
resourceId: mockEnvironmentId,
});
expect(mockGetOrganizationIdFromProjectId).not.toHaveBeenCalled();
expect(mockCheckAuthorizationUpdated).not.toHaveBeenCalled();
});
test("propagates authorization errors from checkAuthorizationUpdated", async () => {
mockGetEnvironment.mockResolvedValue({ projectId: mockProjectId });
mockGetOrganizationIdFromProjectId.mockResolvedValue(mockOrganizationId);
mockCheckAuthorizationUpdated.mockRejectedValue(new Error("Unauthorized"));
const { checkProjectAccess } = await import("./access");
await expect(checkProjectAccess(mockUserId, mockEnvironmentId, "manage")).rejects.toThrow("Unauthorized");
});
});

View File

@@ -0,0 +1,31 @@
import "server-only";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironment } from "@/lib/environment/service";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
export const checkProjectAccess = async (
userId: string,
environmentId: string,
minPermission: TTeamPermission
) => {
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new ResourceNotFoundError("environment", environmentId);
}
const projectId = environment.projectId;
const organizationId = await getOrganizationIdFromProjectId(projectId);
await checkAuthorizationUpdated({
userId,
organizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", minPermission, projectId },
],
});
return { organizationId, projectId };
};

View File

@@ -0,0 +1,83 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZChartConfig, ZChartQuery, ZWidgetLayout } from "@formbricks/types/dashboard";
export const ZChartType = z.enum(["area", "bar", "line", "pie", "big_number"]);
export type TChartType = z.infer<typeof ZChartType>;
// ── Chart input schemas ─────────────────────────────────────────────────────
export const ZChartCreateInput = z.object({
projectId: ZId,
name: z.string().min(1),
type: ZChartType,
query: ZChartQuery,
config: ZChartConfig,
createdBy: ZId,
});
export type TChartCreateInput = z.infer<typeof ZChartCreateInput>;
export const ZChartUpdateInput = z.object({
name: z.string().min(1).optional(),
type: ZChartType.optional(),
query: ZChartQuery.optional(),
config: ZChartConfig.optional(),
});
export type TChartUpdateInput = z.infer<typeof ZChartUpdateInput>;
// ── Chart output type (matches selectChart) ─────────────────────────────────
export type TChart = {
id: string;
name: string;
type: string;
query: unknown;
config: unknown;
createdAt: Date;
updatedAt: Date;
};
export type TChartWithWidgets = TChart & {
widgets: { dashboardId: string }[];
};
// ── Dashboard input schemas ─────────────────────────────────────────────────
export const ZDashboardCreateInput = z.object({
projectId: ZId,
name: z.string().min(1),
description: z.string().optional(),
createdBy: ZId,
});
export type TDashboardCreateInput = z.infer<typeof ZDashboardCreateInput>;
export const ZDashboardUpdateInput = z.object({
name: z.string().min(1).optional(),
description: z.string().optional().nullable(),
});
export type TDashboardUpdateInput = z.infer<typeof ZDashboardUpdateInput>;
// ── Dashboard output type (matches selectDashboard) ─────────────────────────
export type TDashboard = {
id: string;
name: string;
description: string | null;
createdAt: Date;
updatedAt: Date;
};
export type TDashboardWithCount = TDashboard & {
_count: { widgets: number };
};
// ── Widget input schema ─────────────────────────────────────────────────────
export const ZAddWidgetInput = z.object({
dashboardId: ZId,
chartId: ZId,
projectId: ZId,
title: z.string().optional(),
layout: ZWidgetLayout,
});
export type TAddWidgetInput = z.infer<typeof ZAddWidgetInput>;

View File

@@ -229,4 +229,49 @@ describe("withAuditLogging", () => {
// Reset for other tests; clearAllMockHandles will also do this in the next beforeEach
if (mutableConstants) mutableConstants.AUDIT_LOG_ENABLED = true;
});
test("resolves targetId for chart target type", async () => {
const chartCtx = {
...mockCtxBase,
auditLoggingCtx: { ...mockCtxBase.auditLoggingCtx, chartId: "chart-1" },
};
const handlerImpl = vi.fn().mockResolvedValue("ok");
const wrapped = OriginalHandler.withAuditLogging("created", "chart", handlerImpl);
await wrapped({ ctx: chartCtx as any, parsedInput: mockParsedInput });
await new Promise(setImmediate);
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0];
expect(callArgs.target.type).toBe("chart");
expect(callArgs.target.id).toBe("chart-1");
});
test("resolves targetId for dashboard target type", async () => {
const dashCtx = {
...mockCtxBase,
auditLoggingCtx: { ...mockCtxBase.auditLoggingCtx, dashboardId: "dash-1" },
};
const handlerImpl = vi.fn().mockResolvedValue("ok");
const wrapped = OriginalHandler.withAuditLogging("created", "dashboard", handlerImpl);
await wrapped({ ctx: dashCtx as any, parsedInput: mockParsedInput });
await new Promise(setImmediate);
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0];
expect(callArgs.target.type).toBe("dashboard");
expect(callArgs.target.id).toBe("dash-1");
});
test("resolves targetId for dashboardWidget target type", async () => {
const widgetCtx = {
...mockCtxBase,
auditLoggingCtx: { ...mockCtxBase.auditLoggingCtx, dashboardWidgetId: "widget-1" },
};
const handlerImpl = vi.fn().mockResolvedValue("ok");
const wrapped = OriginalHandler.withAuditLogging("created", "dashboardWidget", handlerImpl);
await wrapped({ ctx: widgetCtx as any, parsedInput: mockParsedInput });
await new Promise(setImmediate);
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0];
expect(callArgs.target.type).toBe("dashboardWidget");
expect(callArgs.target.id).toBe("widget-1");
});
});

View File

@@ -292,6 +292,15 @@ export const withAuditLogging = <TParsedInput = Record<string, unknown>, TResult
case "quota":
targetId = auditLoggingCtx.quotaId;
break;
case "chart":
targetId = auditLoggingCtx.chartId;
break;
case "dashboard":
targetId = auditLoggingCtx.dashboardId;
break;
case "dashboardWidget":
targetId = auditLoggingCtx.dashboardWidgetId;
break;
default:
targetId = UNKNOWN_DATA;
break;

View File

@@ -25,6 +25,9 @@ export const ZAuditTarget = z.enum([
"integration",
"file",
"quota",
"chart",
"dashboard",
"dashboardWidget",
]);
export const ZAuditAction = z.enum([
"created",

View File

@@ -2,13 +2,26 @@ import { NextRequest, userAgent } from "next/server";
import { logger } from "@formbricks/logger";
import { TContactAttributesInput } from "@formbricks/types/contact-attribute";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { TJsPersonState } from "@formbricks/types/js";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { updateUser } from "./lib/update-user";
const handleError = (err: unknown, url: string): { response: Response } => {
if (err instanceof ResourceNotFoundError) {
return { response: responses.notFoundResponse(err.resourceType, err.resourceId) };
}
if (err instanceof ValidationError) {
return { response: responses.badRequestResponse(err.message, undefined, true) };
}
logger.error({ error: err, url }, "Error in POST /api/v1/client/[environmentId]/user");
return { response: responses.internalServerErrorResponse("Unable to fetch user state", true) };
};
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
@@ -123,16 +136,7 @@ export const POST = withV1ApiWrapper({
response: responses.successResponse(responseJson, true),
};
} catch (err) {
if (err instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse(err.resourceType, err.resourceId),
};
}
logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/user");
return {
response: responses.internalServerErrorResponse(err.message ?? "Unable to fetch person state", true),
};
return handleError(err, req.url);
}
},
});

View File

@@ -13,22 +13,14 @@ describe("validateAndParseAttributeValue", () => {
}
});
test("converts numbers to string", () => {
test("rejects number values (SDK must pass actual strings)", () => {
const result = validateAndParseAttributeValue(42, "string", "testKey");
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.parsedValue.value).toBe("42");
expect(result.parsedValue.valueNumber).toBeNull();
}
});
test("converts Date to ISO string", () => {
const date = new Date("2024-01-15T10:30:00.000Z");
const result = validateAndParseAttributeValue(date, "string", "testKey");
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.parsedValue.value).toBe("2024-01-15T10:30:00.000Z");
expect(result.parsedValue.valueDate).toBeNull();
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.error.code).toBe("string_type_mismatch");
expect(result.error.params.key).toBe("testKey");
expect(result.error.params.type).toBe("number");
expect(formatValidationError(result.error)).toContain("received a number");
}
});
});

View File

@@ -27,15 +27,6 @@ export type TAttributeValidationResult =
error: TAttributeValidationError;
};
/**
* Converts any value to a string representation
*/
const convertToString = (value: TRawValue): string => {
if (value instanceof Date) return value.toISOString();
if (typeof value === "number") return String(value);
return value;
};
/**
* Gets a human-readable type name for error messages
*/
@@ -45,16 +36,28 @@ const getTypeName = (value: TRawValue): string => {
};
/**
* Validates and parses a string type attribute
* Validates and parses a string type attribute.
*/
const validateStringType = (value: TRawValue): TAttributeValidationResult => ({
valid: true,
parsedValue: {
value: convertToString(value),
valueNumber: null,
valueDate: null,
},
});
const validateStringType = (value: TRawValue, attributeKey: string): TAttributeValidationResult => {
if (typeof value === "string") {
return {
valid: true,
parsedValue: {
value,
valueNumber: null,
valueDate: null,
},
};
}
return {
valid: false,
error: {
code: "string_type_mismatch",
params: { key: attributeKey, type: getTypeName(value) },
},
};
};
/**
* Validates and parses a number type attribute.
@@ -170,13 +173,13 @@ export const validateAndParseAttributeValue = (
): TAttributeValidationResult => {
switch (expectedDataType) {
case "string":
return validateStringType(value);
return validateStringType(value, attributeKey);
case "number":
return validateNumberType(value, attributeKey);
case "date":
return validateDateType(value, attributeKey);
default:
return validateStringType(value);
return validateStringType(value, attributeKey);
}
};
@@ -185,6 +188,8 @@ export const validateAndParseAttributeValue = (
* Used for API/SDK responses.
*/
const VALIDATION_ERROR_TEMPLATES: Record<string, string> = {
string_type_mismatch:
"Attribute '{key}' expects a string but received a {type}. Pass an actual string value.",
number_type_mismatch:
"Attribute '{key}' expects a number but received a string. Pass an actual number value (e.g., 123 instead of \"123\").",
date_invalid: "Attribute '{key}' expects a valid date. Received: Invalid Date",

View File

@@ -5,10 +5,14 @@ import { getSegment } from "../segments";
import { segmentFilterToPrismaQuery } from "./prisma-query";
const mockQueryRawUnsafe = vi.fn();
const mockFindFirst = vi.fn();
vi.mock("@formbricks/database", () => ({
prisma: {
$queryRawUnsafe: (...args: unknown[]) => mockQueryRawUnsafe(...args),
contactAttribute: {
findFirst: (...args: unknown[]) => mockFindFirst(...args),
},
},
}));
@@ -26,7 +30,9 @@ describe("segmentFilterToPrismaQuery", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock: number filter raw SQL returns one matching contact
// Default: backfill is complete, no un-migrated rows
mockFindFirst.mockResolvedValue(null);
// Fallback path mock: raw SQL returns one matching contact when un-migrated rows exist
mockQueryRawUnsafe.mockResolvedValue([{ contactId: "mock-contact-1" }]);
});
@@ -145,7 +151,16 @@ describe("segmentFilterToPrismaQuery", () => {
},
},
],
OR: [{ id: { in: ["mock-contact-1"] } }],
OR: [
{
attributes: {
some: {
attributeKey: { key: "age" },
valueNumber: { gt: 30 },
},
},
},
],
});
}
});
@@ -757,7 +772,12 @@ describe("segmentFilterToPrismaQuery", () => {
});
expect(subgroup.AND[0].AND[2]).toStrictEqual({
id: { in: ["mock-contact-1"] },
attributes: {
some: {
attributeKey: { key: "age" },
valueNumber: { gte: 18 },
},
},
});
// Segment inclusion
@@ -1158,10 +1178,23 @@ describe("segmentFilterToPrismaQuery", () => {
},
});
// Second subgroup (numeric operators - now use raw SQL subquery returning contact IDs)
// Second subgroup (numeric operators - uses clean Prisma filter post-backfill)
const secondSubgroup = whereClause.AND?.[0];
expect(secondSubgroup.AND[1].AND).toContainEqual({
id: { in: ["mock-contact-1"] },
attributes: {
some: {
attributeKey: { key: "loginCount" },
valueNumber: { gt: 5 },
},
},
});
expect(secondSubgroup.AND[1].AND).toContainEqual({
attributes: {
some: {
attributeKey: { key: "purchaseAmount" },
valueNumber: { lte: 1000 },
},
},
});
// Third subgroup (negation operators in OR clause)
@@ -1196,6 +1229,104 @@ describe("segmentFilterToPrismaQuery", () => {
}
});
test("number filter falls back to raw SQL when un-migrated rows exist", async () => {
mockFindFirst.mockResolvedValue({ id: "unmigrated-row-1" });
mockQueryRawUnsafe.mockResolvedValue([{ contactId: "mock-contact-1" }]);
const filters: TBaseFilters = [
{
id: "filter_1",
connector: null,
resource: {
id: "attr_1",
root: {
type: "attribute" as const,
contactAttributeKey: "age",
},
value: 25,
qualifier: {
operator: "greaterThan",
},
},
},
];
const result = await segmentFilterToPrismaQuery(mockSegmentId, filters, mockEnvironmentId);
expect(result.ok).toBe(true);
if (result.ok) {
const filterClause = result.data.whereClause.AND?.[1] as any;
expect(filterClause.AND[0]).toEqual({
OR: [
{
attributes: {
some: {
attributeKey: { key: "age" },
valueNumber: { gt: 25 },
},
},
},
{ id: { in: ["mock-contact-1"] } },
],
});
}
expect(mockFindFirst).toHaveBeenCalledWith({
where: {
attributeKey: {
key: "age",
environmentId: mockEnvironmentId,
dataType: "number",
},
valueNumber: null,
},
select: { id: true },
});
expect(mockQueryRawUnsafe).toHaveBeenCalled();
const sqlCall = mockQueryRawUnsafe.mock.calls[0];
expect(sqlCall[0]).toContain('cak."environmentId" = $4');
expect(sqlCall[4]).toBe(mockEnvironmentId);
});
test("number filter uses clean Prisma query when backfill is complete", async () => {
const filters: TBaseFilters = [
{
id: "filter_1",
connector: null,
resource: {
id: "attr_1",
root: {
type: "attribute" as const,
contactAttributeKey: "score",
},
value: 100,
qualifier: {
operator: "lessEqual",
},
},
},
];
const result = await segmentFilterToPrismaQuery(mockSegmentId, filters, mockEnvironmentId);
expect(result.ok).toBe(true);
if (result.ok) {
const filterClause = result.data.whereClause.AND?.[1] as any;
expect(filterClause.AND[0]).toEqual({
attributes: {
some: {
attributeKey: { key: "score" },
valueNumber: { lte: 100 },
},
},
});
}
expect(mockFindFirst).toHaveBeenCalled();
expect(mockQueryRawUnsafe).not.toHaveBeenCalled();
});
// ==========================================
// DATE FILTER TESTS
// ==========================================
@@ -1232,7 +1363,7 @@ describe("segmentFilterToPrismaQuery", () => {
{
attributes: {
some: {
attributeKey: { key: "purchaseDate" },
attributeKey: { key: "purchaseDate", dataType: "date" },
OR: [
{ valueDate: { lt: new Date(targetDate) } },
{ valueDate: null, value: { lt: new Date(targetDate).toISOString() } },
@@ -1276,7 +1407,7 @@ describe("segmentFilterToPrismaQuery", () => {
{
attributes: {
some: {
attributeKey: { key: "signupDate" },
attributeKey: { key: "signupDate", dataType: "date" },
OR: [
{ valueDate: { gt: new Date(targetDate) } },
{ valueDate: null, value: { gt: new Date(targetDate).toISOString() } },
@@ -1321,7 +1452,7 @@ describe("segmentFilterToPrismaQuery", () => {
{
attributes: {
some: {
attributeKey: { key: "lastActivityDate" },
attributeKey: { key: "lastActivityDate", dataType: "date" },
OR: [
{ valueDate: { gte: new Date(startDate), lte: new Date(endDate) } },
{
@@ -1638,8 +1769,15 @@ describe("segmentFilterToPrismaQuery", () => {
mode: "insensitive",
});
// Number filter uses raw SQL subquery (transition code) returning contact IDs
expect(andConditions[1]).toEqual({ id: { in: ["mock-contact-1"] } });
// Number filter uses clean Prisma filter post-backfill
expect(andConditions[1]).toEqual({
attributes: {
some: {
attributeKey: { key: "purchaseCount" },
valueNumber: { gt: 5 },
},
},
});
// Date filter uses OR fallback with 'valueDate' and string 'value'
expect((andConditions[2] as unknown as any).attributes.some.OR[0].valueDate).toHaveProperty("gte");

View File

@@ -107,7 +107,7 @@ const buildDateAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): P
return {
attributes: {
some: {
attributeKey: { key: contactAttributeKey },
attributeKey: { key: contactAttributeKey, dataType: "date" },
OR: [{ valueDate: dateCondition }, { valueDate: null, value: stringDateCondition }],
},
},
@@ -116,59 +116,102 @@ const buildDateAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): P
/**
* Builds a Prisma where clause for number attribute filters.
* Uses a raw SQL subquery to handle both migrated rows (valueNumber populated)
* and un-migrated rows (valueNumber NULL, value contains numeric string).
* This is transition code for the deferred value backfill.
* Uses a clean Prisma query when all rows have valueNumber populated (post-backfill).
* Falls back to a raw SQL subquery for un-migrated rows (valueNumber NULL, value contains numeric string).
*
* TODO: After the backfill script has been run and all valueNumber columns are populated,
* revert this to the clean Prisma-only version that queries valueNumber directly.
* remove the un-migrated fallback path entirely.
*/
const buildNumberAttributeFilterWhereClause = async (
filter: TSegmentAttributeFilter
filter: TSegmentAttributeFilter,
environmentId: string
): Promise<Prisma.ContactWhereInput> => {
const { root, qualifier, value } = filter;
const { contactAttributeKey } = root;
const { operator } = qualifier;
const numericValue = typeof value === "number" ? value : Number(value);
const sqlOp = SQL_OPERATORS[operator];
if (!sqlOp) {
return {};
let valueNumberCondition: Prisma.FloatNullableFilter;
switch (operator) {
case "greaterThan":
valueNumberCondition = { gt: numericValue };
break;
case "greaterEqual":
valueNumberCondition = { gte: numericValue };
break;
case "lessThan":
valueNumberCondition = { lt: numericValue };
break;
case "lessEqual":
valueNumberCondition = { lte: numericValue };
break;
default:
return {};
}
const matchingContactIds = await prisma.$queryRawUnsafe<{ contactId: string }[]>(
const migratedFilter: Prisma.ContactWhereInput = {
attributes: {
some: {
attributeKey: { key: contactAttributeKey },
valueNumber: valueNumberCondition,
},
},
};
const hasUnmigratedRows = await prisma.contactAttribute.findFirst({
where: {
attributeKey: {
key: contactAttributeKey,
environmentId,
dataType: "number",
},
valueNumber: null,
},
select: { id: true },
});
if (!hasUnmigratedRows) {
return migratedFilter;
}
const sqlOp = SQL_OPERATORS[operator];
const unmigratedMatchingIds = await prisma.$queryRawUnsafe<{ contactId: string }[]>(
`
SELECT DISTINCT ca."contactId"
FROM "ContactAttribute" ca
JOIN "ContactAttributeKey" cak ON ca."attributeKeyId" = cak.id
WHERE cak.key = $1
AND (
(ca."valueNumber" IS NOT NULL AND ca."valueNumber" ${sqlOp} $2)
OR
(ca."valueNumber" IS NULL AND ca.value ~ $3 AND ca.value::double precision ${sqlOp} $2)
)
AND cak."environmentId" = $4
AND cak."dataType" = 'number'
AND ca."valueNumber" IS NULL
AND ca.value ~ $3
AND ca.value::double precision ${sqlOp} $2
`,
contactAttributeKey,
numericValue,
NUMBER_PATTERN_SQL
NUMBER_PATTERN_SQL,
environmentId
);
const contactIds = matchingContactIds.map((r) => r.contactId);
if (contactIds.length === 0) {
// Return an impossible condition so the filter correctly excludes all contacts
return { id: "__NUMBER_FILTER_NO_MATCH__" };
if (unmigratedMatchingIds.length === 0) {
return migratedFilter;
}
return { id: { in: contactIds } };
const contactIds = unmigratedMatchingIds.map((r) => r.contactId);
return {
OR: [migratedFilter, { id: { in: contactIds } }],
};
};
/**
* Builds a Prisma where clause from a segment attribute filter
*/
const buildAttributeFilterWhereClause = async (
filter: TSegmentAttributeFilter
filter: TSegmentAttributeFilter,
environmentId: string
): Promise<Prisma.ContactWhereInput> => {
const { root, qualifier, value } = filter;
const { contactAttributeKey } = root;
@@ -215,7 +258,7 @@ const buildAttributeFilterWhereClause = async (
// Handle number operators
if (["greaterThan", "greaterEqual", "lessThan", "lessEqual"].includes(operator)) {
return await buildNumberAttributeFilterWhereClause(filter);
return await buildNumberAttributeFilterWhereClause(filter, environmentId);
}
// For string operators, ensure value is a primitive (not an object or array)
@@ -253,7 +296,8 @@ const buildAttributeFilterWhereClause = async (
* Builds a Prisma where clause from a person filter
*/
const buildPersonFilterWhereClause = async (
filter: TSegmentPersonFilter
filter: TSegmentPersonFilter,
environmentId: string
): Promise<Prisma.ContactWhereInput> => {
const { personIdentifier } = filter.root;
@@ -265,7 +309,7 @@ const buildPersonFilterWhereClause = async (
contactAttributeKey: personIdentifier,
},
};
return await buildAttributeFilterWhereClause(personFilter);
return await buildAttributeFilterWhereClause(personFilter, environmentId);
}
return {};
@@ -314,6 +358,7 @@ const buildDeviceFilterWhereClause = (
const buildSegmentFilterWhereClause = async (
filter: TSegmentSegmentFilter,
segmentPath: Set<string>,
environmentId: string,
deviceType?: "phone" | "desktop"
): Promise<Prisma.ContactWhereInput> => {
const { root } = filter;
@@ -337,7 +382,7 @@ const buildSegmentFilterWhereClause = async (
const newPath = new Set(segmentPath);
newPath.add(segmentId);
return processFilters(segment.filters, newPath, deviceType);
return processFilters(segment.filters, newPath, environmentId, deviceType);
};
/**
@@ -346,19 +391,25 @@ const buildSegmentFilterWhereClause = async (
const processSingleFilter = async (
filter: TSegmentFilter,
segmentPath: Set<string>,
environmentId: string,
deviceType?: "phone" | "desktop"
): Promise<Prisma.ContactWhereInput> => {
const { root } = filter;
switch (root.type) {
case "attribute":
return await buildAttributeFilterWhereClause(filter as TSegmentAttributeFilter);
return await buildAttributeFilterWhereClause(filter as TSegmentAttributeFilter, environmentId);
case "person":
return await buildPersonFilterWhereClause(filter as TSegmentPersonFilter);
return await buildPersonFilterWhereClause(filter as TSegmentPersonFilter, environmentId);
case "device":
return buildDeviceFilterWhereClause(filter as TSegmentDeviceFilter, deviceType);
case "segment":
return await buildSegmentFilterWhereClause(filter as TSegmentSegmentFilter, segmentPath, deviceType);
return await buildSegmentFilterWhereClause(
filter as TSegmentSegmentFilter,
segmentPath,
environmentId,
deviceType
);
default:
return {};
}
@@ -370,6 +421,7 @@ const processSingleFilter = async (
const processFilters = async (
filters: TBaseFilters,
segmentPath: Set<string>,
environmentId: string,
deviceType?: "phone" | "desktop"
): Promise<Prisma.ContactWhereInput> => {
if (filters.length === 0) return {};
@@ -386,10 +438,10 @@ const processFilters = async (
// Process the resource based on its type
if (isResourceFilter(resource)) {
// If it's a single filter, process it directly
whereClause = await processSingleFilter(resource, segmentPath, deviceType);
whereClause = await processSingleFilter(resource, segmentPath, environmentId, deviceType);
} else {
// If it's a group of filters, process it recursively
whereClause = await processFilters(resource, segmentPath, deviceType);
whereClause = await processFilters(resource, segmentPath, environmentId, deviceType);
}
if (Object.keys(whereClause).length === 0) continue;
@@ -432,7 +484,7 @@ export const segmentFilterToPrismaQuery = reactCache(
// Initialize an empty stack for tracking the current evaluation path
const segmentPath = new Set<string>([segmentId]);
const filtersWhereClause = await processFilters(filters, segmentPath, deviceType);
const filtersWhereClause = await processFilters(filters, segmentPath, environmentId, deviceType);
const whereClause = {
AND: [baseWhereClause, filtersWhereClause],

View File

@@ -37,6 +37,7 @@ vi.mock("@formbricks/database", () => ({
create: vi.fn(),
delete: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
findFirst: vi.fn(),
},
survey: {
@@ -206,6 +207,73 @@ describe("Segment Service Tests", () => {
vi.mocked(prisma.segment.create).mockRejectedValue(new Error("DB error"));
await expect(createSegment(mockSegmentCreateInput)).rejects.toThrow(Error);
});
test("should upsert a private segment without surveyId", async () => {
const privateInput: TSegmentCreateInput = {
...mockSegmentCreateInput,
isPrivate: true,
};
const privateSegmentPrisma = { ...mockSegmentPrisma, isPrivate: true };
vi.mocked(prisma.segment.upsert).mockResolvedValue(privateSegmentPrisma);
const segment = await createSegment(privateInput);
expect(segment).toEqual({ ...mockSegment, isPrivate: true });
expect(prisma.segment.upsert).toHaveBeenCalledWith({
where: {
environmentId_title: {
environmentId,
title: privateInput.title,
},
},
create: {
environmentId,
title: privateInput.title,
description: undefined,
isPrivate: true,
filters: [],
},
update: {
description: undefined,
filters: [],
},
select: selectSegment,
});
expect(prisma.segment.create).not.toHaveBeenCalled();
});
test("should upsert a private segment with surveyId", async () => {
const privateInputWithSurvey: TSegmentCreateInput = {
...mockSegmentCreateInput,
isPrivate: true,
surveyId,
};
const privateSegmentPrisma = { ...mockSegmentPrisma, isPrivate: true };
vi.mocked(prisma.segment.upsert).mockResolvedValue(privateSegmentPrisma);
const segment = await createSegment(privateInputWithSurvey);
expect(segment).toEqual({ ...mockSegment, isPrivate: true });
expect(prisma.segment.upsert).toHaveBeenCalledWith({
where: {
environmentId_title: {
environmentId,
title: privateInputWithSurvey.title,
},
},
create: {
environmentId,
title: privateInputWithSurvey.title,
description: undefined,
isPrivate: true,
filters: [],
surveys: { connect: { id: surveyId } },
},
update: {
description: undefined,
filters: [],
surveys: { connect: { id: surveyId } },
},
select: selectSegment,
});
expect(prisma.segment.create).not.toHaveBeenCalled();
});
});
describe("cloneSegment", () => {

View File

@@ -136,28 +136,48 @@ export const createSegment = async (segmentCreateInput: TSegmentCreateInput): Pr
const { description, environmentId, filters, isPrivate, surveyId, title } = segmentCreateInput;
let data: Prisma.SegmentCreateArgs["data"] = {
environmentId,
title,
description,
isPrivate,
filters,
};
if (surveyId) {
data = {
...data,
surveys: {
connect: {
id: surveyId,
},
},
};
}
const surveyConnect = surveyId ? { surveys: { connect: { id: surveyId } } } : {};
try {
// Private segments use upsert because auto-save may have already created a
// default (empty-filter) segment via connectOrCreate before the user publishes.
// Without upsert the second create hits the (environmentId, title) unique constraint.
if (isPrivate) {
const segment = await prisma.segment.upsert({
where: {
environmentId_title: {
environmentId,
title,
},
},
create: {
environmentId,
title,
description,
isPrivate,
filters,
...surveyConnect,
},
update: {
description,
filters,
...surveyConnect,
},
select: selectSegment,
});
return transformPrismaSegment(segment);
}
const segment = await prisma.segment.create({
data,
data: {
environmentId,
title,
description,
isPrivate,
filters,
...surveyConnect,
},
select: selectSegment,
});

View File

@@ -11,7 +11,12 @@ import { useTranslation } from "react-i18next";
import { TProjectStyling, ZProjectStyling } from "@formbricks/types/project";
import { TSurveyStyling, TSurveyType } from "@formbricks/types/surveys/types";
import { previewSurvey } from "@/app/lib/templates";
import { STYLE_DEFAULTS, getSuggestedColors } from "@/lib/styling/constants";
import {
COLOR_DEFAULTS,
STYLE_DEFAULTS,
deriveNewFieldsFromLegacy,
getSuggestedColors,
} from "@/lib/styling/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateProjectAction } from "@/modules/projects/settings/actions";
import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings";
@@ -62,11 +67,23 @@ export const ThemeStyling = ({
? Object.fromEntries(Object.entries(savedStyling).filter(([, v]) => v != null))
: {};
const legacyFills = deriveNewFieldsFromLegacy(cleanSaved);
const form = useForm<TProjectStyling>({
defaultValues: { ...STYLE_DEFAULTS, ...cleanSaved },
defaultValues: { ...STYLE_DEFAULTS, ...legacyFills, ...cleanSaved },
resolver: zodResolver(ZProjectStyling),
});
// Brand color shown in the preview. Only updated when the user triggers
// "Suggest colors", "Save", or "Reset to default" — NOT on every keystroke
// in the brand-color picker. This prevents the loading-spinner / progress
// bar from updating while the user is still picking a colour.
const [previewBrandColor, setPreviewBrandColor] = useState<string>(
(cleanSaved as Partial<TProjectStyling>).brandColor?.light ??
STYLE_DEFAULTS.brandColor?.light ??
COLOR_DEFAULTS.brandColor
);
const [previewSurveyType, setPreviewSurveyType] = useState<TSurveyType>("link");
const [confirmResetStylingModalOpen, setConfirmResetStylingModalOpen] = useState(false);
const [confirmSuggestColorsOpen, setConfirmSuggestColorsOpen] = useState(false);
@@ -84,6 +101,7 @@ export const ThemeStyling = ({
if (updatedProjectResponse?.data) {
form.reset({ ...STYLE_DEFAULTS });
setPreviewBrandColor(STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor);
toast.success(t("environments.workspace.look.styling_updated_successfully"));
router.refresh();
} else {
@@ -100,7 +118,10 @@ export const ThemeStyling = ({
form.setValue(key as keyof TProjectStyling, value, { shouldDirty: true });
}
toast.success(t("environments.workspace.look.styling_updated_successfully"));
// Commit brand color to the preview now that all derived colours are in sync.
setPreviewBrandColor(brandColor ?? STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor);
toast.success(t("environments.workspace.look.suggested_colors_applied_please_save"));
setConfirmSuggestColorsOpen(false);
};
@@ -113,7 +134,11 @@ export const ThemeStyling = ({
});
if (updatedProjectResponse?.data) {
form.reset({ ...updatedProjectResponse.data.styling });
const saved = updatedProjectResponse.data.styling;
form.reset({ ...saved });
setPreviewBrandColor(
saved?.brandColor?.light ?? STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor
);
toast.success(t("environments.workspace.look.styling_updated_successfully"));
} else {
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
@@ -249,7 +274,9 @@ export const ThemeStyling = ({
survey={previewSurvey(project.name, t)}
project={{
...project,
styling: form.watch("allowStyleOverwrite") ? form.watch() : STYLE_DEFAULTS,
styling: form.watch("allowStyleOverwrite")
? { ...form.watch(), brandColor: { light: previewBrandColor } }
: STYLE_DEFAULTS,
}}
previewType={previewSurveyType}
setPreviewType={setPreviewSurveyType}

View File

@@ -8,6 +8,8 @@ import {
isValidFileTypeForExtension,
isValidImageFile,
resolveStorageUrl,
resolveStorageUrlAuto,
resolveStorageUrlsInObject,
sanitizeFileName,
validateFileUploads,
validateSingleFile,
@@ -406,7 +408,7 @@ describe("storage utils", () => {
expect(resolveStorageUrl("")).toBe("");
});
test("should return absolute URL unchanged (backward compatibility)", () => {
test("should return absolute URL unchanged", () => {
const httpsUrl = "https://example.com/storage/env-123/public/image.jpg";
const httpUrl = "http://example.com/storage/env-123/public/image.jpg";
@@ -415,14 +417,12 @@ describe("storage utils", () => {
});
test("should resolve relative /storage/ path to absolute URL", async () => {
// Use actual implementation with mocked dependencies
const { resolveStorageUrl: actualResolveStorageUrl } =
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
const relativePath = "/storage/env-123/public/image.jpg";
const result = actualResolveStorageUrl(relativePath);
// Should prepend the base URL (from mocked WEBAPP_URL or getPublicDomain)
expect(result).toContain("/storage/env-123/public/image.jpg");
expect(result.startsWith("http")).toBe(true);
});
@@ -432,4 +432,209 @@ describe("storage utils", () => {
expect(resolveStorageUrl("relative/path.jpg")).toBe("relative/path.jpg");
});
});
describe("resolveStorageUrlAuto", () => {
test("should return non-storage strings unchanged", () => {
expect(resolveStorageUrlAuto("hello world")).toBe("hello world");
expect(resolveStorageUrlAuto("/some/other/path")).toBe("/some/other/path");
expect(resolveStorageUrlAuto("https://example.com/image.jpg")).toBe("https://example.com/image.jpg");
});
test("should NOT transform free-text values that merely start with /storage/", () => {
expect(resolveStorageUrlAuto("/storage/help")).toBe("/storage/help");
expect(resolveStorageUrlAuto("/storage/")).toBe("/storage/");
expect(resolveStorageUrlAuto("/storage/some-text")).toBe("/storage/some-text");
expect(resolveStorageUrlAuto("/storage/foo/bar")).toBe("/storage/foo/bar");
});
test("should resolve public storage URL", async () => {
const { resolveStorageUrlAuto: actual } =
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
const result = actual("/storage/env-123/public/image.jpg");
expect(result).toContain("/storage/env-123/public/image.jpg");
expect(result.startsWith("http")).toBe(true);
});
test("should detect private access type from URL path", () => {
const privateUrl = "/storage/env-123/private/file.pdf";
const publicUrl = "/storage/env-123/public/image.jpg";
expect(privateUrl.includes("/private/")).toBe(true);
expect(publicUrl.includes("/private/")).toBe(false);
});
});
describe("resolveStorageUrlsInObject", () => {
test("should return null and undefined as-is", () => {
expect(resolveStorageUrlsInObject(null)).toBeNull();
expect(resolveStorageUrlsInObject(undefined)).toBeUndefined();
});
test("should return primitive values unchanged", () => {
expect(resolveStorageUrlsInObject(42)).toBe(42);
expect(resolveStorageUrlsInObject(true)).toBe(true);
expect(resolveStorageUrlsInObject("hello")).toBe("hello");
});
test("should NOT transform free-text that merely starts with /storage/", () => {
expect(resolveStorageUrlsInObject("/storage/help")).toBe("/storage/help");
expect(resolveStorageUrlsInObject("/storage/")).toBe("/storage/");
const input = {
questionId1: "/storage/",
questionId2: "/storage/help",
questionId3: "/storage/some-text",
questionId4: "/storage/foo/bar",
realUrl: "/storage/env-123/public/image.jpg",
};
const result = resolveStorageUrlsInObject(input);
expect(result.questionId1).toBe("/storage/");
expect(result.questionId2).toBe("/storage/help");
expect(result.questionId3).toBe("/storage/some-text");
expect(result.questionId4).toBe("/storage/foo/bar");
// realUrl still gets resolved because it matches the actual format
expect(result.realUrl).not.toBe("/storage/env-123/public/image.jpg");
});
test("should preserve Date instances", () => {
const date = new Date("2026-01-01");
expect(resolveStorageUrlsInObject(date)).toBe(date);
});
test("should resolve storage URL strings", async () => {
const { resolveStorageUrlsInObject: actual } =
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
const result = actual("/storage/env-123/public/image.jpg");
expect(typeof result).toBe("string");
expect(result).toContain("/storage/env-123/public/image.jpg");
expect((result as string).startsWith("http")).toBe(true);
});
test("should resolve URLs in arrays", async () => {
const { resolveStorageUrlsInObject: actual } =
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
const input = ["/storage/env-123/public/a.jpg", "plain text"];
const result = actual(input);
expect(result[0]).toContain("/storage/env-123/public/a.jpg");
expect(result[0].startsWith("http")).toBe(true);
expect(result[1]).toBe("plain text");
});
test("should resolve URLs in nested objects", async () => {
const { resolveStorageUrlsInObject: actual } =
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
const input = {
name: "Test Survey",
welcomeCard: {
fileUrl: "/storage/env-123/public/welcome.png",
headline: "Hello",
},
elements: [
{
imageUrl: "/storage/env-123/public/q1.jpg",
choices: [
{ id: "c1", imageUrl: "/storage/env-123/public/choice1.jpg" },
{ id: "c2", imageUrl: "https://external.com/image.jpg" },
],
},
],
count: 5,
createdAt: new Date("2026-01-01"),
};
const result = actual(input);
expect(result.welcomeCard.fileUrl.startsWith("http")).toBe(true);
expect(result.welcomeCard.headline).toBe("Hello");
expect(result.elements[0].imageUrl.startsWith("http")).toBe(true);
expect(result.elements[0].choices[0].imageUrl.startsWith("http")).toBe(true);
expect(result.elements[0].choices[1].imageUrl).toBe("https://external.com/image.jpg");
expect(result.count).toBe(5);
expect(result.createdAt).toEqual(new Date("2026-01-01"));
expect(result.name).toBe("Test Survey");
});
test("should resolve URLs in deeply nested objects", async () => {
const { resolveStorageUrlsInObject: actual } =
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
const input = {
level1: {
level2: {
level3: {
level4: {
level5: {
imageUrl: "/storage/env-123/public/deep.png",
items: [
{
nested: {
url: "/storage/env-123/public/nested.jpg",
label: "keep me",
},
},
"plain string",
42,
null,
],
},
},
sibling: "/storage/env-123/public/sibling.png",
},
},
untouched: { a: { b: { c: "no change" } } },
},
};
const result = actual(input);
expect(result.level1.level2.level3.level4.level5.imageUrl).toContain(
"/storage/env-123/public/deep.png"
);
expect(result.level1.level2.level3.level4.level5.imageUrl.startsWith("http")).toBe(true);
// @ts-expect-error - items is an array of unknown types
expect(result.level1.level2.level3.level4.level5.items[0].nested.url).toContain(
"/storage/env-123/public/nested.jpg"
);
// @ts-expect-error - items is an array of unknown types
expect(result.level1.level2.level3.level4.level5.items[0].nested.url.startsWith("http")).toBe(true);
// @ts-expect-error - items is an array of unknown types
expect(result.level1.level2.level3.level4.level5.items[0].nested.label).toBe("keep me");
expect(result.level1.level2.level3.level4.level5.items[1]).toBe("plain string");
expect(result.level1.level2.level3.level4.level5.items[2]).toBe(42);
expect(result.level1.level2.level3.level4.level5.items[3]).toBeNull();
expect(result.level1.level2.level3.sibling).toContain("/storage/env-123/public/sibling.png");
expect(result.level1.level2.level3.sibling.startsWith("http")).toBe(true);
expect(result.level1.untouched.a.b.c).toBe("no change");
});
test("should handle response data with file upload URLs", async () => {
const { resolveStorageUrlsInObject: actual } =
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
const responseData = {
questionId1: "text answer",
questionId2: 42,
fileUploadId: ["/storage/env-123/public/doc.pdf", "/storage/env-123/public/img.png"],
};
const result = actual(responseData);
expect(result.questionId1).toBe("text answer");
expect(result.questionId2).toBe(42);
const fileUrls = result.fileUploadId;
expect(fileUrls[0]).toContain("/storage/env-123/public/doc.pdf");
expect(fileUrls[0].startsWith("http")).toBe(true);
expect(fileUrls[1]).toContain("/storage/env-123/public/img.png");
expect(fileUrls[1].startsWith("http")).toBe(true);
});
});
});

View File

@@ -151,7 +151,7 @@ export const getErrorResponseFromStorageError = (
/**
* Resolves a storage URL to an absolute URL.
* - If already absolute, returns as-is (backward compatibility for old data)
* - If already absolute, returns as-is
* - If relative (/storage/...), prepends the appropriate base URL
* @param url The storage URL (relative or absolute)
* @param accessType The access type to determine which base URL to use (defaults to "public")
@@ -163,7 +163,7 @@ export const resolveStorageUrl = (
): string => {
if (!url) return "";
// Already absolute URL - return as-is (backward compatibility for old data)
// Already absolute URL - return as-is
if (url.startsWith("http://") || url.startsWith("https://")) {
return url;
}
@@ -176,3 +176,41 @@ export const resolveStorageUrl = (
return url;
};
// Matches the actual storage URL format: /storage/{id}/{public|private}/{filename...}
const STORAGE_URL_PATTERN = /^\/storage\/[^/]+\/(public|private)\/.+/;
const isStorageUrl = (value: string): boolean => STORAGE_URL_PATTERN.test(value);
export const resolveStorageUrlAuto = (url: string): string => {
if (!isStorageUrl(url)) return url;
const accessType = url.includes("/private/") ? "private" : "public";
return resolveStorageUrl(url, accessType);
};
/**
* Recursively walks an object/array and resolves all relative storage URLs
* Preserves the original structure; skips Date instances and non-object primitives.
*/
export const resolveStorageUrlsInObject = <T>(obj: T): T => {
if (obj === null || obj === undefined) return obj;
if (typeof obj === "string") {
return resolveStorageUrlAuto(obj) as T;
}
if (typeof obj !== "object") return obj;
if (obj instanceof Date) return obj;
if (Array.isArray(obj)) {
return obj.map((item) => resolveStorageUrlsInObject(item)) as T;
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
result[key] = resolveStorageUrlsInObject(value);
}
return result as T;
};

View File

@@ -9,7 +9,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TProjectStyling } from "@formbricks/types/project";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { STYLE_DEFAULTS, getSuggestedColors } from "@/lib/styling/constants";
import { STYLE_DEFAULTS, deriveNewFieldsFromLegacy, getSuggestedColors } from "@/lib/styling/constants";
import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings";
import { LogoSettingsCard } from "@/modules/survey/editor/components/logo-settings-card";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
@@ -68,10 +68,15 @@ export const StylingView = ({
? Object.fromEntries(Object.entries(localSurvey.styling).filter(([, v]) => v != null))
: {};
const projectLegacyFills = deriveNewFieldsFromLegacy(cleanProject);
const surveyLegacyFills = deriveNewFieldsFromLegacy(cleanSurvey);
const form = useForm<TSurveyStyling>({
defaultValues: {
...STYLE_DEFAULTS,
...projectLegacyFills,
...cleanProject,
...surveyLegacyFills,
...cleanSurvey,
},
});
@@ -94,7 +99,7 @@ export const StylingView = ({
form.setValue(key as keyof TSurveyStyling, value, { shouldDirty: true });
}
toast.success(t("environments.workspace.look.styling_updated_successfully"));
toast.success(t("environments.workspace.look.suggested_colors_applied_please_save"));
setConfirmSuggestColorsOpen(false);
};

View File

@@ -226,7 +226,7 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
tabIndex={0}
aria-controls="options"
aria-expanded={open}
className={cn("flex h-full w-full cursor-pointer items-center justify-end bg-white pr-2", {
className={cn("flex w-full cursor-pointer items-center justify-end bg-white pr-2 h-10", {
"w-10 justify-center pr-0": withInput && inputType !== "dropdown",
"pointer-events-none": isClearing,
})}>

View File

@@ -225,10 +225,10 @@ export const PreviewSurvey = ({
)}>
{previewMode === "mobile" && (
<>
<p className="absolute top-0 left-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
<p className="absolute left-0 top-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
Preview
</p>
<div className="absolute top-0 right-0 m-2">
<div className="absolute right-0 top-0 m-2">
<ResetProgressButton onClick={resetProgress} />
</div>
<MediaBackground
@@ -265,7 +265,7 @@ export const PreviewSurvey = ({
</Modal>
) : (
<div className="flex h-full w-full flex-col justify-center px-1">
<div className="absolute top-5 left-5">
<div className="absolute left-5 top-5">
{!styling.isLogoHidden && (
<ClientLogo
environmentId={environment.id}
@@ -296,7 +296,7 @@ export const PreviewSurvey = ({
</>
)}
{previewMode === "desktop" && (
<div className="flex h-full flex-1 flex-col">
<div className="flex h-full w-full flex-1 flex-col">
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
<div className="ml-6 flex space-x-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
@@ -373,7 +373,7 @@ export const PreviewSurvey = ({
styling={styling}
ContentRef={ContentRef as React.RefObject<HTMLDivElement>}
isEditorView>
<div className="absolute top-5 left-5">
<div className="absolute left-5 top-5">
{!styling.isLogoHidden && (
<ClientLogo
environmentId={environment.id}

View File

@@ -40,7 +40,10 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
isLoadingScript = true;
try {
const scriptUrl = props.appUrl ? `${props.appUrl}/js/surveys.umd.cjs` : "/js/surveys.umd.cjs";
const response = await fetch(scriptUrl);
const response = await fetch(
scriptUrl,
process.env.NODE_ENV === "development" ? { cache: "no-store" } : {}
);
if (!response.ok) {
throw new Error("Failed to load the surveys package");

View File

@@ -23,6 +23,7 @@
"@aws-sdk/s3-presigned-post": "3.971.0",
"@aws-sdk/s3-request-presigner": "3.971.0",
"@boxyhq/saml-jackson": "1.52.2",
"@cubejs-client/core": "1.6.6",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/sortable": "10.0.0",

View File

@@ -96,6 +96,7 @@ test.describe("Survey Styling", async () => {
expect(css).toContain("--fb-input-background-color: #eeeeee");
expect(css).toContain("--fb-input-border-color: #cccccc");
expect(css).toContain("--fb-input-text-color: #024eff");
expect(css).toContain("--fb-input-placeholder-color:");
expect(css).toContain("--fb-input-border-radius: 5px");
expect(css).toContain("--fb-input-height: 50px");
expect(css).toContain("--fb-input-font-size: 16px");

12
cube/cube.js Normal file
View File

@@ -0,0 +1,12 @@
module.exports = {
// queryRewrite runs before every Cube query. Use it to enforce row-level security (RLS)
// by injecting filters based on the caller's identity (e.g. organizationId, projectId).
//
// The securityContext is populated from the decoded JWT passed via the API token.
// Currently a passthrough because access control is handled in the Next.js API layer
// before reaching Cube. When Cube is exposed more broadly or multi-tenancy enforcement
// is needed at the Cube level, add filters here based on securityContext claims.
queryRewrite: (query, { securityContext }) => {
return query;
},
};

View File

@@ -0,0 +1,159 @@
// This schema maps to the `feedback_records` table owned by the Formbricks Hub Postgres.
// If the Hub changes column names, types, or the metadata JSONB shape (e.g. the `topics` array),
// this schema must be updated to match.
cube(`FeedbackRecords`, {
sql: `SELECT * FROM feedback_records`,
measures: {
count: {
type: `count`,
description: `Total number of feedback responses`,
},
promoterCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 9` }],
description: `Number of promoters (NPS score 9-10)`,
},
detractorCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number <= 6` }],
description: `Number of detractors (NPS score 0-6)`,
},
passiveCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
description: `Number of passives (NPS score 7-8)`,
},
npsScore: {
type: `number`,
sql: `
CASE
WHEN COUNT(*) = 0 THEN 0
ELSE ROUND(
(
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
COUNT(CASE WHEN ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
/ COUNT(*)::numeric
) * 100,
2
)
END
`,
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
},
averageScore: {
type: `avg`,
sql: `${CUBE}.value_number`,
description: `Average NPS score`,
},
},
dimensions: {
id: {
sql: `id`,
type: `string`,
primaryKey: true,
},
sentiment: {
sql: `sentiment`,
type: `string`,
description: `Sentiment extracted from metadata JSONB field`,
},
sourceType: {
sql: `source_type`,
type: `string`,
description: `Source type of the feedback (e.g., nps_campaign, survey)`,
},
sourceName: {
sql: `source_name`,
type: `string`,
description: `Human-readable name of the source`,
},
fieldType: {
sql: `field_type`,
type: `string`,
description: `Type of feedback field (e.g., nps, text, rating)`,
},
collectedAt: {
sql: `collected_at`,
type: `time`,
description: `Timestamp when the feedback was collected`,
},
npsValue: {
sql: `value_number`,
type: `number`,
description: `Raw NPS score value (0-10)`,
},
responseId: {
sql: `response_id`,
type: `string`,
description: `Unique identifier linking related feedback records`,
},
userIdentifier: {
sql: `user_identifier`,
type: `string`,
description: `Identifier of the user who provided feedback`,
},
emotion: {
sql: `emotion`,
type: `string`,
description: `Emotion extracted from metadata JSONB field`,
},
},
joins: {
TopicsUnnested: {
sql: `${CUBE}.id = ${TopicsUnnested}.feedback_record_id`,
relationship: `hasMany`,
},
},
});
cube(`TopicsUnnested`, {
sql: `
SELECT
fr.id as feedback_record_id,
topic_elem.topic
FROM feedback_records fr
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(fr.metadata->'topics', '[]'::jsonb)) AS topic_elem(topic)
`,
measures: {
count: {
type: `count`,
},
},
dimensions: {
id: {
sql: `feedback_record_id || '-' || topic`,
type: `string`,
primaryKey: true,
},
feedbackRecordId: {
sql: `feedback_record_id`,
type: `string`,
},
topic: {
sql: `topic`,
type: `string`,
description: `Individual topic from the topics array`,
},
},
});

View File

@@ -36,6 +36,34 @@ services:
volumes:
- minio-data:/data
# Cube connects to the Formbricks Hub Postgres which owns the feedback_records table.
# The CUBEJS_DB_* defaults below must match the Hub's Postgres credentials.
# Override via env vars if your Hub database uses different credentials or runs on another host.
#
# SECURITY: CUBEJS_API_SECRET has no default and must be set explicitly (e.g. in .env).
# Never use a weak secret in production/staging. Generate with: openssl rand -hex 32
cube:
image: cubejs/cube:v1.6.6
ports:
- 4000:4000
- 4001:4001 # Cube Playground UI (dev only)
environment:
CUBEJS_DB_TYPE: postgres
CUBEJS_DB_HOST: ${CUBEJS_DB_HOST:-postgres}
CUBEJS_DB_NAME: ${CUBEJS_DB_NAME:-postgres}
CUBEJS_DB_USER: ${CUBEJS_DB_USER:-postgres}
CUBEJS_DB_PASS: ${CUBEJS_DB_PASS:-postgres}
CUBEJS_DB_PORT: ${CUBEJS_DB_PORT:-5432}
CUBEJS_DEV_MODE: "true"
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET}
CUBEJS_CACHE_AND_QUEUE_DRIVER: memory
volumes:
- ./cube/cube.js:/cube/conf/cube.js
- ./cube/schema:/cube/conf/model
restart: on-failure
depends_on:
- postgres
volumes:
postgres:
driver: local

View File

@@ -4,12 +4,182 @@ description: "Formbricks Self-hosted version migration"
icon: "arrow-right"
---
## v4.7
Formbricks v4.7 introduces **typed contact attributes** with native `number` and `date` data types. This enables comparison-based segment filters (e.g. "signup date before 2025-01-01") that were previously not possible with string-only attribute values.
### What Happens Automatically
When Formbricks v4.7 starts for the first time, the data migration will:
1. Analyze all existing contact attribute keys and infer their data types (`text`, `number`, or `date`) based on the stored values
2. Update the `ContactAttributeKey` table with the detected `dataType` for each key
3. **If your instance has fewer than 1,000,000 contact attribute rows**: backfill the new `valueNumber` and `valueDate` columns inline. No manual action is needed.
4. **If your instance has 1,000,000 or more contact attribute rows**: the value backfill is skipped to avoid hitting the migration timeout. You will need to run a standalone backfill script after the upgrade.
<Info>
Most self-hosted instances have far fewer than 1,000,000 contact attribute rows (a typical setup with 100K
contacts and 5-10 attributes each lands around 500K-1M rows). If you are below the threshold, the migration
handles everything automatically and you can skip the manual backfill step below.
</Info>
### Steps to Migrate
**1. Backup your Database**
<Tabs>
<Tab title="Docker">
Before running these steps, navigate to the `formbricks` directory where your `docker-compose.yml` file is located.
```bash
docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbricks_pre_v4.7_$(date +%Y%m%d_%H%M%S).dump
```
<Info>
If you run into "**No such container**", use `docker ps` to find your container name, e.g.
`formbricks_postgres_1`.
</Info>
</Tab>
<Tab title="Kubernetes">
If you are using the **in-cluster PostgreSQL** deployed by the Helm chart:
```bash
kubectl exec -n formbricks formbricks-postgresql-0 -- pg_dump -Fc -U formbricks -d formbricks > formbricks_pre_v4.7_$(date +%Y%m%d_%H%M%S).dump
```
<Info>
If your PostgreSQL pod has a different name, run `kubectl get pods -n formbricks` to find it.
</Info>
If you are using a **managed PostgreSQL** service (e.g. AWS RDS, Cloud SQL), use your provider's backup/snapshot feature or run `pg_dump` directly against the external host.
</Tab>
</Tabs>
**2. Upgrade to Formbricks v4.7**
<Tabs>
<Tab title="Docker">
```bash
# Pull the latest version
docker compose pull
# Stop the current instance
docker compose down
# Start with Formbricks v4.7
docker compose up -d
```
</Tab>
<Tab title="Kubernetes">
```bash
helm upgrade formbricks oci://ghcr.io/formbricks/helm-charts/formbricks \
-n formbricks \
--set deployment.image.tag=v4.7.0
```
<Info>
The Helm chart includes a migration Job that automatically runs Prisma schema migrations as a
PreSync hook before the new pods start. No manual migration step is needed.
</Info>
</Tab>
</Tabs>
**3. Check the Migration Logs**
After Formbricks starts, check the logs to see whether the value backfill was completed or skipped:
<Tabs>
<Tab title="Docker">
```bash
docker compose logs formbricks | grep -i "backfill"
```
</Tab>
<Tab title="Kubernetes">
```bash
# Check the application pod logs
kubectl logs -n formbricks -l app.kubernetes.io/name=formbricks --tail=200 | grep -i "backfill"
```
If the Helm migration Job ran, you can also inspect its logs:
```bash
kubectl logs -n formbricks job/formbricks-migration
```
</Tab>
</Tabs>
If you see a message like `Skipping value backfill (X rows >= 1000000 threshold)`, proceed to step 4. Otherwise, the migration is complete and no further action is needed.
**4. Run the Backfill Script (large datasets only)**
If the migration skipped the value backfill, run the standalone backfill script inside the running Formbricks container:
<Tabs>
<Tab title="Docker">
```bash
docker exec formbricks node packages/database/dist/scripts/backfill-attribute-values.js
```
<Info>Replace `formbricks` with your actual container name if it differs. Use `docker ps` to find it.</Info>
</Tab>
<Tab title="Kubernetes">
```bash
kubectl exec -n formbricks deploy/formbricks -- node packages/database/dist/scripts/backfill-attribute-values.js
```
<Info>
If your Formbricks deployment has a different name, run `kubectl get deploy -n formbricks` to find it.
</Info>
</Tab>
</Tabs>
The script will output progress as it runs:
```
========================================
Attribute Value Backfill Script
========================================
Fetching number-type attribute keys...
Found 12 number-type keys. Backfilling valueNumber...
Number backfill progress: 10/12 keys (48230 rows updated)
Number backfill progress: 12/12 keys (52104 rows updated)
Fetching date-type attribute keys...
Found 5 date-type keys. Backfilling valueDate...
Date backfill progress: 5/5 keys (31200 rows updated)
========================================
Backfill Complete!
========================================
valueNumber rows updated: 52104
valueDate rows updated: 31200
Duration: 42.3s
========================================
```
Key characteristics of the backfill script:
- **Safe to run while Formbricks is live** -- it does not lock the entire table or wrap work in a long transaction
- **Idempotent** -- it only updates rows where the typed columns are still `NULL`, so you can safely run it multiple times
- **Resumable** -- each batch commits independently, so if the process is interrupted you can re-run it and it picks up where it left off
- **No timeout risk** -- unlike the migration, this script runs outside the migration transaction and has no time limit
**5. Verify the Upgrade**
- Access your Formbricks instance at the same URL as before
- If you use contact segments with number or date filters, verify they return the expected results
- Check that existing surveys and response data are intact
---
## v4.0
<Warning>
**Important: Migration Required**
Formbricks 4 introduces additional requirements for self-hosting setups and makes a dedicated Redis cache as well as S3-compatible file storage mandatory.
Formbricks 4 introduces additional requirements for self-hosting setups and makes a dedicated Redis cache as well as S3-compatible file storage mandatory.
</Warning>
Formbricks 4.0 is a **major milestone** that sets up the technical foundation for future iterations and feature improvements. This release focuses on modernizing core infrastructure components to improve reliability, scalability, and enable advanced features going forward.
@@ -17,9 +187,11 @@ Formbricks 4.0 is a **major milestone** that sets up the technical foundation fo
### What's New in Formbricks 4.0
**🚀 New Enterprise Features:**
- **Quotas Management**: Advanced quota controls for enterprise users
**🏗️ Technical Foundation Improvements:**
- **Enhanced File Storage**: Improved file handling with better performance and reliability
- **Improved Caching**: New caching functionality improving speed, extensibility and reliability
- **Database Optimization**: Removal of unused database tables and fields for better performance
@@ -39,7 +211,8 @@ These services are already included in the updated one-click setup for self-host
We know this represents more moving parts in your infrastructure and might even introduce more complexity in hosting Formbricks, and we don't take this decision lightly. As Formbricks grows into a comprehensive Survey and Experience Management platform, we've reached a point where the simple, single-service approach was holding back our ability to deliver the reliable, feature-rich product our users demand and deserve.
By moving to dedicated, professional-grade services for these critical functions, we're building the foundation needed to deliver:
- **Enterprise-grade reliability** with proper redundancy and backup capabilities
- **Enterprise-grade reliability** with proper redundancy and backup capabilities
- **Advanced features** that require sophisticated caching and file processing
- **Better performance** through optimized, dedicated services
- **Future scalability** to support larger deployments and more complex use cases without the need to maintain two different approaches
@@ -52,7 +225,7 @@ Additional migration steps are needed if you are using a self-hosted Formbricks
### One-Click Setup
For users using our official one-click setup, we provide an automated migration using a migration script:
For users using our official one-click setup, we provide an automated migration using a migration script:
```bash
# Download the latest script
@@ -67,11 +240,11 @@ chmod +x migrate-to-v4.sh
```
This script guides you through the steps for the infrastructure migration and does the following:
- Adds a Redis service to your setup and configures it
- Adds a MinIO service (open source S3-alternative) to your setup, configures it and migrates local files to it
- Pulls the latest Formbricks image and updates your instance
### Manual Setup
If you use a different setup to host your Formbricks instance, you need to make sure to make the necessary adjustments to run Formbricks 4.0.
@@ -87,6 +260,7 @@ You need to configure the `REDIS_URL` environment variable and point it to your
To use file storage (e.g., file upload questions, image choice questions, custom survey backgrounds, etc.), you need to have S3-compatible file storage set up and connected to Formbricks.
Formbricks supports multiple storage providers (among many other S3-compatible storages):
- AWS S3
- Digital Ocean Spaces
- Hetzner Object Storage
@@ -101,6 +275,7 @@ Please make sure to set up a storage bucket with one of these solutions and then
S3_BUCKET_NAME: formbricks-uploads
S3_ENDPOINT_URL: http://minio:9000 # not needed for AWS S3
```
#### Upgrade Process
**1. Backup your Database**
@@ -112,8 +287,8 @@ docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbr
```
<Info>
If you run into "**No such container**", use `docker ps` to find your container name,
e.g. `formbricks_postgres_1`.
If you run into "**No such container**", use `docker ps` to find your container name, e.g.
`formbricks_postgres_1`.
</Info>
**2. Upgrade to Formbricks 4.0**
@@ -134,6 +309,7 @@ docker compose up -d
**3. Automatic Database Migration**
When you start Formbricks 4.0 for the first time, it will **automatically**:
- Detect and apply required database schema updates
- Remove unused database tables and fields
- Optimize the database structure for better performance

View File

@@ -1,41 +1,94 @@
---
title: "Rate Limiting"
description: "Rate limiting for Formbricks"
description: "Current request rate limits in Formbricks"
icon: "timer"
---
To protect the platform from abuse and ensure fair usage, rate limiting is enforced by default on an IP-address basis. If a client exceeds the allowed number of requests within the specified time window, the API will return a `429 Too Many Requests` status code.
Formbricks applies request rate limits to protect against abuse and keep API usage fair.
## Default Rate Limits
Rate limits are scoped by identifier, depending on the endpoint:
The following rate limits apply to various endpoints:
- IP hash (for unauthenticated/client-side routes and public actions)
- API key ID (for authenticated API calls)
- User ID (for authenticated session-based calls and server actions)
- Organization ID (for follow-up email dispatch)
| **Endpoint** | **Rate Limit** | **Time Window** |
| ----------------------- | -------------- | --------------- |
| `POST /login` | 30 requests | 15 minutes |
| `POST /signup` | 30 requests | 60 minutes |
| `POST /verify-email` | 10 requests | 60 minutes |
| `POST /forgot-password` | 5 requests | 60 minutes |
| `GET /client-side-api` | 100 requests | 1 minute |
| `POST /share` | 100 requests | 60 minutes |
When a limit is exceeded, the API returns `429 Too Many Requests`.
If a request exceeds the defined rate limit, the server will respond with:
## Management API Rate Limits
These are the current limits for Management APIs:
| **Route Group** | **Limit** | **Window** | **Identifier** |
| --- | --- | --- | --- |
| `/api/v1/management/*` (except `/api/v1/management/storage`), `/api/v1/webhooks/*`, `/api/v1/integrations/*`, `/api/v1/management/me` | 100 requests | 1 minute | API key ID or session user ID |
| `/api/v2/management/*` (and other v2 authenticated routes that use `authenticatedApiClient`) | 100 requests | 1 minute | API key ID |
| `POST /api/v1/management/storage` | 5 requests | 1 minute | API key ID or session user ID |
## All Enforced Limits
| **Config** | **Limit** | **Window** | **Identifier** | **Used For** |
| --- | --- | --- | --- | --- |
| `auth.login` | 10 requests | 15 minutes | IP hash | Email/password login flow (`/api/auth/callback/credentials`) |
| `auth.signup` | 30 requests | 60 minutes | IP hash | Signup server action |
| `auth.forgotPassword` | 5 requests | 60 minutes | IP hash | Forgot password server action |
| `auth.verifyEmail` | 10 requests | 60 minutes | IP hash | Email verification callback + resend verification action |
| `api.v1` | 100 requests | 1 minute | API key ID or session user ID | v1 management, webhooks, integrations, and `/api/v1/management/me` |
| `api.v2` | 100 requests | 1 minute | API key ID | v2 authenticated API wrapper (`authenticatedApiClient`) |
| `api.client` | 100 requests | 1 minute | IP hash | v1 client API routes (except `/api/v1/client/og` and storage upload override), plus v2 routes that re-use those v1 handlers |
| `storage.upload` | 5 requests | 1 minute | IP hash or authenticated ID | Client storage upload and management storage upload |
| `storage.delete` | 5 requests | 1 minute | API key ID or session user ID | `DELETE /storage/[environmentId]/[accessType]/[fileName]` |
| `actions.emailUpdate` | 3 requests | 60 minutes | User ID | Profile email update action |
| `actions.surveyFollowUp` | 50 requests | 60 minutes | Organization ID | Survey follow-up email processing |
| `actions.sendLinkSurveyEmail` | 10 requests | 60 minutes | IP hash | Link survey email send action |
| `actions.licenseRecheck` | 5 requests | 1 minute | User ID | Enterprise license recheck action |
## Current Endpoint Exceptions
The following routes are currently not rate-limited by the server-side limiter:
- `GET /api/v1/client/og` (explicitly excluded)
- `POST /api/v2/client/[environmentId]/responses`
- `POST /api/v2/client/[environmentId]/displays`
- `GET /api/v2/health`
## 429 Response Shape
v1-style endpoints return:
```json
{
"code": 429,
"error": "Too many requests, Please try after a while!"
"code": "too_many_requests",
"message": "Maximum number of requests reached. Please try again later.",
"details": {}
}
```
v2-style endpoints return:
```json
{
"error": {
"code": 429,
"message": "Too Many Requests"
}
}
```
## Disabling Rate Limiting
For self-hosters, rate limiting can be disabled if necessary. However, we **strongly recommend keeping rate limiting enabled in production environments** to prevent abuse.
For self-hosters, rate limiting can be disabled if necessary. We strongly recommend keeping it enabled in production.
To disable rate limiting, set the following environment variable:
Set:
```bash
RATE_LIMITING_DISABLED=1
```
After making this change, restart your server to apply the new setting.
After changing this value, restart the server.
## Operational Notes
- Redis/Valkey is required for robust rate limiting (`REDIS_URL`).
- If Redis is unavailable at runtime, rate-limiter checks currently fail open (requests are allowed through without enforcement).
- Authentication failure audit logging uses a separate throttle (`shouldLogAuthFailure()`) and is intentionally **fail-closed**: when Redis is unavailable or errors occur, audit log entries are **skipped entirely** rather than written without throttle control. This prevents spam while preserving the hash-integrity chain required for compliance. In other words, if Redis is down, no authentication-failure audit logs will be recorded—requests themselves are still allowed (fail-open rate limiting above), but the audit trail for those failures will not be written.

View File

@@ -85,6 +85,7 @@ When PUBLIC_URL is configured, the following routes are automatically served fro
- `/s/{surveyId}` - Individual survey access
- `/c/{jwt}` - Personalized link survey access (JWT-based access)
- `/p/{survey-slug}` - Pretty URL survey access
- Embedded survey endpoints
#### API Routes

View File

@@ -16,8 +16,6 @@ The Churn Survey is among the most effective ways to identify weaknesses in your
* Follow-up to prevent bad reviews
* Coming soon: Make survey mandatory
## Overview
To run the Churn Survey in your app you want to proceed as follows:
@@ -80,13 +78,6 @@ Whenever a user visits this page, matches the filter conditions above and the re
Here is our complete [Actions manual](/xm-and-surveys/surveys/website-app-surveys/actions/) covering [No-Code](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions) and [Code](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions) Actions.
<Note>
Pre-churn flow coming soon Were currently building full-screen survey
pop-ups. Youll be able to prevent users from closing the survey unless they
respond to it. Its certainly debatable if you want that but you could force
them to click through the survey before letting them cancel 🤷
</Note>
### 5. Select Action in the “When to ask” card
![Select feedback button action](/images/xm-and-surveys/xm/best-practices/cancel-subscription/select-action.webp)

View File

@@ -46,13 +46,7 @@ _Want to change the button color? Adjust it in the project settings!_
Save, and move over to the **Audience** tab.
### 3. Pre-segment your audience (coming soon)
<Note>
### Filter by Attribute Coming Soon
We're working on pre-segmenting users by attributes. This manual will be updated in the coming days.
</Note>
### 3. Pre-segment your audience
Pre-segmentation isn't needed for this survey since you likely want to target all users who cancel their trial. You can use a specific user action, like clicking **Cancel Trial**, to show the survey only to users trying your product.
@@ -62,13 +56,13 @@ How you trigger your survey depends on your product. There are two options:
- **Trigger by Page view:** If you have a page like `/trial-cancelled` for users who cancel their trial subscription, create a user action with the type "Page View." Select "Limit to specific pages" and apply URL filters with these settings:
![Change text content](/images/xm-and-surveys/xm/best-practices/improve-trial-cr/action-pageurl.webp)
![Add page URL action](/images/xm-and-surveys/xm/best-practices/improve-trial-cr/action-pageurl.webp)
Whenever a user visits this page, the survey will be displayed ✅
- **Trigger by Button Click:** In a different case, you have a “Cancel Trial" button in your app. You can setup a user Action with the `Inner Text`:
![Change text content](/images/xm-and-surveys/xm/best-practices/improve-trial-cr/action-innertext.webp)
![Add inner text action](/images/xm-and-surveys/xm/best-practices/improve-trial-cr/action-innertext.webp)
Please have a look at our complete [Actions manual](/xm-and-surveys/surveys/website-app-surveys/actions) if you have questions.

View File

@@ -54,13 +54,7 @@ In the button settings you have to make sure it is set to “External URL”. In
Save, and move over to the “Audience” tab.
### 3. Pre-segment your audience (coming soon)
<Note>
## Filter by attribute coming soon. We're working on pre-segmenting users by
attributes. We will update this manual in the next few days.
</Note>
### 3. Pre-segment your audience
Once you clicked over to the “Audience” tab you can change the settings. In the **Who To Send** card, select “Filter audience by attribute”. This allows you to only show the prompt to a specific segment of your user base.

View File

@@ -2,6 +2,7 @@
/* eslint-disable @typescript-eslint/no-namespace -- using namespaces is required for prisma-json-types-generator */
import { type TActionClassNoCodeConfig } from "../types/action-classes";
import type { TOrganizationAccess } from "../types/api-key";
import type { TChartConfig, TChartQuery, TWidgetLayout } from "../types/dashboard";
import { type TIntegrationConfig } from "../types/integration";
import { type TOrganizationBilling } from "../types/organizations";
import { type TProjectConfig, type TProjectStyling } from "../types/project";
@@ -55,5 +56,8 @@ declare global {
export type OrganizationAccess = TOrganizationAccess;
export type SurveyMetadata = TSurveyMetadata;
export type SurveyQuotaLogic = TSurveyQuotaLogic;
export type ChartQuery = TChartQuery;
export type ChartConfig = TChartConfig;
export type WidgetLayout = TWidgetLayout;
}
}

View File

@@ -0,0 +1,77 @@
-- CreateEnum
CREATE TYPE "public"."ChartType" AS ENUM ('area', 'bar', 'line', 'pie', 'big_number');
-- CreateTable
CREATE TABLE "public"."Chart" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"type" "public"."ChartType" NOT NULL,
"projectId" TEXT NOT NULL,
"query" JSONB NOT NULL DEFAULT '{}',
"config" JSONB NOT NULL DEFAULT '{}',
"createdBy" TEXT,
CONSTRAINT "Chart_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."Dashboard" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"projectId" TEXT NOT NULL,
"createdBy" TEXT,
CONSTRAINT "Dashboard_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."DashboardWidget" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"dashboardId" TEXT NOT NULL,
"title" TEXT,
"chartId" TEXT NOT NULL,
"layout" JSONB NOT NULL DEFAULT '{"x":0,"y":0,"w":4,"h":3}',
"order" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "DashboardWidget_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Chart_projectId_created_at_idx" ON "public"."Chart"("projectId", "created_at");
-- CreateIndex
CREATE UNIQUE INDEX "Chart_projectId_name_key" ON "public"."Chart"("projectId", "name");
-- CreateIndex
CREATE INDEX "Dashboard_projectId_created_at_idx" ON "public"."Dashboard"("projectId", "created_at");
-- CreateIndex
CREATE UNIQUE INDEX "Dashboard_projectId_name_key" ON "public"."Dashboard"("projectId", "name");
-- CreateIndex
CREATE INDEX "DashboardWidget_dashboardId_order_idx" ON "public"."DashboardWidget"("dashboardId", "order");
-- AddForeignKey
ALTER TABLE "public"."Chart" ADD CONSTRAINT "Chart_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Chart" ADD CONSTRAINT "Chart_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Dashboard" ADD CONSTRAINT "Dashboard_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Dashboard" ADD CONSTRAINT "Dashboard_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."DashboardWidget" ADD CONSTRAINT "DashboardWidget_dashboardId_fkey" FOREIGN KEY ("dashboardId") REFERENCES "public"."Dashboard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."DashboardWidget" ADD CONSTRAINT "DashboardWidget_chartId_fkey" FOREIGN KEY ("chartId") REFERENCES "public"."Chart"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -647,6 +647,8 @@ model Project {
logo Json?
projectTeams ProjectTeam[]
customHeadScripts String? // Custom HTML scripts for link surveys (self-hosted only)
charts Chart[]
dashboards Dashboard[]
@@unique([organizationId, name])
}
@@ -867,6 +869,8 @@ model User {
/// [Locale]
locale String @default("en-US")
surveys Survey[]
charts Chart[] @relation("chartCreatedBy")
dashboards Dashboard[] @relation("dashboardCreatedBy")
teamUsers TeamUser[]
lastLoginAt DateTime?
isActive Boolean @default(true)
@@ -1004,3 +1008,92 @@ model ProjectTeam {
@@id([projectId, teamId])
@@index([teamId])
}
enum ChartType {
area
bar
line
pie
big_number
}
/// Represents a chart/visualization that can be used in multiple dashboards.
/// Charts are reusable components that query analytics data.
///
/// @property id - Unique identifier for the chart
/// @property name - Display name of the chart
/// @property type - Type of visualization (bar, line, pie, etc.)
/// @property project - The project this chart belongs to
/// @property query - Cube.js query configuration (JSON)
/// @property config - Chart-specific configuration (colors, labels, etc.)
/// @property createdBy - User who created the chart
/// @property dashboards - Dashboards that use this chart
model Chart {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
type ChartType
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String
/// [ChartQuery] - Cube.js query configuration
query Json @default("{}")
/// [ChartConfig] - Visualization configuration (colors, labels, formatting)
config Json @default("{}")
creator User? @relation("chartCreatedBy", fields: [createdBy], references: [id], onDelete: SetNull)
createdBy String?
widgets DashboardWidget[]
@@unique([projectId, name])
@@index([projectId, createdAt])
}
/// Represents a dashboard containing multiple charts.
/// Dashboards aggregate analytics insights at the project level.
///
/// @property id - Unique identifier for the dashboard
/// @property name - Display name of the dashboard
/// @property description - Optional description
/// @property project - The project this dashboard belongs to
/// @property widgets - Charts on this dashboard
/// @property createdBy - User who created the dashboard
model Dashboard {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
description String?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String
creator User? @relation("dashboardCreatedBy", fields: [createdBy], references: [id], onDelete: SetNull)
createdBy String?
widgets DashboardWidget[]
@@unique([projectId, name])
@@index([projectId, createdAt])
}
/// Represents a chart widget on a dashboard.
/// Widgets are positioned using a grid layout system.
///
/// @property id - Unique identifier for the widget
/// @property dashboard - The dashboard this widget belongs to
/// @property title - Optional title for the widget
/// @property chart - The chart displayed in this widget
/// @property layout - Grid layout configuration (x, y, width, height)
/// @property order - Display order within the dashboard
model DashboardWidget {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
dashboardId String
title String?
chart Chart @relation(fields: [chartId], references: [id], onDelete: Cascade)
chartId String
/// [WidgetLayout] - Grid layout: { x, y, w, h }
layout Json @default("{\"x\":0,\"y\":0,\"w\":4,\"h\":3}")
order Int @default(0)
@@index([dashboardId, order])
}

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