mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-23 21:59:28 -05:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a1816786b | |||
| 219883266c | |||
| 55fc2b2bc8 | |||
| 6e4ef9a099 | |||
| ebf7d1e3a1 | |||
| 998162bc48 | |||
| 4fadc54b4e | |||
| f4ac9a8292 | |||
| 7c8a7606b7 | |||
| 225217330b | |||
| 589c04a530 | |||
| aa538a3a51 | |||
| 817e108ff5 | |||
| 33542d0c54 | |||
| f37d22f13d | |||
| 202ae903ac | |||
| 6ab5cc367c | |||
| 21559045ba | |||
| d7c57a7a48 | |||
| 11b2ef4788 | |||
| 6fefd51cce | |||
| 65af826222 | |||
| 12eb54c653 | |||
| 5aa1427e64 |
@@ -6,19 +6,9 @@ permissions:
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, reopened]
|
types: [opened, synchronize, reopened]
|
||||||
paths:
|
|
||||||
- "apps/web/**/*.ts"
|
|
||||||
- "apps/web/**/*.tsx"
|
|
||||||
- "apps/web/locales/**/*.json"
|
|
||||||
- "scan-translations.ts"
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
|
||||||
- "apps/web/**/*.ts"
|
|
||||||
- "apps/web/**/*.tsx"
|
|
||||||
- "apps/web/locales/**/*.json"
|
|
||||||
- "scan-translations.ts"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate-translations:
|
validate-translations:
|
||||||
@@ -33,30 +23,38 @@ jobs:
|
|||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
|
- name: Check for relevant changes
|
||||||
|
id: changes
|
||||||
|
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||||
with:
|
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
|
- name: Setup Node.js 22.x
|
||||||
|
if: steps.changes.outputs.translations == 'true'
|
||||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
|
if: steps.changes.outputs.translations == 'true'
|
||||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
if: steps.changes.outputs.translations == 'true'
|
||||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||||
|
|
||||||
- name: Validate translation keys
|
- name: Validate translation keys
|
||||||
run: |
|
if: steps.changes.outputs.translations == 'true'
|
||||||
echo ""
|
run: pnpm run scan-translations
|
||||||
echo "🔍 Validating translation keys..."
|
|
||||||
echo ""
|
|
||||||
pnpm run scan-translations
|
|
||||||
|
|
||||||
- name: Summary
|
- name: Skip (no translation-related changes)
|
||||||
if: success()
|
if: steps.changes.outputs.translations != 'true'
|
||||||
run: |
|
run: echo "No translation-related files changed — skipping validation."
|
||||||
echo ""
|
|
||||||
echo "✅ Translation validation completed successfully!"
|
|
||||||
echo ""
|
|
||||||
|
|||||||
+1
-40
@@ -1,40 +1 @@
|
|||||||
# Load environment variables from .env files
|
pnpm lint-staged
|
||||||
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
|
|
||||||
+7
-4
@@ -30,7 +30,7 @@ export const NotificationSwitch = ({
|
|||||||
const isChecked =
|
const isChecked =
|
||||||
notificationType === "unsubscribedOrganizationIds"
|
notificationType === "unsubscribedOrganizationIds"
|
||||||
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
|
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
|
||||||
: notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true;
|
: notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true;
|
||||||
|
|
||||||
const handleSwitchChange = async () => {
|
const handleSwitchChange = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -49,8 +49,11 @@ export const NotificationSwitch = ({
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId] =
|
updatedNotificationSettings[notificationType] = {
|
||||||
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
|
...updatedNotificationSettings[notificationType],
|
||||||
|
[surveyOrProjectOrOrganizationId]:
|
||||||
|
!updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
|
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
|
||||||
@@ -78,7 +81,7 @@ export const NotificationSwitch = ({
|
|||||||
) {
|
) {
|
||||||
switch (notificationType) {
|
switch (notificationType) {
|
||||||
case "alert":
|
case "alert":
|
||||||
if (notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true) {
|
if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) {
|
||||||
handleSwitchChange();
|
handleSwitchChange();
|
||||||
toast.success(
|
toast.success(
|
||||||
t(
|
t(
|
||||||
|
|||||||
+39
-2
@@ -1,12 +1,49 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
|
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 { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
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({
|
const ZGetSpreadsheetNameByIdAction = z.object({
|
||||||
googleSheetIntegration: ZIntegrationGoogleSheets,
|
googleSheetIntegration: ZIntegrationGoogleSheets,
|
||||||
environmentId: z.string(),
|
environmentId: z.string(),
|
||||||
|
|||||||
+19
-5
@@ -20,6 +20,10 @@ import {
|
|||||||
isValidGoogleSheetsUrl,
|
isValidGoogleSheetsUrl,
|
||||||
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
|
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
|
||||||
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
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 { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
@@ -118,6 +122,17 @@ export const AddIntegrationModal = ({
|
|||||||
resetForm();
|
resetForm();
|
||||||
}, [selectedIntegration, surveys]);
|
}, [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 () => {
|
const linkSheet = async () => {
|
||||||
try {
|
try {
|
||||||
if (!isValidGoogleSheetsUrl(spreadsheetUrl)) {
|
if (!isValidGoogleSheetsUrl(spreadsheetUrl)) {
|
||||||
@@ -129,6 +144,7 @@ export const AddIntegrationModal = ({
|
|||||||
if (selectedElements.length === 0) {
|
if (selectedElements.length === 0) {
|
||||||
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
||||||
}
|
}
|
||||||
|
setIsLinkingSheet(true);
|
||||||
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
|
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
|
||||||
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
|
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
|
||||||
googleSheetIntegration,
|
googleSheetIntegration,
|
||||||
@@ -137,13 +153,11 @@ export const AddIntegrationModal = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!spreadsheetNameResponse?.data) {
|
if (!spreadsheetNameResponse?.data) {
|
||||||
const errorMessage = getFormattedErrorMessage(spreadsheetNameResponse);
|
showErrorMessageToast(spreadsheetNameResponse);
|
||||||
throw new Error(errorMessage);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const spreadsheetName = spreadsheetNameResponse.data;
|
const spreadsheetName = spreadsheetNameResponse.data;
|
||||||
|
|
||||||
setIsLinkingSheet(true);
|
|
||||||
integrationData.spreadsheetId = spreadsheetId;
|
integrationData.spreadsheetId = spreadsheetId;
|
||||||
integrationData.spreadsheetName = spreadsheetName;
|
integrationData.spreadsheetName = spreadsheetName;
|
||||||
integrationData.surveyId = selectedSurvey.id;
|
integrationData.surveyId = selectedSurvey.id;
|
||||||
@@ -280,7 +294,7 @@ export const AddIntegrationModal = ({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
<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">
|
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||||
{surveyElements.map((question) => (
|
{surveyElements.map((question) => (
|
||||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||||
|
|||||||
+18
-1
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import {
|
import {
|
||||||
TIntegrationGoogleSheets,
|
TIntegrationGoogleSheets,
|
||||||
@@ -8,9 +8,11 @@ import {
|
|||||||
} from "@formbricks/types/integration/google-sheet";
|
} from "@formbricks/types/integration/google-sheet";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
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 { 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 { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
|
||||||
import googleSheetLogo from "@/images/googleSheetsLogo.png";
|
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 { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||||
import { AddIntegrationModal } from "./AddIntegrationModal";
|
import { AddIntegrationModal } from "./AddIntegrationModal";
|
||||||
|
|
||||||
@@ -35,10 +37,23 @@ export const GoogleSheetWrapper = ({
|
|||||||
googleSheetIntegration ? googleSheetIntegration.config?.key : false
|
googleSheetIntegration ? googleSheetIntegration.config?.key : false
|
||||||
);
|
);
|
||||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||||
|
const [showReconnectButton, setShowReconnectButton] = useState<boolean>(false);
|
||||||
const [selectedIntegration, setSelectedIntegration] = useState<
|
const [selectedIntegration, setSelectedIntegration] = useState<
|
||||||
(TIntegrationGoogleSheetsConfigData & { index: number }) | null
|
(TIntegrationGoogleSheetsConfigData & { index: number }) | null
|
||||||
>(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 () => {
|
const handleGoogleAuthorization = async () => {
|
||||||
authorize(environment.id, webAppUrl).then((url: string) => {
|
authorize(environment.id, webAppUrl).then((url: string) => {
|
||||||
if (url) {
|
if (url) {
|
||||||
@@ -64,6 +79,8 @@ export const GoogleSheetWrapper = ({
|
|||||||
setOpenAddIntegrationModal={setIsModalOpen}
|
setOpenAddIntegrationModal={setIsModalOpen}
|
||||||
setIsConnected={setIsConnected}
|
setIsConnected={setIsConnected}
|
||||||
setSelectedIntegration={setSelectedIntegration}
|
setSelectedIntegration={setSelectedIntegration}
|
||||||
|
showReconnectButton={showReconnectButton}
|
||||||
|
handleGoogleAuthorization={handleGoogleAuthorization}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+31
-2
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Trash2Icon } from "lucide-react";
|
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -12,15 +12,19 @@ import { TUserLocale } from "@formbricks/types/user";
|
|||||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
|
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
|
|
||||||
interface ManageIntegrationProps {
|
interface ManageIntegrationProps {
|
||||||
googleSheetIntegration: TIntegrationGoogleSheets;
|
googleSheetIntegration: TIntegrationGoogleSheets;
|
||||||
setOpenAddIntegrationModal: (v: boolean) => void;
|
setOpenAddIntegrationModal: (v: boolean) => void;
|
||||||
setIsConnected: (v: boolean) => void;
|
setIsConnected: (v: boolean) => void;
|
||||||
setSelectedIntegration: (v: (TIntegrationGoogleSheetsConfigData & { index: number }) | null) => void;
|
setSelectedIntegration: (v: (TIntegrationGoogleSheetsConfigData & { index: number }) | null) => void;
|
||||||
|
showReconnectButton: boolean;
|
||||||
|
handleGoogleAuthorization: () => void;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +33,8 @@ export const ManageIntegration = ({
|
|||||||
setOpenAddIntegrationModal,
|
setOpenAddIntegrationModal,
|
||||||
setIsConnected,
|
setIsConnected,
|
||||||
setSelectedIntegration,
|
setSelectedIntegration,
|
||||||
|
showReconnectButton,
|
||||||
|
handleGoogleAuthorization,
|
||||||
locale,
|
locale,
|
||||||
}: ManageIntegrationProps) => {
|
}: ManageIntegrationProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -68,7 +74,17 @@ export const ManageIntegration = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
|
<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">
|
<div className="mr-6 flex items-center">
|
||||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||||
<span className="text-slate-500">
|
<span className="text-slate-500">
|
||||||
@@ -77,6 +93,19 @@ export const ManageIntegration = ({
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedIntegration(null);
|
setSelectedIntegration(null);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { google } from "googleapis";
|
import { google } from "googleapis";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
|
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import {
|
import {
|
||||||
GOOGLE_SHEETS_CLIENT_ID,
|
GOOGLE_SHEETS_CLIENT_ID,
|
||||||
@@ -8,7 +9,7 @@ import {
|
|||||||
WEBAPP_URL,
|
WEBAPP_URL,
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
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";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
export const GET = async (req: Request) => {
|
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");
|
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
|
||||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
||||||
|
|
||||||
let key;
|
if (!code) {
|
||||||
let userEmail;
|
return Response.redirect(
|
||||||
|
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = {
|
const googleSheetIntegration = {
|
||||||
type: "googleSheets" as "googleSheets",
|
type: integrationType,
|
||||||
environment: environmentId,
|
environment: environmentId,
|
||||||
config: {
|
config: {
|
||||||
key,
|
key,
|
||||||
data: [],
|
data: existingConfig?.data ?? [],
|
||||||
email: userEmail,
|
email: userEmail,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
|
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/")).toBe(false);
|
expect(isAuthProtectedRoute("/")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
|
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
|
||||||
|
expect(isAuthProtectedRoute("/p/pretty-url")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
|
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/health")).toBe(false);
|
expect(isAuthProtectedRoute("/health")).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -312,6 +313,19 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isPublicDomainRoute("/c")).toBe(false);
|
expect(isPublicDomainRoute("/c")).toBe(false);
|
||||||
expect(isPublicDomainRoute("/contact/token")).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", () => {
|
test("should return true for client API routes", () => {
|
||||||
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
|
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("/s/survey-id-with-dashes")).toBe(false);
|
||||||
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
|
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
|
||||||
expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).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/v1/client/test")).toBe(false);
|
||||||
expect(isAdminDomainRoute("/api/v2/client/other")).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", () => {
|
test("should allow public routes on public domain", () => {
|
||||||
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/c/jwt-token", 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/v1/client/test", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/health", 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("/s/survey-id-with-dashes", false)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
|
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", 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/v1/client/test", false)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/api/v2/client/other", 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", () => {
|
test("should handle paths with query parameters and fragments", () => {
|
||||||
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/s/survey123#section", 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", true)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
|
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -450,6 +471,7 @@ describe("endpoint-validator", () => {
|
|||||||
describe("URL parsing edge cases", () => {
|
describe("URL parsing edge cases", () => {
|
||||||
test("should handle paths with query parameters", () => {
|
test("should handle paths with query parameters", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true);
|
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("/api/v1/client/test?query=data")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
|
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
|
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
|
||||||
@@ -458,12 +480,14 @@ describe("endpoint-validator", () => {
|
|||||||
test("should handle paths with fragments", () => {
|
test("should handle paths with fragments", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/c/jwt-token#top")).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(isPublicDomainRoute("/environments/123#overview")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
|
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should handle trailing slashes", () => {
|
test("should handle trailing slashes", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty123/")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
|
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
|
||||||
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
|
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
|
||||||
isManagementApi: true,
|
isManagementApi: true,
|
||||||
@@ -478,6 +502,9 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/s/survey123/thank-you")).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", () => {
|
test("should handle nested client API routes", () => {
|
||||||
@@ -529,6 +556,7 @@ describe("endpoint-validator", () => {
|
|||||||
test("should handle special characters in survey IDs", () => {
|
test("should handle special characters in survey IDs", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).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", () => {
|
test("should properly validate malicious or injection-like URLs", () => {
|
||||||
// SQL injection-like attempts
|
// SQL injection-like attempts
|
||||||
expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format
|
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({
|
expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({
|
||||||
isManagementApi: true,
|
isManagementApi: true,
|
||||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||||
@@ -543,10 +572,12 @@ describe("endpoint-validator", () => {
|
|||||||
|
|
||||||
// Path traversal attempts
|
// Path traversal attempts
|
||||||
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
|
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
|
||||||
|
expect(isPublicDomainRoute("/p/../../../etc/passwd")).toBe(true);
|
||||||
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
|
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
|
||||||
|
|
||||||
// XSS-like attempts
|
// XSS-like attempts
|
||||||
expect(isPublicDomainRoute("/s/<script>alert('xss')</script>")).toBe(true);
|
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({
|
expect(isClientSideApiRoute("/api/v1/client/<script>alert('xss')</script>")).toEqual({
|
||||||
isClientSideApi: true,
|
isClientSideApi: true,
|
||||||
isRateLimited: true,
|
isRateLimited: true,
|
||||||
@@ -556,6 +587,7 @@ describe("endpoint-validator", () => {
|
|||||||
test("should handle URL encoding", () => {
|
test("should handle URL encoding", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
|
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty%20123")).toBe(true);
|
||||||
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
|
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
|
||||||
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
|
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
|
||||||
isManagementApi: true,
|
isManagementApi: true,
|
||||||
@@ -591,6 +623,7 @@ describe("endpoint-validator", () => {
|
|||||||
// These should not match due to case sensitivity
|
// These should not match due to case sensitivity
|
||||||
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
|
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
|
||||||
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
|
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
|
||||||
|
expect(isPublicDomainRoute("/P/pretty123")).toBe(false);
|
||||||
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
|
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
|
||||||
isClientSideApi: false,
|
isClientSideApi: false,
|
||||||
isRateLimited: true,
|
isRateLimited: true,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const PUBLIC_ROUTES = {
|
|||||||
SURVEY_ROUTES: [
|
SURVEY_ROUTES: [
|
||||||
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
|
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
|
||||||
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
|
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
|
||||||
|
/^\/p\/[^/]+/, // /p/[prettyUrl] - pretty URL pages
|
||||||
],
|
],
|
||||||
|
|
||||||
// API routes accessible from public domain
|
// API routes accessible from public domain
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { LinkSurveyLayout, viewport } from "@/modules/survey/link/layout";
|
import { Viewport } from "next";
|
||||||
|
import { LinkSurveyLayout } from "@/modules/survey/link/layout";
|
||||||
|
|
||||||
export { viewport };
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1.0,
|
||||||
|
maximumScale: 1.0,
|
||||||
|
userScalable: false,
|
||||||
|
viewportFit: "contain",
|
||||||
|
};
|
||||||
|
|
||||||
export default LinkSurveyLayout;
|
export default LinkSurveyLayout;
|
||||||
|
|||||||
@@ -711,7 +711,12 @@ checksums:
|
|||||||
environments/integrations/google_sheets/link_google_sheet: fa78146ae26ce5b1d2aaf2678f628943
|
environments/integrations/google_sheets/link_google_sheet: fa78146ae26ce5b1d2aaf2678f628943
|
||||||
environments/integrations/google_sheets/link_new_sheet: 8ad2ea8708f50ed184c00b84577b325e
|
environments/integrations/google_sheets/link_new_sheet: 8ad2ea8708f50ed184c00b84577b325e
|
||||||
environments/integrations/google_sheets/no_integrations_yet: ea46f7747937baf48a47a4c1b1776aee
|
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/spreadsheet_url: b1665f96e6ecce23ea2d9196f4a3e5dd
|
||||||
|
environments/integrations/google_sheets/token_expired_error: 555d34c18c554ec8ac66614f21bd44fc
|
||||||
environments/integrations/include_created_at: 8011355b13e28e638d74e6f3d68a2bbf
|
environments/integrations/include_created_at: 8011355b13e28e638d74e6f3d68a2bbf
|
||||||
environments/integrations/include_hidden_fields: 25f0ea5ca1c6ead2cd121f8754cb8d72
|
environments/integrations/include_hidden_fields: 25f0ea5ca1c6ead2cd121f8754cb8d72
|
||||||
environments/integrations/include_metadata: 750091d965d7cc8d02468b5239816dc5
|
environments/integrations/include_metadata: 750091d965d7cc8d02468b5239816dc5
|
||||||
|
|||||||
@@ -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 { Prisma } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZString } from "@formbricks/types/common";
|
import { ZString } from "@formbricks/types/common";
|
||||||
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
|
import {
|
||||||
|
AuthenticationError,
|
||||||
|
DatabaseError,
|
||||||
|
OperationNotAllowedError,
|
||||||
|
UnknownError,
|
||||||
|
} from "@formbricks/types/errors";
|
||||||
import {
|
import {
|
||||||
TIntegrationGoogleSheets,
|
TIntegrationGoogleSheets,
|
||||||
ZIntegrationGoogleSheets,
|
ZIntegrationGoogleSheets,
|
||||||
@@ -11,8 +16,12 @@ import {
|
|||||||
GOOGLE_SHEETS_CLIENT_ID,
|
GOOGLE_SHEETS_CLIENT_ID,
|
||||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||||
GOOGLE_SHEETS_REDIRECT_URL,
|
GOOGLE_SHEETS_REDIRECT_URL,
|
||||||
|
GOOGLE_SHEET_MESSAGE_LIMIT,
|
||||||
} from "@/lib/constants";
|
} 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 { createOrUpdateIntegration } from "@/lib/integration/service";
|
||||||
import { truncateText } from "../utils/strings";
|
import { truncateText } from "../utils/strings";
|
||||||
import { validateInputs } from "../utils/validate";
|
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 (
|
export const getSpreadsheetNameById = async (
|
||||||
googleSheetIntegrationData: TIntegrationGoogleSheets,
|
googleSheetIntegrationData: TIntegrationGoogleSheets,
|
||||||
spreadsheetId: string
|
spreadsheetId: string
|
||||||
@@ -94,7 +114,17 @@ export const getSpreadsheetNameById = async (
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
sheets.spreadsheets.get({ spreadsheetId }, (err, response) => {
|
sheets.spreadsheets.get({ spreadsheetId }, (err, response) => {
|
||||||
if (err) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const spreadsheetTitle = response.data.properties.title;
|
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 authorize = async (googleSheetIntegrationData: TIntegrationGoogleSheets) => {
|
||||||
const client_id = GOOGLE_SHEETS_CLIENT_ID;
|
const client_id = GOOGLE_SHEETS_CLIENT_ID;
|
||||||
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
|
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
|
||||||
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
|
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
|
||||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
||||||
const refresh_token = googleSheetIntegrationData.config.key.refresh_token;
|
const key = googleSheetIntegrationData.config.key;
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -752,7 +752,12 @@
|
|||||||
"link_google_sheet": "Tabelle verlinken",
|
"link_google_sheet": "Tabelle verlinken",
|
||||||
"link_new_sheet": "Neues Blatt verknüpfen",
|
"link_new_sheet": "Neues Blatt verknüpfen",
|
||||||
"no_integrations_yet": "Deine verknüpften Tabellen werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
|
"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_created_at": "Erstellungsdatum einbeziehen",
|
||||||
"include_hidden_fields": "Versteckte Felder (hidden fields) einbeziehen",
|
"include_hidden_fields": "Versteckte Felder (hidden fields) einbeziehen",
|
||||||
|
|||||||
@@ -752,7 +752,12 @@
|
|||||||
"link_google_sheet": "Link Google Sheet",
|
"link_google_sheet": "Link Google Sheet",
|
||||||
"link_new_sheet": "Link new Sheet",
|
"link_new_sheet": "Link new Sheet",
|
||||||
"no_integrations_yet": "Your google sheet integrations will appear here as soon as you add them. ⏲️",
|
"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_created_at": "Include Created At",
|
||||||
"include_hidden_fields": "Include Hidden Fields",
|
"include_hidden_fields": "Include Hidden Fields",
|
||||||
|
|||||||
@@ -752,7 +752,12 @@
|
|||||||
"link_google_sheet": "Vincular Google Sheet",
|
"link_google_sheet": "Vincular Google Sheet",
|
||||||
"link_new_sheet": "Vincular nueva hoja",
|
"link_new_sheet": "Vincular nueva hoja",
|
||||||
"no_integrations_yet": "Tus integraciones de Google Sheet aparecerán aquí tan pronto como las añadas. ⏲️",
|
"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_created_at": "Incluir fecha de creación",
|
||||||
"include_hidden_fields": "Incluir campos ocultos",
|
"include_hidden_fields": "Incluir campos ocultos",
|
||||||
|
|||||||
@@ -752,7 +752,12 @@
|
|||||||
"link_google_sheet": "Lien Google Sheet",
|
"link_google_sheet": "Lien Google Sheet",
|
||||||
"link_new_sheet": "Lier une nouvelle feuille",
|
"link_new_sheet": "Lier une nouvelle feuille",
|
||||||
"no_integrations_yet": "Vos intégrations Google Sheets apparaîtront ici dès que vous les ajouterez. ⏲️",
|
"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_created_at": "Inclure la date de création",
|
||||||
"include_hidden_fields": "Inclure les champs cachés",
|
"include_hidden_fields": "Inclure les champs cachés",
|
||||||
|
|||||||
@@ -752,7 +752,12 @@
|
|||||||
"link_google_sheet": "Google Táblázatok összekapcsolása",
|
"link_google_sheet": "Google Táblázatok összekapcsolása",
|
||||||
"link_new_sheet": "Új táblázat ö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. ⏲️",
|
"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_created_at": "Létrehozva felvétele",
|
||||||
"include_hidden_fields": "Rejtett mezők felvétele",
|
"include_hidden_fields": "Rejtett mezők felvétele",
|
||||||
|
|||||||
@@ -752,7 +752,12 @@
|
|||||||
"link_google_sheet": "スプレッドシートをリンク",
|
"link_google_sheet": "スプレッドシートをリンク",
|
||||||
"link_new_sheet": "新しいシートをリンク",
|
"link_new_sheet": "新しいシートをリンク",
|
||||||
"no_integrations_yet": "Google スプレッドシート連携は、追加するとここに表示されます。⏲️",
|
"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_created_at": "作成日時を含める",
|
||||||
"include_hidden_fields": "非表示フィールドを含める",
|
"include_hidden_fields": "非表示フィールドを含める",
|
||||||
|
|||||||
@@ -752,7 +752,12 @@
|
|||||||
"link_google_sheet": "Link Google Spreadsheet",
|
"link_google_sheet": "Link Google Spreadsheet",
|
||||||
"link_new_sheet": "Nieuw blad koppelen",
|
"link_new_sheet": "Nieuw blad koppelen",
|
||||||
"no_integrations_yet": "Uw Google Spreadsheet-integraties verschijnen hier zodra u ze toevoegt. ⏲️",
|
"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_created_at": "Inclusief gemaakt op",
|
||||||
"include_hidden_fields": "Inclusief verborgen velden",
|
"include_hidden_fields": "Inclusief verborgen velden",
|
||||||
|
|||||||
@@ -752,7 +752,12 @@
|
|||||||
"link_google_sheet": "Link da Planilha do Google",
|
"link_google_sheet": "Link da Planilha do Google",
|
||||||
"link_new_sheet": "Vincular nova planilha",
|
"link_new_sheet": "Vincular nova planilha",
|
||||||
"no_integrations_yet": "Suas integrações do Google Sheets vão aparecer aqui assim que você adicioná-las. ⏲️",
|
"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_created_at": "Incluir Data de Criação",
|
||||||
"include_hidden_fields": "Incluir Campos Ocultos",
|
"include_hidden_fields": "Incluir Campos Ocultos",
|
||||||
|
|||||||
@@ -752,7 +752,12 @@
|
|||||||
"link_google_sheet": "Ligar Folha do Google",
|
"link_google_sheet": "Ligar Folha do Google",
|
||||||
"link_new_sheet": "Ligar nova Folha",
|
"link_new_sheet": "Ligar nova Folha",
|
||||||
"no_integrations_yet": "As suas integrações com o Google Sheets aparecerão aqui assim que as adicionar. ⏲️",
|
"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_created_at": "Incluir Criado Em",
|
||||||
"include_hidden_fields": "Incluir Campos Ocultos",
|
"include_hidden_fields": "Incluir Campos Ocultos",
|
||||||
|
|||||||
@@ -752,7 +752,12 @@
|
|||||||
"link_google_sheet": "Leagă Google Sheet",
|
"link_google_sheet": "Leagă Google Sheet",
|
||||||
"link_new_sheet": "Leagă un nou 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. ⏲️",
|
"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_created_at": "Include data creării",
|
||||||
"include_hidden_fields": "Include câmpuri ascunse",
|
"include_hidden_fields": "Include câmpuri ascunse",
|
||||||
|
|||||||
@@ -752,7 +752,12 @@
|
|||||||
"link_google_sheet": "Связать с Google Sheet",
|
"link_google_sheet": "Связать с Google Sheet",
|
||||||
"link_new_sheet": "Связать с новой таблицей",
|
"link_new_sheet": "Связать с новой таблицей",
|
||||||
"no_integrations_yet": "Ваши интеграции с Google 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_created_at": "Включить дату создания",
|
||||||
"include_hidden_fields": "Включить скрытые поля",
|
"include_hidden_fields": "Включить скрытые поля",
|
||||||
|
|||||||
@@ -752,7 +752,12 @@
|
|||||||
"link_google_sheet": "Länka Google Kalkylark",
|
"link_google_sheet": "Länka Google Kalkylark",
|
||||||
"link_new_sheet": "Länka nytt 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. ⏲️",
|
"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_created_at": "Inkludera Skapad vid",
|
||||||
"include_hidden_fields": "Inkludera dolda fält",
|
"include_hidden_fields": "Inkludera dolda fält",
|
||||||
|
|||||||
@@ -752,7 +752,12 @@
|
|||||||
"link_google_sheet": "链接 Google 表格",
|
"link_google_sheet": "链接 Google 表格",
|
||||||
"link_new_sheet": "链接 新 表格",
|
"link_new_sheet": "链接 新 表格",
|
||||||
"no_integrations_yet": "您的 Google 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_created_at": "包括 创建 于",
|
||||||
"include_hidden_fields": "包括 隐藏 字段",
|
"include_hidden_fields": "包括 隐藏 字段",
|
||||||
|
|||||||
@@ -752,7 +752,12 @@
|
|||||||
"link_google_sheet": "連結 Google 試算表",
|
"link_google_sheet": "連結 Google 試算表",
|
||||||
"link_new_sheet": "連結新試算表",
|
"link_new_sheet": "連結新試算表",
|
||||||
"no_integrations_yet": "您的 Google 試算表整合將在您新增後立即顯示在此處。⏲️",
|
"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_created_at": "包含建立於",
|
||||||
"include_hidden_fields": "包含隱藏欄位",
|
"include_hidden_fields": "包含隱藏欄位",
|
||||||
|
|||||||
@@ -30,4 +30,4 @@ export const rateLimitConfigs = {
|
|||||||
upload: { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" }, // 5 per minute
|
upload: { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" }, // 5 per minute
|
||||||
delete: { interval: 60, allowedPerInterval: 5, namespace: "storage:delete" }, // 5 per minute
|
delete: { interval: 60, allowedPerInterval: 5, namespace: "storage:delete" }, // 5 per minute
|
||||||
},
|
},
|
||||||
};
|
} as const;
|
||||||
|
|||||||
+26
-75
@@ -7,9 +7,10 @@ import { validateInputs } from "@/lib/utils/validate";
|
|||||||
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
|
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
|
||||||
import { getPersonSegmentIds, getSegments } from "./segments";
|
import { getPersonSegmentIds, getSegments } from "./segments";
|
||||||
|
|
||||||
|
// Mock the cache functions
|
||||||
vi.mock("@/lib/cache", () => ({
|
vi.mock("@/lib/cache", () => ({
|
||||||
cache: {
|
cache: {
|
||||||
withCache: vi.fn(async (fn) => await fn()),
|
withCache: vi.fn(async (fn) => await fn()), // Just execute the function without caching for tests
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -29,15 +30,15 @@ vi.mock("@formbricks/database", () => ({
|
|||||||
contact: {
|
contact: {
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
},
|
},
|
||||||
$transaction: vi.fn(),
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock React cache
|
||||||
vi.mock("react", async () => {
|
vi.mock("react", async () => {
|
||||||
const actual = await vi.importActual("react");
|
const actual = await vi.importActual("react");
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
cache: <T extends (...args: any[]) => any>(fn: T): T => fn,
|
cache: <T extends (...args: any[]) => any>(fn: T): T => fn, // Return the function with the same type signature
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,20 +97,22 @@ describe("segments lib", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getPersonSegmentIds", () => {
|
describe("getPersonSegmentIds", () => {
|
||||||
const mockWhereClause = { AND: [{ environmentId: mockEnvironmentId }, {}] };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(prisma.segment.findMany).mockResolvedValue(
|
vi.mocked(prisma.segment.findMany).mockResolvedValue(
|
||||||
mockSegmentsData as Prisma.Result<typeof prisma.segment, unknown, "findMany">
|
mockSegmentsData as Prisma.Result<typeof prisma.segment, unknown, "findMany">
|
||||||
);
|
);
|
||||||
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
|
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
data: { whereClause: mockWhereClause },
|
data: { whereClause: { AND: [{ environmentId: mockEnvironmentId }, {}] } },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return person segment IDs successfully", async () => {
|
test("should return person segment IDs successfully", async () => {
|
||||||
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }, { id: mockContactId }]);
|
vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: mockContactId } as Prisma.Result<
|
||||||
|
typeof prisma.contact,
|
||||||
|
unknown,
|
||||||
|
"findFirst"
|
||||||
|
>);
|
||||||
|
|
||||||
const result = await getPersonSegmentIds(
|
const result = await getPersonSegmentIds(
|
||||||
mockEnvironmentId,
|
mockEnvironmentId,
|
||||||
@@ -125,12 +128,12 @@ describe("segments lib", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
|
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
|
||||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
expect(prisma.contact.findFirst).toHaveBeenCalledTimes(mockSegmentsData.length);
|
||||||
expect(result).toEqual(mockSegmentsData.map((s) => s.id));
|
expect(result).toEqual(mockSegmentsData.map((s) => s.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return empty array if no segments exist", async () => {
|
test("should return empty array if no segments exist", async () => {
|
||||||
vi.mocked(prisma.segment.findMany).mockResolvedValue([]);
|
vi.mocked(prisma.segment.findMany).mockResolvedValue([]); // No segments
|
||||||
|
|
||||||
const result = await getPersonSegmentIds(
|
const result = await getPersonSegmentIds(
|
||||||
mockEnvironmentId,
|
mockEnvironmentId,
|
||||||
@@ -141,11 +144,10 @@ describe("segments lib", () => {
|
|||||||
|
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
expect(segmentFilterToPrismaQuery).not.toHaveBeenCalled();
|
expect(segmentFilterToPrismaQuery).not.toHaveBeenCalled();
|
||||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return empty array if segments exist but none match", async () => {
|
test("should return empty array if segments exist but none match", async () => {
|
||||||
vi.mocked(prisma.$transaction).mockResolvedValue([null, null]);
|
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
|
||||||
|
|
||||||
const result = await getPersonSegmentIds(
|
const result = await getPersonSegmentIds(
|
||||||
mockEnvironmentId,
|
mockEnvironmentId,
|
||||||
@@ -153,14 +155,16 @@ describe("segments lib", () => {
|
|||||||
mockContactUserId,
|
mockContactUserId,
|
||||||
mockDeviceType
|
mockDeviceType
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
|
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
|
||||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should call validateInputs with correct parameters", async () => {
|
test("should call validateInputs with correct parameters", async () => {
|
||||||
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }, { id: mockContactId }]);
|
vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: mockContactId } as Prisma.Result<
|
||||||
|
typeof prisma.contact,
|
||||||
|
unknown,
|
||||||
|
"findFirst"
|
||||||
|
>);
|
||||||
|
|
||||||
await getPersonSegmentIds(mockEnvironmentId, mockContactId, mockContactUserId, mockDeviceType);
|
await getPersonSegmentIds(mockEnvironmentId, mockContactId, mockContactUserId, mockDeviceType);
|
||||||
expect(validateInputs).toHaveBeenCalledWith(
|
expect(validateInputs).toHaveBeenCalledWith(
|
||||||
@@ -171,7 +175,14 @@ describe("segments lib", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should return only matching segment IDs", async () => {
|
test("should return only matching segment IDs", async () => {
|
||||||
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }, null]);
|
// First segment matches, second doesn't
|
||||||
|
vi.mocked(prisma.contact.findFirst)
|
||||||
|
.mockResolvedValueOnce({ id: mockContactId } as Prisma.Result<
|
||||||
|
typeof prisma.contact,
|
||||||
|
unknown,
|
||||||
|
"findFirst"
|
||||||
|
>) // First segment matches
|
||||||
|
.mockResolvedValueOnce(null); // Second segment does not match
|
||||||
|
|
||||||
const result = await getPersonSegmentIds(
|
const result = await getPersonSegmentIds(
|
||||||
mockEnvironmentId,
|
mockEnvironmentId,
|
||||||
@@ -182,66 +193,6 @@ describe("segments lib", () => {
|
|||||||
|
|
||||||
expect(result).toEqual([mockSegmentsData[0].id]);
|
expect(result).toEqual([mockSegmentsData[0].id]);
|
||||||
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
|
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
|
||||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should include segments with no filters as always-matching", async () => {
|
|
||||||
const segmentsWithEmptyFilters = [
|
|
||||||
{ id: "segment-no-filter", filters: [] },
|
|
||||||
{ id: "segment-with-filter", filters: [{}] as TBaseFilter[] },
|
|
||||||
];
|
|
||||||
vi.mocked(prisma.segment.findMany).mockResolvedValue(
|
|
||||||
segmentsWithEmptyFilters as Prisma.Result<typeof prisma.segment, unknown, "findMany">
|
|
||||||
);
|
|
||||||
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }]);
|
|
||||||
|
|
||||||
const result = await getPersonSegmentIds(
|
|
||||||
mockEnvironmentId,
|
|
||||||
mockContactId,
|
|
||||||
mockContactUserId,
|
|
||||||
mockDeviceType
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toContain("segment-no-filter");
|
|
||||||
expect(result).toContain("segment-with-filter");
|
|
||||||
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(1);
|
|
||||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should skip segments where filter query building fails", async () => {
|
|
||||||
vi.mocked(segmentFilterToPrismaQuery)
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
data: { whereClause: mockWhereClause },
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: false,
|
|
||||||
error: { type: "bad_request", message: "Invalid filters", details: [] },
|
|
||||||
});
|
|
||||||
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }]);
|
|
||||||
|
|
||||||
const result = await getPersonSegmentIds(
|
|
||||||
mockEnvironmentId,
|
|
||||||
mockContactId,
|
|
||||||
mockContactUserId,
|
|
||||||
mockDeviceType
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toEqual(["segment1"]);
|
|
||||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return empty array on unexpected error", async () => {
|
|
||||||
vi.mocked(prisma.segment.findMany).mockRejectedValue(new Error("Unexpected"));
|
|
||||||
|
|
||||||
const result = await getPersonSegmentIds(
|
|
||||||
mockEnvironmentId,
|
|
||||||
mockContactId,
|
|
||||||
mockContactUserId,
|
|
||||||
mockDeviceType
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,47 @@ export const getSegments = reactCache(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a contact matches a segment using Prisma query
|
||||||
|
* This leverages native DB types (valueDate, valueNumber) for accurate comparisons
|
||||||
|
* Device filters are evaluated at query build time using the provided deviceType
|
||||||
|
*/
|
||||||
|
const isContactInSegment = async (
|
||||||
|
contactId: string,
|
||||||
|
segmentId: string,
|
||||||
|
filters: TBaseFilters,
|
||||||
|
environmentId: string,
|
||||||
|
deviceType: "phone" | "desktop"
|
||||||
|
): Promise<boolean> => {
|
||||||
|
// If no filters, segment matches all contacts
|
||||||
|
if (!filters || filters.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryResult = await segmentFilterToPrismaQuery(segmentId, filters, environmentId, deviceType);
|
||||||
|
|
||||||
|
if (!queryResult.ok) {
|
||||||
|
logger.warn(
|
||||||
|
{ segmentId, environmentId, error: queryResult.error },
|
||||||
|
"Failed to build Prisma query for segment"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { whereClause } = queryResult.data;
|
||||||
|
|
||||||
|
// Check if this specific contact matches the segment filters
|
||||||
|
const matchingContact = await prisma.contact.findFirst({
|
||||||
|
where: {
|
||||||
|
id: contactId,
|
||||||
|
...whereClause,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return matchingContact !== null;
|
||||||
|
};
|
||||||
|
|
||||||
export const getPersonSegmentIds = async (
|
export const getPersonSegmentIds = async (
|
||||||
environmentId: string,
|
environmentId: string,
|
||||||
contactId: string,
|
contactId: string,
|
||||||
@@ -48,70 +89,23 @@ export const getPersonSegmentIds = async (
|
|||||||
|
|
||||||
const segments = await getSegments(environmentId);
|
const segments = await getSegments(environmentId);
|
||||||
|
|
||||||
if (!segments || !Array.isArray(segments) || segments.length === 0) {
|
// fast path; if there are no segments, return an empty array
|
||||||
|
if (!segments || !Array.isArray(segments)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1: Build all Prisma where clauses concurrently.
|
// Device filters are evaluated at query build time using the provided deviceType
|
||||||
// This converts segment filters into where clauses without per-contact DB queries.
|
const segmentPromises = segments.map(async (segment) => {
|
||||||
const segmentWithClauses = await Promise.all(
|
const filters = segment.filters;
|
||||||
segments.map(async (segment) => {
|
const isIncluded = await isContactInSegment(contactId, segment.id, filters, environmentId, deviceType);
|
||||||
const filters = segment.filters as TBaseFilters | null;
|
return isIncluded ? segment.id : null;
|
||||||
|
});
|
||||||
|
|
||||||
if (!filters || filters.length === 0) {
|
const results = await Promise.all(segmentPromises);
|
||||||
return { segmentId: segment.id, whereClause: {} as Prisma.ContactWhereInput };
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, environmentId, deviceType);
|
return results.filter((id): id is string => id !== null);
|
||||||
|
|
||||||
if (!queryResult.ok) {
|
|
||||||
logger.warn(
|
|
||||||
{ segmentId: segment.id, environmentId, error: queryResult.error },
|
|
||||||
"Failed to build Prisma query for segment"
|
|
||||||
);
|
|
||||||
return { segmentId: segment.id, whereClause: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { segmentId: segment.id, whereClause: queryResult.data.whereClause };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Separate segments into: always-match (no filters), needs-DB-check, and failed-to-build
|
|
||||||
const alwaysMatchIds: string[] = [];
|
|
||||||
const toCheck: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = [];
|
|
||||||
|
|
||||||
for (const item of segmentWithClauses) {
|
|
||||||
if (item.whereClause === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(item.whereClause).length === 0) {
|
|
||||||
alwaysMatchIds.push(item.segmentId);
|
|
||||||
} else {
|
|
||||||
toCheck.push({ segmentId: item.segmentId, whereClause: item.whereClause });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toCheck.length === 0) {
|
|
||||||
return alwaysMatchIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Batch all contact-match checks into a single DB transaction.
|
|
||||||
// Replaces N individual findFirst queries with one batched round-trip.
|
|
||||||
const batchResults = await prisma.$transaction(
|
|
||||||
toCheck.map(({ whereClause }) =>
|
|
||||||
prisma.contact.findFirst({
|
|
||||||
where: { id: contactId, ...whereClause },
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Phase 3: Collect matching segment IDs
|
|
||||||
const dbMatchIds = toCheck.filter((_, i) => batchResults[i] !== null).map(({ segmentId }) => segmentId);
|
|
||||||
|
|
||||||
return [...alwaysMatchIds, ...dbMatchIds];
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Log error for debugging but don't throw to prevent "segments is not iterable" error
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{
|
{
|
||||||
environmentId,
|
environmentId,
|
||||||
|
|||||||
@@ -437,4 +437,22 @@ describe("updateAttributes", () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.messages).toContainEqual({ code: "email_or_userid_required", params: {} });
|
expect(result.messages).toContainEqual({ code: "email_or_userid_required", params: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("coerces boolean attribute values to strings", async () => {
|
||||||
|
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
||||||
|
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
||||||
|
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||||
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||||
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||||
|
|
||||||
|
const attributes = { name: true, email: "john@example.com" };
|
||||||
|
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalled();
|
||||||
|
const transactionCall = vi.mocked(prisma.$transaction).mock.calls[0][0];
|
||||||
|
// Both name (coerced from boolean) and email should be upserted
|
||||||
|
expect(transactionCall).toHaveLength(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1229,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
|
// DATE FILTER TESTS
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ vi.mock("@formbricks/database", () => ({
|
|||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
upsert: vi.fn(),
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
},
|
},
|
||||||
survey: {
|
survey: {
|
||||||
@@ -206,6 +207,73 @@ describe("Segment Service Tests", () => {
|
|||||||
vi.mocked(prisma.segment.create).mockRejectedValue(new Error("DB error"));
|
vi.mocked(prisma.segment.create).mockRejectedValue(new Error("DB error"));
|
||||||
await expect(createSegment(mockSegmentCreateInput)).rejects.toThrow(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", () => {
|
describe("cloneSegment", () => {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
isValidFileTypeForExtension,
|
isValidFileTypeForExtension,
|
||||||
isValidImageFile,
|
isValidImageFile,
|
||||||
resolveStorageUrl,
|
resolveStorageUrl,
|
||||||
|
resolveStorageUrlAuto,
|
||||||
|
resolveStorageUrlsInObject,
|
||||||
sanitizeFileName,
|
sanitizeFileName,
|
||||||
validateFileUploads,
|
validateFileUploads,
|
||||||
validateSingleFile,
|
validateSingleFile,
|
||||||
@@ -406,7 +408,7 @@ describe("storage utils", () => {
|
|||||||
expect(resolveStorageUrl("")).toBe("");
|
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 httpsUrl = "https://example.com/storage/env-123/public/image.jpg";
|
||||||
const httpUrl = "http://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 () => {
|
test("should resolve relative /storage/ path to absolute URL", async () => {
|
||||||
// Use actual implementation with mocked dependencies
|
|
||||||
const { resolveStorageUrl: actualResolveStorageUrl } =
|
const { resolveStorageUrl: actualResolveStorageUrl } =
|
||||||
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
|
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
|
||||||
|
|
||||||
const relativePath = "/storage/env-123/public/image.jpg";
|
const relativePath = "/storage/env-123/public/image.jpg";
|
||||||
const result = actualResolveStorageUrl(relativePath);
|
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).toContain("/storage/env-123/public/image.jpg");
|
||||||
expect(result.startsWith("http")).toBe(true);
|
expect(result.startsWith("http")).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -432,4 +432,209 @@ describe("storage utils", () => {
|
|||||||
expect(resolveStorageUrl("relative/path.jpg")).toBe("relative/path.jpg");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,3 @@
|
|||||||
import { Viewport } from "next";
|
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
|
||||||
width: "device-width",
|
|
||||||
initialScale: 1.0,
|
|
||||||
maximumScale: 1.0,
|
|
||||||
userScalable: false,
|
|
||||||
viewportFit: "contain",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LinkSurveyLayout = ({ children }) => {
|
export const LinkSurveyLayout = ({ children }) => {
|
||||||
return <div className="h-dvh">{children}</div>;
|
return <div className="h-dvh">{children}</div>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
|
|||||||
isLoadingScript = true;
|
isLoadingScript = true;
|
||||||
try {
|
try {
|
||||||
const scriptUrl = props.appUrl ? `${props.appUrl}/js/surveys.umd.cjs` : "/js/surveys.umd.cjs";
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to load the surveys package");
|
throw new Error("Failed to load the surveys package");
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ Scaling:
|
|||||||
kubectl get hpa -n {{ .Release.Namespace }} {{ include "formbricks.name" . }}
|
kubectl get hpa -n {{ .Release.Namespace }} {{ include "formbricks.name" . }}
|
||||||
```
|
```
|
||||||
{{- else }}
|
{{- else }}
|
||||||
HPA is **not enabled**. Your deployment has a fixed number of `{{ .Values.deployment.replicas }}` replicas.
|
HPA is **not enabled**. Your deployment has a fixed number of `{{ .Values.replicaCount }}` replicas.
|
||||||
Manually scale using:
|
Manually scale using:
|
||||||
```sh
|
```sh
|
||||||
kubectl scale deployment -n {{ .Release.Namespace }} {{ include "formbricks.name" . }} --replicas=<desired_number>
|
kubectl scale deployment -n {{ .Release.Namespace }} {{ include "formbricks.name" . }} --replicas=<desired_number>
|
||||||
@@ -127,34 +127,6 @@ Scaling:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Pod Disruption Budget:
|
|
||||||
|
|
||||||
{{- if .Values.pdb.enabled }}
|
|
||||||
A PodDisruptionBudget is active to protect against voluntary disruptions.
|
|
||||||
{{- if not (kindIs "invalid" .Values.pdb.minAvailable) }}
|
|
||||||
- **Min Available**: `{{ .Values.pdb.minAvailable }}`
|
|
||||||
{{- end }}
|
|
||||||
{{- if not (kindIs "invalid" .Values.pdb.maxUnavailable) }}
|
|
||||||
- **Max Unavailable**: `{{ .Values.pdb.maxUnavailable }}`
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
Check PDB status:
|
|
||||||
```sh
|
|
||||||
kubectl get pdb -n {{ .Release.Namespace }} {{ include "formbricks.name" . }}
|
|
||||||
```
|
|
||||||
{{- if and .Values.autoscaling.enabled (eq (int .Values.autoscaling.minReplicas) 1) }}
|
|
||||||
|
|
||||||
WARNING: autoscaling.minReplicas is 1. With minAvailable: 1, the PDB
|
|
||||||
will block all node drains when only 1 replica is running. Set
|
|
||||||
autoscaling.minReplicas to at least 2 for proper HA protection.
|
|
||||||
{{- end }}
|
|
||||||
{{- else }}
|
|
||||||
PDB is **not enabled**. Voluntary disruptions (node drains, upgrades) may
|
|
||||||
take down all pods simultaneously.
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
External Secrets:
|
External Secrets:
|
||||||
{{- if .Values.externalSecret.enabled }}
|
{{- if .Values.externalSecret.enabled }}
|
||||||
External secrets are enabled.
|
External secrets are enabled.
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
{{- if .Values.pdb.enabled }}
|
|
||||||
{{- $hasMinAvailable := not (kindIs "invalid" .Values.pdb.minAvailable) -}}
|
|
||||||
{{- $hasMaxUnavailable := not (kindIs "invalid" .Values.pdb.maxUnavailable) -}}
|
|
||||||
{{- if and $hasMinAvailable $hasMaxUnavailable }}
|
|
||||||
{{- fail "pdb.minAvailable and pdb.maxUnavailable are mutually exclusive; set only one" }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if not (or $hasMinAvailable $hasMaxUnavailable) }}
|
|
||||||
{{- fail "pdb.enabled is true but neither pdb.minAvailable nor pdb.maxUnavailable is set; set exactly one" }}
|
|
||||||
{{- end }}
|
|
||||||
---
|
|
||||||
apiVersion: policy/v1
|
|
||||||
kind: PodDisruptionBudget
|
|
||||||
metadata:
|
|
||||||
name: {{ template "formbricks.name" . }}
|
|
||||||
labels:
|
|
||||||
{{- include "formbricks.labels" . | nindent 4 }}
|
|
||||||
{{- with .Values.pdb.additionalLabels }}
|
|
||||||
{{- toYaml . | nindent 4 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if .Values.pdb.annotations }}
|
|
||||||
annotations:
|
|
||||||
{{- toYaml .Values.pdb.annotations | nindent 4 }}
|
|
||||||
{{- end }}
|
|
||||||
spec:
|
|
||||||
{{- if $hasMinAvailable }}
|
|
||||||
minAvailable: {{ .Values.pdb.minAvailable }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if $hasMaxUnavailable }}
|
|
||||||
maxUnavailable: {{ .Values.pdb.maxUnavailable }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if .Values.pdb.unhealthyPodEvictionPolicy }}
|
|
||||||
unhealthyPodEvictionPolicy: {{ .Values.pdb.unhealthyPodEvictionPolicy }}
|
|
||||||
{{- end }}
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
{{- include "formbricks.selectorLabels" . | nindent 6 }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -214,42 +214,6 @@ autoscaling:
|
|||||||
value: 2
|
value: 2
|
||||||
periodSeconds: 60 # Add at most 2 pods every minute
|
periodSeconds: 60 # Add at most 2 pods every minute
|
||||||
|
|
||||||
##########################################################
|
|
||||||
# Pod Disruption Budget (PDB)
|
|
||||||
#
|
|
||||||
# Ensures a minimum number of pods remain available during
|
|
||||||
# voluntary disruptions (node drains, cluster upgrades, etc.).
|
|
||||||
#
|
|
||||||
# IMPORTANT:
|
|
||||||
# - minAvailable and maxUnavailable are MUTUALLY EXCLUSIVE.
|
|
||||||
# Setting both will cause a helm install/upgrade failure.
|
|
||||||
# To switch, set the unused one to null in your override file.
|
|
||||||
# - Accepts an integer (e.g., 1) or a percentage string (e.g., "25%").
|
|
||||||
# - For PDB to provide real HA protection, ensure
|
|
||||||
# autoscaling.minReplicas >= 2 (or deployment.replicas >= 2
|
|
||||||
# if HPA is disabled). With only 1 replica and minAvailable: 1,
|
|
||||||
# the PDB will block ALL node drains and cluster upgrades.
|
|
||||||
##########################################################
|
|
||||||
pdb:
|
|
||||||
enabled: true
|
|
||||||
additionalLabels: {}
|
|
||||||
annotations: {}
|
|
||||||
|
|
||||||
# Minimum pods that must remain available during disruptions.
|
|
||||||
# Set to null and configure maxUnavailable instead if preferred.
|
|
||||||
minAvailable: 1
|
|
||||||
|
|
||||||
# Maximum pods that can be unavailable during disruptions.
|
|
||||||
# Mutually exclusive with minAvailable — uncomment and set
|
|
||||||
# minAvailable to null to use this instead.
|
|
||||||
# maxUnavailable: 1
|
|
||||||
|
|
||||||
# Eviction policy for unhealthy pods (Kubernetes 1.27+).
|
|
||||||
# "IfHealthy" — unhealthy pods count toward the budget (default).
|
|
||||||
# "AlwaysAllow" — unhealthy pods can always be evicted,
|
|
||||||
# preventing them from blocking node drain.
|
|
||||||
# unhealthyPodEvictionPolicy: AlwaysAllow
|
|
||||||
|
|
||||||
##########################################################
|
##########################################################
|
||||||
# Service Configuration
|
# Service Configuration
|
||||||
##########################################################
|
##########################################################
|
||||||
|
|||||||
@@ -4,12 +4,182 @@ description: "Formbricks Self-hosted version migration"
|
|||||||
icon: "arrow-right"
|
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
|
## v4.0
|
||||||
|
|
||||||
<Warning>
|
<Warning>
|
||||||
**Important: Migration Required**
|
**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>
|
</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.
|
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
|
### What's New in Formbricks 4.0
|
||||||
|
|
||||||
**🚀 New Enterprise Features:**
|
**🚀 New Enterprise Features:**
|
||||||
|
|
||||||
- **Quotas Management**: Advanced quota controls for enterprise users
|
- **Quotas Management**: Advanced quota controls for enterprise users
|
||||||
|
|
||||||
**🏗️ Technical Foundation Improvements:**
|
**🏗️ Technical Foundation Improvements:**
|
||||||
|
|
||||||
- **Enhanced File Storage**: Improved file handling with better performance and reliability
|
- **Enhanced File Storage**: Improved file handling with better performance and reliability
|
||||||
- **Improved Caching**: New caching functionality improving speed, extensibility and reliability
|
- **Improved Caching**: New caching functionality improving speed, extensibility and reliability
|
||||||
- **Database Optimization**: Removal of unused database tables and fields for better performance
|
- **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.
|
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:
|
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
|
- **Advanced features** that require sophisticated caching and file processing
|
||||||
- **Better performance** through optimized, dedicated services
|
- **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
|
- **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
|
### 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
|
```bash
|
||||||
# Download the latest script
|
# 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:
|
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 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
|
- 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
|
- Pulls the latest Formbricks image and updates your instance
|
||||||
|
|
||||||
|
|
||||||
### Manual Setup
|
### 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.
|
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.
|
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):
|
Formbricks supports multiple storage providers (among many other S3-compatible storages):
|
||||||
|
|
||||||
- AWS S3
|
- AWS S3
|
||||||
- Digital Ocean Spaces
|
- Digital Ocean Spaces
|
||||||
- Hetzner Object Storage
|
- 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_BUCKET_NAME: formbricks-uploads
|
||||||
S3_ENDPOINT_URL: http://minio:9000 # not needed for AWS S3
|
S3_ENDPOINT_URL: http://minio:9000 # not needed for AWS S3
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Upgrade Process
|
#### Upgrade Process
|
||||||
|
|
||||||
**1. Backup your Database**
|
**1. Backup your Database**
|
||||||
@@ -112,8 +287,8 @@ docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbr
|
|||||||
```
|
```
|
||||||
|
|
||||||
<Info>
|
<Info>
|
||||||
If you run into "**No such container**", use `docker ps` to find your container name,
|
If you run into "**No such container**", use `docker ps` to find your container name, e.g.
|
||||||
e.g. `formbricks_postgres_1`.
|
`formbricks_postgres_1`.
|
||||||
</Info>
|
</Info>
|
||||||
|
|
||||||
**2. Upgrade to Formbricks 4.0**
|
**2. Upgrade to Formbricks 4.0**
|
||||||
@@ -134,6 +309,7 @@ docker compose up -d
|
|||||||
**3. Automatic Database Migration**
|
**3. Automatic Database Migration**
|
||||||
|
|
||||||
When you start Formbricks 4.0 for the first time, it will **automatically**:
|
When you start Formbricks 4.0 for the first time, it will **automatically**:
|
||||||
|
|
||||||
- Detect and apply required database schema updates
|
- Detect and apply required database schema updates
|
||||||
- Remove unused database tables and fields
|
- Remove unused database tables and fields
|
||||||
- Optimize the database structure for better performance
|
- Optimize the database structure for better performance
|
||||||
|
|||||||
@@ -1,41 +1,94 @@
|
|||||||
---
|
---
|
||||||
title: "Rate Limiting"
|
title: "Rate Limiting"
|
||||||
description: "Rate limiting for Formbricks"
|
description: "Current request rate limits in Formbricks"
|
||||||
icon: "timer"
|
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** |
|
When a limit is exceeded, the API returns `429 Too Many Requests`.
|
||||||
| ----------------------- | -------------- | --------------- |
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
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
|
```json
|
||||||
{
|
{
|
||||||
"code": 429,
|
"code": "too_many_requests",
|
||||||
"error": "Too many requests, Please try after a while!"
|
"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
|
## 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
|
```bash
|
||||||
RATE_LIMITING_DISABLED=1
|
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
|
- `/s/{surveyId}` - Individual survey access
|
||||||
- `/c/{jwt}` - Personalized link survey access (JWT-based access)
|
- `/c/{jwt}` - Personalized link survey access (JWT-based access)
|
||||||
|
- `/p/{survey-slug}` - Pretty URL survey access
|
||||||
- Embedded survey endpoints
|
- Embedded survey endpoints
|
||||||
|
|
||||||
#### API Routes
|
#### 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
|
* Follow-up to prevent bad reviews
|
||||||
|
|
||||||
* Coming soon: Make survey mandatory
|
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
To run the Churn Survey in your app you want to proceed as follows:
|
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.
|
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
|
### 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.
|
Save, and move over to the **Audience** tab.
|
||||||
|
|
||||||
### 3. Pre-segment your audience (coming soon)
|
### 3. Pre-segment your audience
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
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.
|
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:
|
- **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 ✅
|
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`:
|
- **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.
|
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.
|
Save, and move over to the “Audience” tab.
|
||||||
|
|
||||||
### 3. Pre-segment your audience (coming soon)
|
### 3. Pre-segment your audience
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ sonar.scm.exclusions.disabled=false
|
|||||||
# Encoding of the source code
|
# Encoding of the source code
|
||||||
sonar.sourceEncoding=UTF-8
|
sonar.sourceEncoding=UTF-8
|
||||||
|
|
||||||
# Node.js memory limit for JS/TS analysis (in MB)
|
|
||||||
sonar.javascript.node.maxspace=8192
|
|
||||||
|
|
||||||
# Coverage
|
# Coverage
|
||||||
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.tsx,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/openapi/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,packages/js-core/src/index.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**,packages/survey-ui/**/*.stories.*
|
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.tsx,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/openapi/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,packages/js-core/src/index.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**,packages/survey-ui/**/*.stories.*
|
||||||
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.tsx,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/openapi/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,packages/js-core/src/index.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**,packages/survey-ui/**/*.stories.*
|
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.tsx,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/openapi/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,packages/js-core/src/index.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**,packages/survey-ui/**/*.stories.*
|
||||||
|
|||||||
Reference in New Issue
Block a user