mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-20 18:18:43 -06:00
Compare commits
33 Commits
4.7.3-rc.1
...
feat/crud-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb8c39f007 | ||
|
|
62aa186a81 | ||
|
|
d819359216 | ||
|
|
3007e75d5b | ||
|
|
2c68a007a0 | ||
|
|
07a131dfd3 | ||
|
|
b52cd51771 | ||
|
|
b02dcbb25f | ||
|
|
bb257ed3d2 | ||
|
|
f35e54f21d | ||
|
|
d2038d9770 | ||
|
|
3e7fc6610a | ||
|
|
d01dc80712 | ||
|
|
d32437b4a6 | ||
|
|
f49f40610b | ||
|
|
9e754bad9c | ||
|
|
4dcf6fda40 | ||
|
|
1b8ccd7199 | ||
|
|
4f9088559f | ||
|
|
18550f1d11 | ||
|
|
881cd31f74 | ||
|
|
e00405dca2 | ||
|
|
33542d0c54 | ||
|
|
f37d22f13d | ||
|
|
202ae903ac | ||
|
|
6ab5cc367c | ||
|
|
21559045ba | ||
|
|
d7c57a7a48 | ||
|
|
11b2ef4788 | ||
|
|
6fefd51cce | ||
|
|
65af826222 | ||
|
|
12eb54c653 | ||
|
|
5aa1427e64 |
@@ -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
|
||||
42
.github/workflows/translation-check.yml
vendored
42
.github/workflows/translation-check.yml
vendored
@@ -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."
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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} />
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) =>
|
||||
survey.blocks,
|
||||
responseInputData.data,
|
||||
responseInputData.language ?? "en",
|
||||
responseInputData.finished,
|
||||
survey.questions
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
6
apps/web/lib/googleSheet/constants.ts
Normal file
6
apps/web/lib/googleSheet/constants.ts
Normal 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";
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -22,6 +22,9 @@ export type AuditLoggingCtx = {
|
||||
quotaId?: string;
|
||||
teamId?: string;
|
||||
integrationId?: string;
|
||||
chartId?: string;
|
||||
dashboardId?: string;
|
||||
dashboardWidgetId?: string;
|
||||
};
|
||||
|
||||
export type ActionClientCtx = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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": "すべてのアンケート用のスタイルテーマを作成します。各アンケートでカスタムスタイルを有効にできます。"
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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": "Создайте стиль для всех опросов. Вы можете включить индивидуальное оформление для каждого опроса."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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": "为所有问卷创建一个样式主题。你可以为每个问卷启用自定义样式。"
|
||||
},
|
||||
|
||||
@@ -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": "為所有調查建立樣式主題。您可以為每個調查啟用自訂樣式。"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
43
apps/web/modules/ee/analysis/api/lib/cube-client.test.ts
Normal file
43
apps/web/modules/ee/analysis/api/lib/cube-client.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
26
apps/web/modules/ee/analysis/api/lib/cube-client.ts
Normal file
26
apps/web/modules/ee/analysis/api/lib/cube-client.ts
Normal 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();
|
||||
}
|
||||
209
apps/web/modules/ee/analysis/charts/actions.ts
Normal file
209
apps/web/modules/ee/analysis/charts/actions.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
383
apps/web/modules/ee/analysis/charts/lib/charts.test.ts
Normal file
383
apps/web/modules/ee/analysis/charts/lib/charts.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
246
apps/web/modules/ee/analysis/charts/lib/charts.ts
Normal file
246
apps/web/modules/ee/analysis/charts/lib/charts.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
212
apps/web/modules/ee/analysis/dashboards/actions.ts
Normal file
212
apps/web/modules/ee/analysis/dashboards/actions.ts
Normal 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;
|
||||
}
|
||||
)
|
||||
);
|
||||
474
apps/web/modules/ee/analysis/dashboards/lib/dashboards.test.ts
Normal file
474
apps/web/modules/ee/analysis/dashboards/lib/dashboards.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
227
apps/web/modules/ee/analysis/dashboards/lib/dashboards.ts
Normal file
227
apps/web/modules/ee/analysis/dashboards/lib/dashboards.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
73
apps/web/modules/ee/analysis/lib/access.test.ts
Normal file
73
apps/web/modules/ee/analysis/lib/access.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
31
apps/web/modules/ee/analysis/lib/access.ts
Normal file
31
apps/web/modules/ee/analysis/lib/access.ts
Normal 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 };
|
||||
};
|
||||
83
apps/web/modules/ee/analysis/types/analysis.ts
Normal file
83
apps/web/modules/ee/analysis/types/analysis.ts
Normal 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>;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -25,6 +25,9 @@ export const ZAuditTarget = z.enum([
|
||||
"integration",
|
||||
"file",
|
||||
"quota",
|
||||
"chart",
|
||||
"dashboard",
|
||||
"dashboardWidget",
|
||||
]);
|
||||
export const ZAuditAction = z.enum([
|
||||
"created",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
})}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
12
cube/cube.js
Normal 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;
|
||||
},
|
||||
};
|
||||
159
cube/schema/FeedbackRecords.js
Normal file
159
cube/schema/FeedbackRecords.js
Normal 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`,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 We’re currently building full-screen survey
|
||||
pop-ups. You’ll be able to prevent users from closing the survey unless they
|
||||
respond to it. It’s 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
|
||||
|
||||

|
||||
|
||||
@@ -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:
|
||||
|
||||

|
||||

|
||||
|
||||
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`:
|
||||
|
||||

|
||||

|
||||
|
||||
Please have a look at our complete [Actions manual](/xm-and-surveys/surveys/website-app-surveys/actions) if you have questions.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user